From 7f63353134a775ff21ed3186f8d70301d08085f6 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Wed, 8 Apr 2026 19:01:21 +0000 Subject: [PATCH 1/7] Revert "Migrate test infrastructure from xUnit v2 to xUnit v3 (#52930)" This reverts commit a1f2e26244ad66e91094c523d0e9a87eac051cc4. --- Directory.Build.targets | 10 +++ Directory.Packages.props | 13 ++-- build/SetupHelixEnvironment.cmd | 6 -- build/SetupHelixEnvironment.sh | 6 -- build/helix-debug-entitlements.plist | 8 --- eng/Packages.props | 2 +- eng/Version.Details.props | 16 ++--- eng/Version.Details.xml | 8 +-- eng/Versions.props | 2 +- eng/dependabot/Packages.props | 2 +- ...DoNotUseCountWhenAnyCanBeUsedTests.Data.cs | 13 ++-- .../Test.Utilities/Test.Utilities.csproj | 2 +- test/Common/Program.cs | 32 +++++++++ test/Directory.Build.props | 7 +- test/Directory.Build.targets | 7 +- ...omCreateXUnitWorkItemsWithTestExclusion.cs | 15 +--- .../DebugTestOutputLogger.cs | 18 +---- ...oft.DotNet.HotReload.Test.Utilities.csproj | 1 - .../TestLogger.cs | 2 +- .../WatchSdkTest.cs | 2 +- .../WatchableApp.cs | 1 + .../DockerIsAvailableAndSupportsArchFact.cs | 11 +-- .../DockerIsAvailableAndSupportsArchTheory.cs | 11 +-- .../DockerRegistryManager.cs | 1 + .../DockerSupportsArchInlineData.cs | 20 ++---- .../DockerTestsFixture.cs | 2 - .../DockerAvailableUtils.cs | 17 +---- ...DepsJsonShouldContainVersionInformation.cs | 1 + .../CoreMSBuildAndWindowsOnlyFactAttribute.cs | 5 +- ...oreMSBuildAndWindowsOnlyTheoryAttribute.cs | 5 +- .../CoreMSBuildOnlyFactAttribute.cs | 5 +- .../CoreMSBuildOnlyTheoryAttribute.cs | 5 +- .../FullMSBuildOnlyFactAttribute.cs | 7 +- .../FullMSBuildOnlyTheoryAttribute.cs | 7 +- .../Attributes/MacOsOnlyFactAttribute.cs | 5 +- .../Attributes/PlatformSpecificFact.cs | 57 +++++++++++---- .../Attributes/PlatformSpecificTheory.cs | 41 +++++++---- .../RequiresMSBuildVersionFactAttribute.cs | 5 +- .../RequiresMSBuildVersionTheoryAttribute.cs | 5 +- .../RequiresSpecificFrameworkFactAttribute.cs | 5 +- ...equiresSpecificFrameworkTheoryAttribute.cs | 6 +- ...OnlyRequiresMSBuildVersionFactAttribute.cs | 7 +- ...lyRequiresMSBuildVersionTheoryAttribute.cs | 5 +- .../Commands/SdkCommandSpec.cs | 12 ---- .../Commands/TestCommand.cs | 9 +-- .../Microsoft.NET.TestFramework.csproj | 6 +- .../SharedTestOutputHelper.cs | 22 +----- .../StringTestLogger.cs | 12 ---- .../TestLoggerFactory.cs | 1 - ...rosoft.TemplateEngine.Cli.UnitTests.csproj | 6 +- .../Microsoft.Win32.Msi.Manual.Tests.csproj | 1 + .../Msbuild.Tests.Utilities.csproj | 1 + ...CommandLine.StaticCompletions.Tests.csproj | 3 +- test/UnitTests.proj | 3 +- .../ThirdPartyAnalyzerFormatterTests.cs | 6 +- .../XUnit/ConditionalFactAttribute.cs | 18 ++--- .../XUnit/MSBuildFactAttribute.cs | 25 ++----- .../XUnit/MSBuildFactDiscoverer.cs | 28 ++++++++ .../XUnit/MSBuildTestCase.cs | 72 +++++++++++++++++++ .../XUnit/MSBuildTheoryAttribute.cs | 28 ++------ .../XUnit/MSBuildTheoryDiscoverer.cs | 28 ++++++++ .../Diagnostic/DiagnosticFixture.cs | 2 - .../Diagnostic/XunitNuGetLogger.cs | 3 +- .../DotnetNewDetailsTest.cs | 2 - .../DotnetNewHelpTests.Approval.cs | 18 ++--- .../DotnetNewInstallTests.cs | 3 +- .../DotnetNewTestTemplatesTests.cs | 6 -- .../SharedHomeDirectory.cs | 2 +- .../TemplateDiscoveryTests.cs | 2 +- .../TemplateDiscoveryTool.cs | 2 +- .../WebProjectsTests.cs | 1 - .../dotnet-new.IntegrationTests.csproj | 6 +- .../TestUtilities/DotNetWatchTestBase.cs | 6 +- ...MSBuildFixture.cs => ModuleInitializer.cs} | 12 ++-- .../Package/Add/GivenDotnetPackageAdd.cs | 9 +-- .../Run/GivenDotnetRunIsInterrupted.cs | 4 -- ...enDotnetTestBuildsAndRunsTestfromCsproj.cs | 4 +- test/dotnet.Tests/dotnet.Tests.csproj | 6 +- test/xunit-runner/XUnitRunner.targets | 24 +------ test/xunit.runner.json | 2 +- 80 files changed, 378 insertions(+), 423 deletions(-) delete mode 100644 build/helix-debug-entitlements.plist create mode 100644 test/Common/Program.cs create mode 100644 test/dotnet-format.UnitTests/XUnit/MSBuildFactDiscoverer.cs create mode 100644 test/dotnet-format.UnitTests/XUnit/MSBuildTestCase.cs create mode 100644 test/dotnet-format.UnitTests/XUnit/MSBuildTheoryDiscoverer.cs rename test/dotnet-watch.Tests/TestUtilities/{MSBuildFixture.cs => ModuleInitializer.cs} (84%) diff --git a/Directory.Build.targets b/Directory.Build.targets index 0ef1d2edf1c4..057e70d4eb7d 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -25,6 +25,16 @@ + + + + + false + false + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 26224a9a7e62..de1f7b9b33cf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -58,7 +58,7 @@ - + @@ -79,11 +79,11 @@ - + - + - + @@ -148,9 +148,10 @@ + - - + + - - $(NoWarn);NU5125;NU5123;xUnit1051 + $(NoWarn);NU5125;NU5123 - - false + false embedded diff --git a/test/Directory.Build.targets b/test/Directory.Build.targets index 28bd22c5cdb6..edd1cbc3f09a 100644 --- a/test/Directory.Build.targets +++ b/test/Directory.Build.targets @@ -5,8 +5,6 @@ - XUnitV3 - Exe true @@ -19,6 +17,11 @@ + + + + + diff --git a/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs b/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs index c8bfa9ef0dc4..a3e8eb3e64e4 100644 --- a/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs +++ b/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs @@ -36,11 +36,6 @@ public class SDKCustomCreateXUnitWorkItemsWithTestExclusion : Build.Utilities.Ta [Required] public bool IsPosixShell { get; set; } - /// - /// The runtime identifier of the target Helix queue (e.g. osx-arm64, linux-x64). - /// - public string TargetRid { get; set; } = ""; - /// /// Optional timeout for all created workitems /// Defaults to 300s @@ -163,15 +158,7 @@ private async Task ExecuteAsync() var testFilter = string.IsNullOrEmpty(assemblyPartitionInfo.ClassListArgumentString) ? "" : $"--filter \"{assemblyPartitionInfo.ClassListArgumentString}\""; - // xUnit v3 tests run out-of-process: the VSTest adapter launches the AppHost executable. - // On POSIX, the execute bit is lost when the Helix SDK packages the payload as a zip archive, - // so we need to restore it before running. - string exeName = Path.GetFileNameWithoutExtension(assemblyName); - string chmodPrefix = IsPosixShell ? $"chmod +x {exeName} && " : ""; - // On macOS, ad-hoc sign the test exe with get-task-allow entitlement so createdump can attach via task_for_pid for crash dumps. - string codesignPrefix = IsPosixShell && TargetRid.StartsWith("osx") ? $"codesign -s - -f --entitlements $HELIX_CORRELATION_PAYLOAD/t/helix-debug-entitlements.plist {exeName} && " : ""; - - string command = $"{chmodPrefix}{codesignPrefix}{driver} test {assemblyName} -e HELIX_WORK_ITEM_TIMEOUT={timeout} {testExecutionDirectory} {msbuildAdditionalSdkResolverFolder} " + + string command = $"{driver} test {assemblyName} -e HELIX_WORK_ITEM_TIMEOUT={timeout} {testExecutionDirectory} {msbuildAdditionalSdkResolverFolder} " + $"{(XUnitArguments != null ? " " + XUnitArguments : "")} --results-directory .{Path.DirectorySeparatorChar} --logger trx --logger \"console;verbosity=detailed\" --blame-hang --blame-hang-timeout 60m {testFilter} {enableDiagLogging} {arguments}"; Log.LogMessage($"Creating work item with properties Identity: {assemblyName}, PayloadDirectory: {publishDirectory}, Command: {command}"); diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs index dd5cd0d09508..411cbb360967 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs @@ -3,35 +3,19 @@ using System.Diagnostics; using System.Runtime.CompilerServices; -using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests; public class DebugTestOutputLogger(ITestOutputHelper logger) : ITestOutputHelper { - private readonly StringBuilder _output = new(); - public event Action? OnMessage; - public string Output => _output.ToString(); - public void Log(string message, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) => WriteLine($"[TEST {Path.GetFileName(testPath)}:{testLine}] {message}"); - public void Write(string message) - { - _output.Append(message); - Debug.Write(message); - logger.Write(message); - OnMessage?.Invoke(message); - } - - public void Write(string format, params object[] args) - => Write(string.Format(format, args)); - public void WriteLine(string message) { - _output.AppendLine(message); Debug.WriteLine(message); try diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/Microsoft.DotNet.HotReload.Test.Utilities.csproj b/test/Microsoft.DotNet.HotReload.Test.Utilities/Microsoft.DotNet.HotReload.Test.Utilities.csproj index 113689abef9a..f267d2070886 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/Microsoft.DotNet.HotReload.Test.Utilities.csproj +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/Microsoft.DotNet.HotReload.Test.Utilities.csproj @@ -13,7 +13,6 @@ - diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs index 0b8e8b83f2d1..be7b5f747c80 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using Microsoft.Extensions.Logging; -using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests; diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs index eb58e192490a..ece62d264dac 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs @@ -3,7 +3,7 @@ using System.Runtime.CompilerServices; using Microsoft.NET.TestFramework; -using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests; diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs index 8fa474f5caac..2dacad1cb33a 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs @@ -7,6 +7,7 @@ using Microsoft.DotNet.Cli.Utils; using Microsoft.NET.TestFramework; using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests { diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchFact.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchFact.cs index 404b99624234..ae0e76f3c153 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchFact.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchFact.cs @@ -1,18 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.Build.Containers.IntegrationTests; public class DockerIsAvailableAndSupportsArchFactAttribute : FactAttribute { - public DockerIsAvailableAndSupportsArchFactAttribute( - string arch, - bool checkContainerdStoreAvailability = false, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public DockerIsAvailableAndSupportsArchFactAttribute(string arch, bool checkContainerdStoreAvailability = false) { if (!DockerSupportsArchHelper.DaemonIsAvailable) { diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchTheory.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchTheory.cs index dffff31792da..382e57604fab 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchTheory.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerIsAvailableAndSupportsArchTheory.cs @@ -1,18 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.Build.Containers.IntegrationTests; public class DockerIsAvailableAndSupportsArchTheoryAttribute : TheoryAttribute { - public DockerIsAvailableAndSupportsArchTheoryAttribute( - string arch, - bool checkContainerdStoreAvailability = false, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public DockerIsAvailableAndSupportsArchTheoryAttribute(string arch, bool checkContainerdStoreAvailability = false) { if (!DockerSupportsArchHelper.DaemonIsAvailable) { @@ -27,4 +20,4 @@ public DockerIsAvailableAndSupportsArchTheoryAttribute( base.Skip = $"Skipping test because Docker daemon does not support {arch}."; } } -} +} \ No newline at end of file diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs index e5b1de42ad57..5e3de400ddc3 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs @@ -3,6 +3,7 @@ using Microsoft.DotNet.Cli.Utils; using Microsoft.Extensions.Logging; +using Xunit.Sdk; namespace Microsoft.NET.Build.Containers.IntegrationTests; diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerSupportsArchInlineData.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerSupportsArchInlineData.cs index 502bc3c98e22..7944436b39c9 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerSupportsArchInlineData.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerSupportsArchInlineData.cs @@ -3,7 +3,7 @@ using System.Reflection; using System.Text.Json; -using Xunit.v3; +using Xunit.Sdk; namespace Microsoft.NET.Build.Containers.IntegrationTests; @@ -18,19 +18,17 @@ public DockerSupportsArchInlineData(string arch, params object[] data) _data = data; } - public override bool SupportsDiscoveryEnumeration() => true; - - public override ValueTask> GetData(MethodInfo testMethod, Xunit.Sdk.DisposalTracker disposalTracker) + public override IEnumerable GetData(MethodInfo testMethod) { if (DockerSupportsArchHelper.DaemonSupportsArch(_arch)) { - return new([ConvertDataRow(_data.Prepend(_arch).ToArray())]); + return new object[][] { _data.Prepend(_arch).ToArray() }; } else { base.Skip = $"Skipping test because Docker daemon does not support {_arch}."; } - return new(Array.Empty()); + return Array.Empty(); } } @@ -105,16 +103,6 @@ private NullLogger() { } public static NullLogger Instance { get; } = new NullLogger(); - public string Output => string.Empty; - - public void Write(string message) - { - //do nothing - } - public void Write(string format, params object[] args) - { - //do nothing - } public void WriteLine(string message) { //do nothing diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerTestsFixture.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerTestsFixture.cs index 5a781aee4223..c417e23c6184 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerTestsFixture.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerTestsFixture.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Xunit.Sdk; - namespace Microsoft.NET.Build.Containers.IntegrationTests; public sealed class DockerTestsFixture : IDisposable diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs b/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs index 585e359f213b..b4f3c899b13c 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs @@ -1,19 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.Build.Containers.UnitTests; public class DockerAvailableTheoryAttribute : TheoryAttribute { public static string LocalRegistry => DockerCliStatus.LocalRegistry; - public DockerAvailableTheoryAttribute( - bool skipPodman = false, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public DockerAvailableTheoryAttribute(bool skipPodman = false) { if (!DockerCliStatus.IsAvailable) { @@ -31,12 +25,7 @@ public class DockerAvailableFactAttribute : FactAttribute { public static string LocalRegistry => DockerCliStatus.LocalRegistry; - public DockerAvailableFactAttribute( - bool skipPodman = false, - bool checkContainerdStoreAvailability = false, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public DockerAvailableFactAttribute(bool skipPodman = false, bool checkContainerdStoreAvailability = false) { if (!DockerCliStatus.IsAvailable) { @@ -49,7 +38,7 @@ public DockerAvailableFactAttribute( else if (skipPodman && DockerCliStatus.Command == DockerCli.PodmanCommand) { base.Skip = $"Skipping test with {DockerCliStatus.Command} cli."; - } + } } } diff --git a/test/Microsoft.NET.Publish.Tests/GivenThatAPublishedDepsJsonShouldContainVersionInformation.cs b/test/Microsoft.NET.Publish.Tests/GivenThatAPublishedDepsJsonShouldContainVersionInformation.cs index 534429acbec9..f44f135cc36f 100644 --- a/test/Microsoft.NET.Publish.Tests/GivenThatAPublishedDepsJsonShouldContainVersionInformation.cs +++ b/test/Microsoft.NET.Publish.Tests/GivenThatAPublishedDepsJsonShouldContainVersionInformation.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; using Newtonsoft.Json.Linq; using NuGet.Common; using NuGet.Frameworks; diff --git a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyFactAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyFactAttribute.cs index cafada6546a2..ee3b0b7fac1b 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyFactAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyFactAttribute.cs @@ -1,14 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class CoreMSBuildAndWindowsOnlyFactAttribute : FactAttribute { - public CoreMSBuildAndWindowsOnlyFactAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public CoreMSBuildAndWindowsOnlyFactAttribute() { if (SdkTestContext.Current.ToolsetUnderTest.ShouldUseFullFrameworkMSBuild || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyTheoryAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyTheoryAttribute.cs index 7eef533ff5a4..033d688f05a6 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyTheoryAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildAndWindowsOnlyTheoryAttribute.cs @@ -1,14 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class CoreMSBuildAndWindowsOnlyTheoryAttribute : TheoryAttribute { - public CoreMSBuildAndWindowsOnlyTheoryAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public CoreMSBuildAndWindowsOnlyTheoryAttribute() { if (SdkTestContext.Current.ToolsetUnderTest.ShouldUseFullFrameworkMSBuild || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyFactAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyFactAttribute.cs index 4ba1ef85ae9b..cd9c7efd0472 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyFactAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyFactAttribute.cs @@ -1,14 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class CoreMSBuildOnlyFactAttribute : FactAttribute { - public CoreMSBuildOnlyFactAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public CoreMSBuildOnlyFactAttribute() { if (SdkTestContext.Current.ToolsetUnderTest.ShouldUseFullFrameworkMSBuild) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyTheoryAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyTheoryAttribute.cs index 67fc95f1f41f..729d2e6848ba 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyTheoryAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/CoreMSBuildOnlyTheoryAttribute.cs @@ -1,14 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class CoreMSBuildOnlyTheoryAttribute : TheoryAttribute { - public CoreMSBuildOnlyTheoryAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public CoreMSBuildOnlyTheoryAttribute() { if (SdkTestContext.Current.ToolsetUnderTest.ShouldUseFullFrameworkMSBuild) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyFactAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyFactAttribute.cs index a59f0473eed7..e9ce263c7325 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyFactAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyFactAttribute.cs @@ -1,14 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class FullMSBuildOnlyFactAttribute : FactAttribute { - public FullMSBuildOnlyFactAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public FullMSBuildOnlyFactAttribute() { if (!SdkTestContext.Current.ToolsetUnderTest.ShouldUseFullFrameworkMSBuild) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyTheoryAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyTheoryAttribute.cs index d4435af75e75..e1f35bf516bf 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyTheoryAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/FullMSBuildOnlyTheoryAttribute.cs @@ -1,14 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class FullMSBuildOnlyTheoryAttribute : TheoryAttribute { - public FullMSBuildOnlyTheoryAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public FullMSBuildOnlyTheoryAttribute() { if (!SdkTestContext.Current.ToolsetUnderTest.ShouldUseFullFrameworkMSBuild) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/MacOsOnlyFactAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/MacOsOnlyFactAttribute.cs index d007a9b3110d..c3eabb4b9065 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/MacOsOnlyFactAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/MacOsOnlyFactAttribute.cs @@ -1,14 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class MacOsOnlyFactAttribute : FactAttribute { - public MacOsOnlyFactAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public MacOsOnlyFactAttribute() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificFact.cs b/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificFact.cs index 6089d870179e..bb521f0a1ffe 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificFact.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificFact.cs @@ -1,30 +1,59 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { /// /// Controls which platforms and architectures a test should run on or be skipped on. - /// specifies platforms to include (run on). - /// Optional parameters provide additional skip-based filtering. + /// The constructor parameter specifies platforms to include (run on). + /// Named properties , , and + /// provide additional filtering. /// public class PlatformSpecificFact : FactAttribute { internal const Architecture NoArchitectureFilter = (Architecture)(-1); - public PlatformSpecificFact( - TestPlatforms platforms = TestPlatforms.Any, - TestPlatforms skipPlatforms = 0, - Architecture architecture = NoArchitectureFilter, - Architecture skipArchitecture = NoArchitectureFilter, - string? skipReason = null, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + private readonly TestPlatforms _platforms; + private string? _skip; + + public PlatformSpecificFact() : this(TestPlatforms.Any) + { + } + + public PlatformSpecificFact(TestPlatforms platforms) + { + _platforms = platforms; + } + + /// + /// Platforms to skip on, even if included by the constructor parameter. + /// When is also set, both must match for the test to be skipped. + /// + public TestPlatforms SkipPlatforms { get; set; } + + /// + /// Restrict the test to run only on this process architecture. + /// Tests on other architectures are skipped. + /// + public Architecture Architecture { get; set; } = NoArchitectureFilter; + + /// + /// Architecture to skip on. When is also set, + /// both must match for the test to be skipped. When used alone, skips on the + /// specified architecture regardless of platform. + /// + public Architecture SkipArchitecture { get; set; } = NoArchitectureFilter; + + /// + /// Reason or tracking issue URL for why the test is skipped. + /// Used as the Skip message when a skip condition matches. + /// + public string? SkipReason { get; set; } + + public override string? Skip { - Skip = EvaluateSkip(platforms, skipPlatforms, architecture, skipArchitecture, skipReason); + get => _skip ?? EvaluateSkip(_platforms, SkipPlatforms, Architecture, SkipArchitecture, SkipReason); + set => _skip = value; } internal static string? EvaluateSkip( diff --git a/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificTheory.cs b/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificTheory.cs index d44e14e1115d..81a1cd634e7e 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificTheory.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/PlatformSpecificTheory.cs @@ -1,27 +1,42 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { /// /// Controls which platforms and architectures a theory should run on or be skipped on. - /// See for full documentation on the filtering parameters. + /// See for full documentation on the filtering properties. /// public class PlatformSpecificTheory : TheoryAttribute { - public PlatformSpecificTheory( - TestPlatforms platforms = TestPlatforms.Any, - TestPlatforms skipPlatforms = 0, - Architecture architecture = PlatformSpecificFact.NoArchitectureFilter, - Architecture skipArchitecture = PlatformSpecificFact.NoArchitectureFilter, - string? skipReason = null, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + private readonly TestPlatforms _platforms; + private string? _skip; + + public PlatformSpecificTheory() : this(TestPlatforms.Any) + { + } + + public PlatformSpecificTheory(TestPlatforms platforms) + { + _platforms = platforms; + } + + /// + public TestPlatforms SkipPlatforms { get; set; } + + /// + public Architecture Architecture { get; set; } = PlatformSpecificFact.NoArchitectureFilter; + + /// + public Architecture SkipArchitecture { get; set; } = PlatformSpecificFact.NoArchitectureFilter; + + /// + public string? SkipReason { get; set; } + + public override string? Skip { - Skip = PlatformSpecificFact.EvaluateSkip(platforms, skipPlatforms, architecture, skipArchitecture, skipReason); + get => _skip ?? PlatformSpecificFact.EvaluateSkip(_platforms, SkipPlatforms, Architecture, SkipArchitecture, SkipReason); + set => _skip = value; } } } diff --git a/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionFactAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionFactAttribute.cs index cdc9ba84f4d5..ae359363546e 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionFactAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionFactAttribute.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class RequiresMSBuildVersionFactAttribute : FactAttribute @@ -12,8 +10,7 @@ public class RequiresMSBuildVersionFactAttribute : FactAttribute /// public string? Reason { get; set; } - public RequiresMSBuildVersionFactAttribute(string version, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public RequiresMSBuildVersionFactAttribute(string version) { RequiresMSBuildVersionTheoryAttribute.CheckForRequiredMSBuildVersion(this, version); } diff --git a/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionTheoryAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionTheoryAttribute.cs index 23f9bd6138ac..b09480505f59 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionTheoryAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/RequiresMSBuildVersionTheoryAttribute.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class RequiresMSBuildVersionTheoryAttribute : TheoryAttribute @@ -12,8 +10,7 @@ public class RequiresMSBuildVersionTheoryAttribute : TheoryAttribute /// public string? Reason { get; set; } - public RequiresMSBuildVersionTheoryAttribute(string version, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public RequiresMSBuildVersionTheoryAttribute(string version) { CheckForRequiredMSBuildVersion(this, version); } diff --git a/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkFactAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkFactAttribute.cs index d3a76dcd5e97..8bbd151553f6 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkFactAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkFactAttribute.cs @@ -3,14 +3,11 @@ #if NETCOREAPP -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class RequiresSpecificFrameworkFactAttribute : FactAttribute { - public RequiresSpecificFrameworkFactAttribute(string framework, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public RequiresSpecificFrameworkFactAttribute(string framework) { if (!EnvironmentInfo.SupportsTargetFramework(framework)) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkTheoryAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkTheoryAttribute.cs index 95d563362313..65550cdb8879 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkTheoryAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/RequiresSpecificFrameworkTheoryAttribute.cs @@ -1,17 +1,15 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #if NETCOREAPP -using System.Runtime.CompilerServices; using Microsoft.DotNet.Tools.Test.Utilities; namespace Microsoft.NET.TestFramework { public class RequiresSpecificFrameworkTheoryAttribute : TheoryAttribute { - public RequiresSpecificFrameworkTheoryAttribute(string framework, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public RequiresSpecificFrameworkTheoryAttribute(string framework) { if (!EnvironmentInfo.SupportsTargetFramework(framework)) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionFactAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionFactAttribute.cs index 55df869206e1..73121c780841 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionFactAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionFactAttribute.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class WindowsOnlyRequiresMSBuildVersionFactAttribute : FactAttribute @@ -11,9 +9,8 @@ public class WindowsOnlyRequiresMSBuildVersionFactAttribute : FactAttribute /// Gets or sets the reason for potentially skipping the test if conditions are not met. /// public string? Reason { get; set; } - - public WindowsOnlyRequiresMSBuildVersionFactAttribute(string version, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + + public WindowsOnlyRequiresMSBuildVersionFactAttribute(string version) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionTheoryAttribute.cs b/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionTheoryAttribute.cs index 2b8664843cfe..f9c43590585e 100644 --- a/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionTheoryAttribute.cs +++ b/test/Microsoft.NET.TestFramework/Attributes/WindowsOnlyRequiresMSBuildVersionTheoryAttribute.cs @@ -1,14 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.TestFramework { public class WindowsOnlyRequiresMSBuildVersionTheoryAttribute : TheoryAttribute { - public WindowsOnlyRequiresMSBuildVersionTheoryAttribute(string version, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public WindowsOnlyRequiresMSBuildVersionTheoryAttribute(string version) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs index 6de64a06b733..012d4bb1d797 100644 --- a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs +++ b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs @@ -3,9 +3,6 @@ using System.Diagnostics; using Microsoft.DotNet.Cli.Utils; -#if NET -using System.Runtime.InteropServices; -#endif namespace Microsoft.NET.TestFramework.Commands { @@ -19,8 +16,6 @@ public class SdkCommandSpec public bool RedirectStandardInput { get; set; } public bool DisableOutputAndErrorRedirection { get; set; } - public bool CreateNewProcessGroup { get; set; } - private string EscapeArgs() { // Note: this doesn't handle invoking .cmd files via "cmd /c" on Windows, which probably won't be necessary here @@ -61,13 +56,6 @@ public ProcessStartInfo ToProcessStartInfo(bool doNotEscapeArguments = false) ret.WorkingDirectory = WorkingDirectory; } -#if NET - if (CreateNewProcessGroup && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - ret.CreateNewProcessGroup = true; - } -#endif - return ret; } } diff --git a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs index 499a4f993b35..f0187fed7b8b 100644 --- a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs +++ b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs @@ -18,12 +18,6 @@ public abstract class TestCommand public bool RedirectStandardInput { get; set; } public bool DisableOutputAndErrorRedirection { get; set; } - /// - /// When true, the child process is launched in a new process group so that - /// console signals (e.g. Ctrl+C) sent to it do not propagate to the test host. - /// - public bool CreateNewProcessGroup { get; set; } - // These only work via Execute(), not when using GetProcessStartInfo() public Action? CommandOutputHandler { get; set; } public Action? ProcessStartedHandler { get; set; } @@ -116,7 +110,6 @@ private SdkCommandSpec CreateCommandSpec(IEnumerable args) commandSpec.RedirectStandardInput = RedirectStandardInput; commandSpec.DisableOutputAndErrorRedirection = DisableOutputAndErrorRedirection; - commandSpec.CreateNewProcessGroup = CreateNewProcessGroup; return commandSpec; } @@ -204,7 +197,7 @@ public virtual CommandResult Execute(IEnumerable args) public static void LogCommandResult(ITestOutputHelper log, CommandResult result) { log.WriteLine($"> {result.StartInfo.FileName} {result.StartInfo.Arguments}"); - log.WriteLine(result.StdOut ?? string.Empty); + log.WriteLine(result.StdOut); if (!string.IsNullOrEmpty(result.StdErr)) { diff --git a/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj b/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj index 6aea55861808..6751b7251938 100644 --- a/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj +++ b/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj @@ -60,13 +60,12 @@ --> - + - - + @@ -76,6 +75,7 @@ + diff --git a/test/Microsoft.NET.TestFramework/SharedTestOutputHelper.cs b/test/Microsoft.NET.TestFramework/SharedTestOutputHelper.cs index c7f1a510fbbd..d3591610f624 100644 --- a/test/Microsoft.NET.TestFramework/SharedTestOutputHelper.cs +++ b/test/Microsoft.NET.TestFramework/SharedTestOutputHelper.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Xunit.Sdk; -using Xunit.v3; namespace Microsoft.NET.TestFramework; @@ -13,38 +12,19 @@ namespace Microsoft.NET.TestFramework; public class SharedTestOutputHelper : ITestOutputHelper { private readonly IMessageSink _sink; - private readonly StringBuilder _output = new(); public SharedTestOutputHelper(IMessageSink sink) { _sink = sink; } - public string Output => _output.ToString(); - - public void Write(string message) - { - _output.Append(message); - _sink.OnMessage(new DiagnosticMessage(message)); - } - - public void Write(string format, params object[] args) - { - var formatted = string.Format(format, args); - _output.Append(formatted); - _sink.OnMessage(new DiagnosticMessage(formatted)); - } - public void WriteLine(string message) { - _output.AppendLine(message); _sink.OnMessage(new DiagnosticMessage(message)); } public void WriteLine(string format, params object[] args) { - var formatted = string.Format(format, args); - _output.AppendLine(formatted); - _sink.OnMessage(new DiagnosticMessage(formatted)); + _sink.OnMessage(new DiagnosticMessage(format, args)); } } diff --git a/test/Microsoft.NET.TestFramework/StringTestLogger.cs b/test/Microsoft.NET.TestFramework/StringTestLogger.cs index 1d08f176332b..a5323123fba4 100644 --- a/test/Microsoft.NET.TestFramework/StringTestLogger.cs +++ b/test/Microsoft.NET.TestFramework/StringTestLogger.cs @@ -7,18 +7,6 @@ public class StringTestLogger : ITestOutputHelper { StringBuilder _stringBuilder = new(); - public string Output => _stringBuilder.ToString(); - - public void Write(string message) - { - _stringBuilder.Append(message); - } - - public void Write(string format, params object[] args) - { - _stringBuilder.Append(string.Format(format, args)); - } - public void WriteLine(string message) { _stringBuilder.AppendLine(message); diff --git a/test/Microsoft.NET.TestFramework/TestLoggerFactory.cs b/test/Microsoft.NET.TestFramework/TestLoggerFactory.cs index 02982eb098a9..40ec82ed3041 100644 --- a/test/Microsoft.NET.TestFramework/TestLoggerFactory.cs +++ b/test/Microsoft.NET.TestFramework/TestLoggerFactory.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Logging; -using Xunit.Sdk; namespace Microsoft.NET.TestFramework { diff --git a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj index b1e1394cf095..bf6139c9af64 100644 --- a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj +++ b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj @@ -16,10 +16,10 @@ - + - - + + diff --git a/test/Microsoft.Win32.Msi.Manual.Tests/Microsoft.Win32.Msi.Manual.Tests.csproj b/test/Microsoft.Win32.Msi.Manual.Tests/Microsoft.Win32.Msi.Manual.Tests.csproj index c13835ee50fe..a1465b5f3026 100644 --- a/test/Microsoft.Win32.Msi.Manual.Tests/Microsoft.Win32.Msi.Manual.Tests.csproj +++ b/test/Microsoft.Win32.Msi.Manual.Tests/Microsoft.Win32.Msi.Manual.Tests.csproj @@ -22,5 +22,6 @@ + diff --git a/test/Msbuild.Tests.Utilities/Msbuild.Tests.Utilities.csproj b/test/Msbuild.Tests.Utilities/Msbuild.Tests.Utilities.csproj index 889e80b3c357..9df4924b8ce4 100644 --- a/test/Msbuild.Tests.Utilities/Msbuild.Tests.Utilities.csproj +++ b/test/Msbuild.Tests.Utilities/Msbuild.Tests.Utilities.csproj @@ -16,6 +16,7 @@ + diff --git a/test/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj b/test/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj index 349ac13587c3..b5959c334766 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj +++ b/test/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj @@ -10,7 +10,8 @@ - + + diff --git a/test/UnitTests.proj b/test/UnitTests.proj index 0920f1672a93..2eef751199e5 100644 --- a/test/UnitTests.proj +++ b/test/UnitTests.proj @@ -114,7 +114,6 @@ - @@ -142,7 +141,7 @@ . $HELIX_CORRELATION_PAYLOAD/t/SetupHelixEnvironment.sh;$(HelixPreCommands) PowerShell -ExecutionPolicy ByPass "dotnet nuget locals all -l | ForEach-Object { $_.Split(' ')[1]} | Where-Object{$_ -like '*cache'} | Get-ChildItem -Recurse -File -Filter '*.dat' | Measure";$(HelixPostCommands) PowerShell -ExecutionPolicy ByPass "Get-ChildItem -Recurse -File -Filter '*hangdump.dmp' | Copy-Item -Destination $env:HELIX_WORKITEM_UPLOAD_ROOT";$(HelixPostCommands) - find "$HELIX_WORKITEM_UPLOAD_ROOT/../../.." -name '*hangdump.dmp' -print0 | xargs -0 -I@ cp @ "$HELIX_WORKITEM_UPLOAD_ROOT";$(HelixPostCommands) + find "$HELIX_WORKITEM_UPLOAD_ROOT/../../.." -name '*hangdump.dmp' -exec cp {} "$HELIX_WORKITEM_UPLOAD_ROOT" \;;$(HelixPostCommands) $(Version) $(RepoRoot)artifacts\bin\Microsoft.DotNet.MSBuildSdkResolver $(RepoRoot)artifacts\tmp\HelixStage0.tar.gz diff --git a/test/dotnet-format.UnitTests/Analyzers/ThirdPartyAnalyzerFormatterTests.cs b/test/dotnet-format.UnitTests/Analyzers/ThirdPartyAnalyzerFormatterTests.cs index 939463a91d90..07fae27e9946 100644 --- a/test/dotnet-format.UnitTests/Analyzers/ThirdPartyAnalyzerFormatterTests.cs +++ b/test/dotnet-format.UnitTests/Analyzers/ThirdPartyAnalyzerFormatterTests.cs @@ -26,7 +26,7 @@ public ThirdPartyAnalyzerFormatterTests(ITestOutputHelper output) TestOutputHelper = output; } - public async ValueTask InitializeAsync() + public async Task InitializeAsync() { var logger = new TestLogger(); @@ -52,11 +52,11 @@ public async ValueTask InitializeAsync() } } - public ValueTask DisposeAsync() + public Task DisposeAsync() { _analyzerReferencesProject = null; - return ValueTask.CompletedTask; + return Task.CompletedTask; } private IEnumerable GetAnalyzerReferences(string prefix) diff --git a/test/dotnet-format.UnitTests/XUnit/ConditionalFactAttribute.cs b/test/dotnet-format.UnitTests/XUnit/ConditionalFactAttribute.cs index c7434c6c9d1e..e887376ef1cf 100644 --- a/test/dotnet-format.UnitTests/XUnit/ConditionalFactAttribute.cs +++ b/test/dotnet-format.UnitTests/XUnit/ConditionalFactAttribute.cs @@ -1,8 +1,6 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.CodeAnalysis.Tools.Tests.XUnit { public class ConditionalFactAttribute : FactAttribute @@ -13,7 +11,7 @@ public class ConditionalFactAttribute : FactAttribute /// skipped vs. conditionally skipped which is the entire point of this attribute. /// [Obsolete("ConditionalFact should use Reason or AlwaysSkip", error: true)] - public new string? Skip + public new string Skip { get => base.Skip; set => base.Skip = value; @@ -23,7 +21,7 @@ public class ConditionalFactAttribute : FactAttribute /// Used to unconditionally Skip a test. For the rare occasion when a conditional test needs to be /// unconditionally skipped (typically short term for a bug to be fixed). /// - public string? AlwaysSkip + public string AlwaysSkip { get => base.Skip; set => base.Skip = value; @@ -31,8 +29,7 @@ public string? AlwaysSkip public string? Reason { get; set; } - public ConditionalFactAttribute(Type[] skipConditions, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public ConditionalFactAttribute(params Type[] skipConditions) { foreach (var skipCondition in skipConditions) { @@ -54,7 +51,7 @@ public class ConditionalTheoryAttribute : TheoryAttribute /// skipped vs. conditionally skipped which is the entire point of this attribute. /// [Obsolete("ConditionalTheory should use Reason or AlwaysSkip")] - public new string? Skip + public new string Skip { get => base.Skip; set => base.Skip = value; @@ -64,7 +61,7 @@ public class ConditionalTheoryAttribute : TheoryAttribute /// Used to unconditionally Skip a test. For the rare occasion when a conditional test needs to be /// unconditionally skipped (typically short term for a bug to be fixed). /// - public string? AlwaysSkip + public string AlwaysSkip { get => base.Skip; set => base.Skip = value; @@ -72,8 +69,7 @@ public string? AlwaysSkip public string? Reason { get; set; } - public ConditionalTheoryAttribute(Type[] skipConditions, [CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public ConditionalTheoryAttribute(params Type[] skipConditions) { foreach (var skipCondition in skipConditions) { diff --git a/test/dotnet-format.UnitTests/XUnit/MSBuildFactAttribute.cs b/test/dotnet-format.UnitTests/XUnit/MSBuildFactAttribute.cs index 97caeb8011b7..384f87887586 100644 --- a/test/dotnet-format.UnitTests/XUnit/MSBuildFactAttribute.cs +++ b/test/dotnet-format.UnitTests/XUnit/MSBuildFactAttribute.cs @@ -1,34 +1,17 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection; -using System.Runtime.CompilerServices; -using Microsoft.CodeAnalysis.Tools.Workspaces; -using Xunit.v3; +using Xunit.Sdk; namespace Microsoft.CodeAnalysis.Tools.Tests.XUnit { [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public sealed class MSBuildFactAttribute : ConditionalFactAttribute, IBeforeAfterTestAttribute + [XunitTestCaseDiscoverer("Microsoft.CodeAnalysis.Tools.Tests.XUnit.MSBuildFactDiscoverer", "dotnet-format.UnitTests")] + public sealed class MSBuildFactAttribute : ConditionalFactAttribute { public MSBuildFactAttribute(params Type[] skipConditions) : base(skipConditions) { } - - public MSBuildFactAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(Array.Empty(), sourceFilePath, sourceLineNumber) - { - } - - public void Before(MethodInfo methodUnderTest, IXunitTest test) - { - MSBuildWorkspaceLoader.Guard.Wait(); - } - - public void After(MethodInfo methodUnderTest, IXunitTest test) - { - MSBuildWorkspaceLoader.Guard.Release(); - } } } diff --git a/test/dotnet-format.UnitTests/XUnit/MSBuildFactDiscoverer.cs b/test/dotnet-format.UnitTests/XUnit/MSBuildFactDiscoverer.cs new file mode 100644 index 000000000000..6262d6d78a66 --- /dev/null +++ b/test/dotnet-format.UnitTests/XUnit/MSBuildFactDiscoverer.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit.Sdk; + +namespace Microsoft.CodeAnalysis.Tools.Tests.XUnit +{ + + public sealed class MSBuildFactDiscoverer : IXunitTestCaseDiscoverer + { + private readonly FactDiscoverer _factDiscoverer; + + public MSBuildFactDiscoverer(IMessageSink diagnosticMessageSink) + { + _factDiscoverer = new FactDiscoverer(diagnosticMessageSink); + } + + public IEnumerable Discover( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo factAttribute) + { + return _factDiscoverer + .Discover(discoveryOptions, testMethod, factAttribute) + .Select(testCase => new MSBuildTestCase(testCase)); + } + } +} diff --git a/test/dotnet-format.UnitTests/XUnit/MSBuildTestCase.cs b/test/dotnet-format.UnitTests/XUnit/MSBuildTestCase.cs new file mode 100644 index 000000000000..0104480fdff9 --- /dev/null +++ b/test/dotnet-format.UnitTests/XUnit/MSBuildTestCase.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Diagnostics; +using Microsoft.CodeAnalysis.Tools.Workspaces; +using Xunit.Sdk; + +namespace Microsoft.CodeAnalysis.Tools.Tests.XUnit +{ + [DebuggerDisplay(@"\{ class = {TestMethod.TestClass.Class.Name}, method = {TestMethod.Method.Name}, display = {DisplayName}, skip = {SkipReason} \}")] + public sealed class MSBuildTestCase : Xunit.LongLivedMarshalByRefObject, IXunitTestCase + { + private IXunitTestCase _testCase; + + public string DisplayName => _testCase.DisplayName; + public IMethodInfo Method => _testCase.Method; + public string SkipReason => _testCase.SkipReason; + public ITestMethod TestMethod => _testCase.TestMethod; + public object[] TestMethodArguments => _testCase.TestMethodArguments; + public Dictionary> Traits => _testCase.Traits; + public string UniqueID => _testCase.UniqueID; + + public ISourceInformation SourceInformation + { + get => _testCase.SourceInformation; + set => _testCase.SourceInformation = value; + } + + public Exception InitializationException => _testCase.InitializationException; + + public int Timeout => _testCase.Timeout; + + public MSBuildTestCase(IXunitTestCase testCase) + { + _testCase = testCase ?? throw new ArgumentNullException(nameof(testCase)); + } + + [Obsolete("Called by the deserializer", error: true)] + public MSBuildTestCase() { } + + public async Task RunAsync( + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + await MSBuildWorkspaceLoader.Guard.WaitAsync(); + try + { + var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, TestMethodArguments, messageBus, aggregator, cancellationTokenSource); + return await runner.RunAsync(); + } + finally + { + MSBuildWorkspaceLoader.Guard.Release(); + } + } + + public void Deserialize(IXunitSerializationInfo info) + { + _testCase = info.GetValue("InnerTestCase"); + } + + public void Serialize(IXunitSerializationInfo info) + { + info.AddValue("InnerTestCase", _testCase); + } + } +} diff --git a/test/dotnet-format.UnitTests/XUnit/MSBuildTheoryAttribute.cs b/test/dotnet-format.UnitTests/XUnit/MSBuildTheoryAttribute.cs index abf7c371c024..27b9f39b4d44 100644 --- a/test/dotnet-format.UnitTests/XUnit/MSBuildTheoryAttribute.cs +++ b/test/dotnet-format.UnitTests/XUnit/MSBuildTheoryAttribute.cs @@ -1,33 +1,17 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection; -using System.Runtime.CompilerServices; -using Microsoft.CodeAnalysis.Tools.Workspaces; -using Xunit.v3; +using Xunit.Sdk; namespace Microsoft.CodeAnalysis.Tools.Tests.XUnit { [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public sealed class MSBuildTheoryAttribute : ConditionalTheoryAttribute, IBeforeAfterTestAttribute + [XunitTestCaseDiscoverer("Microsoft.CodeAnalysis.Tools.Tests.XUnit.MSBuildTheoryDiscoverer", "dotnet-format.UnitTests")] + public sealed class MSBuildTheoryAttribute : ConditionalTheoryAttribute { - public MSBuildTheoryAttribute(params Type[] skipConditions) : base(skipConditions) + public MSBuildTheoryAttribute(params Type[] skipConditions) + : base(skipConditions) { } - - public MSBuildTheoryAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = 0) - : base(Array.Empty(), sourceFilePath, sourceLineNumber) - { - } - - public void Before(MethodInfo methodUnderTest, IXunitTest test) - { - MSBuildWorkspaceLoader.Guard.Wait(); - } - - public void After(MethodInfo methodUnderTest, IXunitTest test) - { - MSBuildWorkspaceLoader.Guard.Release(); - } } } diff --git a/test/dotnet-format.UnitTests/XUnit/MSBuildTheoryDiscoverer.cs b/test/dotnet-format.UnitTests/XUnit/MSBuildTheoryDiscoverer.cs new file mode 100644 index 000000000000..22ce8042aaf7 --- /dev/null +++ b/test/dotnet-format.UnitTests/XUnit/MSBuildTheoryDiscoverer.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit.Sdk; + +namespace Microsoft.CodeAnalysis.Tools.Tests.XUnit +{ + + public sealed class MSBuildTheoryDiscoverer : IXunitTestCaseDiscoverer + { + private readonly TheoryDiscoverer _theoryDiscoverer; + + public MSBuildTheoryDiscoverer(IMessageSink diagnosticMessageSink) + { + _theoryDiscoverer = new TheoryDiscoverer(diagnosticMessageSink); + } + + public IEnumerable Discover( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo factAttribute) + { + return _theoryDiscoverer + .Discover(discoveryOptions, testMethod, factAttribute) + .Select(testCase => new MSBuildTestCase(testCase)); + } + } +} diff --git a/test/dotnet-new.IntegrationTests/Diagnostic/DiagnosticFixture.cs b/test/dotnet-new.IntegrationTests/Diagnostic/DiagnosticFixture.cs index 2eab6d6d2b9f..df772d9846de 100644 --- a/test/dotnet-new.IntegrationTests/Diagnostic/DiagnosticFixture.cs +++ b/test/dotnet-new.IntegrationTests/Diagnostic/DiagnosticFixture.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Xunit.Sdk; - namespace Microsoft.DotNet.Cli.New.IntegrationTests { public class DiagnosticFixture diff --git a/test/dotnet-new.IntegrationTests/Diagnostic/XunitNuGetLogger.cs b/test/dotnet-new.IntegrationTests/Diagnostic/XunitNuGetLogger.cs index af6202fb527b..b25feb42326f 100644 --- a/test/dotnet-new.IntegrationTests/Diagnostic/XunitNuGetLogger.cs +++ b/test/dotnet-new.IntegrationTests/Diagnostic/XunitNuGetLogger.cs @@ -2,8 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using NuGet.Common; -using Xunit.Sdk; -using DiagnosticMessage = Xunit.v3.DiagnosticMessage; +using DiagnosticMessage = Xunit.Sdk.DiagnosticMessage; namespace Microsoft.DotNet.Cli.New.IntegrationTests { diff --git a/test/dotnet-new.IntegrationTests/DotnetNewDetailsTest.cs b/test/dotnet-new.IntegrationTests/DotnetNewDetailsTest.cs index 364e95c6580c..80fa07d8d410 100644 --- a/test/dotnet-new.IntegrationTests/DotnetNewDetailsTest.cs +++ b/test/dotnet-new.IntegrationTests/DotnetNewDetailsTest.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Xunit.Sdk; - namespace Microsoft.DotNet.Cli.New.IntegrationTests { public partial class DotnetNewDetailsTest : BaseIntegrationTest, IClassFixture diff --git a/test/dotnet-new.IntegrationTests/DotnetNewHelpTests.Approval.cs b/test/dotnet-new.IntegrationTests/DotnetNewHelpTests.Approval.cs index 974ef1352b55..464fc117c1c4 100644 --- a/test/dotnet-new.IntegrationTests/DotnetNewHelpTests.Approval.cs +++ b/test/dotnet-new.IntegrationTests/DotnetNewHelpTests.Approval.cs @@ -208,16 +208,12 @@ public Task CannotShowHelpForTemplate_FullNameMatch() [Fact] public Task CannotShowHelpForTemplate_WhenAmbiguousLanguageChoice() { - // Use a dedicated home directory to avoid conflicts with other tests that install - // templates with the same 'basic' short name. Tests are not guaranteed to execute - // in declared order. string workingDirectory = CreateTemporaryFolder(); - string homeDirectory = CreateTemporaryFolder("Home"); - InstallTestTemplate("TemplateResolution/DifferentLanguagesGroup/BasicFSharp", _log, homeDirectory, workingDirectory); - InstallTestTemplate("TemplateResolution/DifferentLanguagesGroup/BasicVB", _log, homeDirectory, workingDirectory); + InstallTestTemplate("TemplateResolution/DifferentLanguagesGroup/BasicFSharp", _log, _fixture.HomeDirectory, workingDirectory); + InstallTestTemplate("TemplateResolution/DifferentLanguagesGroup/BasicVB", _log, _fixture.HomeDirectory, workingDirectory); CommandResult commandResult = new DotnetNewCommand(_log, "basic", "--help") - .WithCustomHive(homeDirectory) + .WithCustomHive(_fixture.HomeDirectory) .WithWorkingDirectory(workingDirectory) .Execute(); @@ -397,15 +393,11 @@ public Task CanShowHelpForTemplate_ConditionalParams() [Fact] public Task CanShowHelpForTemplateWhenRequiredParamIsMissed() { - // Use a dedicated home directory to avoid conflicts with other tests that install - // templates with the same 'basic' short name. Tests are not guaranteed to execute - // in declared order. string workingDirectory = CreateTemporaryFolder(); - string homeDirectory = CreateTemporaryFolder("Home"); - InstallTestTemplate($"TemplateResolution/MissedRequiredParameter/BasicTemplate1", _log, homeDirectory, workingDirectory); + InstallTestTemplate($"TemplateResolution/MissedRequiredParameter/BasicTemplate1", _log, _fixture.HomeDirectory, workingDirectory); CommandResult commandResult = new DotnetNewCommand(_log, "basic", "--help") - .WithCustomHive(homeDirectory) + .WithCustomHive(_fixture.HomeDirectory) .WithWorkingDirectory(workingDirectory) .Execute(); diff --git a/test/dotnet-new.IntegrationTests/DotnetNewInstallTests.cs b/test/dotnet-new.IntegrationTests/DotnetNewInstallTests.cs index d8c4a5d28d58..c88f7e31f49b 100644 --- a/test/dotnet-new.IntegrationTests/DotnetNewInstallTests.cs +++ b/test/dotnet-new.IntegrationTests/DotnetNewInstallTests.cs @@ -5,8 +5,7 @@ using System.Text.RegularExpressions; using Microsoft.DotNet.Cli.Utils; using Microsoft.TemplateEngine.TestHelper; -using Xunit.Sdk; -using DiagnosticMessage = Xunit.v3.DiagnosticMessage; +using DiagnosticMessage = Xunit.Sdk.DiagnosticMessage; namespace Microsoft.DotNet.Cli.New.IntegrationTests { diff --git a/test/dotnet-new.IntegrationTests/DotnetNewTestTemplatesTests.cs b/test/dotnet-new.IntegrationTests/DotnetNewTestTemplatesTests.cs index a61bbcc94336..2e6128de171b 100644 --- a/test/dotnet-new.IntegrationTests/DotnetNewTestTemplatesTests.cs +++ b/test/dotnet-new.IntegrationTests/DotnetNewTestTemplatesTests.cs @@ -48,12 +48,6 @@ public static class Languages private class NullTestOutputHelper : ITestOutputHelper { - public string Output => string.Empty; - - public void Write(string message) { } - - public void Write(string format, params object[] args) { } - public void WriteLine(string message) { } public void WriteLine(string format, params object[] args) { } diff --git a/test/dotnet-new.IntegrationTests/SharedHomeDirectory.cs b/test/dotnet-new.IntegrationTests/SharedHomeDirectory.cs index fd946bf2aa4d..e7b91db01f32 100644 --- a/test/dotnet-new.IntegrationTests/SharedHomeDirectory.cs +++ b/test/dotnet-new.IntegrationTests/SharedHomeDirectory.cs @@ -1,7 +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 Xunit.Sdk; +using SharedTestOutputHelper = Microsoft.TemplateEngine.TestHelper.SharedTestOutputHelper; namespace Microsoft.DotNet.Cli.New.IntegrationTests { diff --git a/test/dotnet-new.IntegrationTests/TemplateDiscoveryTests.cs b/test/dotnet-new.IntegrationTests/TemplateDiscoveryTests.cs index c65e813bd950..a87b0fc3cbc2 100644 --- a/test/dotnet-new.IntegrationTests/TemplateDiscoveryTests.cs +++ b/test/dotnet-new.IntegrationTests/TemplateDiscoveryTests.cs @@ -17,7 +17,7 @@ public TemplateDiscoveryTests(ITestOutputHelper log, TemplateDiscoveryTool templ } #pragma warning disable xUnit1004 // Test methods should not be skipped - [PlatformSpecificFact(skipPlatforms: TestPlatforms.OSX, skipArchitecture: Architecture.Arm64, skipReason: "https://github.com/dotnet/sdk/issues/53569")] + [PlatformSpecificFact(SkipPlatforms = TestPlatforms.OSX, SkipArchitecture = Architecture.Arm64, SkipReason = "https://github.com/dotnet/sdk/issues/53569")] #pragma warning restore xUnit1004 // Test methods should not be skipped public async Task CanRunDiscoveryTool() { diff --git a/test/dotnet-new.IntegrationTests/TemplateDiscoveryTool.cs b/test/dotnet-new.IntegrationTests/TemplateDiscoveryTool.cs index 9d2d0a86a677..48034a63a83c 100644 --- a/test/dotnet-new.IntegrationTests/TemplateDiscoveryTool.cs +++ b/test/dotnet-new.IntegrationTests/TemplateDiscoveryTool.cs @@ -1,7 +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 Xunit.Sdk; +using SharedTestOutputHelper = Microsoft.TemplateEngine.TestHelper.SharedTestOutputHelper; namespace Microsoft.DotNet.Cli.New.IntegrationTests { diff --git a/test/dotnet-new.IntegrationTests/WebProjectsTests.cs b/test/dotnet-new.IntegrationTests/WebProjectsTests.cs index 59cc08e55c0c..7cd274107a21 100644 --- a/test/dotnet-new.IntegrationTests/WebProjectsTests.cs +++ b/test/dotnet-new.IntegrationTests/WebProjectsTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.DotNet.Cli.Utils; -using Xunit.Sdk; namespace Microsoft.DotNet.Cli.New.IntegrationTests { diff --git a/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj b/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj index e87d35c501b9..06014f7dfcda 100644 --- a/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj +++ b/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj @@ -13,10 +13,10 @@ - + - - + + diff --git a/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs b/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs index 00e66f1f7762..3bbbeddb34d3 100644 --- a/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs +++ b/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs @@ -19,10 +19,10 @@ public DotNetWatchTestBase(ITestOutputHelper logger) TestAssets = new TestAssetsManager(App.Logger); } - public ValueTask InitializeAsync() - => default; + public Task InitializeAsync() + => Task.CompletedTask; - public async ValueTask DisposeAsync() + public async Task DisposeAsync() { Log("Disposing test"); await App.DisposeAsync(); diff --git a/test/dotnet-watch.Tests/TestUtilities/MSBuildFixture.cs b/test/dotnet-watch.Tests/TestUtilities/ModuleInitializer.cs similarity index 84% rename from test/dotnet-watch.Tests/TestUtilities/MSBuildFixture.cs rename to test/dotnet-watch.Tests/TestUtilities/ModuleInitializer.cs index 71a39705de97..84836479d581 100644 --- a/test/dotnet-watch.Tests/TestUtilities/MSBuildFixture.cs +++ b/test/dotnet-watch.Tests/TestUtilities/ModuleInitializer.cs @@ -2,20 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.Loader; using Microsoft.Build.Locator; -[assembly: AssemblyFixture(typeof(Microsoft.DotNet.Watch.UnitTests.MSBuildFixture))] - namespace Microsoft.DotNet.Watch.UnitTests; -/// -/// Assembly fixture that registers MSBuild and sets up assembly resolution for dotnet-watch tests. -/// A fixture is preferred over a [ModuleInitializer] because it doesn't get invoked for test discovery. -/// -public class MSBuildFixture +public static class ModuleInitializer { - public MSBuildFixture() + [ModuleInitializer] + public static void Initialize() { // Ensure that we load the msbuild binaries from redist deployment. Otherwise, msbuild might use target files // that do not match the implementations of the core tasks. diff --git a/test/dotnet.Tests/CommandTests/Package/Add/GivenDotnetPackageAdd.cs b/test/dotnet.Tests/CommandTests/Package/Add/GivenDotnetPackageAdd.cs index 102640211fe5..744f8bcd9856 100644 --- a/test/dotnet.Tests/CommandTests/Package/Add/GivenDotnetPackageAdd.cs +++ b/test/dotnet.Tests/CommandTests/Package/Add/GivenDotnetPackageAdd.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using Microsoft.DotNet.Cli.Commands; +using Xunit.Runners; namespace Microsoft.DotNet.Cli.Package.Add.Tests { @@ -378,7 +379,7 @@ public void FileBasedApp_NoVersion(string[] inputVersions, string? expectedVersi var file = Path.Join(testInstance.Path, "Program.cs"); var source = $""" - #:property RestoreAdditionalProjectSources={restoreSources} + #:property RestoreSources=$(RestoreSources);{restoreSources} Console.WriteLine(); """; File.WriteAllText(file, source); @@ -415,7 +416,7 @@ public void FileBasedApp_NoVersion_Prerelease(string[] inputVersions, string? _, var file = Path.Join(testInstance.Path, "Program.cs"); var source = $""" - #:property RestoreAdditionalProjectSources={restoreSources} + #:property RestoreSources=$(RestoreSources);{restoreSources} Console.WriteLine(); """; File.WriteAllText(file, source); @@ -636,7 +637,7 @@ public void FileBasedApp_CentralPackageManagement_NoVersionSpecified(bool legacy var file = Path.Join(testInstance.Path, "Program.cs"); var source = $""" - #:property RestoreAdditionalProjectSources={restoreSources} + #:property RestoreSources=$(RestoreSources);{restoreSources} Console.WriteLine(); """; File.WriteAllText(file, source); @@ -686,7 +687,7 @@ public void FileBasedApp_CentralPackageManagement_NoVersionSpecified_KeepExistin var file = Path.Join(testInstance.Path, "Program.cs"); var source = $""" - #:property RestoreAdditionalProjectSources={restoreSources} + #:property RestoreSources=$(RestoreSources);{restoreSources} #:package A Console.WriteLine(); """; diff --git a/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs b/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs index 7ebdcd079eb9..a7a188e14648 100644 --- a/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs +++ b/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs @@ -27,10 +27,6 @@ public void ItTerminatesWinExeAppWithCloseMainWindow() var command = new DotnetCommand(Log, "run") .WithWorkingDirectory(asset.Path); - // Launch dotnet run in a new process group so that GenerateConsoleCtrlEvent - // targets only the child group and does not propagate to the test host. - command.CreateNewProcessGroup = true; - bool signaled = false; bool sawClosingGracefully = false; Process child = null; diff --git a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTestfromCsproj.cs b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTestfromCsproj.cs index ac056bcb2fbe..6bc213d4a4e4 100644 --- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTestfromCsproj.cs +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTestfromCsproj.cs @@ -716,9 +716,7 @@ public void EnsureOutputPathEscaped(string flag) { var testProjectDirectory = CopyAndRestoreVSTestDotNetCoreTestApp([flag]); - // Use a unique subdirectory per flag to avoid conflicts between theory data rows. - // --diag creates a file, while --output and --results-directory create directories. - var pathWithComma = Path.Combine(AppContext.BaseDirectory, "a,b", flag.TrimStart('-')); + var pathWithComma = Path.Combine(AppContext.BaseDirectory, "a,b"); // Call test CommandResult result = new DotnetTestCommand(Log, disableNewOutput: true) diff --git a/test/dotnet.Tests/dotnet.Tests.csproj b/test/dotnet.Tests/dotnet.Tests.csproj index 658735a93ee6..e5655de02a0f 100644 --- a/test/dotnet.Tests/dotnet.Tests.csproj +++ b/test/dotnet.Tests/dotnet.Tests.csproj @@ -89,9 +89,9 @@ - - - + + + diff --git a/test/xunit-runner/XUnitRunner.targets b/test/xunit-runner/XUnitRunner.targets index dce3fb456e8d..03897ea63aab 100644 --- a/test/xunit-runner/XUnitRunner.targets +++ b/test/xunit-runner/XUnitRunner.targets @@ -4,20 +4,12 @@ $(SdkTargetFramework) $(SdkTargetFramework) - $(XUnitV3Version) + 2.4.1 <_SDKCustomXUnitPublishTargetsPath>$(MSBuildThisFileDirectory)XUnitPublish.targets -nocolor - - <_TestPublishRidProperties Condition="'$(TargetRid)' != ''">RuntimeIdentifier=$(TargetRid);SelfContained=false;ErrorOnDuplicatePublishOutputFiles=false - $(ArtifactsBinDir)HelixTasks\$(Configuration)\HelixTasks.dll @@ -45,7 +37,7 @@ Outputs="%(SDKCustomXUnitProject.Identity)%(SDKCustomXUnitProject.TargetFramework)%(SDKCustomXUnitProject.RuntimeTargetFramework)%(SDKCustomXUnitProject.AdditionalProperties)"> + Properties="CustomAfterMicrosoftCommonTargets=$(_SDKCustomXUnitPublishTargetsPath);%(SDKCustomXUnitProject.AdditionalProperties)"> @@ -61,16 +53,7 @@ <_CurrentRuntimeTargetFramework Condition="'$(_CurrentRuntimeTargetFramework)' == ''">$(SDKCustomXUnitRuntimeTargetFramework) <_CurrentAdditionalProperties>%(SDKCustomXUnitProject.AdditionalProperties) - - - - + @@ -94,7 +77,6 @@ diff --git a/test/xunit.runner.json b/test/xunit.runner.json index 650eda816f3b..1fca20845e33 100644 --- a/test/xunit.runner.json +++ b/test/xunit.runner.json @@ -1,5 +1,5 @@ { - "$schema": "https://xunit.net/schema/v3.1/xunit.runner.schema.json", + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", "diagnosticMessages": true, "longRunningTestSeconds": 20, "showLiveOutput": true, From 13e1f8376ab80c1543f02911c2041e3c3ba171e9 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Wed, 8 Apr 2026 22:25:24 +0000 Subject: [PATCH 2/7] Clear MSBuildSDKsPath for out-of-process test commands SdkTestContext.Initialize sets MSBuildSDKsPath in the test runner process for in-process MSBuild SDK resolution. This value was leaking to subprocess dotnet invocations (dotnet build, dotnet new, etc.) via environment variable inheritance, causing MSB4236 'SDK not found' errors when the inherited path didn't match the subprocess's SDK location. This was masked by the xUnit v3 migration (#52930) breaking Helix test discovery between Apr 4-8, during which time the OTel telemetry PR (#53181) merged and changed OptionalWorkloadProvider to also read MSBuildSDKsPath via GetMSBuildSDKsPath(). The combination caused widespread test failures once the xUnit v3 revert re-enabled test execution. The fix: - Set MSBuildSDKsPath to null in AddTestEnvironmentVariables, matching the existing pattern for MSBUILD_EXE_PATH (set for in-process, cleared for out-of-process). - Update SdkCommandSpec.ToProcessStartInfo to treat null environment values as 'remove this variable', so the subprocess dotnet resolves the SDK from its own AppContext.BaseDirectory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/SdkCommandSpec.cs | 9 ++++++++- test/Microsoft.NET.TestFramework/ToolsetInfo.cs | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs index 012d4bb1d797..36e712b6b5e0 100644 --- a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs +++ b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs @@ -44,7 +44,14 @@ public ProcessStartInfo ToProcessStartInfo(bool doNotEscapeArguments = false) }; foreach (var kvp in Environment) { - ret.Environment[kvp.Key] = kvp.Value; + if (kvp.Value is null) + { + ret.Environment.Remove(kvp.Key); + } + else + { + ret.Environment[kvp.Key] = kvp.Value; + } } foreach (var envToRemove in EnvironmentToRemove) { diff --git a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs index 41f076670c94..50f08c6d060a 100644 --- a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs +++ b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs @@ -204,9 +204,12 @@ public void AddTestEnvironmentVariables(IDictionary environment environment.Add("DOTNET_CLI_HOME", CliHomePath); } - // We set this environment variable for in-process tests, but we don't want it to flow to out of process tests - // (especially if we're trying to run on full Framework MSBuild) + // We set these environment variables for in-process tests, but we don't want them to flow to out of process tests. + // MSBUILD_EXE_PATH is set for in-process MSBuild; clearing it lets the subprocess dotnet find MSBuild on its own. + // MSBuildSDKsPath is set by SdkTestContext.Initialize for in-process MSBuild SDK resolution; if it leaks to + // subprocess dotnet invocations it can point to a non-existent path and cause MSB4236 "SDK not found" errors. environment[Constants.MSBUILD_EXE_PATH] = ""; + environment["MSBuildSDKsPath"] = null; } From 061cbdf8723046443ab729de9ccdd510847baa39 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Thu, 9 Apr 2026 01:07:48 +0000 Subject: [PATCH 3/7] Revert "Clear MSBuildSDKsPath for out-of-process test commands" This reverts commit 13e1f8376ab80c1543f02911c2041e3c3ba171e9. --- .../Commands/SdkCommandSpec.cs | 9 +-------- test/Microsoft.NET.TestFramework/ToolsetInfo.cs | 7 ++----- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs index 36e712b6b5e0..012d4bb1d797 100644 --- a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs +++ b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs @@ -44,14 +44,7 @@ public ProcessStartInfo ToProcessStartInfo(bool doNotEscapeArguments = false) }; foreach (var kvp in Environment) { - if (kvp.Value is null) - { - ret.Environment.Remove(kvp.Key); - } - else - { - ret.Environment[kvp.Key] = kvp.Value; - } + ret.Environment[kvp.Key] = kvp.Value; } foreach (var envToRemove in EnvironmentToRemove) { diff --git a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs index 50f08c6d060a..41f076670c94 100644 --- a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs +++ b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs @@ -204,12 +204,9 @@ public void AddTestEnvironmentVariables(IDictionary environment environment.Add("DOTNET_CLI_HOME", CliHomePath); } - // We set these environment variables for in-process tests, but we don't want them to flow to out of process tests. - // MSBUILD_EXE_PATH is set for in-process MSBuild; clearing it lets the subprocess dotnet find MSBuild on its own. - // MSBuildSDKsPath is set by SdkTestContext.Initialize for in-process MSBuild SDK resolution; if it leaks to - // subprocess dotnet invocations it can point to a non-existent path and cause MSB4236 "SDK not found" errors. + // We set this environment variable for in-process tests, but we don't want it to flow to out of process tests + // (especially if we're trying to run on full Framework MSBuild) environment[Constants.MSBUILD_EXE_PATH] = ""; - environment["MSBuildSDKsPath"] = null; } From 5adb58e4ed25a0a470585631f683204494a122ba Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Thu, 9 Apr 2026 01:57:53 +0000 Subject: [PATCH 4/7] Revert "[main] Update dependencies from microsoft/testfx (#53682)" This reverts commit ac1f1e89d48e98aa1142b9b966e11791a5019f20, reversing changes made to 168a80939d6c484a0f71a593dc814b848e60b359. --- eng/Version.Details.props | 4 ++-- eng/Version.Details.xml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/eng/Version.Details.props b/eng/Version.Details.props index 9f891974d7d2..b923ca9681cf 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -144,8 +144,8 @@ This file should be imported by eng/Versions.props 11.0.0-preview.4.26203.108 11.0.0-preview.4.26203.108 - 2.3.0-preview.26203.3 - 4.3.0-preview.26203.3 + 2.2.1-preview.26201.3 + 4.2.1-preview.26201.3 diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 9499ff74ada0..20b32b741196 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -569,13 +569,13 @@ https://github.com/dotnet/dotnet 1a405c76db678301b6f6f5b22d2a63a152c26b97 - + https://github.com/microsoft/testfx - 1ddd2f1a558e9c79b5327c5ccc0e9e89df39d4da + 4b12ce96553c32c1038bf81176b98a79994d6967 - + https://github.com/microsoft/testfx - 1ddd2f1a558e9c79b5327c5ccc0e9e89df39d4da + 4b12ce96553c32c1038bf81176b98a79994d6967 https://github.com/dotnet/dotnet From f860c2b50f5715b68fce310f93c8ce86994274ca Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Thu, 9 Apr 2026 02:01:16 +0000 Subject: [PATCH 5/7] Revert "Use OTel for telemetry (#53181)" This reverts commit 794fa7f2f8d6bef67839b96d785da1cbdf099054, reversing changes made to ac1f1e89d48e98aa1142b9b966e11791a5019f20. --- Directory.Build.props | 4 +- Directory.Packages.props | 8 +- documentation/general/tab-completion.md | 2 +- eng/Signing.props | 14 +- eng/Versions.props | 3 +- .../Commands/DotNetCommandDefinition.cs | 20 +- .../Microsoft.DotNet.Cli.Utils/Activities.cs | 2 + .../BuiltInCommand.cs | 2 + .../ITelemetryFilter.cs | 10 +- .../InstallerSuccessReport.cs | 9 - .../MSBuildForwardingAppWithoutLogging.cs | 5 +- .../Microsoft.DotNet.Cli.Utils.csproj | 4 + .../TelemetryEventEntry.cs | 105 ++-- .../UILanguageOverride.cs | 4 +- .../DotnetFirstTimeUseConfigurer.cs | 56 +- .../FilePath.cs | 45 +- .../Commands/create/TemplateCommand.cs | 2 + .../Help/DotnetHelpAction.cs | 44 ++ src/Cli/dotnet/CliSchema.cs | 48 +- .../ActivityContextFactory.cs | 48 -- .../DotnetToolsCommandResolver.cs | 18 +- .../LocalToolsCommandResolver.cs | 19 +- .../CommandResolution/MuxerCommandResolver.cs | 7 +- .../MuxerCommandSpecMaker.cs | 49 +- src/Cli/dotnet/CommandFactory/CommandSpec.cs | 7 +- src/Cli/dotnet/CommandParsingException.cs | 9 +- src/Cli/dotnet/Commands/Build/BuildCommand.cs | 2 +- .../Commands/Build/BuildCommandParser.cs | 1 + src/Cli/dotnet/Commands/Clean/CleanCommand.cs | 2 +- ...NetCommandFactory.cs => CommandFactory.cs} | 39 +- .../InternalReportInstallSuccessCommand.cs | 41 +- .../Commands/MSBuild/MSBuildForwardingApp.cs | 23 +- .../dotnet/Commands/MSBuild/MSBuildLogger.cs | 78 ++- .../New/BuiltInTemplatePackageProvider.cs | 33 +- .../New/MSBuildEvaluation/MSBuildEvaluator.cs | 16 +- .../dotnet/Commands/New/NewCommandParser.cs | 2 + .../Commands/New/OptionalWorkloadProvider.cs | 17 +- src/Cli/dotnet/Commands/Pack/PackCommand.cs | 4 +- .../dotnet/Commands/Pack/PackCommandParser.cs | 1 + .../Commands/Project/ProjectCommandParser.cs | 1 + .../dotnet/Commands/Publish/PublishCommand.cs | 2 +- .../dotnet/Commands/Restore/RestoreCommand.cs | 2 +- src/Cli/dotnet/Commands/Run/RunTelemetry.cs | 14 +- .../Tool/Execute/ToolExecuteCommand.cs | 2 +- .../Commands/Tool/Run/ToolRunCommand.cs | 4 +- .../Commands/Tool/ToolCommandSpecCreator.cs | 36 +- src/Cli/dotnet/DotNetCommandFactory.cs | 47 ++ .../dotnet/Extensions/ActivityExtensions.cs | 22 - .../Extensions/ParseResultExtensions.cs | 166 +++--- src/Cli/dotnet/Parser.cs | 62 +- src/Cli/dotnet/ParserOptionActions.cs | 171 ------ src/Cli/dotnet/PerformanceLogEventListener.cs | 160 +++++ src/Cli/dotnet/PerformanceLogEventSource.cs | 455 ++++++++++++++ src/Cli/dotnet/PerformanceLogManager.cs | 136 +++++ src/Cli/dotnet/Program.cs | 564 ++++++++++-------- .../AllowListToSendFirstAppliedOptions.cs | 11 +- .../Telemetry/AllowListToSendFirstArgument.cs | 19 +- ...owListToSendVerbSecondVerbFirstArgument.cs | 16 +- .../Telemetry/ExternalTelemetryProperties.cs | 12 +- .../dotnet/Telemetry/IParseResultLogRule.cs | 2 +- .../{ITelemetryClient.cs => ITelemetry.cs} | 10 +- .../PersistenceChannel/BaseStorageService.cs | 61 ++ .../PersistenceChannel/FixedSizeQueue.cs | 43 ++ .../PersistenceChannel/FlushManager.cs | 57 ++ .../PersistenceChannel/PersistenceChannel.cs | 114 ++++ .../PersistenceChannelDebugLog.cs | 34 ++ .../PersistenceTransmitter.cs | 84 +++ .../Telemetry/PersistenceChannel/Sender.cs | 336 +++++++++++ .../SnapshottingCollection.cs | 95 +++ .../SnapshottingDictionary.cs | 71 +++ .../PersistenceChannel/StorageService.cs | 352 +++++++++++ .../PersistenceChannel/StorageTransmission.cs | 127 ++++ src/Cli/dotnet/Telemetry/Telemetry.cs | 263 ++++++++ src/Cli/dotnet/Telemetry/TelemetryClient.cs | 231 ------- .../Telemetry/TelemetryCommonProperties.cs | 100 ++-- .../dotnet/Telemetry/TelemetryDiskLogger.cs | 94 --- src/Cli/dotnet/Telemetry/TelemetryFilter.cs | 199 ++++-- .../TopLevelCommandNameAndOptionToLog.cs | 19 +- src/Cli/dotnet/dotnet.csproj | 13 +- src/Common/CompileOptions.cs | 3 +- src/Common/EnvironmentVariableNames.cs | 20 +- .../TemplateLocator.cs | 7 +- .../Commands/SdkCommandSpec.cs | 11 +- .../Commands/TestCommand.cs | 12 +- test/Microsoft.NET.TestFramework/SdkTest.cs | 59 +- test/dotnet.Tests/CliSchemaTests.cs | 2 +- .../GivenALocalToolsCommandResolver.cs | 16 +- .../CommandDirectoryContextExtensions.cs | 1 + .../MSBuild/DotnetMsbuildInProcTests.cs | 14 +- .../CommandTests/MSBuild/FakeTelemetry.cs | 35 +- .../MSBuild/GivenDotnetBuildInvocation.cs | 4 +- .../GivenDotnetMSBuildBuildsProjects.cs | 10 +- .../MSBuild/GivenDotnetRestoreInvocation.cs | 3 +- .../MSBuild/GivenDotnetTestInvocation.cs | 3 +- .../MSBuild/NullCurrentSessionIdFixture.cs | 25 +- .../CommandTests/Run/RunTelemetryTests.cs | 26 +- .../GivenADotnetFirstTimeUseConfigurer.cs | 72 +++ ...netFirstTimeUseConfigurerWIthStateSetup.cs | 19 +- .../FakeRecordEventNameTelemetry.cs | 47 ++ .../GivenThatTheUserEnablesThePerfLog.cs | 81 +++ ...atTheUserIsRunningDotNetForTheFirstTime.cs | 3 +- test/dotnet.Tests/TelemetryCommandTest.cs | 416 +++++++++++++ .../TelemetryCommonPropertiesTests.cs | 313 ++++++++++ test/dotnet.Tests/TelemetryFilterTest.cs | 211 +++++++ .../FakeRecordEventNameTelemetry.cs | 31 - .../TelemetryTests/SenderTests.cs | 184 ++++++ .../TelemetryTests/StorageTests.cs | 202 +++++++ .../TelemetryTests/TelemetryClientTests.cs | 67 --- .../TelemetryTests/TelemetryCommandTest.cs | 332 ----------- .../TelemetryCommonPropertiesTests.cs | 311 ---------- .../TelemetryTests/TelemetryFilterTest.cs | 123 ---- 111 files changed, 5351 insertions(+), 2322 deletions(-) delete mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs create mode 100644 src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs delete mode 100644 src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs rename src/Cli/dotnet/Commands/{DotNetCommandFactory.cs => CommandFactory.cs} (54%) create mode 100644 src/Cli/dotnet/DotNetCommandFactory.cs delete mode 100644 src/Cli/dotnet/Extensions/ActivityExtensions.cs delete mode 100644 src/Cli/dotnet/ParserOptionActions.cs create mode 100644 src/Cli/dotnet/PerformanceLogEventListener.cs create mode 100644 src/Cli/dotnet/PerformanceLogEventSource.cs create mode 100644 src/Cli/dotnet/PerformanceLogManager.cs rename src/Cli/dotnet/Telemetry/{ITelemetryClient.cs => ITelemetry.cs} (65%) create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs create mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs create mode 100644 src/Cli/dotnet/Telemetry/Telemetry.cs delete mode 100644 src/Cli/dotnet/Telemetry/TelemetryClient.cs delete mode 100644 src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs create mode 100644 test/dotnet.Tests/FakeRecordEventNameTelemetry.cs create mode 100644 test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs create mode 100644 test/dotnet.Tests/TelemetryCommandTest.cs create mode 100644 test/dotnet.Tests/TelemetryCommonPropertiesTests.cs create mode 100644 test/dotnet.Tests/TelemetryFilterTest.cs delete mode 100644 test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs create mode 100644 test/dotnet.Tests/TelemetryTests/SenderTests.cs create mode 100644 test/dotnet.Tests/TelemetryTests/StorageTests.cs delete mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs delete mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs delete mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs delete mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs diff --git a/Directory.Build.props b/Directory.Build.props index e84845c5fe8b..358a629fabb2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -66,9 +66,11 @@ $(NetCurrent) net9.0 + - $(NoWarn);NU1507;NU1202;NU5039 + $(NoWarn);NU1701;NU1507;NU1202;NU5039 true false diff --git a/Directory.Packages.props b/Directory.Packages.props index de1f7b9b33cf..21d623676105 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,23 +8,19 @@ + - - + - - - - diff --git a/documentation/general/tab-completion.md b/documentation/general/tab-completion.md index 39aaa2eaad49..8a1a977d4d97 100644 --- a/documentation/general/tab-completion.md +++ b/documentation/general/tab-completion.md @@ -9,7 +9,7 @@ Input | becomes `dotnet a⇥` | `dotnet add` | `add` is the first subcommand, alphabetically. `dotnet add p⇥` | `dotnet add --help` | it matches substrings and `--help` comes first alphabetically. `dotnet add p⇥⇥` | `dotnet add package` | pressing tab a second time brings up the next suggestion. -`dotnet add package Microsoft⇥` | `dotnet add package Microsoft.AspNetCore.Http` | results are returned alphabetically. +`dotnet add package Microsoft⇥` | `dotnet add package Microsoft.ApplicationInsights.Web` | results are returned alphabetically. `dotnet remove reference ⇥` | `dotnet remove reference ..\..\src\OmniSharp.DotNet\OmniSharp.DotNet.csproj` | it is project file aware. ## How to enable it diff --git a/eng/Signing.props b/eng/Signing.props index 760095763542..fa123b40a992 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -65,22 +65,10 @@ - - - - - - - - - - - - - + diff --git a/eng/Versions.props b/eng/Versions.props index d7d8105699a9..a858ff7d865d 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -47,6 +47,7 @@ 1.0.0-20230414.1 + 2.23.0 2.0.1-servicing-26011-01 2.0.3 13.0.3 @@ -59,8 +60,6 @@ 0.3.264 1.0.52 - 1.4.0 - 1.12.0 diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs index 13a6bf284167..a7357a9f75bc 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs @@ -58,14 +58,6 @@ internal sealed class DotNetCommandDefinition : RootCommand Arity = ArgumentArity.Zero }; - public readonly Option CliSchemaOption = new("--cli-schema") - { - Description = CommandDefinitionStrings.SDKSchemaCommandDefinition, - Arity = ArgumentArity.Zero, - Recursive = true, - Hidden = true, - }; - public readonly Option ListSdksOption = new("--list-sdks") { Arity = ArgumentArity.Zero @@ -76,6 +68,14 @@ internal sealed class DotNetCommandDefinition : RootCommand Arity = ArgumentArity.Zero }; + public readonly Option CliSchemaOption = new("--cli-schema") + { + Description = CommandDefinitionStrings.SDKSchemaCommandDefinition, + Arity = ArgumentArity.Zero, + Recursive = true, + Hidden = true, + }; + public readonly AddCommandDefinition AddCommand; public readonly BuildCommandDefinition BuildCommand; public readonly BuildServerCommandDefinition BuildServerCommand; @@ -121,11 +121,9 @@ public DotNetCommandDefinition() Options.Add(DiagOption); Options.Add(VersionOption); Options.Add(InfoOption); - Options.Add(CliSchemaOption); - - // Host-handled options. Only defined to be shown in help. Options.Add(ListSdksOption); Options.Add(ListRuntimesOption); + Options.Add(CliSchemaOption); Subcommands.Add(AddCommand = new()); Subcommands.Add(BuildCommand = new()); diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs index 6b22d2ad94c4..23180a6b69d1 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs @@ -3,6 +3,8 @@ #if NET +using System.Diagnostics; + namespace Microsoft.DotNet.Cli.Utils; /// diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.cs index d8fb130f272f..4bf3d94e2773 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.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.Diagnostics; + namespace Microsoft.DotNet.Cli.Utils; /// diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs index 593edab4ef76..2e4ff73d8935 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs @@ -1,17 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CommandLine; - namespace Microsoft.DotNet.Cli.Utils; public interface ITelemetryFilter { - IEnumerable Filter(ParseResult parseResult); - - IEnumerable Filter(ParseResultWithGlobalJsonState parseData); - - IEnumerable Filter(InstallerSuccessReport report); - - IEnumerable Filter(Exception exception); + IEnumerable Filter(object o); } diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs deleted file mode 100644 index 42d77b93ae4b..000000000000 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Cli.Utils; - -public class InstallerSuccessReport(string? exeName) -{ - public string ExeName { get; } = exeName ?? throw new ArgumentNullException(nameof(exeName)); -} diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs index 1f94685c28a8..87d021eda157 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs @@ -3,6 +3,7 @@ #if NET +using System.Diagnostics; using Microsoft.DotNet.Cli.Utils.Extensions; namespace Microsoft.DotNet.Cli.Utils; @@ -15,7 +16,7 @@ internal sealed class MSBuildForwardingAppWithoutLogging public static string MSBuildVersion { - get => Build.Evaluation.ProjectCollection.DisplayVersion; + get => Microsoft.Build.Evaluation.ProjectCollection.DisplayVersion; } private const string MSBuildExeName = "MSBuild.dll"; @@ -193,7 +194,7 @@ private static string GetMSBuildExePath() MSBuildExeName); } - public static string GetMSBuildSDKsPath() + private static string GetMSBuildSDKsPath() { var envMSBuildSDKsPath = Environment.GetEnvironmentVariable("MSBuildSDKsPath"); diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj b/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj index 935472e224f2..cfa596437414 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj @@ -79,6 +79,10 @@ + + + + diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs index 032f82bf49e9..772e9845b678 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CommandLine; +using System.Diagnostics; namespace Microsoft.DotNet.Cli.Utils; @@ -10,71 +10,104 @@ public static class TelemetryEventEntry public static event EventHandler? EntryPosted; public static ITelemetryFilter TelemetryFilter { get; set; } = new BlockFilter(); - public static void TrackEvent(string eventName, IDictionary? properties = null) + public static void TrackEvent( + string? eventName = null, + IDictionary? properties = null, + IDictionary? measurements = null) { - EntryPosted?.Invoke(typeof(TelemetryEventEntry), new InstrumentationEventArgs(eventName, properties)); + EntryPosted?.Invoke(typeof(TelemetryEventEntry), + new InstrumentationEventArgs(eventName, properties, measurements)); } - public static void SendFiltered(ParseResult parseResult) => - SendFiltered(TelemetryFilter.Filter(parseResult)); - - public static void SendFiltered(ParseResultWithGlobalJsonState parseData) => - SendFiltered(TelemetryFilter.Filter(parseData)); - - public static void SendFiltered(InstallerSuccessReport report) => - SendFiltered(TelemetryFilter.Filter(report)); - - public static void SendFiltered(Exception exception) => - SendFiltered(TelemetryFilter.Filter(exception)); - - private static void SendFiltered(IEnumerable entries) + public static void SendFiltered(object? o = null) { - foreach (TelemetryEntryFormat entry in entries) + if (o == null) + { + return; + } + + foreach (ApplicationInsightsEntryFormat entry in TelemetryFilter.Filter(o)) { - TrackEvent(entry.EventName, entry.Properties); + TrackEvent(entry.EventName, entry.Properties, entry.Measurements); } } - public static void Subscribe(Action?> subscriber) + public static void Subscribe(Action?, IDictionary?> subscriber) { void Handler(object? sender, InstrumentationEventArgs eventArgs) { - subscriber(eventArgs.EventName, eventArgs.Properties); + subscriber(eventArgs.EventName, eventArgs.Properties, eventArgs.Measurements); } EntryPosted += Handler; } } -public class BlockFilter : ITelemetryFilter +public sealed class PerformanceMeasurement : IDisposable { - private static readonly TelemetryEntryFormat[] s_emptyEntries = []; + private readonly Stopwatch? _timer; + private readonly Dictionary? _data; + private readonly string? _name; - public IEnumerable Filter(ParseResult parseResult) => s_emptyEntries; + public PerformanceMeasurement(Dictionary? data, string name) + { + // Measurement is a no-op if we don't have a dictionary to store the entry. + if (data == null) + { + return; + } - public IEnumerable Filter(ParseResultWithGlobalJsonState parseData) => s_emptyEntries; + _data = data; + _name = name; + _timer = Stopwatch.StartNew(); + } - public IEnumerable Filter(InstallerSuccessReport report) => s_emptyEntries; + public void Dispose() + { + if (_name is not null && _timer is not null) + { + _data?.Add(_name, _timer.Elapsed.TotalMilliseconds); + } + } +} - public IEnumerable Filter(Exception exception) => s_emptyEntries; +public class BlockFilter : ITelemetryFilter +{ + public IEnumerable Filter(object o) + { + return []; + } } -public class InstrumentationEventArgs(string eventName, IDictionary? properties = null) : EventArgs +public class InstrumentationEventArgs : EventArgs { - public string EventName { get; } = eventName; - public IDictionary? Properties { get; } = properties; + internal InstrumentationEventArgs( + string? eventName, + IDictionary? properties, + IDictionary? measurements) + { + EventName = eventName; + Properties = properties; + Measurements = measurements; + } + + public string? EventName { get; } + public IDictionary? Properties { get; } + public IDictionary? Measurements { get; } } -public class TelemetryEntryFormat(string eventName, IDictionary? properties = null) +public class ApplicationInsightsEntryFormat( + string? eventName = null, + IDictionary? properties = null, + IDictionary? measurements = null) { - public string EventName { get; } = eventName; + public string? EventName { get; } = eventName; public IDictionary? Properties { get; } = properties; + public IDictionary? Measurements { get; } = measurements; - public TelemetryEntryFormat WithAppliedToPropertiesValue(Func func) + public ApplicationInsightsEntryFormat WithAppliedToPropertiesValue(Func func) { - var appliedProperties = Properties?.ToDictionary(p => p.Key, p => (string?)func(p.Value ?? string.Empty)); - return new TelemetryEntryFormat(EventName, appliedProperties); + var appliedProperties = Properties?.ToDictionary(p => p.Key, p => (string?)func(p.Value)); + return new ApplicationInsightsEntryFormat(EventName, appliedProperties, Measurements); } } - -public record ParseResultWithGlobalJsonState(ParseResult ParseResult, string? GlobalJsonState); diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.cs index 6f66646cfd93..32e773f1261b 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.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.Globalization; using System.Security; using Microsoft.Win32; @@ -10,7 +11,6 @@ namespace Microsoft.DotNet.Cli.Utils; internal static class UILanguageOverride { internal const string DOTNET_CLI_UI_LANGUAGE = nameof(DOTNET_CLI_UI_LANGUAGE); - private const string DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING = nameof(DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING); private const string VSLANG = nameof(VSLANG); private const string PreferredUILang = nameof(PreferredUILang); // We choose UTF8 as the default encoding as opposed to specific language encodings because it supports emojis & other chars in .NET. @@ -25,7 +25,7 @@ public static void Setup() FlowOverrideToChildProcesses(language); } - if (Env.GetEnvironmentVariable(DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING) != "1") + if (Env.GetEnvironmentVariable("DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING") != "1") { if ( !CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.Equals("en", StringComparison.InvariantCultureIgnoreCase) && diff --git a/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs b/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs index a364b4191d2d..c52628b044ca 100644 --- a/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs +++ b/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs @@ -15,6 +15,7 @@ public class DotnetFirstTimeUseConfigurer private readonly IAspNetCoreCertificateGenerator _aspNetCoreCertificateGenerator; private readonly IFileSentinel _toolPathSentinel; private readonly IEnvironmentPath _pathAdder; + private readonly Dictionary? _performanceMeasurements; private readonly bool _skipFirstTimeUseCheck; public DotnetFirstTimeUseConfigurer( @@ -25,6 +26,7 @@ public DotnetFirstTimeUseConfigurer( DotnetFirstRunConfiguration dotnetFirstRunConfiguration, IReporter reporter, IEnvironmentPath pathAdder, + Dictionary? performanceMeasurements = null, bool skipFirstTimeUseCheck = false) { _firstTimeUseNoticeSentinel = firstTimeUseNoticeSentinel; @@ -34,6 +36,7 @@ public DotnetFirstTimeUseConfigurer( _dotnetFirstRunConfiguration = dotnetFirstRunConfiguration; _reporter = reporter; _pathAdder = pathAdder ?? throw new ArgumentNullException(nameof(pathAdder)); + _performanceMeasurements ??= performanceMeasurements; _skipFirstTimeUseCheck = skipFirstTimeUseCheck; } @@ -41,45 +44,54 @@ public void Configure() { if (_dotnetFirstRunConfiguration.AddGlobalToolsToPath && !_toolPathSentinel.Exists()) { - _pathAdder.AddPackageExecutablePathToUserPath(); - _toolPathSentinel.Create(); + using (new PerformanceMeasurement(_performanceMeasurements, "AddPackageExecutablePath Time")) + { + _pathAdder.AddPackageExecutablePathToUserPath(); + _toolPathSentinel.Create(); + } } var isFirstTimeUse = !_skipFirstTimeUseCheck && !_firstTimeUseNoticeSentinel.Exists(); var canShowFirstUseMessages = isFirstTimeUse && !_dotnetFirstRunConfiguration.NoLogo; if (isFirstTimeUse) { - // Migrate the NuGet state from earlier SDKs - NuGet.Common.Migrations.MigrationRunner.Run(); - - if (canShowFirstUseMessages) + using (new PerformanceMeasurement(_performanceMeasurements, "FirstTimeUseNotice Time")) { - _reporter.WriteLine(); - string productVersion = Product.Version; - _reporter.WriteLine(string.Format(LocalizableStrings.FirstTimeMessageWelcome, ParseDotNetVersion(productVersion), productVersion)); + // Migrate the NuGet state from earlier SDKs + NuGet.Common.Migrations.MigrationRunner.Run(); - if (!_dotnetFirstRunConfiguration.TelemetryOptout) + if (canShowFirstUseMessages) { _reporter.WriteLine(); - _reporter.WriteLine(LocalizableStrings.TelemetryMessage); + string productVersion = Product.Version; + _reporter.WriteLine(string.Format(LocalizableStrings.FirstTimeMessageWelcome, ParseDotNetVersion(productVersion), productVersion)); + + if (!_dotnetFirstRunConfiguration.TelemetryOptout) + { + _reporter.WriteLine(); + _reporter.WriteLine(LocalizableStrings.TelemetryMessage); + } } - } - _firstTimeUseNoticeSentinel.CreateIfNotExists(); + _firstTimeUseNoticeSentinel.CreateIfNotExists(); + } } if (CanGenerateAspNetCertificate()) { - _aspNetCoreCertificateGenerator.GenerateAspNetCoreDevelopmentCertificate(); - _aspNetCertificateSentinel.CreateIfNotExists(); - - if (canShowFirstUseMessages) + using (new PerformanceMeasurement(_performanceMeasurements, "GenerateAspNetCertificate Time")) { - // This message is slightly misleading for (e.g.) FreeBSD, which doesn't officially - // support `dotnet dev-certs https --trust`, but the link in the message should help - // users find the right steps for their platform. - _reporter.WriteLine(); - _reporter.WriteLine(LocalizableStrings.FirstTimeMessageAspNetCertificate); + _aspNetCoreCertificateGenerator.GenerateAspNetCoreDevelopmentCertificate(); + _aspNetCertificateSentinel.CreateIfNotExists(); + + if (canShowFirstUseMessages) + { + // This message is slightly misleading for (e.g.) FreeBSD, which doesn't officially + // support `dotnet dev-certs https --trust`, but the link in the message should help + // users find the right steps for their platform. + _reporter.WriteLine(); + _reporter.WriteLine(LocalizableStrings.FirstTimeMessageAspNetCertificate); + } } } diff --git a/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs b/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs index f08c1f1a759a..d9ad822f9517 100644 --- a/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs +++ b/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs @@ -1,33 +1,34 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.Extensions.EnvironmentAbstractions; - -public readonly struct FilePath +namespace Microsoft.Extensions.EnvironmentAbstractions { - public string Value { get; } - - /// - /// Create FilePath to represent an absolute file path. Note: It may not exist. - /// - /// If the value is not rooted. Path.GetFullPath will be called during the constructor. - public FilePath(string value) + public struct FilePath { - if (!Path.IsPathRooted(value)) + public string Value { get; } + + /// + /// Create FilePath to represent an absolute file path. Note it may not exist. + /// + /// If the value is not rooted. Path.GetFullPath will be called during the constructor. + public FilePath(string value) { - value = Path.GetFullPath(value); - } + if (!Path.IsPathRooted(value)) + { + value = Path.GetFullPath(value); + } - Value = value; - } + Value = value; + } - public override string ToString() - { - return Value; - } + public override string ToString() + { + return Value; + } - public DirectoryPath GetDirectoryPath() - { - return new DirectoryPath(Path.GetDirectoryName(Value)!); + public DirectoryPath GetDirectoryPath() + { + return new DirectoryPath(Path.GetDirectoryName(Value)!); + } } } diff --git a/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs b/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs index 2aee7d6e9360..68e6c1162794 100644 --- a/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs +++ b/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs @@ -22,6 +22,7 @@ internal class TemplateCommand : Command private readonly TemplatePackageManager _templatePackageManager; private readonly IEngineEnvironmentSettings _environmentSettings; private readonly Command _instantiateCommand; + private readonly TemplateGroup _templateGroup; private readonly CliTemplateInfo _template; private Dictionary _templateSpecificOptions = new(); @@ -43,6 +44,7 @@ public TemplateCommand( _instantiateCommand = instantiateCommand; _environmentSettings = environmentSettings; _templatePackageManager = templatePackageManager; + _templateGroup = templateGroup; _template = template; foreach (var item in templateGroup.ShortNames.Skip(1)) { diff --git a/src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs b/src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs new file mode 100644 index 000000000000..884867e08e2f --- /dev/null +++ b/src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Invocation; +using Microsoft.DotNet.Cli.Help; + +namespace Microsoft.TemplateEngine.Cli.Help; + +/// +/// Provides command line help. +/// +public sealed class DotnetHelpAction : SynchronousCommandLineAction +{ + private HelpBuilder? _builder; + + /// + /// Specifies an to be used to format help output when help is requested. + /// + public HelpBuilder Builder + { + get => _builder ??= new HelpBuilder(Console.IsOutputRedirected ? int.MaxValue : Console.WindowWidth); + set => _builder = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + public override bool ClearsParseErrors => true; + + /// + public override int Invoke(ParseResult parseResult) + { + var output = parseResult.InvocationConfiguration.Output; + + var helpContext = new HelpContext( + Builder, + parseResult.CommandResult.Command, + output, + parseResult); + + Builder.Write(helpContext); + + return 0; + } +} diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index 770f33b65fab..99d039df1db2 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -7,7 +7,6 @@ using System.Text.Json; using System.Text.Json.Schema; using System.Text.Json.Serialization; -using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -32,20 +31,8 @@ internal static class CliSchema DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }); - public record ArgumentDetails( - string? description, - int order, - bool hidden, - string? helpName, - string valueType, - bool hasDefaultValue, - object? defaultValue, - ArityDetails arity); - - public record ArityDetails( - int minimum, - int? maximum); - + public record ArgumentDetails(string? description, int order, bool hidden, string? helpName, string valueType, bool hasDefaultValue, object? defaultValue, ArityDetails arity); + public record ArityDetails(int minimum, int? maximum); public record OptionDetails( string? description, bool hidden, @@ -56,8 +43,8 @@ public record OptionDetails( object? defaultValue, ArityDetails arity, bool required, - bool recursive); - + bool recursive + ); public record CommandDetails( string? description, bool hidden, @@ -65,7 +52,6 @@ public record CommandDetails( Dictionary? arguments, Dictionary? options, Dictionary? subcommands); - public record RootCommandDetails( string name, string version, @@ -77,16 +63,17 @@ public record RootCommandDetails( Dictionary? subcommands ) : CommandDetails(description, hidden, aliases, arguments, options, subcommands); - public static void PrintCliSchema(ParseResult parseResult, TextWriter outputWriter, ITelemetryClient? telemetryClient) + + public static void PrintCliSchema(CommandResult commandResult, TextWriter outputWriter, ITelemetry? telemetryClient) { - var command = parseResult.CommandResult.Command; + var command = commandResult.Command; RootCommandDetails transportStructure = CreateRootCommandDetails(command); var result = JsonSerializer.Serialize(transportStructure, s_jsonContext.RootCommandDetails); outputWriter.Write(result.AsSpan()); outputWriter.Flush(); - var commandString = parseResult.GetCommandName(); - var telemetryProperties = new Dictionary { { "command", commandString } }; - telemetryClient?.TrackEvent("schema", telemetryProperties); + var commandString = CommandHierarchyAsString(commandResult); + var telemetryProperties = new Dictionary { { "command", commandString } }; + telemetryClient?.TrackEvent("schema", telemetryProperties, null); } public static object GetJsonSchema() @@ -217,6 +204,21 @@ private static RootCommandDetails CreateRootCommandDetails(Command command) argument.HasDefaultValue ? HumanizeValue(argument.GetDefaultValue()) : null, CreateArityDetails(argument.Arity) ); + + // Produces a string that represents the command call. + // For example, calling the workload install command produces `dotnet workload install`. + private static string CommandHierarchyAsString(CommandResult commandResult) + { + var commands = new List(); + var currentResult = commandResult; + while (currentResult is not null) + { + commands.Add(currentResult.Command.Name); + currentResult = currentResult.Parent as CommandResult; + } + + return string.Join(" ", commands.AsEnumerable().Reverse()); + } } [JsonSerializable(typeof(CliSchema.RootCommandDetails))] diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs deleted file mode 100644 index bd8616a1e06a..000000000000 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using Microsoft.DotNet.Cli.Utils; -#if TARGET_WINDOWS -using OpenTelemetry; -using OpenTelemetry.Context.Propagation; -#endif - -namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; - -public static class ActivityContextFactory -{ - public static Dictionary? MakeActivityContextEnvironment() - { - var currentActivity = Activity.Current; - if (currentActivity is null) - { - return null; - } - var activityContext = currentActivity.Context; - if (activityContext.TraceState is null && activityContext.TraceId == default && activityContext.SpanId == default) - { - return null; - } - - var environment = new Dictionary(capacity: 2); -#if TARGET_WINDOWS - var propagationContext = new PropagationContext(activityContext, Baggage.Current); - Propagators.DefaultTextMapPropagator.Inject(propagationContext, environment, WriteTraceStateIntoEnvironment); -#endif - return environment; - } - - private static void WriteTraceStateIntoEnvironment(Dictionary environment, string key, string value) - { - switch (key) - { - case "traceparent": - environment[Activities.TRACEPARENT] = value; - break; - case "tracestate": - environment[Activities.TRACESTATE] = value; - break; - } - } -} diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs index 836fde7c220d..81b98215429d 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs @@ -1,18 +1,28 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; public class DotnetToolsCommandResolver : ICommandResolver { private readonly string _dotnetToolPath; - public DotnetToolsCommandResolver(string? dotnetToolPath = null) + public DotnetToolsCommandResolver(string dotnetToolPath = null) { - _dotnetToolPath = dotnetToolPath ?? Path.Combine(AppContext.BaseDirectory, "DotnetTools"); + if (dotnetToolPath == null) + { + _dotnetToolPath = Path.Combine(AppContext.BaseDirectory, + "DotnetTools"); + } + else + { + _dotnetToolPath = dotnetToolPath; + } } - public CommandSpec? Resolve(CommandResolverArguments arguments) + public CommandSpec Resolve(CommandResolverArguments arguments) { if (string.IsNullOrEmpty(arguments.CommandName)) { @@ -33,6 +43,6 @@ public DotnetToolsCommandResolver(string? dotnetToolPath = null) return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer( dll.FullName, - arguments.CommandArguments ?? []); + arguments.CommandArguments); } } diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs index 70ba8d1e2056..ac22545eddec 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs @@ -1,27 +1,30 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using Microsoft.DotNet.Cli.Commands.Tool; using Microsoft.DotNet.Cli.ToolManifest; using Microsoft.DotNet.Cli.ToolPackage; using Microsoft.DotNet.Cli.Utils; using Microsoft.Extensions.EnvironmentAbstractions; +using NuGet.DependencyResolver; using NuGet.Frameworks; namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; internal class LocalToolsCommandResolver( - ToolManifestFinder? toolManifest = null, - ILocalToolsResolverCache? localToolsResolverCache = null, - IFileSystem? fileSystem = null, - string? currentWorkingDirectory = null) : ICommandResolver + ToolManifestFinder toolManifest = null, + ILocalToolsResolverCache localToolsResolverCache = null, + IFileSystem fileSystem = null, + string currentWorkingDirectory = null) : ICommandResolver { private readonly ToolManifestFinder _toolManifest = toolManifest ?? new ToolManifestFinder(new DirectoryPath(currentWorkingDirectory ?? Directory.GetCurrentDirectory())); private readonly ILocalToolsResolverCache _localToolsResolverCache = localToolsResolverCache ?? new LocalToolsResolverCache(); private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystemWrapper(); private const string LeadingDotnetPrefix = "dotnet-"; - public CommandSpec? ResolveStrict(CommandResolverArguments arguments, bool allowRollForward = false) + public CommandSpec ResolveStrict(CommandResolverArguments arguments, bool allowRollForward = false) { if (arguments == null || string.IsNullOrWhiteSpace(arguments.CommandName)) { @@ -39,7 +42,7 @@ internal class LocalToolsCommandResolver( return resolveResult; } - public CommandSpec? Resolve(CommandResolverArguments arguments) + public CommandSpec Resolve(CommandResolverArguments arguments) { if (arguments == null || string.IsNullOrWhiteSpace(arguments.CommandName)) { @@ -66,7 +69,7 @@ internal class LocalToolsCommandResolver( return GetPackageCommandSpecUsingMuxer(arguments, new ToolCommandName(arguments.CommandName)); } - private CommandSpec? GetPackageCommandSpecUsingMuxer(CommandResolverArguments arguments, + private CommandSpec GetPackageCommandSpecUsingMuxer(CommandResolverArguments arguments, ToolCommandName toolCommandName, bool allowRollForward = false) { if (!_toolManifest.TryFind(toolCommandName, out var toolManifestPackage)) @@ -90,7 +93,7 @@ internal class LocalToolsCommandResolver( } return ToolCommandSpecCreator.CreateToolCommandSpec(toolCommand.Name.Value, toolCommand.Executable.Value, toolCommand.Runner, - toolManifestPackage.RollForward || allowRollForward, arguments.CommandArguments ?? []); + toolManifestPackage.RollForward || allowRollForward, arguments.CommandArguments); } else { diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.cs index 92f7d50f6abd..1998ae144809 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.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. +#nullable disable + using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -8,15 +10,14 @@ namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; public class MuxerCommandResolver : ICommandResolver { - public CommandSpec? Resolve(CommandResolverArguments commandResolverArguments) + public CommandSpec Resolve(CommandResolverArguments commandResolverArguments) { if (commandResolverArguments.CommandName == Muxer.MuxerName) { var muxer = new Muxer(); var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart( commandResolverArguments.CommandArguments.OrEmptyIfNull()); - var environment = ActivityContextFactory.MakeActivityContextEnvironment(); - return new CommandSpec(muxer.MuxerPath, escapedArgs, environment); + return new CommandSpec(muxer.MuxerPath, escapedArgs); } return null; } diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs index 12f1a372e652..63a3ecae1d5b 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs @@ -1,16 +1,31 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; internal static class MuxerCommandSpecMaker { - internal static CommandSpec CreatePackageCommandSpecUsingMuxer(string commandPath, IEnumerable commandArguments, IDictionary? environment = null) + internal static CommandSpec CreatePackageCommandSpecUsingMuxer( + string commandPath, + IEnumerable commandArguments) { var arguments = new List(); - var rollForwardArgument = commandArguments.Where(arg => arg.Equals("--allow-roll-forward", StringComparison.OrdinalIgnoreCase)); + + var muxer = new Muxer(); + + var host = muxer.MuxerPath; + + if (host == null) + { + throw new Exception(LocalizableStrings.UnableToLocateDotnetMultiplexer); + } + + var rollForwardArgument = (commandArguments ?? []).Where(arg => arg.Equals("--allow-roll-forward", StringComparison.OrdinalIgnoreCase)); + if (rollForwardArgument.Any()) { arguments.Add("--roll-forward"); @@ -18,13 +33,27 @@ internal static CommandSpec CreatePackageCommandSpecUsingMuxer(string commandPat } arguments.Add(commandPath); - var filteredCommandArgs = rollForwardArgument.Any() - ? commandArguments.Except(rollForwardArgument) - : commandArguments; - arguments.AddRange(filteredCommandArgs); - - var host = new Muxer().MuxerPath; - var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(arguments); - return new CommandSpec(host, escapedArgs, environment); + + if (commandArguments != null) + { + if (rollForwardArgument.Any()) + { + arguments.AddRange(commandArguments.Except(rollForwardArgument)); + } + else + { + arguments.AddRange(commandArguments); + } + } + return CreateCommandSpec(host, arguments); + } + + private static CommandSpec CreateCommandSpec( + string commandPath, + IEnumerable commandArguments) + { + var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(commandArguments); + + return new CommandSpec(commandPath, escapedArgs); } } diff --git a/src/Cli/dotnet/CommandFactory/CommandSpec.cs b/src/Cli/dotnet/CommandFactory/CommandSpec.cs index 0b0cf47a9c56..6af14dd0b931 100644 --- a/src/Cli/dotnet/CommandFactory/CommandSpec.cs +++ b/src/Cli/dotnet/CommandFactory/CommandSpec.cs @@ -5,13 +5,16 @@ namespace Microsoft.DotNet.Cli.CommandFactory; -public class CommandSpec(string path, string? args, IDictionary? environmentVariables = null) +public class CommandSpec( + string path, + string? args, + Dictionary? environmentVariables = null) { public string Path { get; } = path; public string? Args { get; } = args; - public IDictionary EnvironmentVariables { get; } = environmentVariables ?? new Dictionary(); + public Dictionary EnvironmentVariables { get; } = environmentVariables ?? []; internal void AddEnvironmentVariablesFromProject(IProject project) { diff --git a/src/Cli/dotnet/CommandParsingException.cs b/src/Cli/dotnet/CommandParsingException.cs index 6582c7373d84..f932b55aefb6 100644 --- a/src/Cli/dotnet/CommandParsingException.cs +++ b/src/Cli/dotnet/CommandParsingException.cs @@ -1,18 +1,21 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System.CommandLine; namespace Microsoft.DotNet.Cli; internal class CommandParsingException : Exception { - public CommandParsingException(string message, ParseResult? parseResult = null) - : base(message) + public CommandParsingException( + string message, + ParseResult parseResult = null) : base(message) { ParseResult = parseResult; Data.Add("CLI_User_Displayed_Exception", true); } - public ParseResult? ParseResult; + public ParseResult ParseResult; } diff --git a/src/Cli/dotnet/Commands/Build/BuildCommand.cs b/src/Cli/dotnet/Commands/Build/BuildCommand.cs index b0276710516d..cde474ec73c2 100644 --- a/src/Cli/dotnet/Commands/Build/BuildCommand.cs +++ b/src/Cli/dotnet/Commands/Build/BuildCommand.cs @@ -29,7 +29,7 @@ public static CommandBase FromParseResult(ParseResult parseResult, string? msbui bool noRestore = parseResult.HasOption(definition.NoRestoreOption); - return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( + return CommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, createVirtualCommand: (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs b/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs index bc1289c7ae2e..316b422c8a85 100644 --- a/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs +++ b/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CommandLine; using Microsoft.DotNet.Cli.CommandLine; namespace Microsoft.DotNet.Cli.Commands.Build; diff --git a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs index 5584a77f2461..d1772e247a4f 100644 --- a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs +++ b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs @@ -22,7 +22,7 @@ public static CommandBase FromParseResult(ParseResult result, string? msbuildPat var definition = (CleanCommandDefinition)result.CommandResult.Command; result.ShowHelpOrErrorIfAppropriate(); - return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( + return CommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, createVirtualCommand: static (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/DotNetCommandFactory.cs b/src/Cli/dotnet/Commands/CommandFactory.cs similarity index 54% rename from src/Cli/dotnet/Commands/DotNetCommandFactory.cs rename to src/Cli/dotnet/Commands/CommandFactory.cs index ba08b4864f7d..6f50a6b2d4d7 100644 --- a/src/Cli/dotnet/Commands/DotNetCommandFactory.cs +++ b/src/Cli/dotnet/Commands/CommandFactory.cs @@ -1,49 +1,16 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.CommandLine.Invocation; -using System.Diagnostics; -using Microsoft.DotNet.Cli.CommandFactory; using Microsoft.DotNet.Cli.CommandLine; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.ProjectTools; -using NuGet.Frameworks; -namespace Microsoft.DotNet.Cli; +namespace Microsoft.DotNet.Cli.Commands; -public class DotNetCommandFactory(bool alwaysRunOutOfProc = false, string? currentWorkingDirectory = null) : ICommandFactory +public static class CommandFactory { - private readonly bool _alwaysRunOutOfProc = alwaysRunOutOfProc; - private readonly string? _currentWorkingDirectory = currentWorkingDirectory; - - public ICommand Create(string commandName, IEnumerable args, NuGetFramework? framework = null, string configuration = Constants.DefaultConfiguration) - { - if (!_alwaysRunOutOfProc && TryGetBuiltInCommand(commandName, out var builtInCommand)) - { - Debug.Assert(framework == null, "BuiltInCommand doesn't support the 'framework' argument."); - Debug.Assert(configuration == Constants.DefaultConfiguration, "BuiltInCommand doesn't support the 'configuration' argument."); - - return new BuiltInCommand(commandName, args, builtInCommand); - } - - return CommandFactoryUsingResolver.CreateDotNet(commandName, args, framework, configuration, _currentWorkingDirectory); - } - - private static bool TryGetBuiltInCommand(string commandName, out Func commandFunc) - { - var command = Parser.GetBuiltInCommand(commandName); - if (command?.Action is AsynchronousCommandLineAction action) - { - commandFunc = (args) => Parser.Invoke([commandName, .. args]); - return true; - } - // No-op delegate for failure case. - commandFunc = (args) => 1; - return false; - } - internal static CommandBase CreateVirtualOrPhysicalCommand( System.CommandLine.Command commandDefinition, Argument catchAllUserInputArgument, diff --git a/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs b/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs index c48ff5353e5c..c6a6d87ff9df 100644 --- a/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs +++ b/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs @@ -1,29 +1,36 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System.CommandLine; +using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Configurer; using Microsoft.DotNet.Utilities; namespace Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; public class InternalReportInstallSuccessCommand { + internal const string TelemetrySessionIdEnvironmentVariableName = "DOTNET_CLI_TELEMETRY_SESSIONID"; + public static int Run(ParseResult parseResult) { var telemetry = new ThreadBlockingTelemetry(); ProcessInputAndSendTelemetry(parseResult, telemetry); + telemetry.Dispose(); return 0; } - public static void ProcessInputAndSendTelemetry(string[] args, ITelemetryClient telemetry) + public static void ProcessInputAndSendTelemetry(string[] args, ITelemetry telemetry) { var result = Parser.Parse(["dotnet", "internal-reportinstallsuccess", .. args]); ProcessInputAndSendTelemetry(result, telemetry); } - public static void ProcessInputAndSendTelemetry(ParseResult result, ITelemetryClient telemetry) + public static void ProcessInputAndSendTelemetry(ParseResult result, ITelemetry telemetry) { var definition = (InternalReportInstallSuccessCommandDefinition)result.CommandResult.Command; var exeName = Path.GetFileName(result.GetValue(definition.Argument)); @@ -31,25 +38,39 @@ public static void ProcessInputAndSendTelemetry(ParseResult result, ITelemetryCl var filter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); foreach (var e in filter.Filter(new InstallerSuccessReport(exeName))) { - telemetry.TrackEvent(e.EventName, e.Properties); + telemetry.TrackEvent(e.EventName, e.Properties, null); } } - internal class ThreadBlockingTelemetry : ITelemetryClient + internal class ThreadBlockingTelemetry : ITelemetry { - private readonly TelemetryClient _telemetry; + private readonly Telemetry.Telemetry telemetry; internal ThreadBlockingTelemetry() { - var sessionId = Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_SESSIONID); - _telemetry = new TelemetryClient(sessionId); + var sessionId = + Environment.GetEnvironmentVariable(TelemetrySessionIdEnvironmentVariableName); + telemetry = new Telemetry.Telemetry(new NoOpFirstTimeUseNoticeSentinel(), sessionId, blockThreadInitialization: true); + } + public bool Enabled => telemetry.Enabled; + + public void Flush() + { } - public bool Enabled => _telemetry.Enabled; + public void Dispose() + { + telemetry.Dispose(); + } - public void TrackEvent(string eventName, IDictionary? properties) + public void TrackEvent(string eventName, IDictionary properties, IDictionary measurements) { - _telemetry.ThreadBlockingTrackEvent(eventName, properties); + telemetry.ThreadBlockingTrackEvent(eventName, properties, measurements); } } } + +internal class InstallerSuccessReport(string exeName) +{ + public string ExeName { get; } = exeName ?? throw new ArgumentNullException(nameof(exeName)); +} diff --git a/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs b/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs index 7f8364ddf11c..d6fb42ac39f0 100644 --- a/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs +++ b/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Reflection; using Microsoft.DotNet.Cli.Commands.Run; -using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -12,6 +11,8 @@ namespace Microsoft.DotNet.Cli.Commands.MSBuild; public class MSBuildForwardingApp : CommandBase { + internal const string TelemetrySessionIdEnvironmentVariableName = "DOTNET_CLI_TELEMETRY_SESSIONID"; + private readonly MSBuildForwardingAppWithoutLogging _forwardingAppWithoutLogging; /// @@ -19,7 +20,7 @@ public class MSBuildForwardingApp : CommandBase /// private static MSBuildArgs ConcatTelemetryLogger(MSBuildArgs msbuildArgs) { - if (TelemetryClient.CurrentSessionId != null) + if (Telemetry.Telemetry.CurrentSessionId != null) { try { @@ -54,6 +55,12 @@ public MSBuildForwardingApp(MSBuildArgs msBuildArgs, string? msbuildPath = null) _forwardingAppWithoutLogging = new MSBuildForwardingAppWithoutLogging( modifiedMSBuildArgs, msbuildPath: msbuildPath); + + // Add the performance log location to the environment of the target process. + if (PerformanceLogManager.Instance != null && !string.IsNullOrEmpty(PerformanceLogManager.Instance.CurrentLogDirectory)) + { + EnvironmentVariable(PerformanceLogManager.PerfLogDirEnvVar, PerformanceLogManager.Instance.CurrentLogDirectory); + } } public IEnumerable MSBuildArguments { get { return _forwardingAppWithoutLogging.GetAllArguments(); } } @@ -72,7 +79,7 @@ public ProcessStartInfo GetProcessStartInfo() private void InitializeRequiredEnvironmentVariables() { - EnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_SESSIONID, TelemetryClient.CurrentSessionId); + EnvironmentVariable(TelemetrySessionIdEnvironmentVariableName, Telemetry.Telemetry.CurrentSessionId); } /// @@ -92,13 +99,23 @@ public override int Execute() if (_forwardingAppWithoutLogging.ExecuteMSBuildOutOfProc) { ProcessStartInfo startInfo = GetProcessStartInfo(); + + PerformanceLogEventSource.Log.LogMSBuildStart(startInfo.FileName, startInfo.Arguments); exitCode = startInfo.Execute(); + PerformanceLogEventSource.Log.MSBuildStop(exitCode); } else { InitializeRequiredEnvironmentVariables(); string[] arguments = _forwardingAppWithoutLogging.GetAllArguments(); + if (PerformanceLogEventSource.Log.IsEnabled()) + { + PerformanceLogEventSource.Log.LogMSBuildStart( + _forwardingAppWithoutLogging.MSBuildPath, + ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(arguments)); + } exitCode = _forwardingAppWithoutLogging.ExecuteInProc(arguments); + PerformanceLogEventSource.Log.MSBuildStop(exitCode); } return exitCode; diff --git a/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs b/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs index 608f4caf5273..bf770da1c33b 100644 --- a/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs +++ b/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs @@ -1,16 +1,20 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Microsoft.Build.Framework; using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Configurer; using Microsoft.DotNet.Utilities; namespace Microsoft.DotNet.Cli.Commands.MSBuild; public sealed class MSBuildLogger : INodeLogger { - private readonly ITelemetryClient? _telemetry; + private readonly IFirstTimeUseNoticeSentinel _sentinel = + new FirstTimeUseNoticeSentinel(); + private readonly ITelemetry? _telemetry; internal const string TargetFrameworkTelemetryEventName = "targetframeworkeval"; internal const string BuildTelemetryEventName = "build"; @@ -62,11 +66,19 @@ public MSBuildLogger() { try { - string? sessionId = Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_SESSIONID); + string? sessionId = + Environment.GetEnvironmentVariable(MSBuildForwardingApp.TelemetrySessionIdEnvironmentVariableName); if (sessionId != null) { - _telemetry = new TelemetryClient(sessionId); + // senderCount: 0 to disable sender. + // When senders in different process running at the same + // time they will read from the same global queue and cause + // sending duplicated events. Disable sender to reduce it. + _telemetry = new Telemetry.Telemetry( + _sentinel, + sessionId, + senderCount: 0); } } catch (Exception) @@ -78,7 +90,7 @@ public MSBuildLogger() /// /// Constructor for testing purposes. /// - internal MSBuildLogger(ITelemetryClient telemetry) + internal MSBuildLogger(ITelemetry telemetry) { _telemetry = telemetry; } @@ -105,6 +117,8 @@ public void Initialize(IEventSource eventSource) { eventSource2.TelemetryLogged += OnTelemetryLogged; } + + eventSource.BuildFinished += OnBuildFinished; } eventSource.BuildFinished += OnBuildFinished; @@ -120,14 +134,14 @@ private void OnBuildFinished(object sender, BuildFinishedEventArgs e) SendAggregatedEventsOnBuildFinished(_telemetry); } - internal void SendAggregatedEventsOnBuildFinished(ITelemetryClient? telemetry) + internal void SendAggregatedEventsOnBuildFinished(ITelemetry? telemetry) { if (telemetry is null) return; if (_aggregatedEvents.TryGetValue(TaskFactoryTelemetryAggregatedEventName, out var taskFactoryData)) { Dictionary taskFactoryProperties = ConvertToStringDictionary(taskFactoryData); - TrackEvent(telemetry, $"msbuild/{TaskFactoryTelemetryAggregatedEventName}", taskFactoryProperties, toBeHashed: []); + TrackEvent(telemetry, $"msbuild/{TaskFactoryTelemetryAggregatedEventName}", taskFactoryProperties, toBeHashed: [], toBeMeasured: []); _aggregatedEvents.Remove(TaskFactoryTelemetryAggregatedEventName); } @@ -135,7 +149,7 @@ internal void SendAggregatedEventsOnBuildFinished(ITelemetryClient? telemetry) { Dictionary tasksProperties = ConvertToStringDictionary(tasksData); - TrackEvent(telemetry, $"msbuild/{TasksTelemetryAggregatedEventName}", tasksProperties, toBeHashed: []); + TrackEvent(telemetry, $"msbuild/{TasksTelemetryAggregatedEventName}", tasksProperties, toBeHashed: [], toBeMeasured: []); _aggregatedEvents.Remove(TasksTelemetryAggregatedEventName); } } @@ -176,7 +190,7 @@ internal void AggregateEvent(TelemetryEventArgs args) } } - internal static void FormatAndSend(ITelemetryClient? telemetry, TelemetryEventArgs args) + internal static void FormatAndSend(ITelemetry? telemetry, TelemetryEventArgs args) { switch (args.EventName) { @@ -185,13 +199,14 @@ internal static void FormatAndSend(ITelemetryClient? telemetry, TelemetryEventAr break; case BuildTelemetryEventName: TrackEvent(telemetry, $"msbuild/{BuildTelemetryEventName}", args.Properties, - toBeHashed: ["ProjectPath", "BuildTarget"] + toBeHashed: ["ProjectPath", "BuildTarget"], + toBeMeasured: ["BuildDurationInMilliseconds", "InnerBuildDurationInMilliseconds"] ); break; case LoggingConfigurationTelemetryEventName: TrackEvent(telemetry, $"msbuild/{LoggingConfigurationTelemetryEventName}", args.Properties, - toBeHashed: [] - ); + toBeHashed: [], + toBeMeasured: []); break; case BuildcheckAcquisitionFailureEventName: TrackEvent(telemetry, $"msbuild/{BuildcheckAcquisitionFailureEventName}", args.Properties, @@ -199,11 +214,14 @@ internal static void FormatAndSend(ITelemetryClient? telemetry, TelemetryEventAr ); break; case BuildcheckRunEventName: - TrackEvent(telemetry, $"msbuild/{BuildcheckRunEventName}", args.Properties); + TrackEvent(telemetry, $"msbuild/{BuildcheckRunEventName}", args.Properties, + toBeMeasured: ["TotalRuntimeInMilliseconds"] + ); break; case BuildcheckRuleStatsEventName: TrackEvent(telemetry, $"msbuild/{BuildcheckRuleStatsEventName}", args.Properties, - toBeHashed: ["RuleId", "CheckFriendlyName"] + toBeHashed: ["RuleId", "CheckFriendlyName"], + toBeMeasured: ["TotalRuntimeInMilliseconds"] ); break; // Pass through events that don't need special handling @@ -222,7 +240,7 @@ internal static void FormatAndSend(ITelemetryClient? telemetry, TelemetryEventAr } } - private static void TrackEvent(ITelemetryClient? telemetry, string eventName, IDictionary eventProperties, string[]? toBeHashed = null) + private static void TrackEvent(ITelemetry? telemetry, string eventName, IDictionary eventProperties, string[]? toBeHashed = null, string[]? toBeMeasured = null) { if (telemetry == null || !telemetry.Enabled) { @@ -230,6 +248,7 @@ private static void TrackEvent(ITelemetryClient? telemetry, string eventName, ID } Dictionary? properties = null; + Dictionary? measurements = null; if (toBeHashed is not null) { @@ -244,7 +263,26 @@ private static void TrackEvent(ITelemetryClient? telemetry, string eventName, ID } } - telemetry?.TrackEvent(eventName, properties ?? eventProperties); + if (toBeMeasured is not null) + { + foreach (var propertyToBeMeasured in toBeMeasured) + { + if (eventProperties.TryGetValue(propertyToBeMeasured, out var value)) + { + // Lets lazy allocate in case there is tons of telemetry + properties ??= new(eventProperties); + properties.Remove(propertyToBeMeasured); + if (double.TryParse(value, CultureInfo.InvariantCulture, out double realValue)) + { + // Lets lazy allocate in case there is tons of telemetry + measurements ??= []; + measurements[propertyToBeMeasured] = realValue; + } + } + } + } + + telemetry.TrackEvent(eventName, properties ?? eventProperties, measurements); } private void OnTelemetryLogged(object sender, TelemetryEventArgs args) @@ -261,6 +299,14 @@ private void OnTelemetryLogged(object sender, TelemetryEventArgs args) public void Shutdown() { + try + { + _sentinel?.Dispose(); + } + catch (Exception) + { + // Exceptions during telemetry shouldn't cause anything else to fail + } } public LoggerVerbosity Verbosity { get; set; } diff --git a/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs b/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs index f25795528dd4..8858145a6c0a 100644 --- a/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs +++ b/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs @@ -1,7 +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 Microsoft.DotNet.Cli.Utils; +#nullable disable + using Microsoft.TemplateEngine.Abstractions; using Microsoft.TemplateEngine.Abstractions.TemplatePackage; using NuGet.Versioning; @@ -17,16 +18,14 @@ internal sealed class BuiltInTemplatePackageProvider(BuiltInTemplatePackageProvi public ITemplatePackageProviderFactory Factory { get; } = factory; +#pragma warning disable CS0067 /// /// We don't trigger this event, we could complicate our life with FileSystemWatcher. - /// But since "dotnet new" is short lived process is not worth it, plus it would cause some perf hit... - /// To avoid warnings about being unused, implement empty add/remove accessors. + /// But since "dotnet new" is short lived process is not worth it, plus it would cause + /// some perf hit... /// - public event Action? TemplatePackagesChanged - { - add { } - remove { } - } + public event Action TemplatePackagesChanged; +#pragma warning restore CS0067 public Task> GetAllTemplatePackagesAsync(CancellationToken cancellationToken) { @@ -45,12 +44,14 @@ private static IEnumerable GetTemplateFolders(IEngineEnvironmentSettings { var templateFoldersToInstall = new List(); - var sdksDirectory = new DirectoryInfo(MSBuildForwardingAppWithoutLogging.GetMSBuildSDKsPath()); - var sdkDirectory = sdksDirectory.Parent; - var sdkPath = sdkDirectory?.FullName ?? string.Empty; - var dotnetRootPath = sdkDirectory?.Parent?.Parent?.FullName ?? string.Empty; +#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file + var sdkDirectory = Path.GetDirectoryName(typeof(Utils.DotnetFiles).Assembly.Location); +#pragma warning restore IL3000 + + var dotnetRootPath = Path.GetDirectoryName(Path.GetDirectoryName(sdkDirectory)); + // First grab templates from dotnet\templates\M.m folders, in ascending order, up to our version - string templatesRootFolder = Path.Combine(dotnetRootPath, "templates"); + string templatesRootFolder = Path.GetFullPath(Path.Combine(dotnetRootPath, "templates")); if (Directory.Exists(templatesRootFolder)) { IReadOnlyDictionary parsedNames = GetVersionDirectoriesInDirectory(templatesRootFolder); @@ -61,7 +62,7 @@ private static IEnumerable GetTemplateFolders(IEngineEnvironmentSettings } // Now grab templates from our base folder, if present. - string templatesDir = Path.Combine(sdkPath, "Templates"); + string templatesDir = Path.Combine(sdkDirectory, "Templates"); if (Directory.Exists(templatesDir)) { templateFoldersToInstall.Add(templatesDir); @@ -78,7 +79,7 @@ private static IReadOnlyDictionary GetVersionDirectorie foreach (string directory in Directory.EnumerateDirectories(fullPath, "*.*", SearchOption.TopDirectoryOnly)) { - if (SemanticVersion.TryParse(Path.GetFileName(directory), out SemanticVersion? versionInfo) && versionInfo is not null) + if (SemanticVersion.TryParse(Path.GetFileName(directory), out SemanticVersion versionInfo)) { versionFileInfo.Add(directory, versionInfo); } @@ -91,7 +92,7 @@ internal static IList GetBestVersionsByMajorMinor(IReadOnlyDictionary bestVersionsByBucket = new Dictionary(); - Version? sdkVersion = typeof(NewCommandParser).Assembly.GetName().Version; + Version sdkVersion = typeof(NewCommandParser).Assembly.GetName().Version; foreach (KeyValuePair dirInfo in versionDirInfo) { var majorMinorDirVersion = new Version(dirInfo.Value.Major, dirInfo.Value.Minor); diff --git a/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs b/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs index 3ddb261d7340..318f1ec64418 100644 --- a/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs +++ b/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs @@ -110,6 +110,8 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin projectPath = _projectFullPath; } + Stopwatch watch = new(); + Stopwatch innerBuildWatch = new(); bool IsSdkStyleProject = false; IReadOnlyList? targetFrameworks = null; string? targetFramework = null; @@ -117,6 +119,7 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin try { + watch.Start(); _logger?.LogDebug("Evaluating project: {0}", projectPath); MSBuildProject evaluatedProject = RunEvaluate(projectPath); @@ -161,11 +164,13 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin //For multi-target project, we need to do additional evaluation for each target framework. Dictionary evaluatedTfmBasedProjects = []; + innerBuildWatch.Start(); foreach (string tfm in targetFrameworks) { _logger?.LogDebug("Evaluating project for target framework: {0}", tfm); evaluatedTfmBasedProjects[tfm] = RunEvaluate(projectPath, tfm); } + innerBuildWatch.Stop(); _logger?.LogDebug("Project is SDK style, multi-target, evaluation succeeded."); return result = MultiTargetEvaluationResult.CreateSuccess(projectPath, evaluatedProject, evaluatedTfmBasedProjects); @@ -177,6 +182,9 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin } finally { + watch.Stop(); + innerBuildWatch.Stop(); + string? targetFrameworksString = null; if (targetFrameworks != null) @@ -196,7 +204,13 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin { "TargetFrameworks", targetFrameworksString ?? ""}, }; - TelemetryEventEntry.TrackEvent("new/msbuild-eval", properties); + Dictionary measurements = new() + { + { "EvaluationTime", watch.ElapsedMilliseconds }, + { "InnerEvaluationTime", innerBuildWatch.ElapsedMilliseconds } + }; + + TelemetryEventEntry.TrackEvent("new/msbuild-eval", properties, measurements); } } diff --git a/src/Cli/dotnet/Commands/New/NewCommandParser.cs b/src/Cli/dotnet/Commands/New/NewCommandParser.cs index 68b5268c8579..4a2f614f2ca8 100644 --- a/src/Cli/dotnet/Commands/New/NewCommandParser.cs +++ b/src/Cli/dotnet/Commands/New/NewCommandParser.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Diagnostics; using Microsoft.DotNet.Cli.CommandLine; using Microsoft.DotNet.Cli.Commands.New.MSBuildEvaluation; using Microsoft.DotNet.Cli.Commands.New.PostActions; @@ -15,6 +16,7 @@ using Microsoft.TemplateEngine.Abstractions.Constraints; using Microsoft.TemplateEngine.Abstractions.TemplatePackage; using Microsoft.TemplateEngine.Cli; +using Microsoft.TemplateEngine.Cli.Commands; using Microsoft.TemplateEngine.Cli.PostActionProcessors; using Command = System.CommandLine.Command; diff --git a/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.cs b/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.cs index 77e48fe7b32a..cf803c32c3c3 100644 --- a/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.cs +++ b/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.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. +#nullable disable + using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Configurer; using Microsoft.TemplateEngine.Abstractions; @@ -20,8 +22,8 @@ internal OptionalWorkloadProvider(ITemplatePackageProviderFactory factory, IEngi public ITemplatePackageProviderFactory Factory { get; } - // To avoid warnings about being unused, implement empty add/remove accessors. - event Action? ITemplatePackageProvider.TemplatePackagesChanged + // To avoid warnings about unused, its implemented via add/remove + event Action ITemplatePackageProvider.TemplatePackagesChanged { add { } remove { } @@ -31,13 +33,14 @@ public Task> GetAllTemplatePackagesAsync(Cancell { var list = new List(); var optionalWorkloadLocator = new TemplateLocator.TemplateLocator(); - var sdksDirectory = new DirectoryInfo(MSBuildForwardingAppWithoutLogging.GetMSBuildSDKsPath()); - var sdkDirectory = sdksDirectory?.Parent; - var sdkVersion = sdkDirectory?.Name; - var dotnetRootPath = sdkDirectory?.Parent?.Parent; +#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file + var sdkDirectory = Path.GetDirectoryName(typeof(DotnetFiles).Assembly.Location); +#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file + var sdkVersion = Path.GetFileName(sdkDirectory); + var dotnetRootPath = Path.GetDirectoryName(Path.GetDirectoryName(sdkDirectory)); string userProfileDir = CliFolderPathCalculator.DotnetUserProfileFolderPath; - var packages = optionalWorkloadLocator.GetDotnetSdkTemplatePackages(sdkVersion, dotnetRootPath?.FullName, userProfileDir); + var packages = optionalWorkloadLocator.GetDotnetSdkTemplatePackages(sdkVersion, dotnetRootPath, userProfileDir); var fileSystem = _environmentSettings.Host.FileSystem; foreach (var packageInfo in packages) { diff --git a/src/Cli/dotnet/Commands/Pack/PackCommand.cs b/src/Cli/dotnet/Commands/Pack/PackCommand.cs index 9300fa1b8310..ab7d0ba0542b 100644 --- a/src/Cli/dotnet/Commands/Pack/PackCommand.cs +++ b/src/Cli/dotnet/Commands/Pack/PackCommand.cs @@ -5,6 +5,7 @@ using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.CommandLine; +using Microsoft.DotNet.Cli.Commands.Build; using Microsoft.DotNet.Cli.Commands.Restore; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Extensions; @@ -12,6 +13,7 @@ using Microsoft.DotNet.Cli.Utils; using NuGet.Commands; using NuGet.Common; +using NuGet.Packaging; namespace Microsoft.DotNet.Cli.Commands.Pack; @@ -38,7 +40,7 @@ public static CommandBase FromParseResult(ParseResult parseResult, string? msbui bool noRestore = noBuild || parseResult.HasOption(definition.NoRestoreOption); - return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( + return CommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs b/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs index 370d35d6c816..7c418218f52d 100644 --- a/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs +++ b/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CommandLine; using Microsoft.DotNet.Cli.CommandLine; namespace Microsoft.DotNet.Cli.Commands.Pack; diff --git a/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs b/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs index 9ec9c4acd7a1..36d44cee3931 100644 --- a/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs +++ b/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Project.Convert; using Microsoft.DotNet.Cli.Extensions; diff --git a/src/Cli/dotnet/Commands/Publish/PublishCommand.cs b/src/Cli/dotnet/Commands/Publish/PublishCommand.cs index 6188060bf16d..bb68394da143 100644 --- a/src/Cli/dotnet/Commands/Publish/PublishCommand.cs +++ b/src/Cli/dotnet/Commands/Publish/PublishCommand.cs @@ -44,7 +44,7 @@ public static CommandBase FromParseResult(ParseResult parseResult, string? msbui bool noRestore = noBuild || parseResult.HasOption(definition.NoRestoreOption); - return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( + return CommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs b/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs index 8f2e96fb3f91..2743d1ffe048 100644 --- a/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs +++ b/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs @@ -24,7 +24,7 @@ public static CommandBase FromParseResult(ParseResult result, string? msbuildPat result.HandleDebugSwitch(); result.ShowHelpOrErrorIfAppropriate(); - return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( + return CommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, static (msbuildArgs, appFilePath) => diff --git a/src/Cli/dotnet/Commands/Run/RunTelemetry.cs b/src/Cli/dotnet/Commands/Run/RunTelemetry.cs index 4a16683d53f8..0d3ab62507b8 100644 --- a/src/Cli/dotnet/Commands/Run/RunTelemetry.cs +++ b/src/Cli/dotnet/Commands/Run/RunTelemetry.cs @@ -48,9 +48,13 @@ public static void TrackRunEvent( { ["app_type"] = isFileBased ? "file_based" : "project_based", ["project_id"] = projectIdentifier, - ["sdk_count"] = sdkCount.ToString(), - ["package_reference_count"] = packageReferenceCount.ToString(), - ["project_reference_count"] = projectReferenceCount.ToString(), + }; + + var measurements = new Dictionary + { + ["sdk_count"] = sdkCount, + ["package_reference_count"] = packageReferenceCount, + ["project_reference_count"] = projectReferenceCount, }; // Launch profile telemetry @@ -76,7 +80,7 @@ public static void TrackRunEvent( // File-based app specific telemetry if (isFileBased) { - properties["additional_properties_count"] = additionalPropertiesCount.ToString(); + measurements["additional_properties_count"] = additionalPropertiesCount; if (usedMSBuild.HasValue) { properties["used_msbuild"] = usedMSBuild.Value ? "true" : "false"; @@ -87,7 +91,7 @@ public static void TrackRunEvent( } } - TelemetryEventEntry.TrackEvent(RunEventName, properties); + TelemetryEventEntry.TrackEvent(RunEventName, properties, measurements); } /// diff --git a/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs b/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs index 708e9d0fa93e..61d4b803baa7 100644 --- a/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs +++ b/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs @@ -38,7 +38,7 @@ public ToolExecuteCommand(ParseResult result, ToolManifestFinder? toolManifestFi : base(result) { _packageToolIdentityArgument = result.GetValue(Definition.PackageIdentityArgument); - _forwardArguments = result.GetValue(Definition.CommandArgument) ?? []; + _forwardArguments = result.GetValue(Definition.CommandArgument) ?? Enumerable.Empty(); _allowRollForward = result.GetValue(Definition.RollForwardOption); _configFile = result.GetValue(Definition.ConfigOption); _sources = result.GetValue(Definition.SourceOption) ?? []; diff --git a/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs b/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs index 72262f1294c0..4b42713ae006 100644 --- a/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs +++ b/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs @@ -34,7 +34,7 @@ public override int Execute() public static int ExecuteCommand(LocalToolsCommandResolver commandResolver, string? toolCommandName, IEnumerable? argumentsToForward, bool allowRollForward) { using var _ = Activities.Source.StartActivity("execute-local-tool"); - CommandSpec? commandSpec = commandResolver.ResolveStrict(new CommandResolverArguments() + CommandSpec commandSpec = commandResolver.ResolveStrict(new CommandResolverArguments() { // since LocalToolsCommandResolver is a resolver, and all resolver input have dotnet- CommandName = $"dotnet-{toolCommandName}", @@ -49,4 +49,4 @@ public static int ExecuteCommand(LocalToolsCommandResolver commandResolver, stri var result = CommandFactoryUsingResolver.Create(commandSpec).Execute(); return result.ExitCode; } -} +} \ No newline at end of file diff --git a/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs b/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs index 84249ba7b5c4..f043eafc93be 100644 --- a/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs +++ b/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs @@ -11,20 +11,30 @@ internal class ToolCommandSpecCreator { public static CommandSpec CreateToolCommandSpec(string toolName, string toolExecutable, string toolRunner, bool allowRollForward, IEnumerable commandArguments) { - var environment = ActivityContextFactory.MakeActivityContextEnvironment(); - switch (toolRunner) + if (toolRunner == "dotnet") { - case "dotnet": - if (allowRollForward) - { - commandArguments = ["--allow-roll-forward", .. commandArguments]; - } - return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer(toolExecutable, commandArguments, environment); - case "executable": - var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(commandArguments); - return new CommandSpec(toolExecutable, escapedArgs, environment); - default: - throw new GracefulException(string.Format(CliStrings.ToolSettingsUnsupportedRunner, toolName, toolRunner)); + if (allowRollForward) + { + commandArguments = ["--allow-roll-forward", .. commandArguments]; + } + + return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer( + toolExecutable, + commandArguments); + } + else if (toolRunner == "executable") + { + var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart( + commandArguments); + + return new CommandSpec( + toolExecutable, + escapedArgs); + } + else + { + throw new GracefulException(string.Format(CliStrings.ToolSettingsUnsupportedRunner, + toolName, toolRunner)); } } } diff --git a/src/Cli/dotnet/DotNetCommandFactory.cs b/src/Cli/dotnet/DotNetCommandFactory.cs new file mode 100644 index 000000000000..dcb70b05e6c9 --- /dev/null +++ b/src/Cli/dotnet/DotNetCommandFactory.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.CommandLine.Invocation; +using System.Diagnostics; +using Microsoft.DotNet.Cli.CommandFactory; +using Microsoft.DotNet.Cli.Utils; +using NuGet.Frameworks; + +namespace Microsoft.DotNet.Cli; + +public class DotNetCommandFactory(bool alwaysRunOutOfProc = false, string currentWorkingDirectory = null) : ICommandFactory +{ + private readonly bool _alwaysRunOutOfProc = alwaysRunOutOfProc; + private readonly string _currentWorkingDirectory = currentWorkingDirectory; + + public ICommand Create( + string commandName, + IEnumerable args, + NuGetFramework framework = null, + string configuration = Constants.DefaultConfiguration) + { + if (!_alwaysRunOutOfProc && TryGetBuiltInCommand(commandName, out var builtInCommand)) + { + Debug.Assert(framework == null, "BuiltInCommand doesn't support the 'framework' argument."); + Debug.Assert(configuration == Constants.DefaultConfiguration, "BuiltInCommand doesn't support the 'configuration' argument."); + + return new BuiltInCommand(commandName, args, builtInCommand); + } + + return CommandFactoryUsingResolver.CreateDotNet(commandName, args, framework, configuration, _currentWorkingDirectory); + } + + private static bool TryGetBuiltInCommand(string commandName, out Func commandFunc) + { + var command = Parser.GetBuiltInCommand(commandName); + if (command?.Action is AsynchronousCommandLineAction action) + { + commandFunc = (args) => Parser.Invoke([commandName, .. args]); + return true; + } + commandFunc = null; + return false; + } +} diff --git a/src/Cli/dotnet/Extensions/ActivityExtensions.cs b/src/Cli/dotnet/Extensions/ActivityExtensions.cs deleted file mode 100644 index b29d0d86de94..000000000000 --- a/src/Cli/dotnet/Extensions/ActivityExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; -using System.Diagnostics; - -namespace Microsoft.DotNet.Cli.Extensions; - -internal static class ActivityExtensions -{ - public static void SetDisplayName(this Activity? activity, ParseResult parseResult) - { - if (activity is null) - { - return; - } - - var name = parseResult.GetCommandName(); - activity.DisplayName = name; - activity.SetTag("command.name", name); - } -} diff --git a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs index 865ae37cdf18..dc23ee379040 100644 --- a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs +++ b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs @@ -14,27 +14,25 @@ namespace Microsoft.DotNet.Cli.Extensions; public static class ParseResultExtensions { - /// + /// /// Finds the command of the parse result and invokes help for that command. /// If no command is specified, invokes help for the application. - /// - /// + /// + /// /// This is accomplished by finding a set of tokens that should be valid and appending a help token /// to that list, then re-parsing the list of tokens. This is not ideal - either we should have a direct way /// of invoking help for a ParseResult, or we should eliminate this custom, ad-hoc help invocation by moving /// more situations that want to show help into Parsing Errors (which trigger help in the default System.CommandLine pipeline) /// or custom Invocation Middleware, so we can more easily create our version of a HelpResult type. - /// + /// public static void ShowHelp(this ParseResult parseResult) { - // Take from the start of the list until we hit an option/--/unparsed token. - // Since commands can have arguments, we must take those as well in order to get accurate help. - var filteredTokenValues = parseResult.Tokens.TakeWhile(token => - token.Type == TokenType.Argument - || token.Type == TokenType.Command - || token.Type == TokenType.Directive) - .Select(t => t.Value); - Parser.Parse([.. filteredTokenValues, "-h"]).Invoke(); + // take from the start of the list until we hit an option/--/unparsed token + // since commands can have arguments, we must take those as well in order to get accurate help + Parser.Parse([ + ..parseResult.Tokens.TakeWhile(token => token.Type == TokenType.Argument || token.Type == TokenType.Command || token.Type == TokenType.Directive).Select(t => t.Value), + "-h" + ]).Invoke(); } public static void ShowHelpOrErrorIfAppropriate(this ParseResult parseResult) @@ -47,26 +45,24 @@ public static void ShowHelpOrErrorIfAppropriate(this ParseResult parseResult) var rawResourcePartsForThisLocale = DistinctFormatStringParts(CliStrings.UnrecognizedCommandOrArgument); return ErrorContainsAllParts(error.Message, rawResourcePartsForThisLocale); }); - - if (parseResult.CommandResult.Command.TreatUnmatchedTokensAsErrors - || parseResult.Errors.Except(unrecognizedTokenErrors).Any()) + if (parseResult.CommandResult.Command.TreatUnmatchedTokensAsErrors || + parseResult.Errors.Except(unrecognizedTokenErrors).Any()) { throw new CommandParsingException( - message: string.Join(Environment.NewLine, parseResult.Errors.Select(e => e.Message)), + message: string.Join(Environment.NewLine, + parseResult.Errors.Select(e => e.Message)), parseResult: parseResult); } } - /// - /// Splits a .NET format string by the format placeholders (the {N} parts) to get an array of the literal parts, to be used in message-checking. - /// - static string[] DistinctFormatStringParts(string formatString) => - // Match the literal '{', followed by any of 0-9 one or more times, followed by the literal '}'. - Regex.Split(formatString, @"{[0-9]+}"); + ///Splits a .NET format string by the format placeholders (the {N} parts) to get an array of the literal parts, to be used in message-checking + static string[] DistinctFormatStringParts(string formatString) + { + return Regex.Split(formatString, @"{[0-9]+}"); // match the literal '{', followed by any of 0-9 one or more times, followed by the literal '}' + } + - /// - /// Given a string and a series of parts, ensures that all parts are present in the string in sequential order. - /// + /// given a string and a series of parts, ensures that all parts are present in the string in sequential order static bool ErrorContainsAllParts(ReadOnlySpan error, string[] parts) { foreach (var part in parts) @@ -77,29 +73,39 @@ static bool ErrorContainsAllParts(ReadOnlySpan error, string[] parts) error = error.Slice(foundIndex + part.Length); continue; } - - return false; + else + { + return false; + } } - return true; } } - public static string RootSubCommandResult(this ParseResult parseResult) => parseResult.RootCommandResult.Children? - .Select(child => parseResult.GetSymbolResultValue(child)) - .FirstOrDefault(subcommand => !string.IsNullOrEmpty(subcommand)) ?? string.Empty; + public static string RootSubCommandResult(this ParseResult parseResult) + { + return parseResult.RootCommandResult.Children? + .Select(child => GetSymbolResultValue(parseResult, child)) + .FirstOrDefault(subcommand => !string.IsNullOrEmpty(subcommand)) ?? string.Empty; + } - public static bool IsDotnetBuiltInCommand(this ParseResult parseResult) => - string.IsNullOrEmpty(parseResult.RootSubCommandResult()) - || Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null; + public static bool IsDotnetBuiltInCommand(this ParseResult parseResult) + { + return string.IsNullOrEmpty(parseResult.RootSubCommandResult()) || + Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null; + } - public static bool IsTopLevelDotnetCommand(this ParseResult parseResult) => - parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult()); + public static bool IsTopLevelDotnetCommand(this ParseResult parseResult) + { + return parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult()); + } - public static bool CanBeInvoked(this ParseResult parseResult) => - Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null - || parseResult.Tokens.Any(token => token.Type == TokenType.Directive) - || (parseResult.IsTopLevelDotnetCommand() && string.IsNullOrEmpty(parseResult.GetValue(Parser.RootCommand.DotnetSubCommand))); + public static bool CanBeInvoked(this ParseResult parseResult) + { + return Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null || + parseResult.Tokens.Any(token => token.Type == TokenType.Directive) || + (parseResult.IsTopLevelDotnetCommand() && string.IsNullOrEmpty(parseResult.GetValue(Parser.RootCommand.DotnetSubCommand))); + } public static int HandleMissingCommand(this ParseResult parseResult) { @@ -108,8 +114,12 @@ public static int HandleMissingCommand(this ParseResult parseResult) return 1; } - public static string[] GetArguments(this ParseResult parseResult) => - parseResult.Tokens.Select(t => t.Value).ToArray().GetSubArguments(); + public static string[] GetArguments(this ParseResult parseResult) + { + return parseResult.Tokens.Select(t => t.Value) + .ToArray() + .GetSubArguments(); + } public static string[] GetSubArguments(this string[] args) { @@ -121,30 +131,54 @@ public static string[] GetSubArguments(this string[] args) var runArgs = dashDashIndex > -1 ? subargs.GetRange(dashDashIndex, subargs.Count() - dashDashIndex) : []; subargs = dashDashIndex > -1 ? subargs.GetRange(0, dashDashIndex) : subargs; - // Remove top level command (ex build or publish). - var subargsFiltered = subargs - .SkipWhile(arg => Parser.RootCommand.DiagOption.Name.Equals(arg) - || Parser.RootCommand.DiagOption.Aliases.Contains(arg) - || arg.Equals("dotnet")) - .Skip(1); + return + [ + .. subargs + .SkipWhile(arg => Parser.RootCommand.DiagOption.Name.Equals(arg) || Parser.RootCommand.DiagOption.Aliases.Contains(arg) || arg.Equals("dotnet")) + .Skip(1), // remove top level command (ex build or publish) + .. runArgs + ]; + } + + public static bool DiagOptionPrecedesSubcommand(this string[] args, string subCommand) + { + if (string.IsNullOrEmpty(subCommand)) + { + return true; + } + + for (var i = 0; i < args.Length; i++) + { + if (args[i].Equals(subCommand)) + { + return false; + } + else if (Parser.RootCommand.DiagOption.Name.Equals(args) || Parser.RootCommand.DiagOption.Aliases.Contains(args[i])) + { + return true; + } + } - return [.. subargsFiltered, .. runArgs]; + return false; } - private static string? GetSymbolResultValue(this ParseResult parseResult, SymbolResult symbolResult) => symbolResult switch + private static string? GetSymbolResultValue(ParseResult parseResult, SymbolResult symbolResult) => symbolResult switch { CommandResult commandResult => commandResult.Command.Name, ArgumentResult argResult => argResult.Tokens.FirstOrDefault()?.Value, _ => parseResult.GetResult(Parser.RootCommand.DotnetSubCommand)?.GetValueOrDefault() }; - public static IEnumerable? GetRunCommandShorthandProjectValues(this ParseResult parseResult) => - parseResult.GetRunPropertyOptions(true)?.Where(property => !property.Contains("=")); + public static IEnumerable? GetRunCommandShorthandProjectValues(this ParseResult parseResult) + { + var properties = GetRunPropertyOptions(parseResult, true); + return properties?.Where(property => !property.Contains("=")); + } public static IEnumerable GetRunCommandPropertyValues(this ParseResult parseResult) { - var shorthandProperties = parseResult.GetRunPropertyOptions(true)?.Where(property => property.Contains("=")); - var longhandProperties = parseResult.GetRunPropertyOptions(false); + var shorthandProperties = GetRunPropertyOptions(parseResult, true)?.Where(property => property.Contains("=")); + var longhandProperties = GetRunPropertyOptions(parseResult, false); return (shorthandProperties, longhandProperties) switch { (null, null) => Enumerable.Empty(), @@ -154,7 +188,7 @@ public static IEnumerable GetRunCommandPropertyValues(this ParseResult p }; } - private static IEnumerable? GetRunPropertyOptions(this ParseResult parseResult, bool shorthand) + private static IEnumerable? GetRunPropertyOptions(ParseResult parseResult, bool shorthand) { var optionString = shorthand ? "-p" : "--property"; var propertyOptions = parseResult.CommandResult.Children.Where(c => GetOptionTokenOrDefault(c)?.Value.Equals(optionString) ?? false); @@ -180,26 +214,4 @@ public static void HandleDebugSwitch(this ParseResult parseResult) DebugHelper.WaitForDebugger(); } } - - public static string GetCommandName(this ParseResult parseResult) - { - // Walk the parent command tree to find the top-level command name and get the full command name for this ParseResult. - List parentNames = [parseResult.CommandResult.Command.Name]; - var current = parseResult.CommandResult.Parent; - while (current is CommandResult parentCommandResult) - { - parentNames.Add(parentCommandResult.Command.Name); - current = parentCommandResult.Parent; - } - parentNames.Reverse(); - - // Options that perform terminating actions are considered part of the command name as they are essentially subcommands themselves. - // Example: dotnet --version - if (parseResult.Action is InvocableOptionAction { Terminating: true } optionAction) - { - parentNames.Add(optionAction.Option.Name); - } - - return string.Join(' ', parentNames); - } } diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 42b129db7067..475d59655a3b 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System.CommandLine; -using System.CommandLine.Help; +using System.CommandLine.Invocation; using System.CommandLine.StaticCompletions; using System.Reflection; using Microsoft.DotNet.Cli.Commands; @@ -45,6 +47,7 @@ using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; using Microsoft.TemplateEngine.Cli; +using Microsoft.TemplateEngine.Cli.Help; using Command = System.CommandLine.Command; namespace Microsoft.DotNet.Cli; @@ -63,14 +66,19 @@ private static DotNetCommandDefinition CreateCommand() { rootCommand.Options.RemoveAt(i); } - else if (option is HelpOption helpOption) + else if (option is System.CommandLine.Help.HelpOption helpOption) { - helpOption.Action = new PrintHelpAction(helpOption, DotnetHelpBuilder.Instance.Value); + helpOption.Action = new DotnetHelpAction() + { + Builder = DotnetHelpBuilder.Instance.Value + }; + option.Description = CliStrings.ShowHelpDescription; } } // Augment the definition of each subcommand with command-specific actions and completions. + AddCommandParser.ConfigureCommand(rootCommand.AddCommand); BuildCommandParser.ConfigureCommand(rootCommand.BuildCommand); BuildServerCommandParser.ConfigureCommand(rootCommand.BuildServerCommand); @@ -114,10 +122,7 @@ private static DotNetCommandDefinition CreateCommand() WorkloadCommandParser.ConfigureCommand(rootCommand.WorkloadCommand); CompletionsCommandParser.ConfigureCommand(rootCommand.CompletionsCommand); - rootCommand.DiagOption.Action = new HandleDiagnosticAction(rootCommand.DiagOption); - rootCommand.VersionOption.Action = new PrintVersionAction(rootCommand.VersionOption); - rootCommand.InfoOption.Action = new PrintInfoAction(rootCommand.InfoOption); - rootCommand.CliSchemaOption.Action = new PrintCliSchemaAction(rootCommand.CliSchemaOption); + rootCommand.CliSchemaOption.Action = new PrintCliSchemaAction(); // TODO: https://github.com/dotnet/sdk/issues/52661 // https://github.com/NuGet/NuGet.Client/blob/bf048eb714eb6b1912ba868edca4c7cfec454841/src/NuGet.Core/NuGet.CommandLine.XPlat/NuGetCommands.cs @@ -128,13 +133,13 @@ private static DotNetCommandDefinition CreateCommand() { if (parseResult.GetValue(rootCommand.DiagOption) && parseResult.Tokens.Count == 1) { - // When user does not specify any args except of diagnostics ("dotnet -d"), - // we do nothing as HandleDiagnosticAction already enabled the diagnostic output. + // when user does not specify any args except of diagnostics ("dotnet -d"), we do nothing + // as Program.ProcessArgs already enabled the diagnostic output return 0; } else { - // When user does not specify any args (just "dotnet"), a usage needs to be printed. + // when user does not specify any args (just "dotnet"), a usage needs to be printed parseResult.InvocationConfiguration.Output.WriteLine(CliUsage.HelpText); return 0; } @@ -143,14 +148,14 @@ private static DotNetCommandDefinition CreateCommand() return rootCommand; } - public static Command? GetBuiltInCommand(string commandName) => + public static Command GetBuiltInCommand(string commandName) => RootCommand.Subcommands.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase)); /// /// Implements token-per-line response file handling for the CLI. We use this instead of the built-in S.CL handling /// to ensure backwards-compatibility with MSBuild. /// - public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList? replacementTokens, out string? errorMessage) + public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList replacementTokens, out string errorMessage) { var filePath = Path.GetFullPath(tokenToReplace); if (File.Exists(filePath)) @@ -210,7 +215,7 @@ public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList public static int Invoke(string[] args) => Invoke(Parse(args)); public static Task InvokeAsync(string[] args, CancellationToken cancellationToken = default) => InvokeAsync(Parse(args), cancellationToken); - internal static int ExceptionHandler(Exception? exception, ParseResult parseResult) + internal static int ExceptionHandler(Exception exception, ParseResult parseResult) { if (exception is TargetInvocationException) { @@ -230,13 +235,13 @@ internal static int ExceptionHandler(Exception? exception, ParseResult parseResu exception.Message.Red().Bold()); parseResult.ShowHelp(); } - else if (exception is not null && exception.GetType().Name.Equals("WorkloadManifestCompositionException")) + else if (exception.GetType().Name.Equals("WorkloadManifestCompositionException")) { Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose ? exception.ToString().Red().Bold() : exception.Message.Red().Bold()); } - else if (exception is not null) + else { Reporter.Error.Write("Unhandled exception: ".Red().Bold()); Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose ? @@ -324,7 +329,7 @@ public override void Write(HelpContext context) } else if (command is FormatCommandDefinition format) { - var arguments = context.ParseResult.GetValue(format.Arguments) ?? []; + var arguments = context.ParseResult.GetValue(format.Arguments); new FormatForwardingApp([.. arguments, .. helpArgs]).Execute(); } else if (command is FsiCommandDefinition) @@ -346,16 +351,14 @@ public override void Write(HelpContext context) if (command.Name.Equals(ListReferenceCommandDefinition.Name)) { - Command? listCommand = command.Parents.Single() as Command; - if (listCommand is not null) + Command listCommand = command.Parents.Single() as Command; + + for (int i = 0; i < listCommand.Arguments.Count; i++) { - for (int i = 0; i < listCommand.Arguments.Count; i++) + if (listCommand.Arguments[i].Name == CliStrings.SolutionOrProjectArgumentName) { - if (listCommand.Arguments[i].Name == CliStrings.SolutionOrProjectArgumentName) - { - // Name is immutable now, so we create a new Argument with the right name.. - listCommand.Arguments[i] = ListCommandDefinition.CreateSlnOrProjectArgument(CliStrings.ProjectArgumentName, CliStrings.ProjectArgumentDescription); - } + // Name is immutable now, so we create a new Argument with the right name.. + listCommand.Arguments[i] = Commands.Hidden.List.ListCommandDefinition.CreateSlnOrProjectArgument(CliStrings.ProjectArgumentName, CliStrings.ProjectArgumentDescription); } } } @@ -377,4 +380,15 @@ public override void Write(HelpContext context) } } } + + private class PrintCliSchemaAction : SynchronousCommandLineAction + { + public override bool Terminating => true; + + public override int Invoke(ParseResult parseResult) + { + CliSchema.PrintCliSchema(parseResult.CommandResult, parseResult.InvocationConfiguration.Output, Program.TelemetryClient); + return 0; + } + } } diff --git a/src/Cli/dotnet/ParserOptionActions.cs b/src/Cli/dotnet/ParserOptionActions.cs deleted file mode 100644 index 8186712fa4de..000000000000 --- a/src/Cli/dotnet/ParserOptionActions.cs +++ /dev/null @@ -1,171 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; -using System.CommandLine.Invocation; -using Microsoft.DotNet.Cli.Commands.Workload; -using Microsoft.DotNet.Cli.Extensions; -using Microsoft.DotNet.Cli.Help; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Configurer; -using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; - -namespace Microsoft.DotNet.Cli; - -/// -/// Represents an option that contains an invocable action. -/// These are essentially commands that are only defined as an option. -/// -internal abstract class InvocableOptionAction(Option option) : SynchronousCommandLineAction -{ - /// - /// The option for which this action is bound. - /// - public Option Option { get; } = option; -} - -internal class HandleDiagnosticAction(Option option) : InvocableOptionAction(option) -{ - public override bool Terminating => false; - - public override int Invoke(ParseResult parseResult) - { - // Only set verbose output on built-in commands. - if (!parseResult.IsDotnetBuiltInCommand()) - { - return 0; - } - - // Determine whether the diagnostic option should be attached to the dotnet command or the subcommand. - if (DiagOptionPrecedesSubcommand(parseResult.Tokens.Select(t => t.Value), parseResult.RootSubCommandResult())) - { - Environment.SetEnvironmentVariable(CommandLoggingContext.Variables.Verbose, bool.TrueString); - CommandLoggingContext.SetVerbose(true); - Reporter.Reset(); - - var home = Env.GetEnvironmentVariable(CliFolderPathCalculator.DotnetHomeVariableName); - if (!string.IsNullOrEmpty(home)) - { - // Output DOTNET_CLI_HOME usage when verbosity is enabled. - Reporter.Verbose.WriteLine(string.Format(LocalizableStrings.DotnetCliHomeUsed, home, CliFolderPathCalculator.DotnetHomeVariableName)); - } - } - - return 0; - } - - private static bool DiagOptionPrecedesSubcommand(IEnumerable tokens, string subCommand) - { - if (string.IsNullOrEmpty(subCommand)) - { - return true; - } - - foreach (var token in tokens) - { - if (token == subCommand) - { - return false; - } - - if (Parser.RootCommand.DiagOption.Name == token - || Parser.RootCommand.DiagOption.Aliases.Contains(token)) - { - return true; - } - } - - return false; - } -} - -internal class PrintHelpAction(Option option, HelpBuilder builder) : InvocableOptionAction(option) -{ - public override bool Terminating => true; - public override bool ClearsParseErrors => true; - - private HelpBuilder Builder { get; } = builder; - - public override int Invoke(ParseResult parseResult) - { - var command = parseResult.CommandResult.Command; - var output = parseResult.InvocationConfiguration.Output; - var helpContext = new HelpContext(Builder, command, output, parseResult); - Builder.Write(helpContext); - - return 0; - } -} - -internal class PrintVersionAction(Option option) : InvocableOptionAction(option) -{ - public override bool Terminating => true; - - public override int Invoke(ParseResult parseResult) - { - // Only print for top-level commands. - if (!parseResult.IsTopLevelDotnetCommand()) - { - return 0; - } - - Reporter.Output.WriteLine(Product.Version); - - return 0; - } -} - -internal class PrintInfoAction(Option option) : InvocableOptionAction(option) -{ - public override bool Terminating => true; - - public override int Invoke(ParseResult parseResult) - { - // Only print for top-level commands. - if (!parseResult.IsTopLevelDotnetCommand()) - { - return 0; - } - - DotnetVersionFile versionFile = DotnetFiles.VersionFileObject; - var commitSha = versionFile.CommitSha ?? "N/A"; - Reporter.Output.WriteLine($"{LocalizableStrings.DotNetSdkInfoLabel}"); - Reporter.Output.WriteLine($" Version: {Product.Version}"); - Reporter.Output.WriteLine($" Commit: {commitSha}"); - Reporter.Output.WriteLine($" Workload version: {WorkloadInfoHelper.GetWorkloadsVersion()}"); - Reporter.Output.WriteLine($" MSBuild version: {MSBuildForwardingAppWithoutLogging.MSBuildVersion}"); - Reporter.Output.WriteLine(); - Reporter.Output.WriteLine($"{LocalizableStrings.DotNetRuntimeInfoLabel}"); - Reporter.Output.WriteLine($" OS Name: {RuntimeEnvironment.OperatingSystem}"); - Reporter.Output.WriteLine($" OS Version: {RuntimeEnvironment.OperatingSystemVersion}"); - Reporter.Output.WriteLine($" OS Platform: {RuntimeEnvironment.OperatingSystemPlatform}"); - Reporter.Output.WriteLine($" RID: {GetDisplayRid(versionFile)}"); - Reporter.Output.WriteLine($" Base Path: {AppContext.BaseDirectory}"); - Reporter.Output.WriteLine(); - Reporter.Output.WriteLine($"{LocalizableStrings.DotnetWorkloadInfoLabel}"); - new WorkloadInfoHelper(isInteractive: false).ShowWorkloadsInfo(showVersion: false); - - return 0; - } - - private static string? GetDisplayRid(DotnetVersionFile versionFile) - { - FrameworkDependencyFile fxDepsFile = new(); - string currentRid = RuntimeInformation.RuntimeIdentifier; - // If the current RID isn't supported by the shared framework, display the RID the CLI was built with instead, - // so the user knows which RID they should put in their "runtimes" section. - return fxDepsFile.IsRuntimeSupported(currentRid) ? currentRid : versionFile.BuildRid; - } -} - -internal class PrintCliSchemaAction(Option option) : InvocableOptionAction(option) -{ - public override bool Terminating => true; - - public override int Invoke(ParseResult parseResult) - { - CliSchema.PrintCliSchema(parseResult, parseResult.InvocationConfiguration.Output, Program.TelemetryInstance); - - return 0; - } -} diff --git a/src/Cli/dotnet/PerformanceLogEventListener.cs b/src/Cli/dotnet/PerformanceLogEventListener.cs new file mode 100644 index 000000000000..201006e5c011 --- /dev/null +++ b/src/Cli/dotnet/PerformanceLogEventListener.cs @@ -0,0 +1,160 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Diagnostics.Tracing; +using Microsoft.Extensions.EnvironmentAbstractions; + +namespace Microsoft.DotNet.Cli; + +internal sealed class PerformanceLogEventListener : EventListener +{ + internal struct ProviderConfiguration + { + internal string Name { get; set; } + internal EventKeywords Keywords { get; set; } + internal EventLevel Level { get; set; } + } + + private static readonly ProviderConfiguration[] s_config = + [ + new ProviderConfiguration() + { + Name = "Microsoft-Dotnet-CLI-Performance", + Keywords = EventKeywords.All, + Level = EventLevel.Verbose + } + ]; + + private const char EventDelimiter = '\n'; + private StreamWriter _writer; + + [ThreadStatic] + private static StringBuilder s_builder = new(); + + internal static PerformanceLogEventListener Create(IFileSystem fileSystem, string logDirectory) + { + // Only create a listener if the log directory exists. + if (string.IsNullOrWhiteSpace(logDirectory) || !fileSystem.Directory.Exists(logDirectory)) + { + return null; + } + + PerformanceLogEventListener eventListener = null; + try + { + // Initialization happens as a separate step and not in the constructor to ensure that + // if an exception is thrown during init, we have the opportunity to dispose of the listener, + // which will disable any EventSources that have been enabled. Any EventSources that existed before + // this EventListener will be passed to OnEventSourceCreated before our constructor is called, so + // we if we do this work in the constructor, and don't get an opportunity to call Dispose, the + // EventSources will remain enabled even if there aren't any consuming EventListeners. + eventListener = new PerformanceLogEventListener(); + eventListener.Initialize(fileSystem, logDirectory); + } + catch + { + if (eventListener != null) + { + eventListener.Dispose(); + } + } + + return eventListener; + } + + private PerformanceLogEventListener() + { + } + + internal void Initialize(IFileSystem fileSystem, string logDirectory) + { + // Use a GUID disambiguator to make sure that we have a unique file name. + string logFilePath = Path.Combine(logDirectory, $"perf-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")}.log"); + + Stream outputStream = fileSystem.File.OpenFile( + logFilePath, + FileMode.Create, // Create or overwrite. + FileAccess.Write, // Open for writing. + FileShare.Read, // Allow others to read. + 4096, // Default buffer size. + FileOptions.None); // No hints about how the file will be written. + + _writer = new StreamWriter(outputStream); + } + + public override void Dispose() + { + lock (this) + { + if (_writer != null) + { + _writer.Dispose(); + _writer = null; + } + } + + base.Dispose(); + } + + protected override void OnEventSourceCreated(EventSource eventSource) + { + try + { + // Enable the provider if it matches a requested configuration. + foreach (ProviderConfiguration entry in s_config) + { + if (entry.Name.Equals(eventSource.Name)) + { + EnableEvents(eventSource, entry.Level, entry.Keywords); + } + } + } + catch + { + // If we fail to enable, just skip it and continue. + } + + base.OnEventSourceCreated(eventSource); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + try + { + if (s_builder == null) + { + s_builder = new StringBuilder(); + } + else + { + s_builder.Clear(); + } + + s_builder.Append($"[{DateTime.UtcNow.ToString("o")}] Event={eventData.EventSource.Name}/{eventData.EventName} ProcessID={Environment.ProcessId} ThreadID={Thread.CurrentThread.ManagedThreadId}\t "); + for (int i = 0; i < eventData.PayloadNames.Count; i++) + { + s_builder.Append($"{eventData.PayloadNames[i]}=\"{eventData.Payload[i]}\" "); + } + + lock (this) + { + if (_writer != null) + { + foreach (ReadOnlyMemory mem in s_builder.GetChunks()) + { + _writer.Write(mem); + } + _writer.Write(EventDelimiter); + } + } + } + catch + { + // If we fail to log an event, just skip it and continue. + } + + base.OnEventWritten(eventData); + } +} diff --git a/src/Cli/dotnet/PerformanceLogEventSource.cs b/src/Cli/dotnet/PerformanceLogEventSource.cs new file mode 100644 index 000000000000..0d1912db6c72 --- /dev/null +++ b/src/Cli/dotnet/PerformanceLogEventSource.cs @@ -0,0 +1,455 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Reflection; +using Microsoft.DotNet.Cli.Utils; +using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; + +namespace Microsoft.DotNet.Cli; + +[EventSource(Name = "Microsoft-Dotnet-CLI-Performance", Guid = "cbd57d06-3b9f-5374-ed53-cfbcc23cf44f")] +internal sealed partial class PerformanceLogEventSource : EventSource +{ + internal static PerformanceLogEventSource Log = new(); + + [NonEvent] + internal void LogStartUpInformation(PerformanceLogStartupInformation startupInfo) + { + if (!IsEnabled()) + { + return; + } + + DotnetVersionFile versionFile = DotnetFiles.VersionFileObject; + string commitSha = versionFile.CommitSha ?? "N/A"; + + LogMachineConfiguration(); + OSInfo(RuntimeEnvironment.OperatingSystem, RuntimeEnvironment.OperatingSystemVersion, RuntimeEnvironment.OperatingSystemPlatform.ToString()); + SDKInfo(Product.Version, commitSha, RuntimeInformation.RuntimeIdentifier, versionFile.BuildRid, AppContext.BaseDirectory); + EnvironmentInfo(Environment.CommandLine); + LogMemoryConfiguration(); + LogDrives(); + + // It's possible that IsEnabled returns true if an out-of-process collector such as ETW is enabled. + // If the perf log hasn't been enabled, then startupInfo will be null, so protect against nullref here. + if (startupInfo != null) + { + if (startupInfo.TimedAssembly != null) + { + AssemblyLoad(startupInfo.TimedAssembly.GetName().Name, startupInfo.AssemblyLoadTime.TotalMilliseconds); + } + + Process currentProcess = Process.GetCurrentProcess(); + TimeSpan latency = startupInfo.MainTimeStamp - currentProcess.StartTime; + HostLatency(latency.TotalMilliseconds); + } + } + + [Event(1)] + internal void OSInfo(string osname, string osversion, string osplatform) + { + WriteEvent(1, osname, osversion, osplatform); + } + + [Event(2)] + internal void SDKInfo(string version, string commit, string currentRid, string buildRid, string basePath) + { + WriteEvent(2, version, commit, currentRid, buildRid, basePath); + } + + [Event(3)] + internal void EnvironmentInfo(string commandLine) + { + WriteEvent(3, commandLine); + } + + [Event(4)] + internal void HostLatency(double timeInMs) + { + WriteEvent(4, timeInMs); + } + + [Event(5)] + internal void CLIStart() + { + WriteEvent(5); + } + + [Event(6)] + internal void CLIStop() + { + WriteEvent(6); + } + + [Event(7)] + internal void FirstTimeConfigurationStart() + { + WriteEvent(7); + } + + [Event(8)] + internal void FirstTimeConfigurationStop() + { + WriteEvent(8); + } + + [Event(9)] + internal void TelemetryRegistrationStart() + { + WriteEvent(9); + } + + [Event(10)] + internal void TelemetryRegistrationStop() + { + WriteEvent(10); + } + + [Event(11)] + internal void TelemetrySaveIfEnabledStart() + { + WriteEvent(11); + } + + [Event(12)] + internal void TelemetrySaveIfEnabledStop() + { + WriteEvent(12); + } + + [Event(13)] + internal void BuiltInCommandStart() + { + WriteEvent(13); + } + + [Event(14)] + internal void BuiltInCommandStop() + { + WriteEvent(14); + } + + [Event(15)] + internal void BuiltInCommandParserStart() + { + WriteEvent(15); + } + + [Event(16)] + internal void BuiltInCommandParserStop() + { + WriteEvent(16); + } + + [Event(17)] + internal void ExtensibleCommandResolverStart() + { + WriteEvent(17); + } + + [Event(18)] + internal void ExtensibleCommandResolverStop() + { + WriteEvent(18); + } + + [Event(19)] + internal void ExtensibleCommandStart() + { + WriteEvent(19); + } + + [Event(20)] + internal void ExtensibleCommandStop() + { + WriteEvent(20); + } + + [Event(21)] + internal void TelemetryClientFlushStart() + { + WriteEvent(21); + } + + [Event(22)] + internal void TelemetryClientFlushStop() + { + WriteEvent(22); + } + + [NonEvent] + internal void LogMachineConfiguration() + { + if (IsEnabled()) + { + MachineConfiguration(Environment.MachineName, Environment.ProcessorCount); + } + } + + [Event(23)] + internal void MachineConfiguration(string machineName, int processorCount) + { + WriteEvent(23, machineName, processorCount); + } + + [NonEvent] + internal void LogDrives() + { + if (IsEnabled()) + { + foreach (DriveInfo driveInfo in DriveInfo.GetDrives()) + { + try + { + DriveConfiguration(driveInfo.Name, driveInfo.DriveFormat, driveInfo.DriveType.ToString(), + (double)driveInfo.TotalSize / 1024 / 1024, (double)driveInfo.AvailableFreeSpace / 1024 / 1024); + } + catch + { + // If we fail to log a drive, skip it and continue. + } + } + } + } + + [Event(24)] + internal void DriveConfiguration(string name, string format, string type, double totalSizeMB, double availableFreeSpaceMB) + { + WriteEvent(24, name, format, type, totalSizeMB, availableFreeSpaceMB); + } + + [Event(25)] + internal void AssemblyLoad(string assemblyName, double timeInMs) + { + WriteEvent(25, assemblyName, timeInMs); + } + + [NonEvent] + internal void LogMemoryConfiguration() + { + if (IsEnabled()) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Interop.MEMORYSTATUSEX memoryStatusEx = new(); + memoryStatusEx.dwLength = (uint)Marshal.SizeOf(memoryStatusEx); + + if (Interop.GlobalMemoryStatusEx(ref memoryStatusEx)) + { + MemoryConfiguration((int)memoryStatusEx.dwMemoryLoad, (int)(memoryStatusEx.ullAvailPhys / 1024 / 1024), + (int)(memoryStatusEx.ullTotalPhys / 1024 / 1024)); + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + ProcMemInfo memInfo = new(); + if (memInfo.Valid) + { + MemoryConfiguration(memInfo.MemoryLoad, memInfo.AvailableMemoryMB, memInfo.TotalMemoryMB); + } + } + } + } + + [Event(26)] + internal void MemoryConfiguration(int memoryLoad, int availablePhysicalMB, int totalPhysicalMB) + { + WriteEvent(26, memoryLoad, availablePhysicalMB, totalPhysicalMB); + } + + [NonEvent] + internal void LogMSBuildStart(string fileName, string arguments) + { + if (IsEnabled()) + { + MSBuildStart($"{fileName} {arguments}"); + } + } + + [Event(27)] + internal void MSBuildStart(string cmdline) + { + WriteEvent(27, cmdline); + } + + [Event(28)] + internal void MSBuildStop(int exitCode) + { + WriteEvent(28, exitCode); + } + + [Event(29)] + internal void CreateBuildCommandStart() + { + WriteEvent(29); + } + + [Event(30)] + internal void CreateBuildCommandStop() + { + WriteEvent(30); + } +} + +internal class PerformanceLogStartupInformation +{ + public PerformanceLogStartupInformation(DateTime mainTimeStamp) + { + // Save the main timestamp. + MainTimeStamp = mainTimeStamp; + + // Attempt to load an assembly. + // Ideally, we've picked one that we'll already need, so we're not adding additional overhead. + MeasureModuleLoad(); + } + + internal DateTime MainTimeStamp { get; private set; } + internal Assembly TimedAssembly { get; private set; } + internal TimeSpan AssemblyLoadTime { get; private set; } + + private void MeasureModuleLoad() + { + // Make sure the assembly hasn't been loaded yet. + string assemblyName = "Microsoft.DotNet.Configurer"; + try + { + foreach (Assembly loadedAssembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (loadedAssembly.GetName().Name.Equals(assemblyName)) + { + // If the assembly is already loaded, then bail. + return; + } + } + } + catch + { + // If we fail to enumerate, just bail. + return; + } + + Stopwatch stopWatch = Stopwatch.StartNew(); + Assembly assembly; + try + { + assembly = Assembly.Load(assemblyName); + } + catch + { + return; + } + stopWatch.Stop(); + if (assembly != null) + { + // Save the results. + TimedAssembly = assembly; + AssemblyLoadTime = stopWatch.Elapsed; + } + } +} + +/// +/// Global memory statistics on Windows. +/// +internal static class Interop +{ + [StructLayout(LayoutKind.Sequential)] + internal struct MEMORYSTATUSEX + { + // The length field must be set to the size of this data structure. + internal uint dwLength; + internal uint dwMemoryLoad; + internal ulong ullTotalPhys; + internal ulong ullAvailPhys; + internal ulong ullTotalPageFile; + internal ulong ullAvailPageFile; + internal ulong ullTotalVirtual; + internal ulong ullAvailVirtual; + internal ulong ullAvailExtendedVirtual; + } + + [DllImport("kernel32.dll")] + internal static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); +} + +/// +/// Global memory statistics on Linux. +/// +internal sealed class ProcMemInfo +{ + private const string MemTotal = "MemTotal:"; + private const string MemAvailable = "MemAvailable:"; + + private short _matchingLineCount = 0; + + internal ProcMemInfo() + { + Initialize(); + } + + /// + /// The data in this class is valid if we parsed the file, found, and properly parsed the two matching lines. + /// + internal bool Valid + { + get { return _matchingLineCount == 2; } + } + + internal int MemoryLoad + { + get { return (int)((double)(TotalMemoryMB - AvailableMemoryMB) / TotalMemoryMB * 100); } + } + + internal int AvailableMemoryMB + { + get; + private set; + } + + internal int TotalMemoryMB + { + get; + private set; + } + + private void Initialize() + { + try + { + using (StreamReader reader = new(File.OpenRead("/proc/meminfo"))) + { + string line; + while (!Valid && ((line = reader.ReadLine()) != null)) + { + if (line.StartsWith(MemTotal) || line.StartsWith(MemAvailable)) + { + string[] tokens = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 3) + { + if (MemTotal.Equals(tokens[0])) + { + TotalMemoryMB = (int)Convert.ToUInt64(tokens[1]) / 1024; + _matchingLineCount++; + } + else if (MemAvailable.Equals(tokens[0])) + { + AvailableMemoryMB = (int)Convert.ToUInt64(tokens[1]) / 1024; + _matchingLineCount++; + } + } + } + } + } + } + catch (Exception ex) when (ex is IOException || ex.InnerException is IOException) + { + // in some environments (restricted docker container, shared hosting etc.), + // procfs is not accessible and we get UnauthorizedAccessException while the + // inner exception is set to IOException. Ignore and continue when that happens. + } + } +} diff --git a/src/Cli/dotnet/PerformanceLogManager.cs b/src/Cli/dotnet/PerformanceLogManager.cs new file mode 100644 index 000000000000..3864adceb84c --- /dev/null +++ b/src/Cli/dotnet/PerformanceLogManager.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Configurer; +using Microsoft.Extensions.EnvironmentAbstractions; + +namespace Microsoft.DotNet.Cli; + +internal sealed class PerformanceLogManager +{ + internal const string PerfLogDirEnvVar = "DOTNET_PERFLOG_DIR"; + private const string PerfLogRoot = "PerformanceLogs"; + private const int DefaultNumLogsToKeep = 10; + + private readonly IFileSystem _fileSystem; + private string _perfLogRoot; + + internal static PerformanceLogManager Instance + { + get; + private set; + } + + internal static void InitializeAndStartCleanup(IFileSystem fileSystem) + { + if (Instance == null) + { + Instance = new PerformanceLogManager(fileSystem); + + // Check to see if this instance is part of an already running chain of processes. + string perfLogDir = Env.GetEnvironmentVariable(PerfLogDirEnvVar); + if (!string.IsNullOrEmpty(perfLogDir)) + { + // This process has been provided with a log directory, so use it. + Instance.UseExistingLogDirectory(perfLogDir); + } + else + { + // This process was not provided with a log root, so make a new one. + Instance._perfLogRoot = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, PerfLogRoot); + Instance.CreateLogDirectory(); + + Task.Factory.StartNew(() => + { + Instance.CleanupOldLogs(); + }); + } + } + } + + internal PerformanceLogManager(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + internal string CurrentLogDirectory { get; private set; } + + private void CreateLogDirectory() + { + // Ensure the log root directory exists. + if (!_fileSystem.Directory.Exists(_perfLogRoot)) + { + _fileSystem.Directory.CreateDirectory(_perfLogRoot); + } + + // Create a new perf log directory. + CurrentLogDirectory = Path.Combine(_perfLogRoot, Guid.NewGuid().ToString("N")); + _fileSystem.Directory.CreateDirectory(CurrentLogDirectory); + } + + private void UseExistingLogDirectory(string logDirectory) + { + CurrentLogDirectory = logDirectory; + } + + private void CleanupOldLogs() + { + if (_fileSystem.Directory.Exists(_perfLogRoot)) + { + List logDirectories = []; + foreach (string directoryPath in _fileSystem.Directory.EnumerateDirectories(_perfLogRoot)) + { + logDirectories.Add(new DirectoryInfo(directoryPath)); + } + + // Sort the list. + logDirectories.Sort(new LogDirectoryComparer()); + + // Figure out how many logs to keep. + int numLogsToKeep; + string strNumLogsToKeep = Env.GetEnvironmentVariable("DOTNET_PERF_LOG_COUNT"); + if (!int.TryParse(strNumLogsToKeep, out numLogsToKeep)) + { + numLogsToKeep = DefaultNumLogsToKeep; + + // -1 == keep all logs + if (numLogsToKeep == -1) + { + numLogsToKeep = int.MaxValue; + } + } + + // Skip the first numLogsToKeep elements. + if (logDirectories.Count > numLogsToKeep) + { + // Prune the old logs. + for (int i = logDirectories.Count - numLogsToKeep - 1; i >= 0; i--) + { + try + { + logDirectories[i].Delete(true); + } + catch + { + // Do nothing if a log can't be deleted. + // We'll get another chance next time around. + } + } + } + } + } +} + +/// +/// Used to sort log directories when deciding which ones to delete. +/// +internal sealed class LogDirectoryComparer : IComparer +{ + int IComparer.Compare(DirectoryInfo x, DirectoryInfo y) + { + return x.CreationTime.CompareTo(y.CreationTime); + } +} diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index f0558ad174f8..73a7d7eb6990 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -1,12 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System.CommandLine; using System.CommandLine.Parsing; using System.Diagnostics; +using System.Runtime.InteropServices; using Microsoft.DotNet.Cli.CommandFactory; using Microsoft.DotNet.Cli.CommandFactory.CommandResolution; +using Microsoft.DotNet.Cli.CommandLine; using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; +using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Workload; using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.ShellShim; @@ -24,329 +29,422 @@ namespace Microsoft.DotNet.Cli; public class Program { - private static readonly string s_toolPathSentinelFileName = $"{Product.Version}.toolpath.sentinel"; - - private static readonly Activity? s_mainActivity; - private static readonly PosixSignalRegistration s_sigIntRegistration; - private static readonly PosixSignalRegistration s_sigQuitRegistration; - private static readonly PosixSignalRegistration s_sigTermRegistration; - private static readonly string? s_globalJsonState; - - public static ITelemetryClient TelemetryInstance { get; private set; } + private static readonly string ToolPathSentinelFileName = $"{Product.Version}.toolpath.sentinel"; - static Program() + public static ITelemetry TelemetryClient; + public static int Main(string[] args) { - var mainTimeStamp = DateTime.Now; - s_sigIntRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, Shutdown); - s_sigQuitRegistration = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, Shutdown); - s_sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, Shutdown); - - // Note: This TelemetryClient instance needs to be created prior to calculating ActivityKind and ParentActivityContext, - // used in the main activity creation below. - TelemetryInstance = new TelemetryClient(); - TelemetryEventEntry.Subscribe(TelemetryInstance.TrackEvent); - TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); - - s_mainActivity = Activities.Source.CreateActivity("main", TelemetryClient.ActivityKind, TelemetryClient.ParentActivityContext) - ?.Start() - ?.SetStartTime(Process.GetCurrentProcess().StartTime) - ?.AddTag("process.pid", Process.GetCurrentProcess().Id) - ?.AddTag("process.executable.name", "dotnet"); + // Register a handler for SIGTERM to allow graceful shutdown of the application on Unix. + // See https://github.com/dotnet/docs/issues/46226. + using var termSignalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => Environment.Exit(0)); - if (CommandLoggingContext.IsVerbose) - { - Console.WriteLine($"Telemetry is: {(TelemetryInstance.Enabled ? "Enabled" : "Disabled")}"); - } + using AutomaticEncodingRestorer _ = new(); - // Creates a host-startup activity which includes the global.json state. - using (var hostStartupActivity = Activities.Source.StartActivity("host-startup")) + if (Env.GetEnvironmentVariable("DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING") != "1") { - hostStartupActivity?.SetStartTime(Process.GetCurrentProcess().StartTime); - if (TelemetryInstance.Enabled && hostStartupActivity is not null) + // Setting output encoding is not available on those platforms + if (UILanguageOverride.OperatingSystemSupportsUtf8()) { - // Get the global.json state to report in telemetry along with this command invocation. - s_globalJsonState = NativeWrapper.NETCoreSdkResolverNativeWrapper.GetGlobalJsonState(Environment.CurrentDirectory); - hostStartupActivity?.AddTag("dotnet.globalJson", s_globalJsonState); + Console.OutputEncoding = Encoding.UTF8; } - hostStartupActivity?.SetEndTime(mainTimeStamp)?.SetStatus(ActivityStatusCode.Ok); } - // We have some behaviors in MSBuild that we want to enforce (either when using MSBuild API or by shelling out to it), - // so we set those ASAP as globally as possible. + DebugHelper.HandleDebugSwitch(ref args); + + // Capture the current timestamp to calculate the host overhead. + DateTime mainTimeStamp = DateTime.Now; + TimeSpan startupTime = mainTimeStamp - Process.GetCurrentProcess().StartTime; + + bool perfLogEnabled = Env.GetEnvironmentVariableAsBool("DOTNET_CLI_PERF_LOG", false); + if (string.IsNullOrEmpty(Env.GetEnvironmentVariable("MSBUILDFAILONDRIVEENUMERATINGWILDCARD"))) { Environment.SetEnvironmentVariable("MSBUILDFAILONDRIVEENUMERATINGWILDCARD", "1"); } - } - - public static int Main(string[] args) - { - // Register a handler for SIGTERM to allow graceful shutdown of the application on Unix. - // See https://github.com/dotnet/docs/issues/46226. - using var termSignalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => Environment.Exit(0)); - using AutomaticEncodingRestorer _ = new(); - - if (Env.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING) != "1" - // Setting output encoding is not available on those platforms - && UILanguageOverride.OperatingSystemSupportsUtf8()) + // Avoid create temp directory with root permission and later prevent access in non sudo + if (SudoEnvironmentDirectoryOverride.IsRunningUnderSudo()) { - Console.OutputEncoding = Encoding.UTF8; + perfLogEnabled = false; } - DebugHelper.HandleDebugSwitch(ref args); - // By default, .NET Core doesn't have all code pages needed for Console apps. - // See the .NET Core Notes: https://docs.microsoft.com/dotnet/api/system.diagnostics.process#-notes - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - UILanguageOverride.Setup(); - - var exitCode = 1; - try + PerformanceLogStartupInformation startupInfo = null; + if (perfLogEnabled) { - exitCode = ProcessArgsAndExecute(args); - s_mainActivity?.AddTag("process.exit.code", exitCode)?.SetStatus(ActivityStatusCode.Ok); - return exitCode; + startupInfo = new PerformanceLogStartupInformation(mainTimeStamp); + PerformanceLogManager.InitializeAndStartCleanup(FileSystemWrapper.Default); } - catch (Exception e) when (e.ShouldBeDisplayedAsError()) + + PerformanceLogEventListener perLogEventListener = null; + try { - Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose - ? e.ToString().Red().Bold() - : e.Message.Red().Bold()); + if (perfLogEnabled) + { + perLogEventListener = PerformanceLogEventListener.Create(FileSystemWrapper.Default, PerformanceLogManager.Instance.CurrentLogDirectory); + } + + PerformanceLogEventSource.Log.LogStartUpInformation(startupInfo); + PerformanceLogEventSource.Log.CLIStart(); - if (e is CommandParsingException { ParseResult: {} exceptionParseResult } ) + InitializeProcess(); + + try { - exceptionParseResult.ShowHelp(); + return ProcessArgs(args, startupTime); + } + catch (Exception e) when (e.ShouldBeDisplayedAsError()) + { + Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose + ? e.ToString().Red().Bold() + : e.Message.Red().Bold()); + + var commandParsingException = e as CommandParsingException; + if (commandParsingException != null && commandParsingException.ParseResult != null) + { + commandParsingException.ParseResult.ShowHelp(); + } + + return 1; + } + catch (Exception e) when (!e.ShouldBeDisplayedAsError()) + { + // If telemetry object has not been initialized yet. It cannot be collected + TelemetryEventEntry.SendFiltered(e); + Reporter.Error.WriteLine(e.ToString().Red().Bold()); + + return 1; + } + finally + { + PerformanceLogEventSource.Log.CLIStop(); } - s_mainActivity?.AddTag("process.exit.code", exitCode)?.SetStatus(ActivityStatusCode.Error); - return exitCode; - } - catch (Exception e) when (!e.ShouldBeDisplayedAsError()) - { - TelemetryEventEntry.SendFiltered(e); - Reporter.Error.WriteLine(e.ToString().Red().Bold()); - s_mainActivity?.AddTag("process.exit.code", exitCode)?.SetStatus(ActivityStatusCode.Error); - return exitCode; } finally { - TelemetryInstance.TrackEvent("command/finish", new Dictionary { { "exitCode", exitCode.ToString() } }); - Shutdown(default!); - TelemetryClient.WriteLogIfNecessary(); + if (perLogEventListener != null) + { + perLogEventListener.Dispose(); + } } } - internal static int ProcessArgsAndExecute(string[] args) + internal static int ProcessArgs(string[] args) { - ParseResult parseResult = ParseArgs(args); - // Options that perform terminating actions are considered to essentially be subcommands. - // These are special as they should not run the first-run setup. - // Example: dotnet --version - if (!(parseResult.Action is InvocableOptionAction { Terminating: true })) + return ProcessArgs(args, new TimeSpan(0)); + } + + internal static int ProcessArgs(string[] args, TimeSpan startupTime) + { + Dictionary performanceData = []; + + PerformanceLogEventSource.Log.BuiltInCommandParserStart(); + ParseResult parseResult; + using (new PerformanceMeasurement(performanceData, "Parse Time")) { - SetupFirstRun(parseResult); + parseResult = Parser.Parse(args); + + // Avoid create temp directory with root permission and later prevent access in non sudo + // This method need to be run very early before temp folder get created + // https://github.com/dotnet/sdk/issues/20195 + SudoEnvironmentDirectoryOverride.OverrideEnvironmentVariableToTmp(parseResult); } + PerformanceLogEventSource.Log.BuiltInCommandParserStop(); - TelemetryEventEntry.SendFiltered(new ParseResultWithGlobalJsonState(parseResult, s_globalJsonState)); - if (parseResult.CanBeInvoked()) + using (IFirstTimeUseNoticeSentinel disposableFirstTimeUseNoticeSentinel = new FirstTimeUseNoticeSentinel()) { - return ExecuteInternalCommand(parseResult); + IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel = disposableFirstTimeUseNoticeSentinel; + IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel(); + IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, ToolPathSentinelFileName))); + + PerformanceLogEventSource.Log.TelemetryRegistrationStart(); + + TelemetryClient ??= new Telemetry.Telemetry(firstTimeUseNoticeSentinel); + TelemetryEventEntry.Subscribe(TelemetryClient.TrackEvent); + TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); + + PerformanceLogEventSource.Log.TelemetryRegistrationStop(); + + if (parseResult.GetValue(Parser.RootCommand.DiagOption) && parseResult.IsDotnetBuiltInCommand()) + { + // We found --diagnostic or -d, but we still need to determine whether the option should + // be attached to the dotnet command or the subcommand. + if (args.DiagOptionPrecedesSubcommand(parseResult.RootSubCommandResult())) + { + Environment.SetEnvironmentVariable(CommandLoggingContext.Variables.Verbose, bool.TrueString); + CommandLoggingContext.SetVerbose(true); + Reporter.Reset(); + } + } + if (parseResult.HasOption(Parser.RootCommand.VersionOption) && parseResult.IsTopLevelDotnetCommand()) + { + CommandLineInfo.PrintVersion(); + return 0; + } + else if (parseResult.HasOption(Parser.RootCommand.InfoOption) && parseResult.IsTopLevelDotnetCommand()) + { + CommandLineInfo.PrintInfo(); + return 0; + } + else + { + PerformanceLogEventSource.Log.FirstTimeConfigurationStart(); + + var environmentProvider = new EnvironmentProvider(); + + bool generateAspNetCertificate = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_GENERATE_ASPNET_CERTIFICATE, defaultValue: true); + bool telemetryOptout = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, defaultValue: CompileOptions.TelemetryOptOutDefault); + bool addGlobalToolsToPath = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_ADD_GLOBAL_TOOLS_TO_PATH, defaultValue: true); + bool nologo = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_NOLOGO, defaultValue: false); + bool skipWorkloadIntegrityCheck = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK, + // Default the workload integrity check skip to true if the command is being ran in CI. Otherwise, false. + defaultValue: new CIEnvironmentDetectorForTelemetry().IsCIEnvironment()); + + ReportDotnetHomeUsage(environmentProvider); + + var isDotnetBeingInvokedFromNativeInstaller = false; + if (parseResult.CommandResult.Command is InternalReportInstallSuccessCommandDefinition) + { + aspNetCertificateSentinel = new NoOpAspNetCertificateSentinel(); + firstTimeUseNoticeSentinel = new NoOpFirstTimeUseNoticeSentinel(); + toolPathSentinel = new NoOpFileSentinel(exists: false); + isDotnetBeingInvokedFromNativeInstaller = true; + } + + var dotnetFirstRunConfiguration = new DotnetFirstRunConfiguration( + generateAspNetCertificate: generateAspNetCertificate, + telemetryOptout: telemetryOptout, + addGlobalToolsToPath: addGlobalToolsToPath, + nologo: nologo, + skipWorkloadIntegrityCheck: skipWorkloadIntegrityCheck); + + string[] getStarOperators = ["getProperty", "getItem", "getTargetResult"]; + char[] switchIndicators = ['-', '/']; + var getStarOptionPassed = parseResult.CommandResult.Tokens.Any(t => + getStarOperators.Any(o => + switchIndicators.Any(i => t.Value.StartsWith(i + o, StringComparison.OrdinalIgnoreCase)))); + + ConfigureDotNetForFirstTimeUse( + firstTimeUseNoticeSentinel, + aspNetCertificateSentinel, + toolPathSentinel, + isDotnetBeingInvokedFromNativeInstaller, + dotnetFirstRunConfiguration, + environmentProvider, + performanceData, + skipFirstTimeUseCheck: getStarOptionPassed); + PerformanceLogEventSource.Log.FirstTimeConfigurationStop(); + } } - try + if (CommandLoggingContext.IsVerbose) { - return ExecuteExternalCommand(args, parseResult); + Console.WriteLine($"Telemetry is: {(TelemetryClient.Enabled ? "Enabled" : "Disabled")}"); } - catch (CommandUnknownException e) + PerformanceLogEventSource.Log.TelemetrySaveIfEnabledStart(); + performanceData.Add("Startup Time", startupTime.TotalMilliseconds); + + string globalJsonState = string.Empty; + if (TelemetryClient.Enabled) { - Reporter.Error.WriteLine(e.Message.Red()); - Reporter.Output.WriteLine(e.InstructionMessage); - return 1; + // Get the global.json state to report in telemetry along with this command invocation. + globalJsonState = NativeWrapper.NETCoreSdkResolverNativeWrapper.GetGlobalJsonState(Environment.CurrentDirectory); } - static ParseResult ParseArgs(string[] args) + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, performanceData, globalJsonState)); + PerformanceLogEventSource.Log.TelemetrySaveIfEnabledStop(); + + int exitCode; + if (parseResult.CanBeInvoked()) + { + InvokeBuiltInCommand(parseResult, out exitCode); + } + else { - ParseResult parseResult; - using (var parseActivity = Activities.Source.StartActivity("parse")) + PerformanceLogEventSource.Log.ExtensibleCommandResolverStart(); + try { - parseResult = Parser.Parse(args); + string commandName = "dotnet-" + parseResult.GetValue(Parser.RootCommand.DotnetSubCommand); + var resolvedCommandSpec = CommandResolver.TryResolveCommandSpec( + new DefaultCommandResolverPolicy(), + commandName, + args.GetSubArguments(), + FrameworkConstants.CommonFrameworks.NetStandardApp15); + + if (resolvedCommandSpec is null && TryRunFileBasedApp(parseResult) is { } fileBasedAppExitCode) + { + exitCode = fileBasedAppExitCode; + } + else + { + var resolvedCommand = CommandFactoryUsingResolver.CreateOrThrow(commandName, resolvedCommandSpec); + PerformanceLogEventSource.Log.ExtensibleCommandResolverStop(); - // Avoid create temp directory with root permission and later prevent access in non sudo - // This method need to be run very early before temp folder get created - // https://github.com/dotnet/sdk/issues/20195 - SudoEnvironmentDirectoryOverride.OverrideEnvironmentVariableToTmp(parseResult); - } - s_mainActivity.SetDisplayName(parseResult); - return parseResult; - } - } + PerformanceLogEventSource.Log.ExtensibleCommandStart(); + var result = resolvedCommand.Execute(); + PerformanceLogEventSource.Log.ExtensibleCommandStop(); - private static void SetupFirstRun(ParseResult parseResult) - { - using var _ = Activities.Source.StartActivity("first-time-use"); - IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel = new FirstTimeUseNoticeSentinel(); - IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel(); - string toolPath = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, s_toolPathSentinelFileName); - IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(toolPath)); - - var environmentProvider = new EnvironmentProvider(); - bool generateAspNetCertificate = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_GENERATE_ASPNET_CERTIFICATE, defaultValue: true); - bool telemetryOptout = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, defaultValue: CompileOptions.TelemetryOptOutDefault); - bool addGlobalToolsToPath = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_ADD_GLOBAL_TOOLS_TO_PATH, defaultValue: true); - bool nologo = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_NOLOGO, defaultValue: false); - bool skipWorkloadIntegrityCheck = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK, - // Default the workload integrity check skip to true if the command is being ran in CI. Otherwise, false. - defaultValue: new CIEnvironmentDetectorForTelemetry().IsCIEnvironment()); - - var isDotnetBeingInvokedFromNativeInstaller = false; - // Note: This should not be special cased like this. Determine if we can skip first run setup entirely for this command. - if (parseResult.CommandResult.Command is InternalReportInstallSuccessCommandDefinition) - { - aspNetCertificateSentinel = new NoOpAspNetCertificateSentinel(); - firstTimeUseNoticeSentinel = new NoOpFirstTimeUseNoticeSentinel(); - toolPathSentinel = new NoOpFileSentinel(exists: false); - isDotnetBeingInvokedFromNativeInstaller = true; + exitCode = result.ExitCode; + } + } + catch (CommandUnknownException e) + { + Reporter.Error.WriteLine(e.Message.Red()); + Reporter.Output.WriteLine(e.InstructionMessage); + exitCode = 1; + } } - var dotnetFirstRunConfiguration = new DotnetFirstRunConfiguration( - generateAspNetCertificate, - telemetryOptout, - addGlobalToolsToPath, - nologo, - skipWorkloadIntegrityCheck); + TelemetryClient.TrackEvent("command/finish", properties: new Dictionary + { + { "exitCode", exitCode.ToString() } + }, + measurements: new Dictionary()); - string[] getStarOperators = ["getProperty", "getItem", "getTargetResult"]; - char[] switchIndicators = ['-', '/']; - var skipFirstTimeUseCheck = parseResult.CommandResult.Tokens.Any(t => - getStarOperators.Any(o => - switchIndicators.Any(i => t.Value.StartsWith(i + o, StringComparison.OrdinalIgnoreCase)))); + PerformanceLogEventSource.Log.TelemetryClientFlushStart(); + TelemetryClient.Flush(); + PerformanceLogEventSource.Log.TelemetryClientFlushStop(); - var isFirstTimeUse = !firstTimeUseNoticeSentinel.Exists() && !skipFirstTimeUseCheck; - var environmentPath = EnvironmentPathFactory.CreateEnvironmentPath(isDotnetBeingInvokedFromNativeInstaller, environmentProvider); - // Note: Not sure why this unused instance type is created. - var __ = new DotNetCommandFactory(alwaysRunOutOfProc: true); - var aspnetCertificateGenerator = new AspNetCoreCertificateGenerator(); - var reporter = Reporter.Error; - var dotnetConfigurer = new DotnetFirstTimeUseConfigurer( - firstTimeUseNoticeSentinel, - aspNetCertificateSentinel, - aspnetCertificateGenerator, - toolPathSentinel, - dotnetFirstRunConfiguration, - reporter, - environmentPath, - skipFirstTimeUseCheck); + TelemetryClient.Dispose(); - dotnetConfigurer.Configure(); + return exitCode; -#if TARGET_WINDOWS - if (isDotnetBeingInvokedFromNativeInstaller && OperatingSystem.IsWindows()) + static int? TryRunFileBasedApp(ParseResult parseResult) { - DotDefaultPathCorrector.Correct(); + // If we didn't match any built-in commands, and a C# file path is the first argument, + // parse as `dotnet run --file file.cs ..rest_of_args` instead. + if (parseResult.GetResult(Parser.RootCommand.DotnetSubCommand) is { Tokens: [{ Type: TokenType.Argument, Value: { } } unmatchedCommandOrFile] } + && VirtualProjectBuilder.IsValidEntryPointPath(unmatchedCommandOrFile.Value)) + { + List otherTokens = new(parseResult.Tokens.Count - 1); + foreach (var token in parseResult.Tokens) + { + if (token != unmatchedCommandOrFile) + { + otherTokens.Add(token.Value); + } + } + + parseResult = Parser.Parse(["run", "--file", unmatchedCommandOrFile.Value, .. otherTokens]); + + InvokeBuiltInCommand(parseResult, out var exitCode); + return exitCode; + } + + return null; } -#endif - if (isFirstTimeUse && !skipWorkloadIntegrityCheck) + static void InvokeBuiltInCommand(ParseResult parseResult, out int exitCode) { + Debug.Assert(parseResult.CanBeInvoked()); + + PerformanceLogEventSource.Log.BuiltInCommandStart(); + try { - WorkloadIntegrityChecker.RunFirstUseCheck(reporter); + exitCode = Parser.Invoke(parseResult); + exitCode = AdjustExitCode(parseResult, exitCode); } - catch (Exception) + catch (Exception exception) { - // If the workload check fails for any reason, we want to eat the failure and continue running the command. - reporter.WriteLine(CliStrings.WorkloadIntegrityCheckError.Yellow()); + exitCode = Parser.ExceptionHandler(exception, parseResult); } + + PerformanceLogEventSource.Log.BuiltInCommandStop(); } } - private static int ExecuteInternalCommand(ParseResult parseResult) + private static int AdjustExitCode(ParseResult parseResult, int exitCode) { - Debug.Assert(parseResult.CanBeInvoked()); - int exitCode; - using var _ = Activities.Source.StartActivity("invocation"); - try - { - exitCode = Parser.Invoke(parseResult); - if (parseResult.Errors.Any()) - { - exitCode = AdjustExitCodeForNew(); - } - } - catch (Exception exception) - { - exitCode = Parser.ExceptionHandler(exception, parseResult); - } - return exitCode; - - int AdjustExitCodeForNew() + if (parseResult.Errors.Count > 0) { var commandResult = parseResult.CommandResult; + while (commandResult is not null) { if (commandResult.Command.Name == "new") { - // Default parse error exit code is 1. - // For the "new" command and its subcommands, it needs to be 127. + // default parse error exit code is 1 + // for the "new" command and its subcommands it needs to be 127 return 127; } + commandResult = commandResult.Parent as CommandResult; } - return exitCode; } + + return exitCode; } - private static int ExecuteExternalCommand(string[] args, ParseResult parseResult) + private static void ReportDotnetHomeUsage(IEnvironmentProvider provider) { - string commandName = "dotnet-" + parseResult.GetValue(Parser.RootCommand.DotnetSubCommand); - CommandSpec? resolvedCommandSpec = null; - using (var _ = Activities.Source.StartActivity("lookup-external-command")) - { - resolvedCommandSpec = CommandResolver.TryResolveCommandSpec( - new DefaultCommandResolverPolicy(), - commandName, - args.GetSubArguments(), - FrameworkConstants.CommonFrameworks.NetStandardApp15); - } - - if (resolvedCommandSpec is null && TryRunFileBasedApp(parseResult) is { } fileBasedAppExitCode) + var home = provider.GetEnvironmentVariable(CliFolderPathCalculator.DotnetHomeVariableName); + if (string.IsNullOrEmpty(home)) { - return fileBasedAppExitCode; + return; } - var resolvedCommand = CommandFactoryUsingResolver.CreateOrThrow(commandName, resolvedCommandSpec); - using var __ = Activities.Source.StartActivity("execute-extensible-command"); - return resolvedCommand.Execute().ExitCode; + Reporter.Verbose.WriteLine( + string.Format( + LocalizableStrings.DotnetCliHomeUsed, + home, + CliFolderPathCalculator.DotnetHomeVariableName)); } - private static int? TryRunFileBasedApp(ParseResult parseResult) + private static void ConfigureDotNetForFirstTimeUse( + IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel, + IAspNetCertificateSentinel aspNetCertificateSentinel, + IFileSentinel toolPathSentinel, + bool isDotnetBeingInvokedFromNativeInstaller, + DotnetFirstRunConfiguration dotnetFirstRunConfiguration, + IEnvironmentProvider environmentProvider, + Dictionary performanceMeasurements, + bool skipFirstTimeUseCheck) { - // If we didn't match any built-in commands, and a C# file path is the first argument, - // parse as `dotnet run file.cs ..rest_of_args` instead. - if (parseResult.GetResult(Parser.RootCommand.DotnetSubCommand) is { Tokens: [{ Type: TokenType.Argument, Value: { } } unmatchedCommandOrFile] } - && VirtualProjectBuilder.IsValidEntryPointPath(unmatchedCommandOrFile.Value)) + var isFirstTimeUse = !firstTimeUseNoticeSentinel.Exists() && !skipFirstTimeUseCheck; + var environmentPath = EnvironmentPathFactory.CreateEnvironmentPath(isDotnetBeingInvokedFromNativeInstaller, environmentProvider); + _ = new DotNetCommandFactory(alwaysRunOutOfProc: true); + var aspnetCertificateGenerator = new AspNetCoreCertificateGenerator(); + var reporter = Reporter.Error; + var dotnetConfigurer = new DotnetFirstTimeUseConfigurer( + firstTimeUseNoticeSentinel, + aspNetCertificateSentinel, + aspnetCertificateGenerator, + toolPathSentinel, + dotnetFirstRunConfiguration, + reporter, + environmentPath, + performanceMeasurements, + skipFirstTimeUseCheck: skipFirstTimeUseCheck); + + dotnetConfigurer.Configure(); + +#if TARGET_WINDOWS + if (isDotnetBeingInvokedFromNativeInstaller && OperatingSystem.IsWindows()) + { + DotDefaultPathCorrector.Correct(); + } +#endif + + if (isFirstTimeUse && !dotnetFirstRunConfiguration.SkipWorkloadIntegrityCheck) { - List otherTokens = new(parseResult.Tokens.Count - 1); - foreach (var token in parseResult.Tokens) + try { - if (token.Type != TokenType.Argument || token != unmatchedCommandOrFile) - { - otherTokens.Add(token.Value); - } + WorkloadIntegrityChecker.RunFirstUseCheck(reporter); + } + catch (Exception) + { + // If the workload check fails for any reason, we want to eat the failure and continue running the command. + reporter.WriteLine(CliStrings.WorkloadIntegrityCheckError.Yellow()); } - parseResult = Parser.Parse(["run", "--file", unmatchedCommandOrFile.Value, .. otherTokens]); - return ExecuteInternalCommand(parseResult); } - - return null; } - public static void Shutdown(PosixSignalContext context) + private static void InitializeProcess() { - s_sigIntRegistration.Dispose(); - s_sigQuitRegistration.Dispose(); - s_sigTermRegistration.Dispose(); - s_mainActivity?.Stop(); - TelemetryClient.FlushProviders(); - Activities.Source.Dispose(); + // by default, .NET Core doesn't have all code pages needed for Console apps. + // see the .NET Core Notes in https://docs.microsoft.com/dotnet/api/system.diagnostics.process#-notes + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + UILanguageOverride.Setup(); } } diff --git a/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs b/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs index 86e124e8e668..abe2058763ff 100644 --- a/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs +++ b/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs @@ -14,10 +14,10 @@ internal class AllowListToSendFirstAppliedOptions( { private HashSet _topLevelCommandNameAllowList { get; } = topLevelCommandNameAllowList; - public List AllowList(ParseResult parseResult) + public List AllowList(ParseResult parseResult, Dictionary measurements = null) { var topLevelCommandNameFromParse = parseResult.RootSubCommandResult(); - var result = new List(); + var result = new List(); if (_topLevelCommandNameAllowList.Contains(topLevelCommandNameFromParse)) { var firstOption = parseResult.RootCommandResult.Children @@ -25,13 +25,14 @@ public List AllowList(ParseResult parseResult) .Children.OfType().FirstOrDefault()?.Command.Name ?? null; if (firstOption != null) { - result.Add(new TelemetryEntryFormat( + result.Add(new ApplicationInsightsEntryFormat( "sublevelparser/command", new Dictionary { - {"verb", topLevelCommandNameFromParse}, + { "verb", topLevelCommandNameFromParse}, {"argument", firstOption} - })); + }, + measurements)); } } return result; diff --git a/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs b/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs index 4961193ca740..0b303a14bcd7 100644 --- a/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs +++ b/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs @@ -1,19 +1,22 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Cli.Telemetry; -internal class AllowListToSendFirstArgument(HashSet topLevelCommandNameAllowList) : IParseResultLogRule +internal class AllowListToSendFirstArgument( + HashSet topLevelCommandNameAllowList) : IParseResultLogRule { private HashSet _topLevelCommandNameAllowList { get; } = topLevelCommandNameAllowList; - public List AllowList(ParseResult parseResult) + public List AllowList(ParseResult parseResult, Dictionary measurements = null) { - var result = new List(); + var result = new List(); var topLevelCommandNameFromParse = parseResult.RootCommandResult.Children.FirstOrDefault() switch { System.CommandLine.Parsing.CommandResult commandResult => commandResult.Command.Name, @@ -25,17 +28,17 @@ public List AllowList(ParseResult parseResult) { if (_topLevelCommandNameAllowList.Contains(topLevelCommandNameFromParse)) { - var firstArgument = parseResult.RootCommandResult.Children.FirstOrDefault()?.Tokens - .Where(t => t.Type.Equals(TokenType.Argument)).FirstOrDefault()?.Value ?? null; + var firstArgument = parseResult.RootCommandResult.Children.FirstOrDefault()?.Tokens.Where(t => t.Type.Equals(TokenType.Argument)).FirstOrDefault()?.Value ?? null; if (firstArgument != null) { - result.Add(new TelemetryEntryFormat( + result.Add(new ApplicationInsightsEntryFormat( "sublevelparser/command", - new Dictionary + new Dictionary { {"verb", topLevelCommandNameFromParse}, {"argument", firstArgument} - })); + }, + measurements)); } } } diff --git a/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.cs b/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.cs index 7159b126ac2a..44eff7eb5707 100644 --- a/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.cs +++ b/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.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. +#nullable disable + using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.Extensions; @@ -8,13 +10,14 @@ namespace Microsoft.DotNet.Cli.Telemetry; -internal class AllowListToSendVerbSecondVerbFirstArgument(HashSet topLevelCommandNameAllowList) : IParseResultLogRule +internal class AllowListToSendVerbSecondVerbFirstArgument( + HashSet topLevelCommandNameAllowList) : IParseResultLogRule { private HashSet TopLevelCommandNameAllowList { get; } = topLevelCommandNameAllowList; - public List AllowList(ParseResult parseResult) + public List AllowList(ParseResult parseResult, Dictionary measurements = null) { - var result = new List(); + var result = new List(); var topLevelCommandNameFromParse = parseResult.RootSubCommandResult(); if (topLevelCommandNameFromParse != null) @@ -26,14 +29,15 @@ public List AllowList(ParseResult parseResult) var firstArgument = parseResult.Tokens.FirstOrDefault(t => t.Type.Equals(TokenType.Argument))?.Value ?? ""; if (secondVerb != null) { - result.Add(new TelemetryEntryFormat( + result.Add(new ApplicationInsightsEntryFormat( "sublevelparser/command", - new Dictionary + new Dictionary { {"verb", topLevelCommandNameFromParse}, {"subcommand", secondVerb}, {"argument", firstArgument} - })); + }, + measurements)); } } } diff --git a/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.cs b/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.cs index b1de4ad11aa7..a333f4127416 100644 --- a/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.cs +++ b/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.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. +#nullable disable + using System.Diagnostics; using System.Globalization; using System.Security; @@ -16,7 +18,7 @@ internal static class ExternalTelemetryProperties /// For Windows, returns the OS installation type, eg. "Nano Server", "Server Core", "Server", or "Client". /// For Unix, or on error, currently returns empty string. /// - internal static string? GetInstallationType() + internal static string GetInstallationType() { if (!OperatingSystem.IsWindows()) { @@ -28,7 +30,7 @@ internal static class ExternalTelemetryProperties try { - return (string?)Registry.GetValue(Key, ValueName, defaultValue: ""); + return (string)Registry.GetValue(Key, ValueName, defaultValue: ""); } // Catch everything: this is for telemetry only. catch (Exception e) @@ -48,7 +50,7 @@ internal static class ExternalTelemetryProperties /// We're not attempting to decode the value on the client side as new Windows releases may add new values. /// For Unix, or on error, returns an empty string. /// - internal static string? GetProductType() + internal static string GetProductType() { if (!OperatingSystem.IsWindows()) { @@ -82,7 +84,7 @@ internal static class ExternalTelemetryProperties /// If the libc is musl, currently returns empty string. /// Otherwise returns empty string. /// - internal static string? GetLibcRelease() + internal static string GetLibcRelease() { if (OperatingSystem.IsWindows()) { @@ -106,7 +108,7 @@ internal static class ExternalTelemetryProperties /// If the libc is musl, currently returns empty string. (In future could run "ldd -version".) /// Otherwise returns empty string. /// - internal static string? GetLibcVersion() + internal static string GetLibcVersion() { if (OperatingSystem.IsWindows()) { diff --git a/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs b/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs index 7dfe292c6c0c..6384f7dbd59a 100644 --- a/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs +++ b/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs @@ -10,5 +10,5 @@ namespace Microsoft.DotNet.Cli.Telemetry; internal interface IParseResultLogRule { - List AllowList(ParseResult parseResult); + List AllowList(ParseResult parseResult, Dictionary measurements = null); } diff --git a/src/Cli/dotnet/Telemetry/ITelemetryClient.cs b/src/Cli/dotnet/Telemetry/ITelemetry.cs similarity index 65% rename from src/Cli/dotnet/Telemetry/ITelemetryClient.cs rename to src/Cli/dotnet/Telemetry/ITelemetry.cs index f022648a59ff..b8ee7c98e118 100644 --- a/src/Cli/dotnet/Telemetry/ITelemetryClient.cs +++ b/src/Cli/dotnet/Telemetry/ITelemetry.cs @@ -1,11 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + namespace Microsoft.DotNet.Cli.Telemetry; -public interface ITelemetryClient +public interface ITelemetry { bool Enabled { get; } - void TrackEvent(string eventName, IDictionary? properties); + void TrackEvent(string eventName, IDictionary properties, IDictionary measurements); + + void Flush(); + + void Dispose(); } diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs new file mode 100644 index 000000000000..2966dfc205a8 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.ApplicationInsights.Channel; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +internal abstract class BaseStorageService +{ + /// + /// Peeked transmissions dictionary (maps file name to its full path). Holds all the transmissions that were peeked. + /// + /// + /// Note: The value (=file's full path) is not required in the Storage implementation. + /// If there was a concurrent Abstract Data Type Set it would have been used instead. + /// However, since there is no concurrent Set, dictionary is used and the second value is ignored. + /// + protected IDictionary PeekedTransmissions; + + /// + /// Gets or sets the maximum size of the storage in bytes. When limit is reached, the Enqueue method will drop new + /// transmissions. + /// + internal ulong CapacityInBytes { get; set; } + + /// + /// Gets or sets the maximum number of files. When limit is reached, the Enqueue method will drop new transmissions. + /// + internal uint MaxFiles { get; set; } + + internal abstract string StorageDirectoryPath { get; } + + /// + /// Initializes the + /// + /// A folder name. Under this folder all the transmissions will be saved. + internal abstract void Init(string desireStorageDirectoryPath); + + internal abstract StorageTransmission Peek(); + + internal abstract void Delete(StorageTransmission transmission); + + internal abstract Task EnqueueAsync(Transmission transmission); + + protected void OnPeekedItemDisposed(string fileName) + { + try + { + if (PeekedTransmissions.ContainsKey(fileName)) + { + PeekedTransmissions.Remove(fileName); + } + } + catch (Exception e) + { + PersistenceChannelDebugLog.WriteException(e, "Failed to remove the item from storage items."); + } + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs new file mode 100644 index 000000000000..4a80be224e9c --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +/// +/// A light fixed size queue. If Enqueue is called and queue's limit has reached the last item will be removed. +/// This data structure is thread safe. +/// +internal class FixedSizeQueue +{ + private readonly int _maxSize; + private readonly Queue _queue = new(); + private readonly object _queueLockObj = new(); + + internal FixedSizeQueue(int maxSize) + { + _maxSize = maxSize; + } + + internal void Enqueue(T item) + { + lock (_queueLockObj) + { + if (_queue.Count == _maxSize) + { + _queue.Dequeue(); + } + + _queue.Enqueue(item); + } + } + + internal bool Contains(T item) + { + lock (_queueLockObj) + { + return _queue.Contains(item); + } + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs new file mode 100644 index 000000000000..8d14b740cca0 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility.Implementation; +using IChannelTelemetry = Microsoft.ApplicationInsights.Channel.ITelemetry; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +/// +/// This class handles all the logic for flushing the In Memory buffer to the persistent storage. +/// +internal class FlushManager +{ + /// + /// The storage that is used to persist all the transmissions. + /// + private readonly BaseStorageService _storage; + + /// + /// Initializes a new instance of the class. + /// + /// The storage that persists the telemetries. + internal FlushManager(BaseStorageService storage) + { + _storage = storage; + } + + /// + /// Gets or sets the service endpoint. + /// + /// + /// Q: Why flushManager knows about the endpoint? + /// A: Storage stores Transmission objects and Transmission objects contain the endpoint address. + /// + internal Uri EndpointAddress { get; set; } + + /// + /// Persist the in-memory telemetry items. + /// + internal void Flush(IChannelTelemetry telemetryItem) + { + if (telemetryItem != null) + { + byte[] data = JsonSerializer.Serialize([telemetryItem]); + Transmission transmission = new( + EndpointAddress, + data, + "application/x-json-stream", + JsonSerializer.CompressionType); + + _storage.EnqueueAsync(transmission).ConfigureAwait(false).GetAwaiter().GetResult(); + } + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs new file mode 100644 index 000000000000..69affdc523a8 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.ApplicationInsights.Channel; +using IChannelTelemetry = Microsoft.ApplicationInsights.Channel.ITelemetry; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +/// +/// Represents a communication channel for sending telemetry to Application Insights via HTTPS. +/// +internal sealed class PersistenceChannel : ITelemetryChannel +{ + internal const string TelemetryServiceEndpoint = "https://dc.services.visualstudio.com/v2/track"; + + private readonly FlushManager _flushManager; + + private int _disposeCount; + private readonly BaseStorageService _storage; + private readonly PersistenceTransmitter _transmitter; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Full path of a directory name. Under this folder all the transmissions will be saved. + /// Setting this value groups channels, even from different processes. + /// If 2 (or more) channels has the same storageFolderName only one channel will perform the sending even if the + /// channel is in a different process/AppDomain/Thread. + /// + /// + /// Defines the number of senders. A sender is a long-running thread that sends telemetry batches in intervals defined + /// by . + /// So the amount of senders also defined the maximum amount of http channels opened at the same time. + /// + public PersistenceChannel(string storageDirectoryPath = null, int sendersCount = 1) + { + _storage = new StorageService(); + _storage.Init(storageDirectoryPath); + _transmitter = new PersistenceTransmitter(_storage, sendersCount); + _flushManager = new FlushManager(_storage); + EndpointAddress = TelemetryServiceEndpoint; + } + + /// + /// Gets or sets an interval between each successful sending. + /// + /// + /// On error scenario this value is ignored and the interval will be defined using an exponential back-off + /// algorithm. + /// + public TimeSpan? SendingInterval + { + get => _transmitter.SendingInterval; + set => _transmitter.SendingInterval = value; + } + + + /// + /// Gets or sets the maximum amount of files allowed in storage. When the limit is reached telemetries will be dropped. + /// + public uint MaxTransmissionStorageFilesCapacity + { + get => _storage.MaxFiles; + set => _storage.MaxFiles = value; + } + + /// + /// This flag has no effect. But it is required by base class + /// + public bool? DeveloperMode { get; set; } + + /// + /// Gets or sets the HTTP address where the telemetry is sent. + /// + public string EndpointAddress + { + get => _flushManager.EndpointAddress.ToString(); + + set + { + string address = value ?? TelemetryServiceEndpoint; + _flushManager.EndpointAddress = new Uri(address); + } + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + public void Dispose() + { + if (Interlocked.Increment(ref _disposeCount) == 1) + { + _transmitter?.Dispose(); + } + } + + /// + /// Sends an instance of ITelemetry through the channel. + /// + public void Send(IChannelTelemetry item) + { + _flushManager.Flush(item); + } + + /// + /// No operation, send will always flush. So nothing will be in memory + /// + public void Flush() + { + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs new file mode 100644 index 000000000000..ff695b79e6c3 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Globalization; +using Microsoft.DotNet.Cli.Utils; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +internal static class PersistenceChannelDebugLog +{ + private static readonly bool _isEnabled = IsEnabledByEnvironment(); + + private static bool IsEnabledByEnvironment() + { + var environmentProvider = new EnvironmentProvider(); + return environmentProvider.GetEnvironmentVariableAsBool("DOTNET_ENABLE_PERSISTENCE_CHANNEL_DEBUG_OUTPUT", false); + } + + public static void WriteLine(string message) + { + if (_isEnabled) + { + Reporter.Output.WriteLine(message); + } + } + + internal static void WriteException(Exception exception, string format, params string[] args) + { + var message = string.Format(CultureInfo.InvariantCulture, format, args); + WriteLine(string.Format(CultureInfo.InvariantCulture, "{0} Exception: {1}", message, exception.ToString())); + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs new file mode 100644 index 000000000000..0cede405eb04 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +/// +/// Implements throttled and persisted transmission of telemetry to Application Insights. +/// +internal class PersistenceTransmitter : IDisposable +{ + /// + /// The number of times this object was disposed. + /// + private int _disposeCount; + + /// + /// A list of senders that sends transmissions. + /// + private readonly List _senders = []; + + /// + /// The storage that is used to persist all the transmissions. + /// + private readonly BaseStorageService _storage; + + /// + /// Initializes a new instance of the class. + /// + /// The transmissions storage. + /// The number of senders to create. + /// + /// A boolean value that indicates if this class should try and create senders. This is a + /// workaround for unit tests purposes only. + /// + internal PersistenceTransmitter(BaseStorageService storage, int sendersCount, bool createSenders = true) + { + _storage = storage; + if (createSenders) + { + for (int i = 0; i < sendersCount; i++) + { + _senders.Add(new Sender(_storage, this)); + } + } + } + + /// + /// Gets or sets the interval between each successful sending. + /// + internal TimeSpan? SendingInterval { get; set; } + + /// + /// Disposes the object. + /// + public void Dispose() + { + if (Interlocked.Increment(ref _disposeCount) == 1) + { + StopSenders(); + } + } + + /// + /// Stops the senders. + /// + /// As long as there is no Start implementation, this method should only be called from Dispose. + private void StopSenders() + { + if (_senders == null) + { + return; + } + + List stoppedTasks = []; + foreach (Sender sender in _senders) + { + stoppedTasks.Add(sender.StopAsync()); + } + + Task.WaitAll([.. stoppedTasks]); + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs new file mode 100644 index 000000000000..6e0f7ceceaa9 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs @@ -0,0 +1,336 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Net; +using System.Net.NetworkInformation; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +/// +/// Fetch transmissions from the storage and sends it. +/// +internal class Sender : IDisposable +{ + /// + /// The default sending interval. + /// + private readonly TimeSpan _defaultSendingInterval; + + /// + /// A wait handle that flags the sender when to start sending again. The type is protected for unit test. + /// + protected readonly AutoResetEvent DelayHandler; + + /// + /// Holds the maximum time for the exponential back-off algorithm. The sending interval will grow on every HTTP + /// Exception until this max value. + /// + private readonly TimeSpan _maxIntervalBetweenRetries = TimeSpan.FromHours(1); + + /// + /// When storage is empty it will be queried again after this interval. + /// Decreasing to 5 sec to send first data (users and sessions). + /// + private readonly TimeSpan _sendingIntervalOnNoData = TimeSpan.FromSeconds(5); + + /// + /// A wait handle that is being set when Sender is no longer sending. + /// + private readonly AutoResetEvent _stoppedHandler; + + /// + /// The number of times this object was disposed. + /// + private int _disposeCount; + + /// + /// The amount of time to wait, in the stop method, until the last transmission is sent. + /// If time expires, the stop method will return even if the transmission hasn't been sent. + /// + private readonly TimeSpan _drainingTimeout; + + /// + /// A boolean value that indicates if the sender should be stopped. The sender's while loop is checking this boolean + /// value. + /// + private bool _stopped; + + /// + /// The transmissions storage. + /// + private readonly BaseStorageService _storage; + + /// + /// Holds the transmitter. + /// + private readonly PersistenceTransmitter _transmitter; + + /// + /// Initializes a new instance of the class. + /// + /// The storage that holds the transmissions to send. + /// + /// The persistence transmitter that manages this Sender. + /// The transmitter will be used as a configuration class, it exposes properties like SendingInterval that will be read + /// by Sender. + /// + /// + /// A boolean value that determines if Sender should start sending immediately. This is only + /// used for unit tests. + /// + internal Sender(BaseStorageService storage, PersistenceTransmitter transmitter, bool startSending = true) + { + _stopped = false; + DelayHandler = new AutoResetEvent(false); + _stoppedHandler = new AutoResetEvent(false); + _drainingTimeout = TimeSpan.FromSeconds(100); + _defaultSendingInterval = TimeSpan.FromSeconds(5); + + _transmitter = transmitter; + _storage = storage; + + if (startSending) + { + // It is currently possible for the long - running task to be executed(and thereby block during WaitOne) on the UI thread when + // called by a task scheduled on the UI thread. Explicitly specifying TaskScheduler.Default + // when calling StartNew guarantees that Sender never blocks the main thread. + Task.Factory.StartNew(SendLoop, CancellationToken.None, TaskCreationOptions.LongRunning, + TaskScheduler.Default) + .ContinueWith( + t => PersistenceChannelDebugLog.WriteException(t.Exception, "Sender: Failure in SendLoop"), + TaskContinuationOptions.OnlyOnFaulted); + } + } + + /// + /// Gets the interval between each successful sending. + /// + private TimeSpan SendingInterval + { + get + { + if (_transmitter.SendingInterval != null) + { + return _transmitter.SendingInterval.Value; + } + + return _defaultSendingInterval; + } + } + + /// + /// Disposes the managed objects. + /// + public void Dispose() + { + if (Interlocked.Increment(ref _disposeCount) == 1) + { + StopAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + + DelayHandler.Dispose(); + _stoppedHandler.Dispose(); + } + } + + /// + /// Stops the sender. + /// + internal Task StopAsync() + { + // After delayHandler is set, a sending iteration will immediately start. + // Setting stopped to true, will cause the iteration to skip the actual sending and stop immediately. + _stopped = true; + DelayHandler.Set(); + + // if delayHandler was set while a transmission was being sent, the return task will wait for it to finish, for an additional second, + // before it will mark the task as completed. + return Task.Run(() => + { + try + { + _stoppedHandler.WaitOne(_drainingTimeout); + } + catch (ObjectDisposedException) + { + } + }); + } + + /// + /// Send transmissions in a loop. + /// + protected void SendLoop() + { + TimeSpan prevSendingInterval = TimeSpan.Zero; + TimeSpan sendingInterval = _sendingIntervalOnNoData; + try + { + while (!_stopped) + { + using (StorageTransmission transmission = _storage.Peek()) + { + if (_stopped) + { + // This second verification is required for cases where 'stopped' was set while peek was happening. + // Once the actual sending starts the design is to wait until it finishes and deletes the transmission. + // So no extra validation is required. + break; + } + + // If there is a transmission to send - send it. + if (transmission != null) + { + bool shouldRetry = Send(transmission, ref sendingInterval); + if (!shouldRetry) + { + // If retry is not required - delete the transmission. + _storage.Delete(transmission); + } + } + else + { + sendingInterval = _sendingIntervalOnNoData; + } + } + + LogInterval(prevSendingInterval, sendingInterval); + DelayHandler.WaitOne(sendingInterval); + prevSendingInterval = sendingInterval; + } + + _stoppedHandler.Set(); + } + catch (ObjectDisposedException) + { + } + } + + /// + /// Sends a transmission and handle errors. + /// + /// The transmission to send. + /// + /// When this value returns it will hold a recommendation for when to start the next sending + /// iteration. + /// + /// True, if there was sent error and we need to retry sending, otherwise false. + protected virtual bool Send(StorageTransmission transmission, ref TimeSpan nextSendInterval) + { + try + { + if (transmission != null) + { + bool isConnected = NetworkInterface.GetIsNetworkAvailable(); + + // there is no internet connection available, return than. + if (!isConnected) + { + PersistenceChannelDebugLog.WriteLine( + "Cannot send data to the server. Internet connection is not available"); + return true; + } + + transmission.SendAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + + // After a successful sending, try immediately to send another transmission. + nextSendInterval = SendingInterval; + } + } + catch (WebException e) + { + int? statusCode = GetStatusCode(e); + nextSendInterval = CalculateNextInterval(statusCode, nextSendInterval, _maxIntervalBetweenRetries); + return IsRetryable(statusCode, e.Status); + } + catch (Exception e) + { + nextSendInterval = CalculateNextInterval(null, nextSendInterval, _maxIntervalBetweenRetries); + PersistenceChannelDebugLog.WriteException(e, "Unknown exception during sending"); + } + + return false; + } + + /// + /// Log next interval. Only log the interval when it changes by more then a minute. So if interval grow by 1 minute or + /// decreased by 1 minute it will be logged. + /// Logging every interval will just make the log noisy. + /// + private static void LogInterval(TimeSpan prevSendInterval, TimeSpan nextSendInterval) + { + if (Math.Abs(nextSendInterval.TotalSeconds - prevSendInterval.TotalSeconds) > 60) + { + PersistenceChannelDebugLog.WriteLine("next sending interval: " + nextSendInterval); + } + } + + /// + /// Return the status code from the web exception or null if no such code exists. + /// + private static int? GetStatusCode(WebException e) + { + if (e.Response is HttpWebResponse httpWebResponse) + { + return (int)httpWebResponse.StatusCode; + } + + return null; + } + + /// + /// Returns true if or are retryable. + /// + private static bool IsRetryable(int? httpStatusCode, WebExceptionStatus webExceptionStatus) + { + switch (webExceptionStatus) + { + case WebExceptionStatus.ProxyNameResolutionFailure: + case WebExceptionStatus.NameResolutionFailure: + case WebExceptionStatus.Timeout: + case WebExceptionStatus.ConnectFailure: + return true; + } + + if (httpStatusCode == null) + { + return false; + } + + switch (httpStatusCode.Value) + { + case 503: // Server in maintenance. + case 408: // invalid request + case 500: // Internal Server Error + case 502: // Bad Gateway, can be common when there is no network. + case 511: // Network Authentication Required + return true; + } + + return false; + } + + /// + /// Calculates the next interval using exponential back-off algorithm (with the exceptions of few error codes that + /// reset the interval to . + /// + private TimeSpan CalculateNextInterval(int? httpStatusCode, TimeSpan currentSendInterval, TimeSpan maxInterval) + { + // if item is expired, no need for exponential back-off + if (httpStatusCode != null && httpStatusCode.Value == 400 /* expired */) + { + return SendingInterval; + } + + // exponential back-off. + if (Math.Abs(currentSendInterval.TotalSeconds) < 1) + { + return TimeSpan.FromSeconds(1); + } + + double nextIntervalInSeconds = Math.Min(currentSendInterval.TotalSeconds * 2, maxInterval.TotalSeconds); + + return TimeSpan.FromSeconds(nextIntervalInSeconds); + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs new file mode 100644 index 000000000000..3e98a0d598a8 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Collections; +using System.Diagnostics; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +internal abstract class SnapshottingCollection : ICollection + where TCollection : class, ICollection +{ + protected readonly TCollection Collection; + protected TCollection snapshot; + + protected SnapshottingCollection(TCollection collection) + { + Debug.Assert(collection != null, "collection"); + Collection = collection; + } + + public int Count => GetSnapshot().Count; + + public bool IsReadOnly => false; + + public void Add(TItem item) + { + lock (Collection) + { + Collection.Add(item); + snapshot = default; + } + } + + public void Clear() + { + lock (Collection) + { + Collection.Clear(); + snapshot = default; + } + } + + public bool Contains(TItem item) + { + return GetSnapshot().Contains(item); + } + + public void CopyTo(TItem[] array, int arrayIndex) + { + GetSnapshot().CopyTo(array, arrayIndex); + } + + public bool Remove(TItem item) + { + lock (Collection) + { + bool removed = Collection.Remove(item); + if (removed) + { + snapshot = default; + } + + return removed; + } + } + + public IEnumerator GetEnumerator() + { + return GetSnapshot().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + protected abstract TCollection CreateSnapshot(TCollection collection); + + protected TCollection GetSnapshot() + { + TCollection localSnapshot = snapshot; + if (localSnapshot == null) + { + lock (Collection) + { + snapshot = CreateSnapshot(Collection); + localSnapshot = snapshot; + } + } + + return localSnapshot; + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs new file mode 100644 index 000000000000..cc6cc51a1f57 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +internal class SnapshottingDictionary : + SnapshottingCollection, IDictionary>, IDictionary +{ + public SnapshottingDictionary() + : base(new Dictionary()) + { + } + + public ICollection Keys => GetSnapshot().Keys; + + public ICollection Values => GetSnapshot().Values; + + public TValue this[TKey key] + { + get => GetSnapshot()[key]; + + set + { + lock (Collection) + { + Collection[key] = value; + snapshot = null; + } + } + } + + public void Add(TKey key, TValue value) + { + lock (Collection) + { + Collection.Add(key, value); + snapshot = null; + } + } + + public bool ContainsKey(TKey key) + { + return GetSnapshot().ContainsKey(key); + } + + public bool Remove(TKey key) + { + lock (Collection) + { + bool removed = Collection.Remove(key); + if (removed) + { + snapshot = null; + } + + return removed; + } + } + + public bool TryGetValue(TKey key, out TValue value) + { + return GetSnapshot().TryGetValue(key, out value); + } + + protected sealed override IDictionary CreateSnapshot(IDictionary collection) + { + return new Dictionary(collection); + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs new file mode 100644 index 000000000000..dc44a0bf3cbb --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs @@ -0,0 +1,352 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Globalization; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.DotNet.Configurer; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +internal sealed class StorageService : BaseStorageService +{ + private const string DefaultStorageFolderName = "TelemetryStorageService"; + private readonly FixedSizeQueue _deletedFilesQueue = new(10); + + private readonly object _peekLockObj = new(); + private readonly object _storageFolderLock = new(); + private string _storageDirectoryPath; + private string _storageDirectoryPathUsed; + private long _storageCountFiles; + private bool _storageFolderInitialized; + private long _storageSize; + private uint _transmissionsDropped; + + /// + /// Gets the storage's folder name. + /// + internal override string StorageDirectoryPath => _storageDirectoryPath; + + /// + /// Gets the storage folder. If storage folder couldn't be created, null will be returned. + /// + private string StorageFolder + { + get + { + if (!_storageFolderInitialized) + { + lock (_storageFolderLock) + { + if (!_storageFolderInitialized) + { + try + { + _storageDirectoryPathUsed = _storageDirectoryPath; + + if (!Directory.Exists(_storageDirectoryPathUsed)) + { + Directory.CreateDirectory(_storageDirectoryPathUsed); + } + } + catch (Exception e) + { + _storageDirectoryPathUsed = null; + PersistenceChannelDebugLog.WriteException(e, "Failed to create storage folder"); + } + + _storageFolderInitialized = true; + } + } + } + + return _storageDirectoryPathUsed; + } + } + + internal override void Init(string storageDirectoryPath) + { + PeekedTransmissions = new SnapshottingDictionary(); + + VerifyOrSetDefaultStorageDirectoryPath(storageDirectoryPath); + + CapacityInBytes = 10 * 1024 * 1024; // 10 MB + MaxFiles = 100; + + Task.Run(DeleteObsoleteFiles) + .ContinueWith( + task => + { + PersistenceChannelDebugLog.WriteException( + task.Exception, + "Storage: Unhandled exception in DeleteObsoleteFiles"); + }, + TaskContinuationOptions.OnlyOnFaulted); + } + + private void VerifyOrSetDefaultStorageDirectoryPath(string desireStorageDirectoryPath) + { + if (string.IsNullOrEmpty(desireStorageDirectoryPath)) + { + _storageDirectoryPath = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, + DefaultStorageFolderName); + } + else + { + if (!Path.IsPathRooted(desireStorageDirectoryPath)) + { + throw new ArgumentException($"{nameof(desireStorageDirectoryPath)} need to be rooted (full path)"); + } + + _storageDirectoryPath = desireStorageDirectoryPath; + } + } + + /// + /// Reads an item from the storage. Order is Last-In-First-Out. + /// When the Transmission is no longer needed (it was either sent or failed with a non-retryable error) it should be + /// disposed. + /// + internal override StorageTransmission Peek() + { + IEnumerable files = GetFiles("*.trn", 50); + + lock (_peekLockObj) + { + foreach (string file in files) + { + try + { + // if a file was peeked before, skip it (wait until it is disposed). + if (PeekedTransmissions.ContainsKey(file) == false && + _deletedFilesQueue.Contains(file) == false) + { + // Load the transmission from disk. + StorageTransmission storageTransmissionItem = LoadTransmissionFromFileAsync(file) + .ConfigureAwait(false).GetAwaiter().GetResult(); + + // when item is disposed it should be removed from the peeked list. + storageTransmissionItem.Disposing = item => OnPeekedItemDisposed(file); + + // add the transmission to the list. + PeekedTransmissions.Add(file, storageTransmissionItem.FullFilePath); + return storageTransmissionItem; + } + } + catch (Exception e) + { + PersistenceChannelDebugLog.WriteException( + e, + "Failed to load an item from the storage. file: {0}", + file); + } + } + } + + return null; + } + + internal override void Delete(StorageTransmission item) + { + try + { + if (StorageFolder == null) + { + return; + } + + // Initial storage size calculation. + CalculateSize(); + + long fileSize = GetSize(item.FileName); + File.Delete(Path.Combine(StorageFolder, item.FileName)); + + _deletedFilesQueue.Enqueue(item.FileName); + + // calculate size + Interlocked.Add(ref _storageSize, -fileSize); + Interlocked.Decrement(ref _storageCountFiles); + } + catch (IOException e) + { + PersistenceChannelDebugLog.WriteException(e, "Failed to delete a file. file: {0}", item == null ? "null" : item.FullFilePath); + } + } + + internal override async Task EnqueueAsync(Transmission transmission) + { + try + { + if (transmission == null || StorageFolder == null) + { + return; + } + + // Initial storage size calculation. + CalculateSize(); + + if ((ulong)_storageSize >= CapacityInBytes || _storageCountFiles >= MaxFiles) + { + // if max storage capacity has reached, drop the transmission (but log every 100 lost transmissions). + if (_transmissionsDropped++ % 100 == 0) + { + PersistenceChannelDebugLog.WriteLine("Total transmissions dropped: " + _transmissionsDropped); + } + + return; + } + + // Writes content to a temporary file and only then rename to avoid the Peek from reading the file before it is being written. + // Creates the temp file name + string tempFileName = Guid.NewGuid().ToString("N"); + + // Now that the file got created we can increase the files count + Interlocked.Increment(ref _storageCountFiles); + + // Saves transmission to the temp file + await SaveTransmissionToFileAsync(transmission, tempFileName).ConfigureAwait(false); + + // Now that the file is written increase storage size. + long temporaryFileSize = GetSize(tempFileName); + Interlocked.Add(ref _storageSize, temporaryFileSize); + + // Creates a new file name + string now = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string newFileName = string.Format(CultureInfo.InvariantCulture, "{0}_{1}.trn", now, tempFileName); + + // Renames the file + File.Move(Path.Combine(StorageFolder, tempFileName), Path.Combine(StorageFolder, newFileName)); + } + catch (Exception e) + { + PersistenceChannelDebugLog.WriteException(e, "EnqueueAsync"); + } + } + + private async Task SaveTransmissionToFileAsync(Transmission transmission, string file) + { + try + { + using (Stream stream = File.OpenWrite(Path.Combine(StorageFolder, file))) + { + await StorageTransmission.SaveAsync(transmission, stream).ConfigureAwait(false); + } + } + catch (UnauthorizedAccessException) + { + string message = + string.Format( + "Failed to save transmission to file. UnauthorizedAccessException. File path: {0}, FileName: {1}", + StorageFolder, file); + PersistenceChannelDebugLog.WriteLine(message); + throw; + } + } + + private async Task LoadTransmissionFromFileAsync(string file) + { + try + { + using (Stream stream = File.OpenRead(Path.Combine(StorageFolder, file))) + { + StorageTransmission storageTransmissionItem = + await StorageTransmission.CreateFromStreamAsync(stream, file).ConfigureAwait(false); + return storageTransmissionItem; + } + } + catch (Exception e) + { + string message = + string.Format( + "Failed to load transmission from file. File path: {0}, FileName: {1}, Exception: {2}", + "storageFolderName", file, e); + PersistenceChannelDebugLog.WriteLine(message); + throw; + } + } + + /// + /// Get files from . + /// + /// Define the logic for sorting the files. + /// Defines a file extension. This method will return only files with this extension. + /// + /// Define how many files to return. This can be useful when the directory has a lot of files, in that case + /// GetFilesAsync will have a performance hit. + /// + private IEnumerable GetFiles(string filterByExtension, int top) + { + try + { + if (StorageFolder != null) + { + return Directory.GetFiles(StorageFolder, filterByExtension).Take(top); + } + } + catch (Exception e) + { + PersistenceChannelDebugLog.WriteException(e, "Peek failed while get files from storage."); + } + + return []; + } + + /// + /// Gets a file's size. + /// + private long GetSize(string file) + { + using (FileStream stream = File.OpenRead(Path.Combine(StorageFolder, file))) + { + return stream.Length; + } + } + + /// + /// Check the storage limits and return true if they reached. + /// Storage limits are defined by the number of files and the total size on disk. + /// + private void CalculateSize() + { + string[] storageFiles = Directory.GetFiles(StorageFolder, "*.*"); + + _storageCountFiles = storageFiles.Count(); + + long storageSizeInBytes = 0; + foreach (string file in storageFiles) + { + storageSizeInBytes += GetSize(file); + } + + _storageSize = storageSizeInBytes; + } + + /// + /// Enqueue is saving a transmission to a file with a guid, and after a successful write operation it renames it to a + /// trn file. + /// A file without a trn extension is ignored by Storage.Peek(), so if a process is taken down before rename + /// happens it will stay on the disk forever. + /// This thread deletes files with the trn extension that exists on disk for more than 5 minutes. + /// + private void DeleteObsoleteFiles() + { + try + { + IEnumerable files = GetFiles("*.trn", 50); + foreach (string file in files) + { + DateTime creationTime = File.GetCreationTimeUtc(Path.Combine(StorageFolder, file)); + // if the file is older then 5 minutes - delete it. + if (DateTime.UtcNow - creationTime >= TimeSpan.FromMinutes(5)) + { + File.Delete(Path.Combine(StorageFolder, file)); + } + } + } + catch (Exception e) + { + PersistenceChannelDebugLog.WriteException(e, "Failed to delete tmp files."); + } + } +} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs new file mode 100644 index 000000000000..69b6132f1b9f --- /dev/null +++ b/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Globalization; +using Microsoft.ApplicationInsights.Channel; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; + +internal class StorageTransmission : Transmission, IDisposable +{ + internal Action Disposing; + + protected StorageTransmission(string fullPath, Uri address, byte[] content, string contentType, + string contentEncoding) + : base(address, content, contentType, contentEncoding) + { + FullFilePath = fullPath; + FileName = Path.GetFileName(fullPath); + } + + internal string FileName { get; } + + internal string FullFilePath { get; } + + /// + /// Disposing the storage transmission. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Creates a new transmission from the specified . + /// + /// Return transmission loaded from file; return null if the file is corrupted. + internal static async Task CreateFromStreamAsync(Stream stream, string fileName) + { + StreamReader reader = new(stream); + Uri address = await ReadAddressAsync(reader).ConfigureAwait(false); + string contentType = await ReadHeaderAsync(reader, "Content-Type").ConfigureAwait(false); + string contentEncoding = await ReadHeaderAsync(reader, "Content-Encoding").ConfigureAwait(false); + byte[] content = await ReadContentAsync(reader).ConfigureAwait(false); + return new StorageTransmission(fileName, address, content, contentType, contentEncoding); + } + + /// + /// Saves the transmission to the specified . + /// + internal static async Task SaveAsync(Transmission transmission, Stream stream) + { + StreamWriter writer = new(stream); + try + { + await writer.WriteLineAsync(transmission.EndpointAddress.ToString()).ConfigureAwait(false); + await writer.WriteLineAsync("Content-Type" + ":" + transmission.ContentType).ConfigureAwait(false); + await writer.WriteLineAsync("Content-Encoding" + ":" + transmission.ContentEncoding) + .ConfigureAwait(false); + await writer.WriteLineAsync(string.Empty).ConfigureAwait(false); + await writer.WriteAsync(Convert.ToBase64String(transmission.Content)).ConfigureAwait(false); + } + finally + { + writer.Flush(); + } + } + + private static async Task ReadHeaderAsync(TextReader reader, string headerName) + { + string line = await reader.ReadLineAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(line)) + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, "{0} header is expected.", + headerName)); + } + + string[] parts = line.Split(':'); + if (parts.Length != 2) + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, + "Unexpected header format. {0} header is expected. Actual header: {1}", headerName, line)); + } + + if (parts[0] != headerName) + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, + "{0} header is expected. Actual header: {1}", headerName, line)); + } + + return parts[1].Trim(); + } + + private static async Task ReadAddressAsync(TextReader reader) + { + string addressLine = await reader.ReadLineAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(addressLine)) + { + throw new FormatException("Transmission address is expected."); + } + + Uri address = new(addressLine); + return address; + } + + private static async Task ReadContentAsync(TextReader reader) + { + string content = await reader.ReadToEndAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(content) || content == Environment.NewLine) + { + throw new FormatException("Content is expected."); + } + + return Convert.FromBase64String(content); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + Action disposingDelegate = Disposing; + disposingDelegate?.Invoke(this); + } + } +} diff --git a/src/Cli/dotnet/Telemetry/Telemetry.cs b/src/Cli/dotnet/Telemetry/Telemetry.cs new file mode 100644 index 000000000000..38f0d1c7ca19 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/Telemetry.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Frozen; +using System.Diagnostics; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Configurer; +using CLIRuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; + +namespace Microsoft.DotNet.Cli.Telemetry; + +public class Telemetry : ITelemetry +{ + internal static string? CurrentSessionId = null; + internal static bool DisabledForTests = false; + private readonly int _senderCount; + private TelemetryClient? _client = null; + private FrozenDictionary? _commonProperties = null; + private FrozenDictionary? _commonMeasurements = null; + private Task? _trackEventTask = null; + + private const string ConnectionString = "InstrumentationKey=74cc1c9e-3e6e-4d05-b3fc-dde9101d0254"; + + public bool Enabled { get; } + + public Telemetry() : this(null) { } + + public Telemetry(IFirstTimeUseNoticeSentinel? sentinel) : this(sentinel, null) { } + + public Telemetry( + IFirstTimeUseNoticeSentinel? sentinel, + string? sessionId, + bool blockThreadInitialization = false, + IEnvironmentProvider? environmentProvider = null, + int senderCount = 3) + { + + if (DisabledForTests) + { + return; + } + + environmentProvider ??= new EnvironmentProvider(); + + Enabled = !environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, defaultValue: CompileOptions.TelemetryOptOutDefault) + && PermissionExists(sentinel); + + if (!Enabled) + { + return; + } + + // Store the session ID in a static field so that it can be reused + CurrentSessionId = sessionId ?? Guid.NewGuid().ToString(); + _senderCount = senderCount; + if (blockThreadInitialization) + { + InitializeTelemetry(); + } + else + { + //initialize in task to offload to parallel thread + _trackEventTask = Task.Run(() => InitializeTelemetry()); + } + } + + internal static void DisableForTests() + { + DisabledForTests = true; + CurrentSessionId = null; + } + + internal static void EnableForTests() + { + DisabledForTests = false; + } + + private static bool PermissionExists(IFirstTimeUseNoticeSentinel? sentinel) + { + if (sentinel == null) + { + return false; + } + + return sentinel.Exists(); + } + + public void TrackEvent(string eventName, IDictionary properties, + IDictionary measurements) + { + if (!Enabled) + { + return; + } + + //continue the task in different threads + if (_trackEventTask == null) + { + _trackEventTask = Task.Run(() => TrackEventTask(eventName, properties, measurements)); + return; + } + else + { + _trackEventTask = _trackEventTask.ContinueWith( + x => TrackEventTask(eventName, properties, measurements) + ); + } + } + + public void Flush() + { + if (!Enabled || _trackEventTask == null) + { + return; + } + + _trackEventTask.Wait(); + } + + // Adding dispose on graceful shutdown per https://github.com/microsoft/ApplicationInsights-dotnet/issues/1152#issuecomment-518742922 + public void Dispose() + { + if (_client != null) + { + _client.TelemetryConfiguration.Dispose(); + _client = null; + } + } + + public void ThreadBlockingTrackEvent(string eventName, IDictionary properties, IDictionary measurements) + { + if (!Enabled) + { + return; + } + TrackEventTask(eventName, properties, measurements); + } + + private void InitializeTelemetry() + { + try + { + var persistenceChannel = new PersistenceChannel.PersistenceChannel(sendersCount: _senderCount) + { + SendingInterval = TimeSpan.FromMilliseconds(1) + }; + + var config = TelemetryConfiguration.CreateDefault(); + config.TelemetryChannel = persistenceChannel; + config.ConnectionString = ConnectionString; + _client = new TelemetryClient(config); + _client.Context.Session.Id = CurrentSessionId; + _client.Context.Device.OperatingSystem = CLIRuntimeEnvironment.OperatingSystem; + + _commonProperties = new TelemetryCommonProperties().GetTelemetryCommonProperties(CurrentSessionId); + _commonMeasurements = FrozenDictionary.Empty; + } + catch (Exception e) + { + _client = null; + // we dont want to fail the tool if telemetry fails. + Debug.Fail(e.ToString()); + } + } + + private void TrackEventTask( + string eventName, + IDictionary properties, + IDictionary measurements) + { + if (_client == null) + { + return; + } + + try + { + var eventProperties = GetEventProperties(properties); + var eventMeasurements = GetEventMeasures(measurements); + + eventProperties ??= new Dictionary(); + eventProperties.Add("event id", Guid.NewGuid().ToString()); + + _client.TrackEvent(PrependProducerNamespace(eventName), eventProperties, eventMeasurements); + Activity.Current?.AddEvent(CreateActivityEvent(eventName, eventProperties, eventMeasurements)); + } + catch (Exception e) + { + Debug.Fail(e.ToString()); + } + } + + private static ActivityEvent CreateActivityEvent( + string eventName, + IDictionary? properties, + IDictionary? measurements) + { + var tags = MakeTags(properties, measurements); + return new ActivityEvent( + PrependProducerNamespace(eventName), + tags: tags); + } + + private static ActivityTagsCollection? MakeTags( + IDictionary? properties, + IDictionary? measurements) + { + if (properties == null && measurements == null) + { + return null; + } + else if (properties != null && measurements == null) + { + return [.. properties.Select(p => new KeyValuePair(p.Key, p.Value))]; + } + else if (properties == null && measurements != null) + { + return [.. measurements.Select(m => new KeyValuePair(m.Key, m.Value.ToString()))]; + } + else return [ .. properties!.Select(p => new KeyValuePair(p.Key, p.Value)), + .. measurements!.Select(m => new KeyValuePair(m.Key, m.Value.ToString())) ]; + } + + private static string PrependProducerNamespace(string eventName) => $"dotnet/cli/{eventName}"; + + private IDictionary? GetEventMeasures(IDictionary? measurements) + { + return (measurements, _commonMeasurements) switch + { + (null, null) => null, + (null, not null) => _commonMeasurements == FrozenDictionary.Empty ? null : new Dictionary(_commonMeasurements), + (not null, null) => measurements, + (not null, not null) => Combine(_commonMeasurements, measurements), + }; + } + + private IDictionary? GetEventProperties(IDictionary? properties) + { + return (properties, _commonProperties) switch + { + (null, null) => null, + (null, not null) => _commonProperties == FrozenDictionary.Empty ? null : new Dictionary(_commonProperties), + (not null, null) => properties, + (not null, not null) => Combine(_commonProperties, properties), + }; + } + + static IDictionary Combine(IDictionary common, IDictionary specific) where TKey : notnull + { + IDictionary eventMeasurements = new Dictionary(capacity: common.Count + specific.Count); + foreach (KeyValuePair measurement in common) + { + eventMeasurements[measurement.Key] = measurement.Value; + } + foreach (KeyValuePair measurement in specific) + { + eventMeasurements[measurement.Key] = measurement.Value; + } + return eventMeasurements; + } +} diff --git a/src/Cli/dotnet/Telemetry/TelemetryClient.cs b/src/Cli/dotnet/Telemetry/TelemetryClient.cs deleted file mode 100644 index aaba4e686ef1..000000000000 --- a/src/Cli/dotnet/Telemetry/TelemetryClient.cs +++ /dev/null @@ -1,231 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Frozen; -using System.Diagnostics; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Configurer; - -#if TARGET_WINDOWS -using Azure.Monitor.OpenTelemetry.Exporter; -using OpenTelemetry; -using OpenTelemetry.Context.Propagation; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; -#endif - -namespace Microsoft.DotNet.Cli.Telemetry; - -public class TelemetryClient : ITelemetryClient -{ - private static FrozenDictionary s_commonProperties = []; - private Task? _trackEventTask; - -#if TARGET_WINDOWS - private static readonly MeterProviderBuilder s_metricsProviderBuilder; - private static MeterProvider? s_metricsProvider; - private static readonly TracerProviderBuilder s_tracerProviderBuilder; - private static TracerProvider? s_tracerProvider; - private static readonly List s_activities = []; - - private static readonly string s_connectionString = "InstrumentationKey=74cc1c9e-3e6e-4d05-b3fc-dde9101d0254"; - private static readonly string s_defaultStorageDirectory = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, "TelemetryStorageService"); - // Note: The TelemetryClient instance constructor takes in an environment provider. These fields don't use that currently. - private static readonly string? s_environmentStoragePath = Env.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_STORAGE_PATH); - private static readonly string? s_diskLogPath = Env.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_LOG_PATH); - private static readonly bool s_disableTraceExport = Env.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT); - private static readonly int s_flushTimeoutMs = 200; -#endif - - public static string? CurrentSessionId { get; private set; } = null; - public static bool DisabledForTests - { - get => field; - set - { - field = value; - // When disabled, clear the session ID. - if (field) - { - CurrentSessionId = null; - } - } - } = false; - public static ActivityContext ParentActivityContext { get; private set; } - public static ActivityKind ActivityKind { get; private set; } - - public bool Enabled { get; } - - static TelemetryClient() - { -#if TARGET_WINDOWS - s_metricsProviderBuilder = Sdk.CreateMeterProviderBuilder() - .ConfigureResource(r => { r.AddService("dotnet-cli", serviceVersion: Product.Version); }) - .AddMeter(Activities.Source.Name) - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddOtlpExporter(); - - s_tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() - .ConfigureResource(r => { r.AddService("dotnet-cli", serviceVersion: Product.Version); }) - .AddSource(Activities.Source.Name) - .AddHttpClientInstrumentation() - .AddOtlpExporter() - .AddInMemoryExporter(s_activities) - .SetSampler(new AlwaysOnSampler()); - - if (!s_disableTraceExport) - { - var storageDirectory = string.IsNullOrWhiteSpace(s_environmentStoragePath) ? s_defaultStorageDirectory : s_environmentStoragePath; - s_tracerProviderBuilder.AddAzureMonitorTraceExporter(o => - { - o.ConnectionString = s_connectionString; - o.EnableLiveMetrics = false; - o.StorageDirectory = storageDirectory; - }); - } -#endif - - var parentActivityContext = GetParentActivityContext(); - ActivityKind = GetActivityKind(parentActivityContext); - ParentActivityContext = parentActivityContext ?? default; - } - - public TelemetryClient() : this(null) { } - - public TelemetryClient(string? sessionId, IEnvironmentProvider? environmentProvider = null) - { - // This is some kind of special condition for MSBuild-related tests. - if (DisabledForTests) - { - return; - } - - environmentProvider ??= new EnvironmentProvider(); - Enabled = !environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, - // When building in the official CI pipeline, this makes the complier enable telemetry by default. Otherwise, it is disabled. - // It is the reason tests don't send telemetry, because we don't run tests in the official CI pipeline. - defaultValue: CompileOptions.TelemetryOptOutDefault); - if (!Enabled) - { - return; - } - -#if TARGET_WINDOWS - if (s_metricsProvider is null || s_tracerProvider is null) - { - // Create a new OTel meter and tracer provider. - // It is important to keep the provider instances active throughout the process lifetime. - s_metricsProvider ??= s_metricsProviderBuilder.Build(); - s_tracerProvider ??= s_tracerProviderBuilder.Build(); - } -#endif - - CurrentSessionId ??= !string.IsNullOrEmpty(sessionId) ? sessionId : Guid.NewGuid().ToString(); - s_commonProperties = new TelemetryCommonProperties().GetTelemetryCommonProperties(CurrentSessionId); - } - - /// - /// Uses the OpenTelemetry SDK's Propagation API to derive the parent activity context from the DOTNET_CLI_TRACEPARENT and DOTNET_CLI_TRACESTATE environment variables. - /// - private static ActivityContext? GetParentActivityContext() - { - var traceParent = Env.GetEnvironmentVariable(Activities.TRACEPARENT); - if (string.IsNullOrEmpty(traceParent)) - { - return null; - } - - var carrierMap = new Dictionary?> { { "traceparent", [traceParent] } }; - var traceState = Env.GetEnvironmentVariable(Activities.TRACESTATE); - if (!string.IsNullOrEmpty(traceState)) - { - carrierMap.Add("tracestate", [traceState]); - } - - ActivityContext? parentContext = null; -#if TARGET_WINDOWS - // Use the propegator to extract the parent activity context and kind. - // For some reason, this isn't set by the OTel SDK like docs say it should be. - Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator([new TraceContextPropagator(), new BaggagePropagator()])); - parentContext = Propagators.DefaultTextMapPropagator.Extract(default, carrierMap, GetValueFromCarrier).ActivityContext; -#endif - return parentContext; - -#if TARGET_WINDOWS - static IEnumerable? GetValueFromCarrier(Dictionary?> carrier, string key) => - carrier.TryGetValue(key, out var value) ? value : null; -#endif - } - - private static ActivityKind GetActivityKind(ActivityContext? parentActivityContext) => - parentActivityContext is ActivityContext { IsRemote: true } ? ActivityKind.Server : ActivityKind.Internal; - - public static void FlushProviders() - { -#if TARGET_WINDOWS - s_tracerProvider?.ForceFlush(s_flushTimeoutMs); - s_metricsProvider?.ForceFlush(s_flushTimeoutMs); -#endif - } - - public static void WriteLogIfNecessary() - { -#if TARGET_WINDOWS - if (!string.IsNullOrWhiteSpace(s_diskLogPath) && s_activities.Any()) - { - TelemetryDiskLogger.WriteLog(s_diskLogPath, s_activities); - } -#endif - } - - public void TrackEvent(string eventName, IDictionary? properties) - { - if (!Enabled) - { - return; - } - - // Continue the task in different threads. - _trackEventTask = _trackEventTask == null - ? Task.Run(() => TrackEventTask(eventName, properties)) - : _trackEventTask.ContinueWith(_ => TrackEventTask(eventName, properties)); - } - - public void ThreadBlockingTrackEvent(string eventName, IDictionary? properties) - { - if (!Enabled) - { - return; - } - - TrackEventTask(eventName, properties); - } - - private static void TrackEventTask(string eventName, IDictionary? properties) - { - try - { - properties ??= new Dictionary(); - properties.Add("event id", Guid.NewGuid().ToString()); - var @event = new ActivityEvent($"dotnet/cli/{eventName}", tags: MakeTags(properties)); - Activity.Current?.AddEvent(@event); - } - catch (Exception e) - { - Debug.Fail(e.ToString()); - } - } - - private static ActivityTagsCollection MakeTags(IDictionary eventProperties) - { - var common = s_commonProperties - .Select(p => new KeyValuePair(p.Key, p.Value)); - var properties = eventProperties - .Where(p => p.Value is not null) - .Select(p => new KeyValuePair(p.Key, p.Value)) - .OrderBy(p => p.Key); - return [.. common, .. properties]; - } -} diff --git a/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs b/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs index 12d8395b7688..8c71bff14e7d 100644 --- a/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs +++ b/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.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. +#nullable disable + using System.Collections.Frozen; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Configurer; @@ -11,24 +13,23 @@ namespace Microsoft.DotNet.Cli.Telemetry; internal class TelemetryCommonProperties( - Func? getCurrentDirectory = null, - Func? hasher = null, - Func? getMACAddress = null, - Func? getDeviceId = null, - IDockerContainerDetector? dockerContainerDetector = null, - IUserLevelCacheWriter? userLevelCacheWriter = null, - ICIEnvironmentDetector? ciEnvironmentDetector = null, - ILLMEnvironmentDetector? llmEnvironmentDetector = null) + Func getCurrentDirectory = null, + Func hasher = null, + Func getMACAddress = null, + Func getDeviceId = null, + IDockerContainerDetector dockerContainerDetector = null, + IUserLevelCacheWriter userLevelCacheWriter = null, + ICIEnvironmentDetector ciEnvironmentDetector = null, + ILLMEnvironmentDetector llmEnvironmentDetector = null) { private readonly IDockerContainerDetector _dockerContainerDetector = dockerContainerDetector ?? new DockerContainerDetectorForTelemetry(); private readonly ICIEnvironmentDetector _ciEnvironmentDetector = ciEnvironmentDetector ?? new CIEnvironmentDetectorForTelemetry(); private readonly ILLMEnvironmentDetector _llmEnvironmentDetector = llmEnvironmentDetector ?? new LLMEnvironmentDetectorForTelemetry(); private readonly Func _getCurrentDirectory = getCurrentDirectory ?? Directory.GetCurrentDirectory; private readonly Func _hasher = hasher ?? Sha256Hasher.Hash; - private readonly Func _getMACAddress = getMACAddress ?? MacAddressGetter.GetMacAddress; + private readonly Func _getMACAddress = getMACAddress ?? MacAddressGetter.GetMacAddress; private readonly Func _getDeviceId = getDeviceId ?? DeviceIdGetter.GetDeviceId; private readonly IUserLevelCacheWriter _userLevelCacheWriter = userLevelCacheWriter ?? new UserLevelCacheWriter(); - private const string OSVersion = "OS Version"; private const string OSPlatform = "OS Platform"; private const string OSArchitecture = "OS Architecture"; @@ -47,39 +48,63 @@ internal class TelemetryCommonProperties( private const string LibcRelease = "Libc Release"; private const string LibcVersion = "Libc Version"; private const string SessionId = "SessionId"; + private const string CI = "Continuous Integration"; private const string LLM = "llm"; + private const string TelemetryProfileEnvironmentVariable = "DOTNET_CLI_TELEMETRY_PROFILE"; + private const string CannotFindMacAddress = "Unknown"; + private const string MachineIdCacheKey = "MachineId"; private const string IsDockerContainerCacheKey = "IsDockerContainer"; - public FrozenDictionary GetTelemetryCommonProperties(string? currentSessionId) => new Dictionary + public FrozenDictionary GetTelemetryCommonProperties(string currentSessionId) { - { OSVersion, RuntimeEnvironment.OperatingSystemVersion }, - { OSPlatform, RuntimeEnvironment.OperatingSystemPlatform.ToString() }, - { OSArchitecture, RuntimeInformation.OSArchitecture.ToString() }, - { OutputRedirected, Console.IsOutputRedirected.ToString() }, - { RuntimeId, RuntimeInformation.RuntimeIdentifier }, - { ProductVersion, Product.Version }, - { TelemetryProfile, Environment.GetEnvironmentVariable(TelemetryProfileEnvironmentVariable) }, - { DockerContainer, _userLevelCacheWriter.RunWithCache(IsDockerContainerCacheKey, () => _dockerContainerDetector.IsDockerContainer().ToString("G") ) }, - { CI, _ciEnvironmentDetector.IsCIEnvironment().ToString() }, - { LLM, _llmEnvironmentDetector.GetLLMEnvironment() }, - { CurrentPathHash, _hasher(_getCurrentDirectory()) }, - { MachineIdOld, _userLevelCacheWriter.RunWithCache(MachineIdCacheKey, GetMachineId) }, - // We don't want to recalcuate a new id for every new SDK version. Reuse the same path across versions. - // If we change the format of the cache later, we need to rename the cache from v1 to v2. - { MachineId, _userLevelCacheWriter.RunWithCacheInFilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, $"{MachineIdCacheKey}.v1.dotnetUserLevelCache"), GetMachineId) }, - { DeviceId, _getDeviceId() }, - { KernelVersion, GetKernelVersion() }, - { InstallationType, ExternalTelemetryProperties.GetInstallationType() }, - { ProductType, ExternalTelemetryProperties.GetProductType() }, - { LibcRelease, ExternalTelemetryProperties.GetLibcRelease() }, - { LibcVersion, ExternalTelemetryProperties.GetLibcVersion() }, - { SessionId, currentSessionId } - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + return new Dictionary + { + {OSVersion, RuntimeEnvironment.OperatingSystemVersion}, + {OSPlatform, RuntimeEnvironment.OperatingSystemPlatform.ToString()}, + {OSArchitecture, RuntimeInformation.OSArchitecture.ToString()}, + {OutputRedirected, Console.IsOutputRedirected.ToString()}, + {RuntimeId, RuntimeInformation.RuntimeIdentifier}, + {ProductVersion, Product.Version}, + {TelemetryProfile, Environment.GetEnvironmentVariable(TelemetryProfileEnvironmentVariable)}, + {DockerContainer, _userLevelCacheWriter.RunWithCache(IsDockerContainerCacheKey, () => _dockerContainerDetector.IsDockerContainer().ToString("G") )}, + {CI, _ciEnvironmentDetector.IsCIEnvironment().ToString() }, + {LLM, _llmEnvironmentDetector.GetLLMEnvironment() }, + {CurrentPathHash, _hasher(_getCurrentDirectory())}, + {MachineIdOld, _userLevelCacheWriter.RunWithCache(MachineIdCacheKey, GetMachineId)}, + // we don't want to recalcuate a new id for every new SDK version. Reuse the same path across versions. + // If we change the format of the cache later. + // We need to rename the cache from v1 to v2 + {MachineId, + _userLevelCacheWriter.RunWithCacheInFilePath( + Path.Combine( + CliFolderPathCalculator.DotnetUserProfileFolderPath, + $"{MachineIdCacheKey}.v1.dotnetUserLevelCache"), + GetMachineId)}, + {DeviceId, _getDeviceId()}, + {KernelVersion, GetKernelVersion()}, + {InstallationType, ExternalTelemetryProperties.GetInstallationType()}, + {ProductType, ExternalTelemetryProperties.GetProductType()}, + {LibcRelease, ExternalTelemetryProperties.GetLibcRelease()}, + {LibcVersion, ExternalTelemetryProperties.GetLibcVersion()}, + {SessionId, currentSessionId} + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } - private string GetMachineId() => _getMACAddress() is { } macAddress ? _hasher(macAddress) : Guid.NewGuid().ToString(); + private string GetMachineId() + { + var macAddress = _getMACAddress(); + if (macAddress != null) + { + return _hasher(macAddress); + } + else + { + return Guid.NewGuid().ToString(); + } + } /// /// Returns a string identifying the OS kernel. @@ -115,5 +140,8 @@ internal class TelemetryCommonProperties( /// Windows.7 Microsoft Windows 6.1.7601 S /// Windows.81 Microsoft Windows 6.3.9600 /// - private static string GetKernelVersion() => RuntimeInformation.OSDescription; + private static string GetKernelVersion() + { + return RuntimeInformation.OSDescription; + } } diff --git a/src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs b/src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs deleted file mode 100644 index aa287369406c..000000000000 --- a/src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace Microsoft.DotNet.Cli.Telemetry; - -internal static class TelemetryDiskLogger -{ - private static readonly JsonSerializerOptions s_jsonOptions; - - private static readonly TelemetryDiskLoggerJsonSerializerContext s_jsonContext; - - public record EventModel( - string name, - DateTimeOffset timestamp, - Dictionary tags); - - public record SourceModel( - string name, - string? version, - Dictionary? tags); - - public record IdentifiersModel( - string? id, - string traceId, - string spanId, - string parentSpanId, - string? parentId, - string? rootId); - - public record ActivityModel( - string operationName, - string displayName, - TimeSpan duration, - IdentifiersModel identifiers, - SourceModel source, - Dictionary tags, - EventModel[] events); - - static TelemetryDiskLogger() - { - s_jsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; - s_jsonContext = new(s_jsonOptions); - } - - public static void WriteLog(string logPath, IEnumerable activies) - { - try - { - var jsonText = !File.Exists(logPath) ? """{"activities":[]}""" : File.ReadAllText(logPath); - var root = JsonNode.Parse(jsonText)!; - var activitiesArray = root["activities"]!.AsArray(); - activitiesArray.AddRange(activies.Select(r => JsonNode.Parse(JsonSerializer.Serialize(CreateActivityJsonModel(r), s_jsonContext.ActivityModel)))); - root["activities"] = activitiesArray; - File.WriteAllText(logPath, root.ToJsonString(s_jsonOptions)); - } - catch - { - // Swallow any exceptions to avoid interfering with telemetry shutdown. - } - } - - private static ActivityModel CreateActivityJsonModel(Activity activity) => new( - operationName: activity.OperationName, - displayName: activity.DisplayName, - duration: activity.Duration, - identifiers: new( - id: activity.Id, - traceId: activity.TraceId.ToString(), - spanId: activity.SpanId.ToString(), - parentSpanId: activity.ParentSpanId.ToString(), - parentId: activity.ParentId, - rootId: activity.RootId - ), - source: new( - name: activity.Source.Name, - version: activity.Source.Version, - tags: activity.Source.Tags?.ToDictionary() - ), - tags: activity.Tags.ToDictionary(), - events: [.. activity.Events.Select(e => new EventModel( - name: e.Name, - timestamp: e.Timestamp, - tags: e.Tags.ToDictionary() - ))] - ); -} - -[JsonSerializable(typeof(TelemetryDiskLogger.ActivityModel))] -internal partial class TelemetryDiskLoggerJsonSerializerContext : JsonSerializerContext; diff --git a/src/Cli/dotnet/Telemetry/TelemetryFilter.cs b/src/Cli/dotnet/Telemetry/TelemetryFilter.cs index 00a758add1de..36d088115a01 100644 --- a/src/Cli/dotnet/Telemetry/TelemetryFilter.cs +++ b/src/Cli/dotnet/Telemetry/TelemetryFilter.cs @@ -1,97 +1,109 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System.CommandLine; using System.Globalization; using Microsoft.DotNet.Cli.CommandLine; +using Microsoft.DotNet.Cli.Commands.Build; +using Microsoft.DotNet.Cli.Commands.Clean; +using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; +using Microsoft.DotNet.Cli.Commands.Pack; +using Microsoft.DotNet.Cli.Commands.Publish; +using Microsoft.DotNet.Cli.Commands.Run; +using Microsoft.DotNet.Cli.Commands.Test; using Microsoft.DotNet.Cli.Commands.VSTest; using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Cli.Telemetry; -internal class TelemetryFilter(Func? hash) : ITelemetryFilter +internal class TelemetryFilter(Func hash) : ITelemetryFilter { private const string ExceptionEventName = "mainCatchException/exception"; private readonly Func _hash = hash ?? throw new ArgumentNullException(nameof(hash)); - public IEnumerable Filter(ParseResult parseResult) => - Hash(FilterImpl(parseResult, globalJsonState: null)); - - public IEnumerable Filter(ParseResultWithGlobalJsonState parseData) => - Hash(FilterImpl(parseData.ParseResult, parseData.GlobalJsonState)); - - public IEnumerable Filter(InstallerSuccessReport report) - { - var reportProperties = new Dictionary - { - { "exeName", report.ExeName } - }; - return Hash([new TelemetryEntryFormat("install/reportsuccess", reportProperties)]); - } - - public IEnumerable Filter(Exception exception) + public IEnumerable Filter(object objectToFilter) { - var exceptionProperties = new Dictionary + var result = new List(); + Dictionary measurements = null; + string globalJsonState = string.Empty; + if (objectToFilter is Tuple> parseResultWithMeasurements) { - { "exceptionType", exception.GetType().ToString() }, - { "detail", ExceptionToStringWithoutMessage(exception) } - }; - return Hash([new TelemetryEntryFormat(ExceptionEventName, exceptionProperties)]); - } - - private static IEnumerable FilterImpl(ParseResult parseResult, string? globalJsonState) - { - var topLevelCommandName = parseResult.RootSubCommandResult(); - if (topLevelCommandName is null) - { - yield break; + objectToFilter = parseResultWithMeasurements.Item1; + measurements = parseResultWithMeasurements.Item2; + measurements = RemoveZeroTimes(measurements); } - - Dictionary properties = new() { ["verb"] = topLevelCommandName }; - if (!string.IsNullOrEmpty(globalJsonState)) + else if (objectToFilter is Tuple, string> parseResultWithMeasurementsAndGlobalJsonState) { - properties["globalJson"] = globalJsonState; + objectToFilter = parseResultWithMeasurementsAndGlobalJsonState.Item1; + measurements = parseResultWithMeasurementsAndGlobalJsonState.Item2; + measurements = RemoveZeroTimes(measurements); + globalJsonState = parseResultWithMeasurementsAndGlobalJsonState.Item3; } - yield return new TelemetryEntryFormat("toplevelparser/command", properties); - - if (parseResult.IsDotnetBuiltInCommand() && - parseResult.SafelyGetValueForOption("--verbosity") is VerbosityOptions verbosity) + if (objectToFilter is ParseResult parseResult) { - var verbosityProperties = new Dictionary() + var topLevelCommandName = parseResult.RootSubCommandResult(); + if (topLevelCommandName != null) { - { "verb", topLevelCommandName}, - { "verbosity", Enum.GetName(verbosity)} - }; - yield return new TelemetryEntryFormat("sublevelparser/command", verbosityProperties); + Dictionary properties = new() + { + ["verb"] = topLevelCommandName + }; + if (!string.IsNullOrEmpty(globalJsonState)) + { + properties["globalJson"] = globalJsonState; + } + + result.Add(new ApplicationInsightsEntryFormat( + "toplevelparser/command", + properties, + measurements + )); + + LogVerbosityForAllTopLevelCommand(result, parseResult, topLevelCommandName, measurements); + LogVulnerableOptionForPackageUpdateCommand(result, parseResult, topLevelCommandName, measurements); + + foreach (IParseResultLogRule rule in ParseResultLogRules) + { + result.AddRange(rule.AllowList(parseResult, measurements)); + } + } } - - if (topLevelCommandName == "package" && - parseResult.CommandResult.Command != null && - parseResult.CommandResult.Command.Name == "update") + else if (objectToFilter is InstallerSuccessReport installerSuccessReport) { - var hasVulnerableOption = parseResult.HasOption("--vulnerable"); - var vulnerableProperties = new Dictionary() - { - { "verb", "package update" }, - { "vulnerable", hasVulnerableOption.ToString()} - }; - yield return new TelemetryEntryFormat("sublevelparser/command", vulnerableProperties); + result.Add(new ApplicationInsightsEntryFormat( + "install/reportsuccess", + new Dictionary { { "exeName", installerSuccessReport.ExeName } } + )); + } + else if (objectToFilter is Exception exception) + { + result.Add(new ApplicationInsightsEntryFormat( + ExceptionEventName, + new Dictionary + { + {"exceptionType", exception.GetType().ToString()}, + {"detail", ExceptionToStringWithoutMessage(exception) } + } + )); } - foreach (IParseResultLogRule rule in ParseResultLogRules) + return [.. result.Select(r => { - foreach (TelemetryEntryFormat allowList in rule.AllowList(parseResult)) + if (r.EventName == ExceptionEventName) { - yield return allowList; + return r; } - } + else + { + return r.WithAppliedToPropertiesValue(_hash); + } + })]; } - public IEnumerable Hash(IEnumerable entries) => - entries.Select(entry => entry.EventName == ExceptionEventName ? entry : entry.WithAppliedToPropertiesValue(_hash)); - private static List ParseResultLogRules => [ new AllowListToSendFirstArgument(["new", "help"]), @@ -124,6 +136,47 @@ public IEnumerable Hash(IEnumerable new AllowListToSendVerbSecondVerbFirstArgument(["workload", "tool", "new"]), ]; + private static void LogVulnerableOptionForPackageUpdateCommand( + ICollection result, + ParseResult parseResult, + string topLevelCommandName, + Dictionary measurements = null) + { + if (topLevelCommandName == "package" && parseResult.CommandResult.Command != null && parseResult.CommandResult.Command.Name == "update") + { + var hasVulnerableOption = parseResult.HasOption("--vulnerable"); + + result.Add(new ApplicationInsightsEntryFormat( + "sublevelparser/command", + new Dictionary() + { + { "verb", "package update" }, + { "vulnerable", hasVulnerableOption.ToString()} + }, + measurements)); + } + } + + private static void LogVerbosityForAllTopLevelCommand( + ICollection result, + ParseResult parseResult, + string topLevelCommandName, + Dictionary measurements = null) + { + if (parseResult.IsDotnetBuiltInCommand() && + parseResult.SafelyGetValueForOption("--verbosity") is VerbosityOptions verbosity) + { + result.Add(new ApplicationInsightsEntryFormat( + "sublevelparser/command", + new Dictionary() + { + { "verb", topLevelCommandName}, + { "verbosity", Enum.GetName(verbosity)} + }, + measurements)); + } + } + private static string ExceptionToStringWithoutMessage(Exception e) { const string AggregateException_ToString = "{0}{1}---> (Inner Exception #{2}) {3}{4}{5}"; @@ -156,18 +209,42 @@ private static string NonAggregateExceptionToStringWithoutMessage(Exception e) string s; const string Exception_EndOfInnerExceptionStack = "--- End of inner exception stack trace ---"; + s = e.GetType().ToString(); + if (e.InnerException != null) { s = s + " ---> " + ExceptionToStringWithoutMessage(e.InnerException) + Environment.NewLine + " " + Exception_EndOfInnerExceptionStack; + } var stackTrace = e.StackTrace; + if (stackTrace != null) { s += Environment.NewLine + stackTrace; } + return s; } + + private static Dictionary RemoveZeroTimes(Dictionary measurements) + { + if (measurements != null) + { + foreach (var measurement in measurements) + { + if (measurement.Value == 0) + { + measurements.Remove(measurement.Key); + } + } + if (measurements.Count == 0) + { + measurements = null; + } + } + return measurements; + } } diff --git a/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.cs b/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.cs index a19e98c315ba..62d9d6e93630 100644 --- a/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.cs +++ b/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.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. +#nullable disable + using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.Extensions; @@ -9,15 +11,17 @@ namespace Microsoft.DotNet.Cli.Telemetry; -internal class TopLevelCommandNameAndOptionToLog(HashSet topLevelCommandName, HashSet optionsToLog) : IParseResultLogRule +internal class TopLevelCommandNameAndOptionToLog( + HashSet topLevelCommandName, + HashSet optionsToLog) : IParseResultLogRule { private HashSet _topLevelCommandName { get; } = topLevelCommandName; private HashSet _optionsToLog { get; } = optionsToLog; - public List AllowList(ParseResult parseResult) + public List AllowList(ParseResult parseResult, Dictionary measurements = null) { var topLevelCommandName = parseResult.RootSubCommandResult(); - var result = new List(); + var result = new List(); foreach (var optionName in _optionsToLog) { if (_topLevelCommandName.Contains(topLevelCommandName) @@ -26,13 +30,14 @@ public List AllowList(ParseResult parseResult) && optionResult.GetValueOrDefault() is object optionValue && optionValue is not null) { - result.Add(new TelemetryEntryFormat( + result.Add(new ApplicationInsightsEntryFormat( "sublevelparser/command", - new Dictionary + new Dictionary { { "verb", topLevelCommandName}, { optionName.RemovePrefix(), Stringify(optionValue) } - })); + }, + measurements)); } } return result; @@ -41,7 +46,7 @@ public List AllowList(ParseResult parseResult) /// /// We're dealing with untyped payloads here, so we need to handle arrays vs non-array values /// - private static string? Stringify(object value) + private static string Stringify(object value) { if (value is null) { diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index 8dacbda0359c..de6c67859456 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -6,7 +6,7 @@ Exe MicrosoftAspNetCore true - true + true dotnet5.4 Microsoft.DotNet.Cli $(DefineConstants);EXCLUDE_ASPNETCORE @@ -84,6 +84,7 @@ + @@ -99,15 +100,7 @@ - - - - - - - - - + diff --git a/src/Common/CompileOptions.cs b/src/Common/CompileOptions.cs index cb13ce8aa671..88e90823d6bf 100644 --- a/src/Common/CompileOptions.cs +++ b/src/Common/CompileOptions.cs @@ -1,8 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. + #if MICROSOFT_ENABLE_TELEMETRY -[assembly: System.Reflection.AssemblyMetadata("TelemetryOptOutDefault", Microsoft.DotNet.Cli.CompileOptions.TelemetryOptOutDefaultString)] + [assembly: System.Reflection.AssemblyMetadata("TelemetryOptOutDefault", Microsoft.DotNet.Cli.CompileOptions.TelemetryOptOutDefaultString)] #endif namespace Microsoft.DotNet.Cli; diff --git a/src/Common/EnvironmentVariableNames.cs b/src/Common/EnvironmentVariableNames.cs index a2dce0704346..6d73b33ab38f 100644 --- a/src/Common/EnvironmentVariableNames.cs +++ b/src/Common/EnvironmentVariableNames.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Cli; -internal static class EnvironmentVariableNames +static class EnvironmentVariableNames { public static readonly string ALLOW_TARGETING_PACK_CACHING = "DOTNETSDK_ALLOW_TARGETING_PACK_CACHING"; public static readonly string WORKLOAD_PACK_ROOTS = "DOTNETSDK_WORKLOAD_PACK_ROOTS"; @@ -13,24 +13,18 @@ internal static class EnvironmentVariableNames public static readonly string WORKLOAD_UPDATE_NOTIFY_INTERVAL_HOURS = "DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_INTERVAL_HOURS"; public static readonly string WORKLOAD_DISABLE_PACK_GROUPS = "DOTNET_CLI_WORKLOAD_DISABLE_PACK_GROUPS"; public static readonly string DISABLE_PUBLISH_AND_PACK_RELEASE = "DOTNET_CLI_DISABLE_PUBLISH_AND_PACK_RELEASE"; - public static readonly string DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS = nameof(DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS); + public static readonly string DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS = "DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS"; public static readonly string DOTNET_CLI_FORCE_UTF8_ENCODING = nameof(DOTNET_CLI_FORCE_UTF8_ENCODING); public static readonly string TELEMETRY_OPTOUT = "DOTNET_CLI_TELEMETRY_OPTOUT"; - public static readonly string DOTNET_ROOT = nameof(DOTNET_ROOT); - public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG = nameof(DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG); - public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR = nameof(DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR); - public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER = nameof(DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER); - public static readonly string DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT = nameof(DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT); + public static readonly string DOTNET_ROOT = "DOTNET_ROOT"; + public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG = "DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG"; + public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR = "DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR"; + public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER = "DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER"; + public static readonly string DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT = "DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT"; public static readonly string DOTNET_GENERATE_ASPNET_CERTIFICATE = nameof(DOTNET_GENERATE_ASPNET_CERTIFICATE); public static readonly string DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = nameof(DOTNET_ADD_GLOBAL_TOOLS_TO_PATH); public static readonly string DOTNET_NOLOGO = nameof(DOTNET_NOLOGO); public static readonly string DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK = nameof(DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK); - public static readonly string DOTNET_CLI_TELEMETRY_SESSIONID = nameof(DOTNET_CLI_TELEMETRY_SESSIONID); - public static readonly string DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING = nameof(DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING); - // Telemetry logging/debug/testing. - public static readonly string DOTNET_CLI_TELEMETRY_STORAGE_PATH = nameof(DOTNET_CLI_TELEMETRY_STORAGE_PATH); - public static readonly string DOTNET_CLI_TELEMETRY_LOG_PATH = nameof(DOTNET_CLI_TELEMETRY_LOG_PATH); - public static readonly string DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT = nameof(DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT); #if NET7_0_OR_GREATER private static readonly Version s_version6_0 = new(6, 0); diff --git a/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs b/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs index 653b5608bafd..7ff2785a7b79 100644 --- a/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs +++ b/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs @@ -36,8 +36,8 @@ public TemplateLocator(Func getEnvironmentVariable, Func GetDotnetSdkTemplatePackages( - string? sdkVersion, - string? dotnetRootPath, + string sdkVersion, + string dotnetRootPath, string? userProfileDir) { if (string.IsNullOrWhiteSpace(sdkVersion)) @@ -47,7 +47,8 @@ public IReadOnlyCollection GetDotnetSdkTemplate if (string.IsNullOrWhiteSpace(dotnetRootPath)) { - throw new ArgumentException($"'{nameof(dotnetRootPath)}' cannot be null or whitespace", nameof(dotnetRootPath)); + throw new ArgumentException($"'{nameof(dotnetRootPath)}' cannot be null or whitespace", + nameof(dotnetRootPath)); } // Will the current directory correspond to the folder we are creating a project in? If we need diff --git a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs index 012d4bb1d797..9bc513f6ff6c 100644 --- a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs +++ b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs @@ -9,11 +9,16 @@ namespace Microsoft.NET.TestFramework.Commands public class SdkCommandSpec { public string? FileName { get; set; } - public List Arguments { get; set; } = []; - public Dictionary Environment { get; set; } = []; - public List EnvironmentToRemove { get; } = []; + public List Arguments { get; set; } = new List(); + + public Dictionary Environment { get; set; } = new(); + + public List EnvironmentToRemove { get; } = new List(); + public string? WorkingDirectory { get; set; } + public bool RedirectStandardInput { get; set; } + public bool DisableOutputAndErrorRedirection { get; set; } private string EscapeArgs() diff --git a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs index f0187fed7b8b..56cf1002d2ae 100644 --- a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs +++ b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs @@ -9,13 +9,19 @@ namespace Microsoft.NET.TestFramework.Commands { public abstract class TestCommand { - private readonly Dictionary _environment = []; + private Dictionary _environment = new(); private bool _doNotEscapeArguments; + public ITestOutputHelper Log { get; } + public string? WorkingDirectory { get; set; } - public List Arguments { get; set; } = []; - public List EnvironmentToRemove { get; } = []; + + public List Arguments { get; set; } = new List(); + + public List EnvironmentToRemove { get; } = new List(); + public bool RedirectStandardInput { get; set; } + public bool DisableOutputAndErrorRedirection { get; set; } // These only work via Execute(), not when using GetProcessStartInfo() diff --git a/test/Microsoft.NET.TestFramework/SdkTest.cs b/test/Microsoft.NET.TestFramework/SdkTest.cs index b377b9b2be92..56c6649e4ae8 100644 --- a/test/Microsoft.NET.TestFramework/SdkTest.cs +++ b/test/Microsoft.NET.TestFramework/SdkTest.cs @@ -3,42 +3,43 @@ using System.Runtime.CompilerServices; -namespace Microsoft.NET.TestFramework; - -public abstract class SdkTest +namespace Microsoft.NET.TestFramework { - protected bool? UsingFullFrameworkMSBuild => SdkTestContext.Current.ToolsetUnderTest?.ShouldUseFullFrameworkMSBuild; + public abstract class SdkTest + { + protected bool? UsingFullFrameworkMSBuild => SdkTestContext.Current.ToolsetUnderTest?.ShouldUseFullFrameworkMSBuild; - protected ITestOutputHelper Log { get; } + protected ITestOutputHelper Log { get; } - protected TestAssetsManager TestAssetsManager { get; } + protected TestAssetsManager TestAssetsManager { get; } - protected SdkTest(ITestOutputHelper log) - { - Log = log; - TestAssetsManager = new TestAssetsManager(log); - } - - protected static void WaitForUtcNowToAdvance() - { - var start = DateTime.UtcNow; + protected SdkTest(ITestOutputHelper log) + { + Log = log; + TestAssetsManager = new TestAssetsManager(log); + } - while (DateTime.UtcNow <= start) + protected static void WaitForUtcNowToAdvance() { - Thread.Sleep(millisecondsTimeout: 1); + var start = DateTime.UtcNow; + + while (DateTime.UtcNow <= start) + { + Thread.Sleep(millisecondsTimeout: 1); + } } - } - /// - /// Generates a MSBuild binlog argument with a unique name based on the caller and provided parts, and places it in a location that will be collected by Helix if running in that environment. - /// - protected string BinLogArgument(ReadOnlySpan parts, [CallerMemberName] string callerName = "") - { - // combine the name and parts into a unique binlog - var fileName = $"{callerName}{(parts.Length > 0 ? "-" + string.Join("-", parts.ToArray()) : "")}-{{}}.binlog"; - var binlogDestPath = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot && Environment.GetEnvironmentVariable("HELIX_WORKITEM_ID") is { } helixGuid ? - Path.Combine(ciOutputRoot, "binlog", helixGuid, fileName) : - $"./{fileName}"; - return $"/bl:{binlogDestPath}"; + /// + /// Generates a MSBuild binlog argument with a unique name based on the caller and provided parts, and places it in a location that will be collected by Helix if running in that environment. + /// + protected string BinLogArgument(ReadOnlySpan parts, [CallerMemberName] string callerName = "") + { + // combine the name and parts into a unique binlog + var fileName = $"{callerName}{(parts.Length > 0 ? "-" + string.Join("-", parts.ToArray()) : "")}-{{}}.binlog"; + var binlogDestPath = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot && Environment.GetEnvironmentVariable("HELIX_WORKITEM_ID") is { } helixGuid ? + Path.Combine(ciOutputRoot, "binlog", helixGuid, fileName) : + $"./{fileName}"; + return $"/bl:{binlogDestPath}"; + } } } diff --git a/test/dotnet.Tests/CliSchemaTests.cs b/test/dotnet.Tests/CliSchemaTests.cs index 71a507d3ebb9..8478d3c0d706 100644 --- a/test/dotnet.Tests/CliSchemaTests.cs +++ b/test/dotnet.Tests/CliSchemaTests.cs @@ -1208,7 +1208,7 @@ public void PrintCliSchema_WritesExpectedJson(string[] commandArgs, string json) { var stream = new MemoryStream(); var writer = new StreamWriter(stream); - CliSchema.PrintCliSchema(Parser.Parse(commandArgs), writer, null); + CliSchema.PrintCliSchema(Parser.Parse(commandArgs).CommandResult, writer, null); stream.Position = 0; var reader = new StreamReader(stream); var output = reader.ReadToEnd(); diff --git a/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs b/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs index d526082c400d..b0ff2d4f0bd4 100644 --- a/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs +++ b/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs @@ -214,23 +214,15 @@ [new RestoredCommandIdentifier( _localToolsResolverCache, _fileSystem); - var commandSpecA = localToolsCommandResolver.Resolve(new CommandResolverArguments() + localToolsCommandResolver.Resolve(new CommandResolverArguments() { CommandName = "dotnet-a", - }); - commandSpecA.Should().NotBeNull(); - var argsA = commandSpecA.Args; - argsA.Should().NotBeNull(); - argsA.Trim('"').Should().Be(fakeExecutableA.Value); + }).Args!.Trim('"').Should().Be(fakeExecutableA.Value); - var commandSpecDotnetA = localToolsCommandResolver.Resolve(new CommandResolverArguments() + localToolsCommandResolver.Resolve(new CommandResolverArguments() { CommandName = "dotnet-dotnet-a", - }); - commandSpecDotnetA.Should().NotBeNull(); - var argsDotnetA = commandSpecDotnetA.Args; - argsDotnetA.Should().NotBeNull(); - argsDotnetA.Trim('"').Should().Be(fakeExecutableDotnetA.Value); + }).Args!.Trim('"').Should().Be(fakeExecutableDotnetA.Value); } private string _jsonContent = diff --git a/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs b/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs index 9f82d05313e5..360342b83c30 100644 --- a/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs +++ b/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs @@ -22,6 +22,7 @@ public static void PerformActionWithBasePath(string basePath, Action action) } CommandDirectoryContext.CurrentBaseDirectory_TestOnly = basePath; + Telemetry.Telemetry.CurrentSessionId = null; try { action(); diff --git a/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs b/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs index 433b5b5cf2de..a2a26316e3d8 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs @@ -5,7 +5,6 @@ using System.Reflection; using Microsoft.DotNet.Cli.Commands.MSBuild; -using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Configurer; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -20,7 +19,8 @@ public DotnetMsbuildInProcTests(ITestOutputHelper log) : base(log) [Fact] public void WhenTelemetryIsEnabledTheLoggerIsAddedToTheCommandLine() { - string[] allArgs = GetArgsForMSBuild(() => true, out TelemetryClient telemetry); + Telemetry.Telemetry telemetry; + string[] allArgs = GetArgsForMSBuild(() => true, out telemetry); // telemetry will still be disabled if environment variable is set if (telemetry.Enabled) { @@ -46,13 +46,15 @@ public void WhenTelemetryIsDisabledTheLoggerIsNotAddedToTheCommandLine() private string[] GetArgsForMSBuild(Func sentinelExists) { - return GetArgsForMSBuild(sentinelExists, out TelemetryClient telemetry); + Telemetry.Telemetry telemetry; + return GetArgsForMSBuild(sentinelExists, out telemetry); } - private string[] GetArgsForMSBuild(Func sentinelExists, out TelemetryClient telemetry) + private string[] GetArgsForMSBuild(Func sentinelExists, out Telemetry.Telemetry telemetry) { - TelemetryClient.DisabledForTests = true; // reset static session id modified by telemetry constructor - telemetry = new TelemetryClient(); + + Telemetry.Telemetry.DisableForTests(); // reset static session id modified by telemetry constructor + telemetry = new Telemetry.Telemetry(new MockFirstTimeUseNoticeSentinel(sentinelExists)); MSBuildForwardingApp msBuildForwardingApp = new(Enumerable.Empty()); diff --git a/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs b/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs index 5bf3435c507f..bcb6a6dbfde1 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs @@ -1,23 +1,34 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Cli.Telemetry; +#nullable disable -namespace Microsoft.DotNet.Cli.MSBuild.Tests; +using Microsoft.DotNet.Cli.Telemetry; -public class FakeTelemetry : ITelemetryClient +namespace Microsoft.DotNet.Cli.MSBuild.Tests { - public bool Enabled { get; set; } = true; + public class FakeTelemetry : ITelemetry + { + public bool Enabled { get; set; } = true; - private readonly List _logEntries = new List(); + private readonly List _logEntries = new List(); - public void TrackEvent(string eventName, IDictionary? properties) - { - var entry = new LogEntry { EventName = eventName, Properties = properties }; - _logEntries.Add(entry); - } + public void TrackEvent(string eventName, IDictionary properties, IDictionary measurements) + { + var entry = new LogEntry { EventName = eventName, Properties = properties, Measurement = measurements }; + _logEntries.Add(entry); + } + + public void Flush() + { + } - public LogEntry? LogEntry => _logEntries.Count > 0 ? _logEntries[_logEntries.Count - 1] : null; + public void Dispose() + { + } - public IReadOnlyList LogEntries => _logEntries.AsReadOnly(); + public LogEntry LogEntry => _logEntries.Count > 0 ? _logEntries[_logEntries.Count - 1] : null; + + public IReadOnlyList LogEntries => _logEntries.AsReadOnly(); + } } diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs index 812ec22b2c8f..ff6543518696 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs @@ -3,7 +3,7 @@ using Microsoft.DotNet.Cli.Commands.Restore; using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Tests.TelemetryTests; +using Microsoft.DotNet.Tests; using BuildCommand = Microsoft.DotNet.Cli.Commands.Build.BuildCommand; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -122,7 +122,7 @@ public void MsbuildInvocationIsCorrectForSeparateRestore( } [Theory] - [MemberData(memberName: nameof(TelemetryCommonPropertiesTests.LLMTelemetryTestCases), MemberType = typeof(TelemetryCommonPropertiesTests))] + [MemberData(memberName: nameof(TelemetryCommonPropertiesTests.LLMTelemetryTestCases), MemberType =typeof(TelemetryCommonPropertiesTests))] public void WhenLLMIsDetectedTLLiveUpdateIsDisabled(Dictionary? llmEnvVarsToSet, string? expectedLLMName) { CommandDirectoryContext.PerformActionWithBasePath(WorkingDirectory, () => diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs index beeac7148703..e995a09ae611 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -// There are tests which modify static TelemetryClient.CurrentSessionId. They cannot run in parallel. + + +// There are tests which modify static Telemetry.CurrentSessionId and they cannot run in parallel [assembly: CollectionBehavior(DisableTestParallelization = true)] namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -104,5 +106,11 @@ public void WhenDotnetRunHelpIsInvokedAppArgumentsTextIsIncludedInOutput() result.ExitCode.Should().Be(0); result.StdOut.Should().Contain(AppArgumentsText); } + + + + } + + } diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs index db007ec0cdc9..9b4dca228442 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.DotNet.Cli.Commands.MSBuild; -using Microsoft.DotNet.Cli.Telemetry; using RestoreCommand = Microsoft.DotNet.Cli.Commands.Restore.RestoreCommand; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -42,7 +41,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona { CommandDirectoryContext.PerformActionWithBasePath(WorkingDirectory, () => { - TelemetryClient.DisabledForTests = true; + Telemetry.Telemetry.DisableForTests(); expectedAdditionalArgs = expectedAdditionalArgs .Select(arg => arg.Replace("", WorkingDirectory)) diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.cs index d089f147ff6f..c017646949b0 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Cli.Telemetry; using TestCommand = Microsoft.DotNet.Cli.Commands.Test.TestCommand; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -27,7 +26,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona { CommandDirectoryContext.PerformActionWithBasePath(WorkingDirectory, () => { - TelemetryClient.DisabledForTests = true; + Telemetry.Telemetry.DisableForTests(); expectedAdditionalArgs = expectedAdditionalArgs .Select(arg => arg.Replace("", WorkingDirectory)) diff --git a/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs b/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs index 54ddd2256b19..b2974fba1006 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs @@ -1,20 +1,19 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Cli.Telemetry; - -namespace Microsoft.DotNet.Cli.MSBuild.Tests; - -public class NullCurrentSessionIdFixture +namespace Microsoft.DotNet.Cli.MSBuild.Tests { - public NullCurrentSessionIdFixture() + public class NullCurrentSessionIdFixture { - // We need to set this to guarantee that the telemetry logging - // information will not be added to the msbuild generated parameters - // when testing the translation between CLI params and msbuild params. - // This is now needed because before we set SKIP FIRST RUN in the CLI - // build scripts, but now we don't and we don't want to rely on scripts - // to make our build/tests work. - TelemetryClient.DisabledForTests = true; + public NullCurrentSessionIdFixture() + { + // We need to set this to guarantee that the telemetry logging + // information will not be added to the msbuild generated parameters + // when testing the translation between CLI params and msbuild params. + // This is now needed because before we set SKIP FIRST RUN in the CLI + // build scripts, but now we don't and we don't want to rely on scripts + // to make our build/tests work. + Telemetry.Telemetry.DisableForTests(); + } } } diff --git a/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs b/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs index 0bfaa370f9cf..ae6425892777 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs @@ -144,9 +144,9 @@ public void CountAdditionalProperties_CountsPropertyDirectives() public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() { // Arrange - var events = new List<(string? eventName, IDictionary? properties)>(); + var events = new List<(string? eventName, IDictionary? properties, IDictionary? measurements)>(); - void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties)); + void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties, args.Measurements)); TelemetryEventEntry.EntryPosted += handler; @@ -171,6 +171,7 @@ public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() var eventData = events[0]; eventData.eventName.Should().Be("run"); eventData.properties.Should().NotBeNull(); + eventData.measurements.Should().NotBeNull(); var props = eventData.properties!; props["app_type"].Should().Be("file_based"); @@ -179,6 +180,12 @@ public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() props["used_roslyn_compiler"].Should().Be("false"); props["launch_profile_requested"].Should().Be("explicit"); props["launch_profile_is_default"].Should().Be("true"); + + var measurements = eventData.measurements!; + measurements["sdk_count"].Should().Be(2); + measurements["package_reference_count"].Should().Be(3); + measurements["project_reference_count"].Should().Be(1); + measurements["additional_properties_count"].Should().Be(2); } finally { @@ -191,9 +198,9 @@ public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() { // Arrange - var events = new List<(string? eventName, IDictionary? properties)>(); + var events = new List<(string? eventName, IDictionary? properties, IDictionary? measurements)>(); - void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties)); + void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties, args.Measurements)); TelemetryEventEntry.EntryPosted += handler; @@ -215,6 +222,7 @@ public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() var eventData = events[0]; eventData.eventName.Should().Be("run"); eventData.properties.Should().NotBeNull(); + eventData.measurements.Should().NotBeNull(); var props = eventData.properties!; props["app_type"].Should().Be("project_based"); @@ -222,6 +230,12 @@ public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() props["launch_profile_requested"].Should().Be("none"); props.Should().NotContainKey("used_msbuild"); props.Should().NotContainKey("used_roslyn_compiler"); + + var measurements = eventData.measurements!; + measurements["sdk_count"].Should().Be(1); + measurements["package_reference_count"].Should().Be(5); + measurements["project_reference_count"].Should().Be(2); + measurements.Should().NotContainKey("additional_properties_count"); } finally { @@ -234,9 +248,9 @@ public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() public void TrackRunEvent_WithDefaultLaunchProfile_MarksTelemetryCorrectly() { // Arrange - var events = new List<(string? eventName, IDictionary? properties)>(); + var events = new List<(string? eventName, IDictionary? properties, IDictionary? measurements)>(); - void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties)); + void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties, args.Measurements)); TelemetryEventEntry.EntryPosted += handler; diff --git a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs index 756766a8aa5f..f5b87dadeee8 100644 --- a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs +++ b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs @@ -259,5 +259,77 @@ public void It_does_not_add_the_tool_path_to_the_environment_if_addGlobalToolsTo _pathAdderMock.Verify(p => p.AddPackageExecutablePathToUserPath(), Times.Never); } + + [Fact] + public void It_does_add_telemetry_when_all_firsttimeuse_values_run() + { + + _firstTimeUseNoticeSentinelMock.Setup(n => n.Exists()).Returns(false); + + Dictionary measurements = new(); + var dotnetFirstTimeUseConfigurer = new DotnetFirstTimeUseConfigurer( + _firstTimeUseNoticeSentinelMock.Object, + _aspNetCertificateSentinelMock.Object, + _aspNetCoreCertificateGeneratorMock.Object, + _toolPathSentinelMock.Object, + new DotnetFirstRunConfiguration + ( + generateAspNetCertificate: true, + telemetryOptout: false, + addGlobalToolsToPath: true, + nologo: false, + skipWorkloadIntegrityCheck: false + ), + _reporterMock.Object, + _pathAdderMock.Object, + measurements); + + DateTime beforeConfigure = DateTime.Now; + dotnetFirstTimeUseConfigurer.Configure(); + double configureTime = (DateTime.Now - beforeConfigure).TotalMilliseconds; + + measurements.Should().HaveCount(3); + measurements.Should().ContainKey("AddPackageExecutablePath Time"); + measurements.Should().ContainKey("FirstTimeUseNotice Time"); + measurements.Should().ContainKey("GenerateAspNetCertificate Time"); + measurements["AddPackageExecutablePath Time"].Should().BeGreaterThan(0); + measurements["FirstTimeUseNotice Time"].Should().BeGreaterThan(0); + measurements["GenerateAspNetCertificate Time"].Should().BeGreaterThan(0); + measurements["AddPackageExecutablePath Time"].Should().BeLessThan(configureTime); + measurements["FirstTimeUseNotice Time"].Should().BeLessThan(configureTime); + measurements["GenerateAspNetCertificate Time"].Should().BeLessThan(configureTime); + } + + [Fact] + public void It_does_add_telemetry_when_no_firsttimeuse_values_run() + { + + _firstTimeUseNoticeSentinelMock.Setup(n => n.Exists()).Returns(true); + + Dictionary measurements = new(); + var dotnetFirstTimeUseConfigurer = new DotnetFirstTimeUseConfigurer( + _firstTimeUseNoticeSentinelMock.Object, + _aspNetCertificateSentinelMock.Object, + _aspNetCoreCertificateGeneratorMock.Object, + _toolPathSentinelMock.Object, + new DotnetFirstRunConfiguration + ( + generateAspNetCertificate: false, + telemetryOptout: false, + addGlobalToolsToPath: false, + nologo: false, + skipWorkloadIntegrityCheck: false + ), + _reporterMock.Object, + _pathAdderMock.Object, + measurements); + + dotnetFirstTimeUseConfigurer.Configure(); + + measurements.Should().HaveCount(0); + measurements.Should().NotContainKey("AddPackageExecutablePath Time"); + measurements.Should().NotContainKey("FirstTimeUseNotice Time"); + measurements.Should().NotContainKey("GenerateAspNetCertificate Time"); + } } } diff --git a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs index 89cc75f779b1..d694240d865b 100644 --- a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs +++ b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs @@ -28,7 +28,7 @@ public GivenADotnetFirstTimeUseConfigurerWithStateSetup(ITestOutputHelper output private void ResetObjectState() { - TelemetryClient.DisabledForTests = false; + Telemetry.EnableForTests(); _firstTimeUseNoticeSentinelMock = new MockBasicSentinel(); _aspNetCertificateSentinelMock = new MockBasicSentinel(); _aspNetCoreCertificateGeneratorMock = new Mock(MockBehavior.Strict); @@ -183,6 +183,16 @@ public void Assert(ActionCalledTime expectedActionCalledTime) } } + private static ActionCalledTime GetCalledTime(bool predicate, ActionCalledTime actionCalledTime) + { + if (actionCalledTime != FirstRun && predicate) + { + actionCalledTime = SecondRun; + } + + return actionCalledTime; + } + public enum ActionCalledTime { Never, @@ -190,7 +200,7 @@ public enum ActionCalledTime SecondRun } - private TelemetryClient RunConfigUsingMocks(bool isInstallerRun) + private Telemetry RunConfigUsingMocks(bool isInstallerRun) { // Assume the following objects set up are in sync with production behavior. // subject to future refactoring to de-dup with production code. @@ -242,7 +252,10 @@ private TelemetryClient RunConfigUsingMocks(bool isInstallerRun) configurer.Configure(); - return new TelemetryClient("test", environmentProvider: _environmentProviderObject); + return new Telemetry(firstTimeUseNoticeSentinel, + "test", + environmentProvider: _environmentProviderObject, + senderCount: 0); } private class MockBasicSentinel : IFileSentinel, IFirstTimeUseNoticeSentinel, IAspNetCertificateSentinel diff --git a/test/dotnet.Tests/FakeRecordEventNameTelemetry.cs b/test/dotnet.Tests/FakeRecordEventNameTelemetry.cs new file mode 100644 index 000000000000..4ce548e97621 --- /dev/null +++ b/test/dotnet.Tests/FakeRecordEventNameTelemetry.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Collections.Concurrent; +using Microsoft.DotNet.Cli.Telemetry; + +namespace Microsoft.DotNet.Tests +{ + public class FakeRecordEventNameTelemetry : ITelemetry + { + public bool Enabled { get; set; } + + public string EventName { get; set; } + + public void TrackEvent(string eventName, + IDictionary properties, + IDictionary measurements) + { + LogEntries.Add( + new LogEntry + { + EventName = eventName, + Measurement = measurements, + Properties = properties + }); + } + + public void Flush() + { + } + + public void Dispose() + { + } + + public ConcurrentBag LogEntries { get; set; } = new ConcurrentBag(); + + public class LogEntry + { + public string EventName { get; set; } + public IDictionary Properties { get; set; } + public IDictionary Measurement { get; set; } + } + } +} diff --git a/test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs b/test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs new file mode 100644 index 000000000000..06a8418a6acb --- /dev/null +++ b/test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs @@ -0,0 +1,81 @@ +// 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.Tracing; + +namespace Microsoft.DotNet.Tests +{ + public class GivenThatTheUserEnablesThePerfLog : SdkTest + { + public GivenThatTheUserEnablesThePerfLog(ITestOutputHelper log) : base(log) + { + } + + [Fact] + public void WhenPerfLogDisabledDotNetDoesNotWriteToThePerfLog() + { + var dir = TestAssetsManager.CreateTestDirectory(); + + var result = new DotnetCommand(Log, "--help") + .WithEnvironmentVariable("DOTNET_PERFLOG_DIR", dir.Path) + .Execute(); + + result.ExitCode.Should().Be(0); + Assert.Empty(new DirectoryInfo(dir.Path).GetFiles()); + } + + [Fact] + public void WhenPerfLogEnabledDotNetWritesToThePerfLog() + { + var dir = TestAssetsManager.CreateTestDirectory(); + + var result = new DotnetCommand(Log, "--help") + .WithEnvironmentVariable("DOTNET_CLI_PERF_LOG", "1") + .WithEnvironmentVariable("DOTNET_PERFLOG_DIR", dir.Path) + .Execute(); + + result.ExitCode.Should().Be(0); + + DirectoryInfo logDir = new(dir.Path); + FileInfo[] logFiles = logDir.GetFiles(); + Assert.NotEmpty(logFiles); + Assert.All(logFiles, f => Assert.StartsWith("perf-", f.Name)); + Assert.All(logFiles, f => Assert.NotEqual(0, f.Length)); + } + + [Fact] + public void WhenPerfLogEnabledDotNetBuildWritesAPerfLog() + { + using (PerfLogTestEventListener listener = new()) + { + int exitCode = Cli.Program.Main(new string[] { "--help" }); + Assert.Equal(0, exitCode); + Assert.NotEqual(0, listener.EventCount); + } + } + } + + internal sealed class PerfLogTestEventListener : EventListener + { + private const string PerfLogEventSourceName = "Microsoft-Dotnet-CLI-Performance"; + + public int EventCount + { + get; private set; + } + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (eventSource.Name.Equals(PerfLogEventSourceName)) + { + EnableEvents(eventSource, EventLevel.Verbose); + } + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + Assert.Equal(PerfLogEventSourceName, eventData.EventSource.Name); + EventCount++; + } + } +} diff --git a/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs b/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs index f0950fe9b9fd..5e9191d076f0 100644 --- a/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs +++ b/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs @@ -165,7 +165,8 @@ public void ItDoesNotCreateAFirstUseSentinelFileNorAnAspNetCertificateSentinelFi var command = dotnetFirstTime.Setup(Log, TestAssetsManager); - // Disable telemetry to prevent the creation of the .dotnet folder for machineid and docker cache files. + // Disable telemetry to prevent the creation of the .dotnet folder + // for machineid and docker cache files command = command.WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", "true"); command.Execute("internal-reportinstallsuccess", "test").Should().Pass(); diff --git a/test/dotnet.Tests/TelemetryCommandTest.cs b/test/dotnet.Tests/TelemetryCommandTest.cs new file mode 100644 index 000000000000..aa180fe14930 --- /dev/null +++ b/test/dotnet.Tests/TelemetryCommandTest.cs @@ -0,0 +1,416 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; +using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Utilities; + +namespace Microsoft.DotNet.Tests +{ + [Collection(TestConstants.UsesStaticTelemetryState)] + public class TelemetryCommandTests : SdkTest + { + private readonly FakeRecordEventNameTelemetry _fakeTelemetry; + + public string EventName { get; set; } + + public IDictionary Properties { get; set; } + + public TelemetryCommandTests(ITestOutputHelper log) : base(log) + { + _fakeTelemetry = new FakeRecordEventNameTelemetry(); + TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); + TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); + } + + [Fact] + public void NoTelemetryIfCommandIsInvalid() + { + string[] args = { "publish", "-r" }; + Action a = () => { Cli.Program.ProcessArgs(args); }; + a.Should().NotThrow(); + } + + [Fact] + public void NoTelemetryIfCommandIsInvalid2() + { + string[] args = { "restore", "-v" }; + Action a = () => { Cli.Program.ProcessArgs(args); }; + a.Should().NotThrow(); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetry() + { + string[] args = { "help" }; + Cli.Program.ProcessArgs(args); + + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("HELP")); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithPerformanceData() + { + string[] args = { "help" }; + Cli.Program.ProcessArgs(args, new TimeSpan(12345)); + + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("HELP") && + e.Measurement.ContainsKey("Startup Time") && + e.Measurement["Startup Time"] == 1.2345 && + e.Measurement.ContainsKey("Parse Time") && + e.Measurement["Parse Time"] > 0); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithoutStartupTime() + { + string[] args = { "help" }; + Cli.Program.ProcessArgs(args); + + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("HELP") && + !e.Measurement.ContainsKey("Startup Time") && + e.Measurement.ContainsKey("Parse Time") && + e.Measurement["Parse Time"] > 0); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryZeroStartupTime() + { + string[] args = { "help" }; + Cli.Program.ProcessArgs(args, new TimeSpan(0)); + + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("HELP") && + !e.Measurement.ContainsKey("Startup Time") && + e.Measurement.ContainsKey("Parse Time") && + e.Measurement["Parse Time"] > 0); + } + + [Fact] + public void DotnetNewCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "console"; + string[] args = { "new", argumentToSend }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW")); + } + + [Fact(Skip = "https://github.com/dotnet/sdk/issues/24190")] + public void DotnetNewCommandFirstArgumentShouldBeSentToTelemetryWithPerformanceData() + { + const string argumentToSend = "console"; + string[] args = { "new", argumentToSend }; + Cli.Program.ProcessArgs(args, new TimeSpan(23456)); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW") && + e.Measurement.ContainsKey("Startup Time") && + e.Measurement["Startup Time"] == 2.3456 && + e.Measurement.ContainsKey("Parse Time") && + e.Measurement["Parse Time"] > 0); + } + + [Fact] + public void DotnetHelpCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "something"; + string[] args = { "help", argumentToSend }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("HELP")); + } + + [Fact] + public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "package"; + string[] args = { "add", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("ADD")); + } + + [Fact] + public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry2() + { + const string argumentToSend = "reference"; + string[] args = { "add", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("ADD")); + } + + [Fact] + public void DotnetRemoveCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "package"; + string[] args = { "remove", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("REMOVE")); + } + + [Fact] + public void DotnetListCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "reference"; + string[] args = { "list", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("LIST")); + } + + [Fact] + public void DotnetSlnCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "list"; + string[] args = { "sln", "aSolution", argumentToSend }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("SOLUTION")); + } + + [Fact] + public void DotnetNugetCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "push"; + + string[] args = { "nuget", argumentToSend, "path" }; + + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NUGET")); + } + + [Fact(Skip = "https://github.com/dotnet/sdk/issues/47862")] + public void DotnetNewCommandLanguageOpinionShouldBeSentToTelemetry() + { + const string optionKey = "language"; + const string optionValueToSend = "c#"; + string[] args = { "new", "console", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW")); + } + + [Fact] + public void AnyDotnetCommandVerbosityOpinionShouldBeSentToTelemetry() + { + const string optionKey = "verbosity"; + const string optionValueToSend = "minimal"; + string[] args = { "restore", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("RESTORE")); + } + + [Fact] + public void AnyDotnetCommandVerbosityOpinionShouldBeSentToTelemetryWithPerformanceData() + { + const string optionKey = "verbosity"; + const string optionValueToSend = "minimal"; + string[] args = { "restore", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgs(args, new TimeSpan(34567)); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("RESTORE") && + e.Measurement.ContainsKey("Startup Time") && + e.Measurement["Startup Time"] == 3.4567 && + e.Measurement.ContainsKey("Parse Time") && + e.Measurement["Parse Time"] > 0); + } + + [Fact] + public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetry() + { + const string optionKey = "configuration"; + const string optionValueToSend = "Debug"; + string[] args = { "build", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); + } + + [Fact] + public void DotnetPublishCommandRuntimeOpinionsShouldBeSentToTelemetry() + { + const string optionKey = "runtime"; + const string optionValueToSend = $"{ToolsetInfo.LatestWinRuntimeIdentifier}-x64"; + string[] args = { "publish", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("PUBLISH")); + } + + [Fact] + public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() + { + string[] args = { "build", "--configuration", "Debug", "--runtime", $"{ToolsetInfo.LatestMacRuntimeIdentifier}-x64" }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && + e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); + + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("runtime") && + e.Properties["runtime"] == Sha256Hasher.Hash($"{ToolsetInfo.LatestMacRuntimeIdentifier.ToUpper()}-X64") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); + } + + [Fact] + public void DotnetRunCleanTestCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() + { + string[] args = { "clean", "--configuration", "Debug", "--framework", ToolsetInfo.CurrentTargetFramework }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && + e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); + + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("framework") && + e.Properties["framework"] == Sha256Hasher.Hash(ToolsetInfo.CurrentTargetFramework.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); + } + + [Fact] + public void DotnetUpdatePackageVulnerableOptionShouldBeSentToTelemetry() + { + const string optionKey = "vulnerable"; + string[] args = { "package", "update", "--vulnerable" }; + Cli.Program.ProcessArgs(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("PACKAGE UPDATE")); + } + + [WindowsOnlyFact] + public void InternalreportinstallsuccessCommandCollectExeNameWithEventname() + { + FakeRecordEventNameTelemetry fakeTelemetry = new(); + string[] args = { "c:\\mypath\\dotnet-sdk-latest-win-x64.exe" }; + + InternalReportInstallSuccessCommand.ProcessInputAndSendTelemetry(args, fakeTelemetry); + + fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "install/reportsuccess" && e.Properties.ContainsKey("exeName") && + e.Properties["exeName"] == Sha256Hasher.Hash("DOTNET-SDK-LATEST-WIN-X64.EXE")); + } + + [Fact] + public void ExceptionShouldBeSentToTelemetry() + { + Exception caughtException = null; + try + { + string[] args = { "build" }; + Cli.Program.ProcessArgs(args); + throw new ArgumentException("test exception"); + } + catch (Exception ex) + { + caughtException = ex; + TelemetryEventEntry.SendFiltered(ex); + } + + var exception = new Exception(); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "mainCatchException/exception" && + e.Properties.ContainsKey("exceptionType") && + e.Properties["exceptionType"] == "System.ArgumentException" && + e.Properties.ContainsKey("detail") && + e.Properties["detail"].Contains(caughtException.StackTrace)); + } + } +} diff --git a/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs b/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs new file mode 100644 index 000000000000..5939d8ffeb2c --- /dev/null +++ b/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs @@ -0,0 +1,313 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Configurer; + +namespace Microsoft.DotNet.Tests +{ + public class TelemetryCommonPropertiesTests : SdkTest + { + public TelemetryCommonPropertiesTests(ITestOutputHelper log) : base(log) + { + } + + [Fact] + public void TelemetryCommonPropertiesShouldContainIfItIsInDockerOrNot() + { + var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId").Should().ContainKey("Docker Container"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnHashedPath() + { + var unitUnderTest = new TelemetryCommonProperties(() => "ADirectory", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Current Path Hash"].Should().NotBe("ADirectory"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnHashedMachineId() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"].Should().NotBe("plaintext"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnDevDeviceId() + { + var unitUnderTest = new TelemetryCommonProperties(getDeviceId: () => "plaintext", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"].Should().Be("plaintext"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddress() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"]; + + Guid.TryParse(assignedMachineId, out var _).Should().BeTrue("it should be a guid"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldEnsureDevDeviceIDIsCached() + { + var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); + var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; + + Guid.TryParse(assignedMachineId, out var _).Should().BeTrue("it should be a guid"); + var secondAssignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; + + Guid.TryParse(secondAssignedMachineId, out var _).Should().BeTrue("it should be a guid"); + secondAssignedMachineId.Should().Be(assignedMachineId, "it should match the previously assigned guid"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnHashedMachineIdOld() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"].Should().NotBe("plaintext"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddressOld() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"]; + + Guid.TryParse(assignedMachineId, out var _).Should().BeTrue("it should be a guid"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnIsOutputRedirected() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Output Redirected"].Should().BeOneOf("True", "False"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnIsCIDetection() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Continuous Integration"].Should().BeOneOf("True", "False"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldContainKernelVersion() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Kernel Version"].Should().Be(RuntimeInformation.OSDescription); + } + + [Fact] + public void TelemetryCommonPropertiesShouldContainArchitectureInformation() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["OS Architecture"].Should().Be(RuntimeInformation.OSArchitecture.ToString()); + } + + [WindowsOnlyFact] + public void TelemetryCommonPropertiesShouldContainWindowsInstallType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().NotBeEmpty(); + } + + [UnixOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyWindowsInstallType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().BeEmpty(); + } + + [WindowsOnlyFact] + public void TelemetryCommonPropertiesShouldContainWindowsProductType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().NotBeEmpty(); + } + + [UnixOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyWindowsProductType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().BeEmpty(); + } + + [WindowsOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); + } + + [MacOsOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion2() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); + } + + [LinuxOnlyFact] + public void TelemetryCommonPropertiesShouldContainLibcReleaseAndVersion() + { + if (!RuntimeInformation.RuntimeIdentifier.Contains("alpine", StringComparison.OrdinalIgnoreCase)) + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().NotBeEmpty(); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().NotBeEmpty(); + } + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnIsLLMDetection() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["llm"].Should().BeOneOf("claude", null); + } + + [Theory] + [MemberData(nameof(CITelemetryTestCases))] + public void CanDetectCIStatusForEnvVars(Dictionary envVars, bool expected) + { + try + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, value); + } + new CIEnvironmentDetectorForTelemetry().IsCIEnvironment().Should().Be(expected); + } + finally + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, null); + } + } + } + + [Theory] + [MemberData(nameof(LLMTelemetryTestCases))] + public void CanDetectLLMStatusForEnvVars(Dictionary? envVars, string? expected) + { + try + { + if (envVars is not null) + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, value); + } + } + new LLMEnvironmentDetectorForTelemetry().GetLLMEnvironment().Should().Be(expected); + } + finally + { + if (envVars is not null) + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, null); + } + } + } + } + + [Theory] + [InlineData("dummySessionId")] + [InlineData(null)] + public void TelemetryCommonPropertiesShouldContainSessionId(string? sessionId) + { + var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); + var commonProperties = unitUnderTest.GetTelemetryCommonProperties(sessionId); + + commonProperties.Should().ContainKey("SessionId"); + commonProperties["SessionId"].Should().Be(sessionId); + } + + + public static TheoryData?, string?> LLMTelemetryTestCases => new() + { + { new Dictionary { {"CLAUDECODE", "1" } }, "claude" }, + { new Dictionary { {"CLAUDE_CODE_ENTRYPOINT", "some_value" } }, "claude" }, + { new Dictionary { { "CURSOR_EDITOR", "1" } }, "cursor" }, + { new Dictionary { { "CURSOR_AI", "1" } }, "cursor" }, + { new Dictionary { { "GEMINI_CLI", "true" } }, "gemini" }, + { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "true" } }, "copilot" }, + { new Dictionary { { "GH_COPILOT_WORKING_DIRECTORY", "/repo" } }, "copilot" }, + { new Dictionary { { "CODEX_CLI", "1" } }, "codex" }, + { new Dictionary { { "CODEX_SANDBOX", "1" } }, "codex" }, + { new Dictionary { { "OR_APP_NAME", "Aider" } }, "aider" }, + { new Dictionary { { "OR_APP_NAME", "aider" } }, "aider" }, + { new Dictionary { { "OR_APP_NAME", "plandex" } }, "plandex" }, + { new Dictionary { { "OR_APP_NAME", "Plandex" } }, "plandex" }, + { new Dictionary { { "AMP_HOME", "/path/to/amp" } }, "amp" }, + { new Dictionary { { "QWEN_CODE", "1" } }, "qwen" }, + { new Dictionary { { "DROID_CLI", "true" } }, "droid" }, + { new Dictionary { { "OPENCODE_AI", "1" } }, "opencode" }, + { new Dictionary { { "ZED_ENVIRONMENT", "1" } }, "zed" }, + { new Dictionary { { "ZED_TERM", "1" } }, "zed" }, + { new Dictionary { { "KIMI_CLI", "true" } }, "kimi" }, + { new Dictionary { { "OR_APP_NAME", "OpenHands" } }, "openhands" }, + { new Dictionary { { "OR_APP_NAME", "openhands" } }, "openhands" }, + { new Dictionary { { "GOOSE_TERMINAL", "1" } }, "goose" }, + { new Dictionary { { "CLINE_TASK_ID", "task123" } }, "cline" }, + { new Dictionary { { "ROO_CODE_TASK_ID", "task456" } }, "roo" }, + { new Dictionary { { "WINDSURF_SESSION", "session789" } }, "windsurf" }, + { new Dictionary { { "AGENT_CLI", "true" } }, "generic_agent" }, + // Test combinations of older tools + { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" }, + { new Dictionary { { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" } }, "gemini, copilot" }, + { new Dictionary { { "CLAUDECODE", "1" }, { "GEMINI_CLI", "true" }, { "AGENT_CLI", "true" } }, "claude, gemini, generic_agent" }, + { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" }, { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" }, { "AGENT_CLI", "true" } }, "claude, cursor, gemini, copilot, generic_agent" }, + // Test combinations of newer tools + { new Dictionary { { "OR_APP_NAME", "Aider" }, { "CLINE_TASK_ID", "task123" } }, "aider, cline" }, + { new Dictionary { { "CODEX_CLI", "1" }, { "WINDSURF_SESSION", "session789" } }, "codex, windsurf" }, + { new Dictionary { { "GOOSE_TERMINAL", "1" }, { "ROO_CODE_TASK_ID", "task456" } }, "goose, roo" }, + { new Dictionary { { "GEMINI_CLI", "false" } }, null }, + { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "false" } }, null }, + { new Dictionary { { "AGENT_CLI", "false" } }, null }, + { new Dictionary { { "DROID_CLI", "false" } }, null }, + { new Dictionary { { "KIMI_CLI", "false" } }, null }, + { new Dictionary { { "OR_APP_NAME", "SomeOtherApp" } }, null }, + { new Dictionary(), null }, + }; + + public static TheoryData, bool> CITelemetryTestCases => new() + { + { new Dictionary { { "TF_BUILD", "true" } }, true }, + { new Dictionary { { "GITHUB_ACTIONS", "true" } }, true }, + { new Dictionary { { "APPVEYOR", "true"} }, true }, + { new Dictionary { { "CI", "true"} }, true }, + { new Dictionary { { "TRAVIS", "true"} }, true }, + { new Dictionary { { "CIRCLECI", "true"} }, true }, + { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" }, { "AWS_REGION", "hi" } }, true }, + { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" } }, false }, + { new Dictionary { { "BUILD_ID", "hi" }, { "BUILD_URL", "hi" } }, true }, + { new Dictionary { { "BUILD_ID", "hi" } }, false }, + { new Dictionary { { "BUILD_ID", "hi" }, { "PROJECT_ID", "hi" } }, true }, + { new Dictionary { { "BUILD_ID", "hi" } }, false }, + { new Dictionary { { "TEAMCITY_VERSION", "hi" } }, true }, + { new Dictionary { { "TEAMCITY_VERSION", "" } }, false }, + { new Dictionary { { "JB_SPACE_API_URL", "hi" } }, true }, + { new Dictionary { { "JB_SPACE_API_URL", "" } }, false }, + { new Dictionary { { "SomethingElse", "hi" } }, false }, + }; + + private class NothingCache : IUserLevelCacheWriter + { + public string RunWithCache(string cacheKey, Func getValueToCache) + { + return getValueToCache(); + } + + public string RunWithCacheInFilePath(string cacheFilepath, Func getValueToCache) + { + return getValueToCache(); + } + } + } +} diff --git a/test/dotnet.Tests/TelemetryFilterTest.cs b/test/dotnet.Tests/TelemetryFilterTest.cs new file mode 100644 index 000000000000..db5f835e791d --- /dev/null +++ b/test/dotnet.Tests/TelemetryFilterTest.cs @@ -0,0 +1,211 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Utilities; +using Parser = Microsoft.DotNet.Cli.Parser; + +namespace Microsoft.DotNet.Tests +{ + /// + /// Only adding the performance data tests for now as the TelemetryCommandTests cover most other scenarios already + /// + public class TelemetryFilterTests : SdkTest + { + private readonly FakeRecordEventNameTelemetry _fakeTelemetry; + + public string EventName { get; set; } + + public IDictionary Properties { get; set; } + + public TelemetryFilterTests(ITestOutputHelper log) : base(log) + { + _fakeTelemetry = new FakeRecordEventNameTelemetry(); + TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); + TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithoutPerformanceData() + { + var parseResult = Parser.Parse(["build"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && + e.Measurement == null); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithPerformanceData() + { + var parseResult = Parser.Parse(["build"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 12345 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && + e.Measurement.ContainsKey("Startup Time") && + e.Measurement["Startup Time"] == 12345); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithGlobalJsonState() + { + string globalJsonState = "invalid_data"; + var parseResult = Parser.Parse(["build"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary(), globalJsonState)); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && + e.Measurement == null && + e.Properties.ContainsKey("globalJson") && + e.Properties["globalJson"] == Sha256Hasher.HashWithNormalizedCasing(globalJsonState)); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithZeroPerformanceData() + { + var parseResult = Parser.Parse(["build"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && + e.Measurement == null); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithSomeZeroPerformanceData() + { + var parseResult = Parser.Parse(["build"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && + !e.Measurement.ContainsKey("Startup Time") && + e.Measurement.ContainsKey("Parse Time") && + e.Measurement["Parse Time"] == 23456); + } + + [Fact] + public void SubLevelCommandNameShouldBeSentToTelemetryWithoutPerformanceData() + { + var parseResult = Parser.Parse(["new", "console"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW") && + e.Measurement == null); + } + + [Fact] + public void SubLevelCommandNameShouldBeSentToTelemetryWithPerformanceData() + { + var parseResult = Parser.Parse(["new", "console"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 34567 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW") && + e.Measurement.ContainsKey("Startup Time") && + e.Measurement["Startup Time"] == 34567); + } + + [Fact] + public void SubLevelCommandNameShouldBeSentToTelemetryWithZeroPerformanceData() + { + var parseResult = Parser.Parse(["new", "console"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW") && + e.Measurement == null); + } + + [Fact] + public void SubLevelCommandNameShouldBeSentToTelemetryWithSomeZeroPerformanceData() + { + var parseResult = Parser.Parse(["new", "console"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 45678 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW") && + !e.Measurement.ContainsKey("Startup Time") && + e.Measurement.ContainsKey("Parse Time") && + e.Measurement["Parse Time"] == 45678); + } + + [Fact] + public void WorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["workload", "install", "microsoft-ios-sdk-full"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, + new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL") && + e.Properties["argument"] == + Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); + } + + [Fact] + public void ToolsSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["tool", "install", "dotnet-format"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, + new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("TOOL") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL") && + e.Properties["argument"] == + Sha256Hasher.Hash("DOTNET-FORMAT")); + } + + [Fact] + public void WhenCalledWithDiagnosticWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["-d", "workload", "install", "microsoft-ios-sdk-full"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, + new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL") && + e.Properties["argument"] == + Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); + } + + [Fact] + public void WhenCalledWithMissingArgumentWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["-d", "workload", "install"]); + TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, + new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL")); + } + } +} diff --git a/test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs b/test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs deleted file mode 100644 index 277e89826a8d..000000000000 --- a/test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Concurrent; -using Microsoft.DotNet.Cli.Telemetry; - -namespace Microsoft.DotNet.Tests.TelemetryTests; - -public class FakeRecordEventNameTelemetry : ITelemetryClient -{ - public bool Enabled { get; set; } - - public string? EventName { get; set; } - - public void TrackEvent(string eventName, IDictionary? properties) - { - LogEntries.Add(new LogEntry - { - EventName = eventName, - Properties = properties ?? new Dictionary() - }); - } - - public ConcurrentBag LogEntries { get; set; } = []; - - public class LogEntry - { - public string? EventName { get; set; } - public IDictionary Properties { get; set; } = new Dictionary(); - } -} diff --git a/test/dotnet.Tests/TelemetryTests/SenderTests.cs b/test/dotnet.Tests/TelemetryTests/SenderTests.cs new file mode 100644 index 000000000000..6d6a4983ea23 --- /dev/null +++ b/test/dotnet.Tests/TelemetryTests/SenderTests.cs @@ -0,0 +1,184 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Net; +using System.Runtime.CompilerServices; +using Moq; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel.Tests +{ + public class SenderTests : SdkTest + { + private int _deleteCount; + + private Mock TransmissionMock { get; } + + private Mock StorageBaseMock { get; } + + public SenderTests(ITestOutputHelper log) : base(log) + { + StorageBaseMock = new Mock(); + TransmissionMock = new Mock(string.Empty, new Uri("http://some/url"), new byte[] { }, + string.Empty, string.Empty); + _deleteCount = 0; + StorageBaseMock.Setup(storage => storage.Delete(It.IsAny())) + .Callback(() => _deleteCount++); + } + + [Fact] + public void WhenServerReturn503TransmissionWillBeRetried() + { + var Sender = GetSenderUnderTest(); + int peekCounts = 0; + + // Setup transmission.SendAsync() to throw WebException that has 503 status Code + TransmissionMock.Setup(transmission => transmission.SendAsync()) + .Throws(GenerateWebException((HttpStatusCode)503)); + + // Setup Storage.Peek() to return the mocked transmission, and stop the loop after 10 peeks. + StorageBaseMock.Setup(storage => storage.Peek()) + .Returns(TransmissionMock.Object) + .Callback(() => + { + if (peekCounts++ == 10) + { + Sender.StopAsync(); + } + }); + + // Act + Sender.SendLoop(); + _deleteCount.Should().Be(0, + "delete is not expected to be called on 503, request is expected to be send forever."); + } + + [Fact] + public void WhenServerReturn400IntervalWillBe10Seconds() + { + var Sender = GetSenderUnderTest(); + int peekCounts = 0; + + // Setup transmission.SendAsync() to throw WebException that has 400 status Code + TransmissionMock.Setup(transmission => transmission.SendAsync()) + .Throws(GenerateWebException((HttpStatusCode)400)); + + // Setup Storage.Peek() to return the mocked transmission, and stop the loop after 10 peeks. + StorageBaseMock.Setup(storage => storage.Peek()) + .Returns(TransmissionMock.Object) + .Callback(() => + { + if (peekCounts++ == 10) + { + Sender.StopAsync(); + } + }); + + // Cache the interval (it is a parameter passed to the Send method). + TimeSpan intervalOnSixIteration = TimeSpan.Zero; + Sender.OnSend = interval => intervalOnSixIteration = interval; + + // Act + Sender.SendLoop(); + + intervalOnSixIteration.TotalSeconds.Should().Be(5); + _deleteCount.Should().Be(10, "400 should not be retried so delete should always be called."); + } + + [Fact] + public void DisposeDoesNotThrow() + { + new Sender(StorageBaseMock.Object, + new PersistenceTransmitter( + CreateStorageService(), + 3)) + .Dispose(); + } + + [Fact] + public void WhenServerReturnDnsErrorRequestWillBeRetried() + { + var Sender = GetSenderUnderTest(); + int peekCounts = 0; + + // Setup transmission.SendAsync() to throw WebException with ProxyNameResolutionFailure failure + WebException webException = new( + string.Empty, + new Exception(), + WebExceptionStatus.ProxyNameResolutionFailure, + null); + TransmissionMock.Setup(transmission => transmission.SendAsync()).Throws(webException); + + // Setup Storage.Peek() to return the mocked transmission, and stop the loop after 10 peeks. + StorageBaseMock.Setup(storage => storage.Peek()) + .Returns(TransmissionMock.Object) + .Callback(() => + { + if (peekCounts++ == 10) + { + Sender.StopAsync(); + } + }); + + // Act + Sender.SendLoop(); + + _deleteCount.Should().Be(0, + "delete is not expected to be called on Dns errors since it , request is expected to be retried forever."); + } + + private WebException GenerateWebException(HttpStatusCode httpStatusCode) + { + Mock httpWebResponse = new(); + httpWebResponse.SetupGet(webResponse => webResponse.StatusCode).Returns(httpStatusCode); + + WebException webException = new(string.Empty, new Exception(), WebExceptionStatus.SendFailure, + httpWebResponse.Object); + + return webException; + } + + /// + /// A class that inherits from Sender, to expose its protected methods. + /// + internal class SenderUnderTest : Sender + { + internal Action OnSend = nextSendInterval => { }; + + internal SenderUnderTest(BaseStorageService storage, PersistenceTransmitter transmitter) + : base(storage, transmitter, false) + { + } + + internal AutoResetEvent IntervalAutoResetEvent => DelayHandler; + + internal new void SendLoop() + { + base.SendLoop(); + } + + protected override bool Send(StorageTransmission transmission, ref TimeSpan nextSendInterval) + { + OnSend(nextSendInterval); + DelayHandler.Set(); + return base.Send(transmission, ref nextSendInterval); + } + } + + private StorageService CreateStorageService([CallerMemberName] string testName = null) + { + string tempPath = Path.Combine(TestAssetsManager.CreateTestDirectory("TestStorageService", identifier: testName).Path, Path.GetTempFileName()); + StorageService storageService = new(); + storageService.Init(tempPath); + return storageService; + } + + private SenderUnderTest GetSenderUnderTest([CallerMemberName] string testName = null) + { + StorageService storageService = CreateStorageService(testName); + PersistenceTransmitter transmitter = new(storageService, 0); + return new SenderUnderTest(StorageBaseMock.Object, transmitter); + } + } +} diff --git a/test/dotnet.Tests/TelemetryTests/StorageTests.cs b/test/dotnet.Tests/TelemetryTests/StorageTests.cs new file mode 100644 index 000000000000..bd56db879b8d --- /dev/null +++ b/test/dotnet.Tests/TelemetryTests/StorageTests.cs @@ -0,0 +1,202 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Runtime.CompilerServices; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility.Implementation; +using IChannelTelemetry = Microsoft.ApplicationInsights.Channel.ITelemetry; + +namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel.Tests +{ + /// + /// Tests for Storage. + /// + /// + /// To reduce complexity, there was a design decision to make Storage the file system abstraction layer. + /// That means that Storage knows about the file system types (e.g. IStorageFile or FileInfo). + /// Those types are not easy to mock (even IStorageFile is using extension methods that makes it very hard to mock). + /// Therefore those UnitTests just doesn't mock the file system. Every unit test in + /// reads and writes files to/from the disk. + /// + public class StorageTests : SdkTest + { + public StorageTests(ITestOutputHelper log) : base(log) + { + } + + [Fact] + public async Task EnqueuedContentIsEqualToPeekedContent() + { + // Setup + StorageService storage = new(); + storage.Init(GetTemporaryPath()); + Transmission transmissionToEnqueue = CreateTransmission(new TraceTelemetry("mock_item")); + + // Act + await storage.EnqueueAsync(transmissionToEnqueue); + StorageTransmission peekedTransmission = storage.Peek(); + + // Asserts + string enqueuedContent = + Encoding.UTF8.GetString(transmissionToEnqueue.Content, 0, transmissionToEnqueue.Content.Length); + string peekedContent = + Encoding.UTF8.GetString(peekedTransmission.Content, 0, peekedTransmission.Content.Length); + enqueuedContent.Should().Be(peekedContent); + } + + [Fact] + public void DeletedItemIsNotReturnedInCallsToPeek() + { + // Setup - create a storage with one item + StorageService storage = new(); + storage.Init(GetTemporaryPath()); + Transmission transmissionToEnqueue = CreateTransmissionAndEnqueueIt(storage); + + // Act + StorageTransmission firstPeekedTransmission; + + // if item is not disposed,peek will not return it (regardless of the call to delete). + // So for this test to actually test something, using 'using' is required. + using (firstPeekedTransmission = storage.Peek()) + { + storage.Delete(firstPeekedTransmission); + } + + StorageTransmission secondPeekedTransmission = storage.Peek(); + + // Asserts + firstPeekedTransmission.Should().NotBeNull(); + secondPeekedTransmission.Should().BeNull(); + } + + [Fact] + public void PeekedItemIsOnlyReturnedOnce() + { + // Setup - create a storage with one item + StorageService storage = new(); + storage.Init(GetTemporaryPath()); + + Transmission transmissionToEnqueue = CreateTransmissionAndEnqueueIt(storage); + + // Act + StorageTransmission firstPeekedTransmission = storage.Peek(); + StorageTransmission secondPeekedTransmission = storage.Peek(); + + // Asserts + firstPeekedTransmission.Should().NotBeNull(); + secondPeekedTransmission.Should().BeNull(); + } + + [Fact] + public async Task PeekedItemIsReturnedAgainAfterTheItemInTheFirstCallToPeekIsDisposed() + { + // Setup - create a storage with one item + StorageService storage = new(); + storage.Init(GetTemporaryPath()); + + Transmission transmissionToEnqueue = CreateTransmission(new TraceTelemetry("mock_item")); + await storage.EnqueueAsync(transmissionToEnqueue); + + // Act + StorageTransmission firstPeekedTransmission; + using (firstPeekedTransmission = storage.Peek()) + { + } + + StorageTransmission secondPeekedTransmission = storage.Peek(); + + // Asserts + firstPeekedTransmission.Should().NotBeNull(); + secondPeekedTransmission.Should().NotBeNull(); + } + + [Fact] + public void WhenStorageHasTwoItemsThenTwoCallsToPeekReturns2DifferentItems() + { + // Setup - create a storage with 2 items + StorageService storage = new(); + storage.Init(GetTemporaryPath()); + + Transmission firstTransmission = CreateTransmissionAndEnqueueIt(storage); + Transmission secondTransmission = CreateTransmissionAndEnqueueIt(storage); + + // Act + StorageTransmission firstPeekedTransmission = storage.Peek(); + StorageTransmission secondPeekedTransmission = storage.Peek(); + + // Asserts + firstPeekedTransmission.Should().NotBeNull(); + secondPeekedTransmission.Should().NotBeNull(); + + string first = Encoding.UTF8.GetString(firstPeekedTransmission.Content, 0, + firstPeekedTransmission.Content.Length); + string second = Encoding.UTF8.GetString(secondPeekedTransmission.Content, 0, + secondPeekedTransmission.Content.Length); + first.Should().NotBe(second); + } + + [Fact] + public void WhenMaxFilesIsOneThenSecondTransmissionIsDropped() + { + // Setup + StorageService storage = new(); + storage.Init(GetTemporaryPath()); + + storage.MaxFiles = 1; + + // Act - Enqueue twice + CreateTransmissionAndEnqueueIt(storage); + CreateTransmissionAndEnqueueIt(storage); + + // Asserts - Second Peek should be null + storage.Peek().Should().NotBeNull(); + storage.Peek().Should().BeNull(); + } + + [Fact] + public void WhenMaxSizeIsReachedThenEnqueuedTransmissionsAreDropped() + { + // Setup - create a storage with 2 items + StorageService storage = new(); + storage.Init(GetTemporaryPath()); + + storage.CapacityInBytes = 200; // Each file enqueued in CreateTransmissionAndEnqueueIt is ~300 bytes. + + // Act - Enqueue twice + CreateTransmissionAndEnqueueIt(storage); + CreateTransmissionAndEnqueueIt(storage); + + // Asserts - Second Peek should be null + storage.Peek().Should().NotBeNull(); + storage.Peek().Should().BeNull(); + } + + private static Transmission CreateTransmission(IChannelTelemetry telemetry) + { + byte[] data = JsonSerializer.Serialize(new[] { telemetry }); + Transmission transmission = new( + new Uri(@"http://some.url"), + data, + "application/x-json-stream", + JsonSerializer.CompressionType); + + return transmission; + } + + private static Transmission CreateTransmissionAndEnqueueIt(StorageService storage) + { + Transmission firstTransmission = CreateTransmission(new TraceTelemetry(Guid.NewGuid().ToString())); + storage.EnqueueAsync(firstTransmission).ConfigureAwait(false).GetAwaiter().GetResult(); + + return firstTransmission; + } + + private string GetTemporaryPath([CallerMemberName] string callingMethod = null) + { + return TestAssetsManager.CreateTestDirectory(callingMethod).Path; + } + } +} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs b/test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs deleted file mode 100644 index eb64306bfa69..000000000000 --- a/test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Nodes; - -namespace Microsoft.DotNet.Tests.TelemetryTests; - -public class TelemetryClientTests(ITestOutputHelper log) : SdkTest(log) -{ - public static TheoryData CommandsWithExitCode => new() - { - { new[] { "--help" }, "0" }, - { new[] { "--info" }, "0" }, - { new[] { "workload", "list" }, "0" }, - { new[] { "sdk", "check" }, "0" }, - { new[] { "build-server", "shutdown" }, "0" }, - { new[] { "solution", "list" }, "1" }, - { new[] { "clean" }, "1" }, - { new[] { "run" }, "1" }, - { new[] { "new", "details" }, "127" } - }; - - // Only runs on Windows because OTel libraries are only referenced on Windows builds. - // Thus, this test that writes telemetry logs will not work on other platforms. - [PlatformSpecificTheory(TestPlatforms.Windows)] - [MemberData(nameof(CommandsWithExitCode))] - public void ItProcessesTelemetryData(string[] commandArgs, string exitCodeExpected) - { - var testDir = TestAssetsManager.CreateTestDirectory().Path; - var commandString = string.Join(' ', commandArgs); - var logFile = Path.Combine(testDir, $"TelemLog_{commandString}.json"); - - new DotnetCommand(Log, commandArgs) - .WithWorkingDirectory(testDir) - .WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", "false") - .WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT", "true") - .WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_LOG_PATH", logFile) - .Execute(); - - var logFileInfo = new FileInfo(logFile); - logFileInfo.Should().Exist(); - - var telemetryJson = JsonNode.Parse(logFileInfo.ReadAllText()); - telemetryJson.Should().NotBeNull(); - - var activities = telemetryJson["activities"]?.AsArray(); - activities.Should().NotBeNull(); - - var mainOperation = activities.FirstOrDefault(n => n?["operationName"]?.GetValue() == "main"); - mainOperation.Should().NotBeNull(); - - var displayName = mainOperation["displayName"]?.GetValue(); - displayName.Should().Be($"dotnet {commandString}"); - - var events = mainOperation["events"]?.AsArray(); - events.Should().NotBeNull(); - - var finishEvent = events.FirstOrDefault(n => n?["name"]?.GetValue() == "dotnet/cli/command/finish"); - finishEvent.Should().NotBeNull(); - - var tags = finishEvent["tags"]; - tags.Should().NotBeNull(); - - var exitCode = tags["exitCode"]?.GetValue(); - exitCode.Should().Be(exitCodeExpected); - } -} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs b/test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs deleted file mode 100644 index cef125282576..000000000000 --- a/test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs +++ /dev/null @@ -1,332 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; -using Microsoft.DotNet.Cli.Telemetry; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Utilities; - -namespace Microsoft.DotNet.Tests.TelemetryTests; - -[Collection(TestConstants.UsesStaticTelemetryState)] -public class TelemetryCommandTests : SdkTest -{ - private readonly FakeRecordEventNameTelemetry _fakeTelemetry; - - public string? EventName { get; set; } - - public IDictionary Properties { get; set; } = new Dictionary(); - - public TelemetryCommandTests(ITestOutputHelper log) : base(log) - { - _fakeTelemetry = new FakeRecordEventNameTelemetry(); - TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); - TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); - } - - [Fact] - public void NoTelemetryIfCommandIsInvalid() - { - string[] args = { "publish", "-r" }; - Action a = () => { Cli.Program.ProcessArgsAndExecute(args); }; - a.Should().NotThrow(); - } - - [Fact] - public void NoTelemetryIfCommandIsInvalid2() - { - string[] args = { "restore", "-v" }; - Action a = () => { Cli.Program.ProcessArgsAndExecute(args); }; - a.Should().NotThrow(); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetry() - { - string[] args = { "help" }; - Cli.Program.ProcessArgsAndExecute(args); - - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("HELP")); - } - - [Fact] - public void DotnetNewCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "console"; - string[] args = { "new", argumentToSend }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW")); - } - - [Fact] - public void DotnetHelpCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "something"; - string[] args = { "help", argumentToSend }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("HELP")); - } - - [Fact] - public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "package"; - string[] args = { "add", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("ADD")); - } - - [Fact] - public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry2() - { - const string argumentToSend = "reference"; - string[] args = { "add", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("ADD")); - } - - [Fact] - public void DotnetRemoveCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "package"; - string[] args = { "remove", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("REMOVE")); - } - - [Fact] - public void DotnetListCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "reference"; - string[] args = { "list", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("LIST")); - } - - [Fact] - public void DotnetSlnCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "list"; - string[] args = { "sln", "aSolution", argumentToSend }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("SOLUTION")); - } - - [Fact] - public void DotnetNugetCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "push"; - - string[] args = { "nuget", argumentToSend, "path" }; - - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NUGET")); - } - - [Fact(Skip = "https://github.com/dotnet/sdk/issues/47862")] - public void DotnetNewCommandLanguageOpinionShouldBeSentToTelemetry() - { - const string optionKey = "language"; - const string optionValueToSend = "c#"; - string[] args = { "new", "console", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW")); - } - - [Fact] - public void AnyDotnetCommandVerbosityOpinionShouldBeSentToTelemetry() - { - const string optionKey = "verbosity"; - const string optionValueToSend = "minimal"; - string[] args = { "restore", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("RESTORE")); - } - - [Fact] - public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetry() - { - const string optionKey = "configuration"; - const string optionValueToSend = "Debug"; - string[] args = { "build", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); - } - - [Fact] - public void DotnetPublishCommandRuntimeOpinionsShouldBeSentToTelemetry() - { - const string optionKey = "runtime"; - const string optionValueToSend = $"{ToolsetInfo.LatestWinRuntimeIdentifier}-x64"; - string[] args = { "publish", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("PUBLISH")); - } - - [Fact] - public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() - { - string[] args = { "build", "--configuration", "Debug", "--runtime", $"{ToolsetInfo.LatestMacRuntimeIdentifier}-x64" }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && - e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); - - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("runtime") && - e.Properties["runtime"] == Sha256Hasher.Hash($"{ToolsetInfo.LatestMacRuntimeIdentifier.ToUpper()}-X64") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); - } - - [Fact] - public void DotnetRunCleanTestCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() - { - string[] args = { "clean", "--configuration", "Debug", "--framework", ToolsetInfo.CurrentTargetFramework }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && - e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); - - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("framework") && - e.Properties["framework"] == Sha256Hasher.Hash(ToolsetInfo.CurrentTargetFramework.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); - } - - [Fact] - public void DotnetUpdatePackageVulnerableOptionShouldBeSentToTelemetry() - { - const string optionKey = "vulnerable"; - string[] args = { "package", "update", "--vulnerable" }; - Cli.Program.ProcessArgsAndExecute(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("PACKAGE UPDATE")); - } - - [WindowsOnlyFact] - public void InternalreportinstallsuccessCommandCollectExeNameWithEventname() - { - FakeRecordEventNameTelemetry fakeTelemetry = new(); - string[] args = { "c:\\mypath\\dotnet-sdk-latest-win-x64.exe" }; - - InternalReportInstallSuccessCommand.ProcessInputAndSendTelemetry(args, fakeTelemetry); - - fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "install/reportsuccess" && e.Properties.ContainsKey("exeName") && - e.Properties["exeName"] == Sha256Hasher.Hash("DOTNET-SDK-LATEST-WIN-X64.EXE")); - } - - [Fact] - public void ExceptionShouldBeSentToTelemetry() - { - Exception? caughtException = null; - try - { - string[] args = { "build" }; - Cli.Program.ProcessArgsAndExecute(args); - throw new ArgumentException("test exception"); - } - catch (Exception ex) - { - caughtException = ex; - TelemetryEventEntry.SendFiltered(ex); - } - - var stackTrace = caughtException?.StackTrace ?? string.Empty; - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "mainCatchException/exception" && - e.Properties.ContainsKey("exceptionType") && - e.Properties["exceptionType"] == "System.ArgumentException" && - e.Properties.ContainsKey("detail") && - e.Properties["detail"] != null && - e.Properties["detail"]!.Contains(stackTrace)); - } -} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs b/test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs deleted file mode 100644 index fb4382e0ae04..000000000000 --- a/test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs +++ /dev/null @@ -1,311 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.Cli.Telemetry; -using Microsoft.DotNet.Configurer; - -namespace Microsoft.DotNet.Tests.TelemetryTests; - -public class TelemetryCommonPropertiesTests : SdkTest -{ - public TelemetryCommonPropertiesTests(ITestOutputHelper log) : base(log) - { - } - - [Fact] - public void TelemetryCommonPropertiesShouldContainIfItIsInDockerOrNot() - { - var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId").Should().ContainKey("Docker Container"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnHashedPath() - { - var unitUnderTest = new TelemetryCommonProperties(() => "ADirectory", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Current Path Hash"].Should().NotBe("ADirectory"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnHashedMachineId() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"].Should().NotBe("plaintext"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnDevDeviceId() - { - var unitUnderTest = new TelemetryCommonProperties(getDeviceId: () => "plaintext", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"].Should().Be("plaintext"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddress() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"]; - - Guid.TryParse((string?)assignedMachineId, out var _).Should().BeTrue("it should be a guid"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldEnsureDevDeviceIDIsCached() - { - var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); - var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; - - Guid.TryParse((string?)assignedMachineId, out var _).Should().BeTrue("it should be a guid"); - var secondAssignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; - - Guid.TryParse((string?)secondAssignedMachineId, out var _).Should().BeTrue("it should be a guid"); - secondAssignedMachineId.Should().Be(assignedMachineId, "it should match the previously assigned guid"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnHashedMachineIdOld() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"].Should().NotBe("plaintext"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddressOld() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"]; - - Guid.TryParse((string?)assignedMachineId, out var _).Should().BeTrue("it should be a guid"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnIsOutputRedirected() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Output Redirected"].Should().BeOneOf("True", "False"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnIsCIDetection() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Continuous Integration"].Should().BeOneOf("True", "False"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldContainKernelVersion() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Kernel Version"].Should().Be(RuntimeInformation.OSDescription); - } - - [Fact] - public void TelemetryCommonPropertiesShouldContainArchitectureInformation() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["OS Architecture"].Should().Be(RuntimeInformation.OSArchitecture.ToString()); - } - - [WindowsOnlyFact] - public void TelemetryCommonPropertiesShouldContainWindowsInstallType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().NotBeEmpty(); - } - - [UnixOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyWindowsInstallType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().BeEmpty(); - } - - [WindowsOnlyFact] - public void TelemetryCommonPropertiesShouldContainWindowsProductType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().NotBeEmpty(); - } - - [UnixOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyWindowsProductType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().BeEmpty(); - } - - [WindowsOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); - } - - [MacOsOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion2() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); - } - - [LinuxOnlyFact] - public void TelemetryCommonPropertiesShouldContainLibcReleaseAndVersion() - { - if (!RuntimeInformation.RuntimeIdentifier.Contains("alpine", StringComparison.OrdinalIgnoreCase)) - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().NotBeEmpty(); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().NotBeEmpty(); - } - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnIsLLMDetection() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["llm"].Should().BeOneOf("claude", null); - } - - [Theory] - [MemberData(nameof(CITelemetryTestCases))] - public void CanDetectCIStatusForEnvVars(Dictionary envVars, bool expected) - { - try - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, value); - } - new CIEnvironmentDetectorForTelemetry().IsCIEnvironment().Should().Be(expected); - } - finally - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, null); - } - } - } - - [Theory] - [MemberData(nameof(LLMTelemetryTestCases))] - public void CanDetectLLMStatusForEnvVars(Dictionary? envVars, string? expected) - { - try - { - if (envVars is not null) - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, value); - } - } - new LLMEnvironmentDetectorForTelemetry().GetLLMEnvironment().Should().Be(expected); - } - finally - { - if (envVars is not null) - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, null); - } - } - } - } - - [Theory] - [InlineData("dummySessionId")] - [InlineData(null)] - public void TelemetryCommonPropertiesShouldContainSessionId(string? sessionId) - { - var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); - var commonProperties = unitUnderTest.GetTelemetryCommonProperties(sessionId); - - commonProperties.Should().ContainKey("SessionId"); - commonProperties["SessionId"].Should().Be(sessionId); - } - - public static TheoryData?, string?> LLMTelemetryTestCases => new() - { - { new Dictionary { {"CLAUDECODE", "1" } }, "claude" }, - { new Dictionary { {"CLAUDE_CODE_ENTRYPOINT", "some_value" } }, "claude" }, - { new Dictionary { { "CURSOR_EDITOR", "1" } }, "cursor" }, - { new Dictionary { { "CURSOR_AI", "1" } }, "cursor" }, - { new Dictionary { { "GEMINI_CLI", "true" } }, "gemini" }, - { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "true" } }, "copilot" }, - { new Dictionary { { "GH_COPILOT_WORKING_DIRECTORY", "/repo" } }, "copilot" }, - { new Dictionary { { "CODEX_CLI", "1" } }, "codex" }, - { new Dictionary { { "CODEX_SANDBOX", "1" } }, "codex" }, - { new Dictionary { { "OR_APP_NAME", "Aider" } }, "aider" }, - { new Dictionary { { "OR_APP_NAME", "aider" } }, "aider" }, - { new Dictionary { { "OR_APP_NAME", "plandex" } }, "plandex" }, - { new Dictionary { { "OR_APP_NAME", "Plandex" } }, "plandex" }, - { new Dictionary { { "AMP_HOME", "/path/to/amp" } }, "amp" }, - { new Dictionary { { "QWEN_CODE", "1" } }, "qwen" }, - { new Dictionary { { "DROID_CLI", "true" } }, "droid" }, - { new Dictionary { { "OPENCODE_AI", "1" } }, "opencode" }, - { new Dictionary { { "ZED_ENVIRONMENT", "1" } }, "zed" }, - { new Dictionary { { "ZED_TERM", "1" } }, "zed" }, - { new Dictionary { { "KIMI_CLI", "true" } }, "kimi" }, - { new Dictionary { { "OR_APP_NAME", "OpenHands" } }, "openhands" }, - { new Dictionary { { "OR_APP_NAME", "openhands" } }, "openhands" }, - { new Dictionary { { "GOOSE_TERMINAL", "1" } }, "goose" }, - { new Dictionary { { "CLINE_TASK_ID", "task123" } }, "cline" }, - { new Dictionary { { "ROO_CODE_TASK_ID", "task456" } }, "roo" }, - { new Dictionary { { "WINDSURF_SESSION", "session789" } }, "windsurf" }, - { new Dictionary { { "AGENT_CLI", "true" } }, "generic_agent" }, - // Test combinations of older tools - { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" }, - { new Dictionary { { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" } }, "gemini, copilot" }, - { new Dictionary { { "CLAUDECODE", "1" }, { "GEMINI_CLI", "true" }, { "AGENT_CLI", "true" } }, "claude, gemini, generic_agent" }, - { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" }, { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" }, { "AGENT_CLI", "true" } }, "claude, cursor, gemini, copilot, generic_agent" }, - // Test combinations of newer tools - { new Dictionary { { "OR_APP_NAME", "Aider" }, { "CLINE_TASK_ID", "task123" } }, "aider, cline" }, - { new Dictionary { { "CODEX_CLI", "1" }, { "WINDSURF_SESSION", "session789" } }, "codex, windsurf" }, - { new Dictionary { { "GOOSE_TERMINAL", "1" }, { "ROO_CODE_TASK_ID", "task456" } }, "goose, roo" }, - { new Dictionary { { "GEMINI_CLI", "false" } }, null }, - { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "false" } }, null }, - { new Dictionary { { "AGENT_CLI", "false" } }, null }, - { new Dictionary { { "DROID_CLI", "false" } }, null }, - { new Dictionary { { "KIMI_CLI", "false" } }, null }, - { new Dictionary { { "OR_APP_NAME", "SomeOtherApp" } }, null }, - { new Dictionary(), null }, - }; - - public static TheoryData, bool> CITelemetryTestCases => new() - { - { new Dictionary { { "TF_BUILD", "true" } }, true }, - { new Dictionary { { "GITHUB_ACTIONS", "true" } }, true }, - { new Dictionary { { "APPVEYOR", "true"} }, true }, - { new Dictionary { { "CI", "true"} }, true }, - { new Dictionary { { "TRAVIS", "true"} }, true }, - { new Dictionary { { "CIRCLECI", "true"} }, true }, - { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" }, { "AWS_REGION", "hi" } }, true }, - { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" } }, false }, - { new Dictionary { { "BUILD_ID", "hi" }, { "BUILD_URL", "hi" } }, true }, - { new Dictionary { { "BUILD_ID", "hi" } }, false }, - { new Dictionary { { "BUILD_ID", "hi" }, { "PROJECT_ID", "hi" } }, true }, - { new Dictionary { { "BUILD_ID", "hi" } }, false }, - { new Dictionary { { "TEAMCITY_VERSION", "hi" } }, true }, - { new Dictionary { { "TEAMCITY_VERSION", "" } }, false }, - { new Dictionary { { "JB_SPACE_API_URL", "hi" } }, true }, - { new Dictionary { { "JB_SPACE_API_URL", "" } }, false }, - { new Dictionary { { "SomethingElse", "hi" } }, false }, - }; - - private class NothingCache : IUserLevelCacheWriter - { - public string RunWithCache(string cacheKey, Func getValueToCache) - { - return getValueToCache(); - } - - public string RunWithCacheInFilePath(string cacheFilepath, Func getValueToCache) - { - return getValueToCache(); - } - } -} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs b/test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs deleted file mode 100644 index 300f1f90e0e1..000000000000 --- a/test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.Cli.Telemetry; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Utilities; -using Parser = Microsoft.DotNet.Cli.Parser; - -namespace Microsoft.DotNet.Tests.TelemetryTests; - -/// -/// Only adding the performance data tests for now as the TelemetryCommandTests cover most other scenarios already -/// -public class TelemetryFilterTests : SdkTest -{ - private readonly FakeRecordEventNameTelemetry _fakeTelemetry; - - public string? EventName { get; set; } - - public IDictionary Properties { get; set; } = new Dictionary(); - - public TelemetryFilterTests(ITestOutputHelper log) : base(log) - { - _fakeTelemetry = new FakeRecordEventNameTelemetry(); - TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); - TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetry() - { - var parseResult = Parser.Parse(["build"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithGlobalJsonState() - { - string globalJsonState = "invalid_data"; - var parseResult = Parser.Parse(["build"]); - TelemetryEventEntry.SendFiltered(new ParseResultWithGlobalJsonState(parseResult, globalJsonState)); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && - e.Properties.ContainsKey("globalJson") && - e.Properties["globalJson"] == Sha256Hasher.HashWithNormalizedCasing(globalJsonState)); - } - - [Fact] - public void SubLevelCommandNameShouldBeSentToTelemetry() - { - var parseResult = Parser.Parse(["new", "console"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW")); - } - - [Fact] - public void WorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["workload", "install", "microsoft-ios-sdk-full"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL") && - e.Properties["argument"] == - Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); - } - - [Fact] - public void ToolsSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["tool", "install", "dotnet-format"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("TOOL") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL") && - e.Properties["argument"] == - Sha256Hasher.Hash("DOTNET-FORMAT")); - } - - [Fact] - public void WhenCalledWithDiagnosticWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["-d", "workload", "install", "microsoft-ios-sdk-full"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL") && - e.Properties["argument"] == - Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); - } - - [Fact] - public void WhenCalledWithMissingArgumentWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["-d", "workload", "install"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL")); - } -} From 349128b2944e0efeaaaba2ca04becc425b252130 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Thu, 9 Apr 2026 02:07:56 +0000 Subject: [PATCH 6/7] Re-apply testfx version revert after merge with main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/Version.Details.props | 20 ++++++++++---------- eng/Version.Details.xml | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/eng/Version.Details.props b/eng/Version.Details.props index 11fa5b413c19..a377b9fc4e05 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -61,7 +61,7 @@ This file should be imported by eng/Versions.props 11.0.0-preview.4.26208.106 11.0.0-preview.4.26208.106 11.0.0-beta.26208.106 - 11.0.0-beta.26208.106 + 11.0.0-beta.26208.106 11.0.0-preview.4.26208.106 11.0.0-preview.4.26208.106 11.0.0-preview.4.26208.106 @@ -91,11 +91,11 @@ This file should be imported by eng/Versions.props 11.0.100-preview.4.26208.106 11.0.100-preview.4.26208.106 11.0.100-preview.4.26208.106 - 11.0.100-preview.4.26208.106 + 11.0.100-preview.4.26208.106 11.0.100-preview.4.26208.106 - 11.0.100-preview.4.26208.106 + 11.0.100-preview.4.26208.106 11.0.100-preview.4.26208.106 - 11.0.100-preview.4.26208.106 + 11.0.100-preview.4.26208.106 11.0.100-preview.4.26208.106 11.0.100-preview.4.26208.106 11.0.100-preview.4.26208.106 @@ -144,8 +144,8 @@ This file should be imported by eng/Versions.props 11.0.0-preview.4.26208.106 11.0.0-preview.4.26208.106 - 2.3.0-preview.26203.3 - 4.3.0-preview.26203.3 + 2.2.1-preview.26201.3 + 4.2.1-preview.26201.3 @@ -205,7 +205,7 @@ This file should be imported by eng/Versions.props $(MicrosoftDotnetWinFormsProjectTemplatesPackageVersion) $(MicrosoftDotNetWpfProjectTemplatesPackageVersion) $(MicrosoftDotNetXliffTasksPackageVersion) - $(MicrosoftDotNetXUnitV3ExtensionsPackageVersion) + $(MicrosoftDotNetXUnitExtensionsPackageVersion) $(MicrosoftExtensionsConfigurationIniPackageVersion) $(MicrosoftExtensionsDependencyInjectionAbstractionsPackageVersion) $(MicrosoftExtensionsDependencyModelPackageVersion) @@ -235,11 +235,11 @@ This file should be imported by eng/Versions.props $(MicrosoftSourceLinkGitHubPackageVersion) $(MicrosoftSourceLinkGitLabPackageVersion) $(MicrosoftTemplateEngineAbstractionsPackageVersion) - $(MicrosoftTemplateEngineAuthoringTemplateVerifierXunitV3PackageVersion) + $(MicrosoftTemplateEngineAuthoringTemplateVerifierPackageVersion) $(MicrosoftTemplateEngineEdgePackageVersion) - $(MicrosoftTemplateEngineMocksXunitV3PackageVersion) + $(MicrosoftTemplateEngineMocksPackageVersion) $(MicrosoftTemplateEngineOrchestratorRunnableProjectsPackageVersion) - $(MicrosoftTemplateEngineTestHelperXunitV3PackageVersion) + $(MicrosoftTemplateEngineTestHelperPackageVersion) $(MicrosoftTemplateEngineUtilsPackageVersion) $(MicrosoftTemplateSearchCommonPackageVersion) $(MicrosoftTemplateSearchTemplateDiscoveryPackageVersion) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index c94a366536fe..a8bdce226677 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -22,15 +22,15 @@ https://github.com/dotnet/dotnet e52493553982a80cd4c7d372c104e3a9dbff0e0d - + https://github.com/dotnet/dotnet e52493553982a80cd4c7d372c104e3a9dbff0e0d - + https://github.com/dotnet/dotnet e52493553982a80cd4c7d372c104e3a9dbff0e0d - + https://github.com/dotnet/dotnet e52493553982a80cd4c7d372c104e3a9dbff0e0d @@ -557,7 +557,7 @@ https://github.com/dotnet/dotnet e52493553982a80cd4c7d372c104e3a9dbff0e0d - + https://github.com/dotnet/dotnet e52493553982a80cd4c7d372c104e3a9dbff0e0d @@ -569,11 +569,11 @@ https://github.com/dotnet/dotnet e52493553982a80cd4c7d372c104e3a9dbff0e0d - + https://github.com/microsoft/testfx 1ddd2f1a558e9c79b5327c5ccc0e9e89df39d4da - + https://github.com/microsoft/testfx 1ddd2f1a558e9c79b5327c5ccc0e9e89df39d4da From 253611204935c5b12819ec3587c3b44ba7e621b2 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Thu, 9 Apr 2026 10:51:51 +0000 Subject: [PATCH 7/7] Reapply "Use OTel for telemetry (#53181)" This reverts commit f860c2b50f5715b68fce310f93c8ce86994274ca. --- Directory.Build.props | 4 +- Directory.Packages.props | 8 +- documentation/general/tab-completion.md | 2 +- eng/Signing.props | 14 +- eng/Versions.props | 3 +- .../Commands/DotNetCommandDefinition.cs | 20 +- .../Microsoft.DotNet.Cli.Utils/Activities.cs | 2 - .../BuiltInCommand.cs | 2 - .../ITelemetryFilter.cs | 10 +- .../InstallerSuccessReport.cs | 9 + .../MSBuildForwardingAppWithoutLogging.cs | 5 +- .../Microsoft.DotNet.Cli.Utils.csproj | 4 - .../TelemetryEventEntry.cs | 105 ++-- .../UILanguageOverride.cs | 4 +- .../DotnetFirstTimeUseConfigurer.cs | 56 +- .../FilePath.cs | 45 +- .../Commands/create/TemplateCommand.cs | 2 - .../Help/DotnetHelpAction.cs | 44 -- src/Cli/dotnet/CliSchema.cs | 48 +- .../ActivityContextFactory.cs | 48 ++ .../DotnetToolsCommandResolver.cs | 18 +- .../LocalToolsCommandResolver.cs | 19 +- .../CommandResolution/MuxerCommandResolver.cs | 7 +- .../MuxerCommandSpecMaker.cs | 49 +- src/Cli/dotnet/CommandFactory/CommandSpec.cs | 7 +- src/Cli/dotnet/CommandParsingException.cs | 9 +- src/Cli/dotnet/Commands/Build/BuildCommand.cs | 2 +- .../Commands/Build/BuildCommandParser.cs | 1 - src/Cli/dotnet/Commands/Clean/CleanCommand.cs | 2 +- ...mandFactory.cs => DotNetCommandFactory.cs} | 39 +- .../InternalReportInstallSuccessCommand.cs | 41 +- .../Commands/MSBuild/MSBuildForwardingApp.cs | 23 +- .../dotnet/Commands/MSBuild/MSBuildLogger.cs | 78 +-- .../New/BuiltInTemplatePackageProvider.cs | 33 +- .../New/MSBuildEvaluation/MSBuildEvaluator.cs | 16 +- .../dotnet/Commands/New/NewCommandParser.cs | 2 - .../Commands/New/OptionalWorkloadProvider.cs | 17 +- src/Cli/dotnet/Commands/Pack/PackCommand.cs | 4 +- .../dotnet/Commands/Pack/PackCommandParser.cs | 1 - .../Commands/Project/ProjectCommandParser.cs | 1 - .../dotnet/Commands/Publish/PublishCommand.cs | 2 +- .../dotnet/Commands/Restore/RestoreCommand.cs | 2 +- src/Cli/dotnet/Commands/Run/RunTelemetry.cs | 14 +- .../Tool/Execute/ToolExecuteCommand.cs | 2 +- .../Commands/Tool/Run/ToolRunCommand.cs | 4 +- .../Commands/Tool/ToolCommandSpecCreator.cs | 36 +- src/Cli/dotnet/DotNetCommandFactory.cs | 47 -- .../dotnet/Extensions/ActivityExtensions.cs | 22 + .../Extensions/ParseResultExtensions.cs | 166 +++--- src/Cli/dotnet/Parser.cs | 62 +- src/Cli/dotnet/ParserOptionActions.cs | 171 ++++++ src/Cli/dotnet/PerformanceLogEventListener.cs | 160 ----- src/Cli/dotnet/PerformanceLogEventSource.cs | 455 -------------- src/Cli/dotnet/PerformanceLogManager.cs | 136 ----- src/Cli/dotnet/Program.cs | 564 ++++++++---------- .../AllowListToSendFirstAppliedOptions.cs | 11 +- .../Telemetry/AllowListToSendFirstArgument.cs | 19 +- ...owListToSendVerbSecondVerbFirstArgument.cs | 16 +- .../Telemetry/ExternalTelemetryProperties.cs | 12 +- .../dotnet/Telemetry/IParseResultLogRule.cs | 2 +- .../{ITelemetry.cs => ITelemetryClient.cs} | 10 +- .../PersistenceChannel/BaseStorageService.cs | 61 -- .../PersistenceChannel/FixedSizeQueue.cs | 43 -- .../PersistenceChannel/FlushManager.cs | 57 -- .../PersistenceChannel/PersistenceChannel.cs | 114 ---- .../PersistenceChannelDebugLog.cs | 34 -- .../PersistenceTransmitter.cs | 84 --- .../Telemetry/PersistenceChannel/Sender.cs | 336 ----------- .../SnapshottingCollection.cs | 95 --- .../SnapshottingDictionary.cs | 71 --- .../PersistenceChannel/StorageService.cs | 352 ----------- .../PersistenceChannel/StorageTransmission.cs | 127 ---- src/Cli/dotnet/Telemetry/Telemetry.cs | 263 -------- src/Cli/dotnet/Telemetry/TelemetryClient.cs | 231 +++++++ .../Telemetry/TelemetryCommonProperties.cs | 100 ++-- .../dotnet/Telemetry/TelemetryDiskLogger.cs | 94 +++ src/Cli/dotnet/Telemetry/TelemetryFilter.cs | 199 ++---- .../TopLevelCommandNameAndOptionToLog.cs | 19 +- src/Cli/dotnet/dotnet.csproj | 13 +- src/Common/CompileOptions.cs | 3 +- src/Common/EnvironmentVariableNames.cs | 20 +- .../TemplateLocator.cs | 7 +- .../Commands/SdkCommandSpec.cs | 11 +- .../Commands/TestCommand.cs | 12 +- test/Microsoft.NET.TestFramework/SdkTest.cs | 59 +- test/dotnet.Tests/CliSchemaTests.cs | 2 +- .../GivenALocalToolsCommandResolver.cs | 16 +- .../CommandDirectoryContextExtensions.cs | 1 - .../MSBuild/DotnetMsbuildInProcTests.cs | 14 +- .../CommandTests/MSBuild/FakeTelemetry.cs | 35 +- .../MSBuild/GivenDotnetBuildInvocation.cs | 4 +- .../GivenDotnetMSBuildBuildsProjects.cs | 10 +- .../MSBuild/GivenDotnetRestoreInvocation.cs | 3 +- .../MSBuild/GivenDotnetTestInvocation.cs | 3 +- .../MSBuild/NullCurrentSessionIdFixture.cs | 25 +- .../CommandTests/Run/RunTelemetryTests.cs | 26 +- .../GivenADotnetFirstTimeUseConfigurer.cs | 72 --- ...netFirstTimeUseConfigurerWIthStateSetup.cs | 19 +- .../FakeRecordEventNameTelemetry.cs | 47 -- .../GivenThatTheUserEnablesThePerfLog.cs | 81 --- ...atTheUserIsRunningDotNetForTheFirstTime.cs | 3 +- test/dotnet.Tests/TelemetryCommandTest.cs | 416 ------------- .../TelemetryCommonPropertiesTests.cs | 313 ---------- test/dotnet.Tests/TelemetryFilterTest.cs | 211 ------- .../FakeRecordEventNameTelemetry.cs | 31 + .../TelemetryTests/SenderTests.cs | 184 ------ .../TelemetryTests/StorageTests.cs | 202 ------- .../TelemetryTests/TelemetryClientTests.cs | 67 +++ .../TelemetryTests/TelemetryCommandTest.cs | 332 +++++++++++ .../TelemetryCommonPropertiesTests.cs | 311 ++++++++++ .../TelemetryTests/TelemetryFilterTest.cs | 123 ++++ 111 files changed, 2322 insertions(+), 5351 deletions(-) create mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs delete mode 100644 src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs create mode 100644 src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs rename src/Cli/dotnet/Commands/{CommandFactory.cs => DotNetCommandFactory.cs} (54%) delete mode 100644 src/Cli/dotnet/DotNetCommandFactory.cs create mode 100644 src/Cli/dotnet/Extensions/ActivityExtensions.cs create mode 100644 src/Cli/dotnet/ParserOptionActions.cs delete mode 100644 src/Cli/dotnet/PerformanceLogEventListener.cs delete mode 100644 src/Cli/dotnet/PerformanceLogEventSource.cs delete mode 100644 src/Cli/dotnet/PerformanceLogManager.cs rename src/Cli/dotnet/Telemetry/{ITelemetry.cs => ITelemetryClient.cs} (65%) delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs delete mode 100644 src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs delete mode 100644 src/Cli/dotnet/Telemetry/Telemetry.cs create mode 100644 src/Cli/dotnet/Telemetry/TelemetryClient.cs create mode 100644 src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs delete mode 100644 test/dotnet.Tests/FakeRecordEventNameTelemetry.cs delete mode 100644 test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs delete mode 100644 test/dotnet.Tests/TelemetryCommandTest.cs delete mode 100644 test/dotnet.Tests/TelemetryCommonPropertiesTests.cs delete mode 100644 test/dotnet.Tests/TelemetryFilterTest.cs create mode 100644 test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs delete mode 100644 test/dotnet.Tests/TelemetryTests/SenderTests.cs delete mode 100644 test/dotnet.Tests/TelemetryTests/StorageTests.cs create mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs create mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs create mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs create mode 100644 test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs diff --git a/Directory.Build.props b/Directory.Build.props index 358a629fabb2..e84845c5fe8b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -66,11 +66,9 @@ $(NetCurrent) net9.0 - - $(NoWarn);NU1701;NU1507;NU1202;NU5039 + $(NoWarn);NU1507;NU1202;NU5039 true false diff --git a/Directory.Packages.props b/Directory.Packages.props index 21d623676105..de1f7b9b33cf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,19 +8,23 @@ - + + - + + + + diff --git a/documentation/general/tab-completion.md b/documentation/general/tab-completion.md index 8a1a977d4d97..39aaa2eaad49 100644 --- a/documentation/general/tab-completion.md +++ b/documentation/general/tab-completion.md @@ -9,7 +9,7 @@ Input | becomes `dotnet a⇥` | `dotnet add` | `add` is the first subcommand, alphabetically. `dotnet add p⇥` | `dotnet add --help` | it matches substrings and `--help` comes first alphabetically. `dotnet add p⇥⇥` | `dotnet add package` | pressing tab a second time brings up the next suggestion. -`dotnet add package Microsoft⇥` | `dotnet add package Microsoft.ApplicationInsights.Web` | results are returned alphabetically. +`dotnet add package Microsoft⇥` | `dotnet add package Microsoft.AspNetCore.Http` | results are returned alphabetically. `dotnet remove reference ⇥` | `dotnet remove reference ..\..\src\OmniSharp.DotNet\OmniSharp.DotNet.csproj` | it is project file aware. ## How to enable it diff --git a/eng/Signing.props b/eng/Signing.props index fa123b40a992..760095763542 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -65,10 +65,22 @@ + + + + + + + + + + + - + + diff --git a/eng/Versions.props b/eng/Versions.props index a858ff7d865d..d7d8105699a9 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -47,7 +47,6 @@ 1.0.0-20230414.1 - 2.23.0 2.0.1-servicing-26011-01 2.0.3 13.0.3 @@ -60,6 +59,8 @@ 0.3.264 1.0.52 + 1.4.0 + 1.12.0 diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs index a7357a9f75bc..13a6bf284167 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/DotNetCommandDefinition.cs @@ -58,22 +58,22 @@ internal sealed class DotNetCommandDefinition : RootCommand Arity = ArgumentArity.Zero }; - public readonly Option ListSdksOption = new("--list-sdks") + public readonly Option CliSchemaOption = new("--cli-schema") { - Arity = ArgumentArity.Zero + Description = CommandDefinitionStrings.SDKSchemaCommandDefinition, + Arity = ArgumentArity.Zero, + Recursive = true, + Hidden = true, }; - public readonly Option ListRuntimesOption = new("--list-runtimes") + public readonly Option ListSdksOption = new("--list-sdks") { Arity = ArgumentArity.Zero }; - public readonly Option CliSchemaOption = new("--cli-schema") + public readonly Option ListRuntimesOption = new("--list-runtimes") { - Description = CommandDefinitionStrings.SDKSchemaCommandDefinition, - Arity = ArgumentArity.Zero, - Recursive = true, - Hidden = true, + Arity = ArgumentArity.Zero }; public readonly AddCommandDefinition AddCommand; @@ -121,9 +121,11 @@ public DotNetCommandDefinition() Options.Add(DiagOption); Options.Add(VersionOption); Options.Add(InfoOption); + Options.Add(CliSchemaOption); + + // Host-handled options. Only defined to be shown in help. Options.Add(ListSdksOption); Options.Add(ListRuntimesOption); - Options.Add(CliSchemaOption); Subcommands.Add(AddCommand = new()); Subcommands.Add(BuildCommand = new()); diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs index 23180a6b69d1..6b22d2ad94c4 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Activities.cs @@ -3,8 +3,6 @@ #if NET -using System.Diagnostics; - namespace Microsoft.DotNet.Cli.Utils; /// diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.cs index 4bf3d94e2773..d8fb130f272f 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/BuiltInCommand.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; - namespace Microsoft.DotNet.Cli.Utils; /// diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs index 2e4ff73d8935..593edab4ef76 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/ITelemetryFilter.cs @@ -1,9 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CommandLine; + namespace Microsoft.DotNet.Cli.Utils; public interface ITelemetryFilter { - IEnumerable Filter(object o); + IEnumerable Filter(ParseResult parseResult); + + IEnumerable Filter(ParseResultWithGlobalJsonState parseData); + + IEnumerable Filter(InstallerSuccessReport report); + + IEnumerable Filter(Exception exception); } diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs new file mode 100644 index 000000000000..42d77b93ae4b --- /dev/null +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/InstallerSuccessReport.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Cli.Utils; + +public class InstallerSuccessReport(string? exeName) +{ + public string ExeName { get; } = exeName ?? throw new ArgumentNullException(nameof(exeName)); +} diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs index 87d021eda157..1f94685c28a8 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs @@ -3,7 +3,6 @@ #if NET -using System.Diagnostics; using Microsoft.DotNet.Cli.Utils.Extensions; namespace Microsoft.DotNet.Cli.Utils; @@ -16,7 +15,7 @@ internal sealed class MSBuildForwardingAppWithoutLogging public static string MSBuildVersion { - get => Microsoft.Build.Evaluation.ProjectCollection.DisplayVersion; + get => Build.Evaluation.ProjectCollection.DisplayVersion; } private const string MSBuildExeName = "MSBuild.dll"; @@ -194,7 +193,7 @@ private static string GetMSBuildExePath() MSBuildExeName); } - private static string GetMSBuildSDKsPath() + public static string GetMSBuildSDKsPath() { var envMSBuildSDKsPath = Environment.GetEnvironmentVariable("MSBuildSDKsPath"); diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj b/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj index cfa596437414..935472e224f2 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj @@ -79,10 +79,6 @@ - - - - diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs index 772e9845b678..032f82bf49e9 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/TelemetryEventEntry.cs @@ -1,7 +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.CommandLine; namespace Microsoft.DotNet.Cli.Utils; @@ -10,104 +10,71 @@ public static class TelemetryEventEntry public static event EventHandler? EntryPosted; public static ITelemetryFilter TelemetryFilter { get; set; } = new BlockFilter(); - public static void TrackEvent( - string? eventName = null, - IDictionary? properties = null, - IDictionary? measurements = null) + public static void TrackEvent(string eventName, IDictionary? properties = null) { - EntryPosted?.Invoke(typeof(TelemetryEventEntry), - new InstrumentationEventArgs(eventName, properties, measurements)); + EntryPosted?.Invoke(typeof(TelemetryEventEntry), new InstrumentationEventArgs(eventName, properties)); } - public static void SendFiltered(object? o = null) - { - if (o == null) - { - return; - } + public static void SendFiltered(ParseResult parseResult) => + SendFiltered(TelemetryFilter.Filter(parseResult)); + + public static void SendFiltered(ParseResultWithGlobalJsonState parseData) => + SendFiltered(TelemetryFilter.Filter(parseData)); - foreach (ApplicationInsightsEntryFormat entry in TelemetryFilter.Filter(o)) + public static void SendFiltered(InstallerSuccessReport report) => + SendFiltered(TelemetryFilter.Filter(report)); + + public static void SendFiltered(Exception exception) => + SendFiltered(TelemetryFilter.Filter(exception)); + + private static void SendFiltered(IEnumerable entries) + { + foreach (TelemetryEntryFormat entry in entries) { - TrackEvent(entry.EventName, entry.Properties, entry.Measurements); + TrackEvent(entry.EventName, entry.Properties); } } - public static void Subscribe(Action?, IDictionary?> subscriber) + public static void Subscribe(Action?> subscriber) { void Handler(object? sender, InstrumentationEventArgs eventArgs) { - subscriber(eventArgs.EventName, eventArgs.Properties, eventArgs.Measurements); + subscriber(eventArgs.EventName, eventArgs.Properties); } EntryPosted += Handler; } } -public sealed class PerformanceMeasurement : IDisposable +public class BlockFilter : ITelemetryFilter { - private readonly Stopwatch? _timer; - private readonly Dictionary? _data; - private readonly string? _name; + private static readonly TelemetryEntryFormat[] s_emptyEntries = []; - public PerformanceMeasurement(Dictionary? data, string name) - { - // Measurement is a no-op if we don't have a dictionary to store the entry. - if (data == null) - { - return; - } + public IEnumerable Filter(ParseResult parseResult) => s_emptyEntries; - _data = data; - _name = name; - _timer = Stopwatch.StartNew(); - } + public IEnumerable Filter(ParseResultWithGlobalJsonState parseData) => s_emptyEntries; - public void Dispose() - { - if (_name is not null && _timer is not null) - { - _data?.Add(_name, _timer.Elapsed.TotalMilliseconds); - } - } -} + public IEnumerable Filter(InstallerSuccessReport report) => s_emptyEntries; -public class BlockFilter : ITelemetryFilter -{ - public IEnumerable Filter(object o) - { - return []; - } + public IEnumerable Filter(Exception exception) => s_emptyEntries; } -public class InstrumentationEventArgs : EventArgs +public class InstrumentationEventArgs(string eventName, IDictionary? properties = null) : EventArgs { - internal InstrumentationEventArgs( - string? eventName, - IDictionary? properties, - IDictionary? measurements) - { - EventName = eventName; - Properties = properties; - Measurements = measurements; - } - - public string? EventName { get; } - public IDictionary? Properties { get; } - public IDictionary? Measurements { get; } + public string EventName { get; } = eventName; + public IDictionary? Properties { get; } = properties; } -public class ApplicationInsightsEntryFormat( - string? eventName = null, - IDictionary? properties = null, - IDictionary? measurements = null) +public class TelemetryEntryFormat(string eventName, IDictionary? properties = null) { - public string? EventName { get; } = eventName; + public string EventName { get; } = eventName; public IDictionary? Properties { get; } = properties; - public IDictionary? Measurements { get; } = measurements; - public ApplicationInsightsEntryFormat WithAppliedToPropertiesValue(Func func) + public TelemetryEntryFormat WithAppliedToPropertiesValue(Func func) { - var appliedProperties = Properties?.ToDictionary(p => p.Key, p => (string?)func(p.Value)); - return new ApplicationInsightsEntryFormat(EventName, appliedProperties, Measurements); + var appliedProperties = Properties?.ToDictionary(p => p.Key, p => (string?)func(p.Value ?? string.Empty)); + return new TelemetryEntryFormat(EventName, appliedProperties); } } + +public record ParseResultWithGlobalJsonState(ParseResult ParseResult, string? GlobalJsonState); diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.cs index 32e773f1261b..6f66646cfd93 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/UILanguageOverride.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; using System.Globalization; using System.Security; using Microsoft.Win32; @@ -11,6 +10,7 @@ namespace Microsoft.DotNet.Cli.Utils; internal static class UILanguageOverride { internal const string DOTNET_CLI_UI_LANGUAGE = nameof(DOTNET_CLI_UI_LANGUAGE); + private const string DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING = nameof(DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING); private const string VSLANG = nameof(VSLANG); private const string PreferredUILang = nameof(PreferredUILang); // We choose UTF8 as the default encoding as opposed to specific language encodings because it supports emojis & other chars in .NET. @@ -25,7 +25,7 @@ public static void Setup() FlowOverrideToChildProcesses(language); } - if (Env.GetEnvironmentVariable("DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING") != "1") + if (Env.GetEnvironmentVariable(DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING) != "1") { if ( !CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.Equals("en", StringComparison.InvariantCultureIgnoreCase) && diff --git a/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs b/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs index c52628b044ca..a364b4191d2d 100644 --- a/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs +++ b/src/Cli/Microsoft.DotNet.Configurer/DotnetFirstTimeUseConfigurer.cs @@ -15,7 +15,6 @@ public class DotnetFirstTimeUseConfigurer private readonly IAspNetCoreCertificateGenerator _aspNetCoreCertificateGenerator; private readonly IFileSentinel _toolPathSentinel; private readonly IEnvironmentPath _pathAdder; - private readonly Dictionary? _performanceMeasurements; private readonly bool _skipFirstTimeUseCheck; public DotnetFirstTimeUseConfigurer( @@ -26,7 +25,6 @@ public DotnetFirstTimeUseConfigurer( DotnetFirstRunConfiguration dotnetFirstRunConfiguration, IReporter reporter, IEnvironmentPath pathAdder, - Dictionary? performanceMeasurements = null, bool skipFirstTimeUseCheck = false) { _firstTimeUseNoticeSentinel = firstTimeUseNoticeSentinel; @@ -36,7 +34,6 @@ public DotnetFirstTimeUseConfigurer( _dotnetFirstRunConfiguration = dotnetFirstRunConfiguration; _reporter = reporter; _pathAdder = pathAdder ?? throw new ArgumentNullException(nameof(pathAdder)); - _performanceMeasurements ??= performanceMeasurements; _skipFirstTimeUseCheck = skipFirstTimeUseCheck; } @@ -44,54 +41,45 @@ public void Configure() { if (_dotnetFirstRunConfiguration.AddGlobalToolsToPath && !_toolPathSentinel.Exists()) { - using (new PerformanceMeasurement(_performanceMeasurements, "AddPackageExecutablePath Time")) - { - _pathAdder.AddPackageExecutablePathToUserPath(); - _toolPathSentinel.Create(); - } + _pathAdder.AddPackageExecutablePathToUserPath(); + _toolPathSentinel.Create(); } var isFirstTimeUse = !_skipFirstTimeUseCheck && !_firstTimeUseNoticeSentinel.Exists(); var canShowFirstUseMessages = isFirstTimeUse && !_dotnetFirstRunConfiguration.NoLogo; if (isFirstTimeUse) { - using (new PerformanceMeasurement(_performanceMeasurements, "FirstTimeUseNotice Time")) + // Migrate the NuGet state from earlier SDKs + NuGet.Common.Migrations.MigrationRunner.Run(); + + if (canShowFirstUseMessages) { - // Migrate the NuGet state from earlier SDKs - NuGet.Common.Migrations.MigrationRunner.Run(); + _reporter.WriteLine(); + string productVersion = Product.Version; + _reporter.WriteLine(string.Format(LocalizableStrings.FirstTimeMessageWelcome, ParseDotNetVersion(productVersion), productVersion)); - if (canShowFirstUseMessages) + if (!_dotnetFirstRunConfiguration.TelemetryOptout) { _reporter.WriteLine(); - string productVersion = Product.Version; - _reporter.WriteLine(string.Format(LocalizableStrings.FirstTimeMessageWelcome, ParseDotNetVersion(productVersion), productVersion)); - - if (!_dotnetFirstRunConfiguration.TelemetryOptout) - { - _reporter.WriteLine(); - _reporter.WriteLine(LocalizableStrings.TelemetryMessage); - } + _reporter.WriteLine(LocalizableStrings.TelemetryMessage); } - - _firstTimeUseNoticeSentinel.CreateIfNotExists(); } + + _firstTimeUseNoticeSentinel.CreateIfNotExists(); } if (CanGenerateAspNetCertificate()) { - using (new PerformanceMeasurement(_performanceMeasurements, "GenerateAspNetCertificate Time")) - { - _aspNetCoreCertificateGenerator.GenerateAspNetCoreDevelopmentCertificate(); - _aspNetCertificateSentinel.CreateIfNotExists(); + _aspNetCoreCertificateGenerator.GenerateAspNetCoreDevelopmentCertificate(); + _aspNetCertificateSentinel.CreateIfNotExists(); - if (canShowFirstUseMessages) - { - // This message is slightly misleading for (e.g.) FreeBSD, which doesn't officially - // support `dotnet dev-certs https --trust`, but the link in the message should help - // users find the right steps for their platform. - _reporter.WriteLine(); - _reporter.WriteLine(LocalizableStrings.FirstTimeMessageAspNetCertificate); - } + if (canShowFirstUseMessages) + { + // This message is slightly misleading for (e.g.) FreeBSD, which doesn't officially + // support `dotnet dev-certs https --trust`, but the link in the message should help + // users find the right steps for their platform. + _reporter.WriteLine(); + _reporter.WriteLine(LocalizableStrings.FirstTimeMessageAspNetCertificate); } } diff --git a/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs b/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs index d9ad822f9517..f08c1f1a759a 100644 --- a/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs +++ b/src/Cli/Microsoft.DotNet.InternalAbstractions/FilePath.cs @@ -1,34 +1,33 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.Extensions.EnvironmentAbstractions +namespace Microsoft.Extensions.EnvironmentAbstractions; + +public readonly struct FilePath { - public struct FilePath - { - public string Value { get; } + public string Value { get; } - /// - /// Create FilePath to represent an absolute file path. Note it may not exist. - /// - /// If the value is not rooted. Path.GetFullPath will be called during the constructor. - public FilePath(string value) + /// + /// Create FilePath to represent an absolute file path. Note: It may not exist. + /// + /// If the value is not rooted. Path.GetFullPath will be called during the constructor. + public FilePath(string value) + { + if (!Path.IsPathRooted(value)) { - if (!Path.IsPathRooted(value)) - { - value = Path.GetFullPath(value); - } - - Value = value; + value = Path.GetFullPath(value); } - public override string ToString() - { - return Value; - } + Value = value; + } - public DirectoryPath GetDirectoryPath() - { - return new DirectoryPath(Path.GetDirectoryName(Value)!); - } + public override string ToString() + { + return Value; + } + + public DirectoryPath GetDirectoryPath() + { + return new DirectoryPath(Path.GetDirectoryName(Value)!); } } diff --git a/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs b/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs index 68e6c1162794..2aee7d6e9360 100644 --- a/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs +++ b/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs @@ -22,7 +22,6 @@ internal class TemplateCommand : Command private readonly TemplatePackageManager _templatePackageManager; private readonly IEngineEnvironmentSettings _environmentSettings; private readonly Command _instantiateCommand; - private readonly TemplateGroup _templateGroup; private readonly CliTemplateInfo _template; private Dictionary _templateSpecificOptions = new(); @@ -44,7 +43,6 @@ public TemplateCommand( _instantiateCommand = instantiateCommand; _environmentSettings = environmentSettings; _templatePackageManager = templatePackageManager; - _templateGroup = templateGroup; _template = template; foreach (var item in templateGroup.ShortNames.Skip(1)) { diff --git a/src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs b/src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs deleted file mode 100644 index 884867e08e2f..000000000000 --- a/src/Cli/Microsoft.TemplateEngine.Cli/Help/DotnetHelpAction.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; -using System.CommandLine.Invocation; -using Microsoft.DotNet.Cli.Help; - -namespace Microsoft.TemplateEngine.Cli.Help; - -/// -/// Provides command line help. -/// -public sealed class DotnetHelpAction : SynchronousCommandLineAction -{ - private HelpBuilder? _builder; - - /// - /// Specifies an to be used to format help output when help is requested. - /// - public HelpBuilder Builder - { - get => _builder ??= new HelpBuilder(Console.IsOutputRedirected ? int.MaxValue : Console.WindowWidth); - set => _builder = value ?? throw new ArgumentNullException(nameof(value)); - } - - /// - public override bool ClearsParseErrors => true; - - /// - public override int Invoke(ParseResult parseResult) - { - var output = parseResult.InvocationConfiguration.Output; - - var helpContext = new HelpContext( - Builder, - parseResult.CommandResult.Command, - output, - parseResult); - - Builder.Write(helpContext); - - return 0; - } -} diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index 99d039df1db2..770f33b65fab 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Text.Json.Schema; using System.Text.Json.Serialization; +using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -31,8 +32,20 @@ internal static class CliSchema DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }); - public record ArgumentDetails(string? description, int order, bool hidden, string? helpName, string valueType, bool hasDefaultValue, object? defaultValue, ArityDetails arity); - public record ArityDetails(int minimum, int? maximum); + public record ArgumentDetails( + string? description, + int order, + bool hidden, + string? helpName, + string valueType, + bool hasDefaultValue, + object? defaultValue, + ArityDetails arity); + + public record ArityDetails( + int minimum, + int? maximum); + public record OptionDetails( string? description, bool hidden, @@ -43,8 +56,8 @@ public record OptionDetails( object? defaultValue, ArityDetails arity, bool required, - bool recursive - ); + bool recursive); + public record CommandDetails( string? description, bool hidden, @@ -52,6 +65,7 @@ public record CommandDetails( Dictionary? arguments, Dictionary? options, Dictionary? subcommands); + public record RootCommandDetails( string name, string version, @@ -63,17 +77,16 @@ public record RootCommandDetails( Dictionary? subcommands ) : CommandDetails(description, hidden, aliases, arguments, options, subcommands); - - public static void PrintCliSchema(CommandResult commandResult, TextWriter outputWriter, ITelemetry? telemetryClient) + public static void PrintCliSchema(ParseResult parseResult, TextWriter outputWriter, ITelemetryClient? telemetryClient) { - var command = commandResult.Command; + var command = parseResult.CommandResult.Command; RootCommandDetails transportStructure = CreateRootCommandDetails(command); var result = JsonSerializer.Serialize(transportStructure, s_jsonContext.RootCommandDetails); outputWriter.Write(result.AsSpan()); outputWriter.Flush(); - var commandString = CommandHierarchyAsString(commandResult); - var telemetryProperties = new Dictionary { { "command", commandString } }; - telemetryClient?.TrackEvent("schema", telemetryProperties, null); + var commandString = parseResult.GetCommandName(); + var telemetryProperties = new Dictionary { { "command", commandString } }; + telemetryClient?.TrackEvent("schema", telemetryProperties); } public static object GetJsonSchema() @@ -204,21 +217,6 @@ private static RootCommandDetails CreateRootCommandDetails(Command command) argument.HasDefaultValue ? HumanizeValue(argument.GetDefaultValue()) : null, CreateArityDetails(argument.Arity) ); - - // Produces a string that represents the command call. - // For example, calling the workload install command produces `dotnet workload install`. - private static string CommandHierarchyAsString(CommandResult commandResult) - { - var commands = new List(); - var currentResult = commandResult; - while (currentResult is not null) - { - commands.Add(currentResult.Command.Name); - currentResult = currentResult.Parent as CommandResult; - } - - return string.Join(" ", commands.AsEnumerable().Reverse()); - } } [JsonSerializable(typeof(CliSchema.RootCommandDetails))] diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs new file mode 100644 index 000000000000..bd8616a1e06a --- /dev/null +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/ActivityContextFactory.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.DotNet.Cli.Utils; +#if TARGET_WINDOWS +using OpenTelemetry; +using OpenTelemetry.Context.Propagation; +#endif + +namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; + +public static class ActivityContextFactory +{ + public static Dictionary? MakeActivityContextEnvironment() + { + var currentActivity = Activity.Current; + if (currentActivity is null) + { + return null; + } + var activityContext = currentActivity.Context; + if (activityContext.TraceState is null && activityContext.TraceId == default && activityContext.SpanId == default) + { + return null; + } + + var environment = new Dictionary(capacity: 2); +#if TARGET_WINDOWS + var propagationContext = new PropagationContext(activityContext, Baggage.Current); + Propagators.DefaultTextMapPropagator.Inject(propagationContext, environment, WriteTraceStateIntoEnvironment); +#endif + return environment; + } + + private static void WriteTraceStateIntoEnvironment(Dictionary environment, string key, string value) + { + switch (key) + { + case "traceparent": + environment[Activities.TRACEPARENT] = value; + break; + case "tracestate": + environment[Activities.TRACESTATE] = value; + break; + } + } +} diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs index 81b98215429d..836fde7c220d 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/DotnetToolsCommandResolver.cs @@ -1,28 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; public class DotnetToolsCommandResolver : ICommandResolver { private readonly string _dotnetToolPath; - public DotnetToolsCommandResolver(string dotnetToolPath = null) + public DotnetToolsCommandResolver(string? dotnetToolPath = null) { - if (dotnetToolPath == null) - { - _dotnetToolPath = Path.Combine(AppContext.BaseDirectory, - "DotnetTools"); - } - else - { - _dotnetToolPath = dotnetToolPath; - } + _dotnetToolPath = dotnetToolPath ?? Path.Combine(AppContext.BaseDirectory, "DotnetTools"); } - public CommandSpec Resolve(CommandResolverArguments arguments) + public CommandSpec? Resolve(CommandResolverArguments arguments) { if (string.IsNullOrEmpty(arguments.CommandName)) { @@ -43,6 +33,6 @@ public CommandSpec Resolve(CommandResolverArguments arguments) return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer( dll.FullName, - arguments.CommandArguments); + arguments.CommandArguments ?? []); } } diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs index ac22545eddec..70ba8d1e2056 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs @@ -1,30 +1,27 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using Microsoft.DotNet.Cli.Commands.Tool; using Microsoft.DotNet.Cli.ToolManifest; using Microsoft.DotNet.Cli.ToolPackage; using Microsoft.DotNet.Cli.Utils; using Microsoft.Extensions.EnvironmentAbstractions; -using NuGet.DependencyResolver; using NuGet.Frameworks; namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; internal class LocalToolsCommandResolver( - ToolManifestFinder toolManifest = null, - ILocalToolsResolverCache localToolsResolverCache = null, - IFileSystem fileSystem = null, - string currentWorkingDirectory = null) : ICommandResolver + ToolManifestFinder? toolManifest = null, + ILocalToolsResolverCache? localToolsResolverCache = null, + IFileSystem? fileSystem = null, + string? currentWorkingDirectory = null) : ICommandResolver { private readonly ToolManifestFinder _toolManifest = toolManifest ?? new ToolManifestFinder(new DirectoryPath(currentWorkingDirectory ?? Directory.GetCurrentDirectory())); private readonly ILocalToolsResolverCache _localToolsResolverCache = localToolsResolverCache ?? new LocalToolsResolverCache(); private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystemWrapper(); private const string LeadingDotnetPrefix = "dotnet-"; - public CommandSpec ResolveStrict(CommandResolverArguments arguments, bool allowRollForward = false) + public CommandSpec? ResolveStrict(CommandResolverArguments arguments, bool allowRollForward = false) { if (arguments == null || string.IsNullOrWhiteSpace(arguments.CommandName)) { @@ -42,7 +39,7 @@ public CommandSpec ResolveStrict(CommandResolverArguments arguments, bool allowR return resolveResult; } - public CommandSpec Resolve(CommandResolverArguments arguments) + public CommandSpec? Resolve(CommandResolverArguments arguments) { if (arguments == null || string.IsNullOrWhiteSpace(arguments.CommandName)) { @@ -69,7 +66,7 @@ public CommandSpec Resolve(CommandResolverArguments arguments) return GetPackageCommandSpecUsingMuxer(arguments, new ToolCommandName(arguments.CommandName)); } - private CommandSpec GetPackageCommandSpecUsingMuxer(CommandResolverArguments arguments, + private CommandSpec? GetPackageCommandSpecUsingMuxer(CommandResolverArguments arguments, ToolCommandName toolCommandName, bool allowRollForward = false) { if (!_toolManifest.TryFind(toolCommandName, out var toolManifestPackage)) @@ -93,7 +90,7 @@ private CommandSpec GetPackageCommandSpecUsingMuxer(CommandResolverArguments arg } return ToolCommandSpecCreator.CreateToolCommandSpec(toolCommand.Name.Value, toolCommand.Executable.Value, toolCommand.Runner, - toolManifestPackage.RollForward || allowRollForward, arguments.CommandArguments); + toolManifestPackage.RollForward || allowRollForward, arguments.CommandArguments ?? []); } else { diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.cs index 1998ae144809..92f7d50f6abd 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandResolver.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -10,14 +8,15 @@ namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; public class MuxerCommandResolver : ICommandResolver { - public CommandSpec Resolve(CommandResolverArguments commandResolverArguments) + public CommandSpec? Resolve(CommandResolverArguments commandResolverArguments) { if (commandResolverArguments.CommandName == Muxer.MuxerName) { var muxer = new Muxer(); var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart( commandResolverArguments.CommandArguments.OrEmptyIfNull()); - return new CommandSpec(muxer.MuxerPath, escapedArgs); + var environment = ActivityContextFactory.MakeActivityContextEnvironment(); + return new CommandSpec(muxer.MuxerPath, escapedArgs, environment); } return null; } diff --git a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs index 63a3ecae1d5b..12f1a372e652 100644 --- a/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs +++ b/src/Cli/dotnet/CommandFactory/CommandResolution/MuxerCommandSpecMaker.cs @@ -1,31 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution; internal static class MuxerCommandSpecMaker { - internal static CommandSpec CreatePackageCommandSpecUsingMuxer( - string commandPath, - IEnumerable commandArguments) + internal static CommandSpec CreatePackageCommandSpecUsingMuxer(string commandPath, IEnumerable commandArguments, IDictionary? environment = null) { var arguments = new List(); - - var muxer = new Muxer(); - - var host = muxer.MuxerPath; - - if (host == null) - { - throw new Exception(LocalizableStrings.UnableToLocateDotnetMultiplexer); - } - - var rollForwardArgument = (commandArguments ?? []).Where(arg => arg.Equals("--allow-roll-forward", StringComparison.OrdinalIgnoreCase)); - + var rollForwardArgument = commandArguments.Where(arg => arg.Equals("--allow-roll-forward", StringComparison.OrdinalIgnoreCase)); if (rollForwardArgument.Any()) { arguments.Add("--roll-forward"); @@ -33,27 +18,13 @@ internal static CommandSpec CreatePackageCommandSpecUsingMuxer( } arguments.Add(commandPath); - - if (commandArguments != null) - { - if (rollForwardArgument.Any()) - { - arguments.AddRange(commandArguments.Except(rollForwardArgument)); - } - else - { - arguments.AddRange(commandArguments); - } - } - return CreateCommandSpec(host, arguments); - } - - private static CommandSpec CreateCommandSpec( - string commandPath, - IEnumerable commandArguments) - { - var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(commandArguments); - - return new CommandSpec(commandPath, escapedArgs); + var filteredCommandArgs = rollForwardArgument.Any() + ? commandArguments.Except(rollForwardArgument) + : commandArguments; + arguments.AddRange(filteredCommandArgs); + + var host = new Muxer().MuxerPath; + var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(arguments); + return new CommandSpec(host, escapedArgs, environment); } } diff --git a/src/Cli/dotnet/CommandFactory/CommandSpec.cs b/src/Cli/dotnet/CommandFactory/CommandSpec.cs index 6af14dd0b931..0b0cf47a9c56 100644 --- a/src/Cli/dotnet/CommandFactory/CommandSpec.cs +++ b/src/Cli/dotnet/CommandFactory/CommandSpec.cs @@ -5,16 +5,13 @@ namespace Microsoft.DotNet.Cli.CommandFactory; -public class CommandSpec( - string path, - string? args, - Dictionary? environmentVariables = null) +public class CommandSpec(string path, string? args, IDictionary? environmentVariables = null) { public string Path { get; } = path; public string? Args { get; } = args; - public Dictionary EnvironmentVariables { get; } = environmentVariables ?? []; + public IDictionary EnvironmentVariables { get; } = environmentVariables ?? new Dictionary(); internal void AddEnvironmentVariablesFromProject(IProject project) { diff --git a/src/Cli/dotnet/CommandParsingException.cs b/src/Cli/dotnet/CommandParsingException.cs index f932b55aefb6..6582c7373d84 100644 --- a/src/Cli/dotnet/CommandParsingException.cs +++ b/src/Cli/dotnet/CommandParsingException.cs @@ -1,21 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; namespace Microsoft.DotNet.Cli; internal class CommandParsingException : Exception { - public CommandParsingException( - string message, - ParseResult parseResult = null) : base(message) + public CommandParsingException(string message, ParseResult? parseResult = null) + : base(message) { ParseResult = parseResult; Data.Add("CLI_User_Displayed_Exception", true); } - public ParseResult ParseResult; + public ParseResult? ParseResult; } diff --git a/src/Cli/dotnet/Commands/Build/BuildCommand.cs b/src/Cli/dotnet/Commands/Build/BuildCommand.cs index cde474ec73c2..b0276710516d 100644 --- a/src/Cli/dotnet/Commands/Build/BuildCommand.cs +++ b/src/Cli/dotnet/Commands/Build/BuildCommand.cs @@ -29,7 +29,7 @@ public static CommandBase FromParseResult(ParseResult parseResult, string? msbui bool noRestore = parseResult.HasOption(definition.NoRestoreOption); - return CommandFactory.CreateVirtualOrPhysicalCommand( + return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, createVirtualCommand: (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs b/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs index 316b422c8a85..bc1289c7ae2e 100644 --- a/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs +++ b/src/Cli/dotnet/Commands/Build/BuildCommandParser.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CommandLine; using Microsoft.DotNet.Cli.CommandLine; namespace Microsoft.DotNet.Cli.Commands.Build; diff --git a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs index d1772e247a4f..5584a77f2461 100644 --- a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs +++ b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs @@ -22,7 +22,7 @@ public static CommandBase FromParseResult(ParseResult result, string? msbuildPat var definition = (CleanCommandDefinition)result.CommandResult.Command; result.ShowHelpOrErrorIfAppropriate(); - return CommandFactory.CreateVirtualOrPhysicalCommand( + return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, createVirtualCommand: static (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/CommandFactory.cs b/src/Cli/dotnet/Commands/DotNetCommandFactory.cs similarity index 54% rename from src/Cli/dotnet/Commands/CommandFactory.cs rename to src/Cli/dotnet/Commands/DotNetCommandFactory.cs index 6f50a6b2d4d7..ba08b4864f7d 100644 --- a/src/Cli/dotnet/Commands/CommandFactory.cs +++ b/src/Cli/dotnet/Commands/DotNetCommandFactory.cs @@ -1,16 +1,49 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.CommandLine.Invocation; +using System.Diagnostics; +using Microsoft.DotNet.Cli.CommandFactory; using Microsoft.DotNet.Cli.CommandLine; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.ProjectTools; +using NuGet.Frameworks; -namespace Microsoft.DotNet.Cli.Commands; +namespace Microsoft.DotNet.Cli; -public static class CommandFactory +public class DotNetCommandFactory(bool alwaysRunOutOfProc = false, string? currentWorkingDirectory = null) : ICommandFactory { + private readonly bool _alwaysRunOutOfProc = alwaysRunOutOfProc; + private readonly string? _currentWorkingDirectory = currentWorkingDirectory; + + public ICommand Create(string commandName, IEnumerable args, NuGetFramework? framework = null, string configuration = Constants.DefaultConfiguration) + { + if (!_alwaysRunOutOfProc && TryGetBuiltInCommand(commandName, out var builtInCommand)) + { + Debug.Assert(framework == null, "BuiltInCommand doesn't support the 'framework' argument."); + Debug.Assert(configuration == Constants.DefaultConfiguration, "BuiltInCommand doesn't support the 'configuration' argument."); + + return new BuiltInCommand(commandName, args, builtInCommand); + } + + return CommandFactoryUsingResolver.CreateDotNet(commandName, args, framework, configuration, _currentWorkingDirectory); + } + + private static bool TryGetBuiltInCommand(string commandName, out Func commandFunc) + { + var command = Parser.GetBuiltInCommand(commandName); + if (command?.Action is AsynchronousCommandLineAction action) + { + commandFunc = (args) => Parser.Invoke([commandName, .. args]); + return true; + } + // No-op delegate for failure case. + commandFunc = (args) => 1; + return false; + } + internal static CommandBase CreateVirtualOrPhysicalCommand( System.CommandLine.Command commandDefinition, Argument catchAllUserInputArgument, diff --git a/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs b/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs index c6a6d87ff9df..c48ff5353e5c 100644 --- a/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs +++ b/src/Cli/dotnet/Commands/Hidden/InternalReportInstallSuccess/InternalReportInstallSuccessCommand.cs @@ -1,36 +1,29 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; -using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Configurer; using Microsoft.DotNet.Utilities; namespace Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; public class InternalReportInstallSuccessCommand { - internal const string TelemetrySessionIdEnvironmentVariableName = "DOTNET_CLI_TELEMETRY_SESSIONID"; - public static int Run(ParseResult parseResult) { var telemetry = new ThreadBlockingTelemetry(); ProcessInputAndSendTelemetry(parseResult, telemetry); - telemetry.Dispose(); return 0; } - public static void ProcessInputAndSendTelemetry(string[] args, ITelemetry telemetry) + public static void ProcessInputAndSendTelemetry(string[] args, ITelemetryClient telemetry) { var result = Parser.Parse(["dotnet", "internal-reportinstallsuccess", .. args]); ProcessInputAndSendTelemetry(result, telemetry); } - public static void ProcessInputAndSendTelemetry(ParseResult result, ITelemetry telemetry) + public static void ProcessInputAndSendTelemetry(ParseResult result, ITelemetryClient telemetry) { var definition = (InternalReportInstallSuccessCommandDefinition)result.CommandResult.Command; var exeName = Path.GetFileName(result.GetValue(definition.Argument)); @@ -38,39 +31,25 @@ public static void ProcessInputAndSendTelemetry(ParseResult result, ITelemetry t var filter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); foreach (var e in filter.Filter(new InstallerSuccessReport(exeName))) { - telemetry.TrackEvent(e.EventName, e.Properties, null); + telemetry.TrackEvent(e.EventName, e.Properties); } } - internal class ThreadBlockingTelemetry : ITelemetry + internal class ThreadBlockingTelemetry : ITelemetryClient { - private readonly Telemetry.Telemetry telemetry; + private readonly TelemetryClient _telemetry; internal ThreadBlockingTelemetry() { - var sessionId = - Environment.GetEnvironmentVariable(TelemetrySessionIdEnvironmentVariableName); - telemetry = new Telemetry.Telemetry(new NoOpFirstTimeUseNoticeSentinel(), sessionId, blockThreadInitialization: true); - } - public bool Enabled => telemetry.Enabled; - - public void Flush() - { + var sessionId = Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_SESSIONID); + _telemetry = new TelemetryClient(sessionId); } - public void Dispose() - { - telemetry.Dispose(); - } + public bool Enabled => _telemetry.Enabled; - public void TrackEvent(string eventName, IDictionary properties, IDictionary measurements) + public void TrackEvent(string eventName, IDictionary? properties) { - telemetry.ThreadBlockingTrackEvent(eventName, properties, measurements); + _telemetry.ThreadBlockingTrackEvent(eventName, properties); } } } - -internal class InstallerSuccessReport(string exeName) -{ - public string ExeName { get; } = exeName ?? throw new ArgumentNullException(nameof(exeName)); -} diff --git a/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs b/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs index d6fb42ac39f0..7f8364ddf11c 100644 --- a/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs +++ b/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Reflection; using Microsoft.DotNet.Cli.Commands.Run; +using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -11,8 +12,6 @@ namespace Microsoft.DotNet.Cli.Commands.MSBuild; public class MSBuildForwardingApp : CommandBase { - internal const string TelemetrySessionIdEnvironmentVariableName = "DOTNET_CLI_TELEMETRY_SESSIONID"; - private readonly MSBuildForwardingAppWithoutLogging _forwardingAppWithoutLogging; /// @@ -20,7 +19,7 @@ public class MSBuildForwardingApp : CommandBase /// private static MSBuildArgs ConcatTelemetryLogger(MSBuildArgs msbuildArgs) { - if (Telemetry.Telemetry.CurrentSessionId != null) + if (TelemetryClient.CurrentSessionId != null) { try { @@ -55,12 +54,6 @@ public MSBuildForwardingApp(MSBuildArgs msBuildArgs, string? msbuildPath = null) _forwardingAppWithoutLogging = new MSBuildForwardingAppWithoutLogging( modifiedMSBuildArgs, msbuildPath: msbuildPath); - - // Add the performance log location to the environment of the target process. - if (PerformanceLogManager.Instance != null && !string.IsNullOrEmpty(PerformanceLogManager.Instance.CurrentLogDirectory)) - { - EnvironmentVariable(PerformanceLogManager.PerfLogDirEnvVar, PerformanceLogManager.Instance.CurrentLogDirectory); - } } public IEnumerable MSBuildArguments { get { return _forwardingAppWithoutLogging.GetAllArguments(); } } @@ -79,7 +72,7 @@ public ProcessStartInfo GetProcessStartInfo() private void InitializeRequiredEnvironmentVariables() { - EnvironmentVariable(TelemetrySessionIdEnvironmentVariableName, Telemetry.Telemetry.CurrentSessionId); + EnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_SESSIONID, TelemetryClient.CurrentSessionId); } /// @@ -99,23 +92,13 @@ public override int Execute() if (_forwardingAppWithoutLogging.ExecuteMSBuildOutOfProc) { ProcessStartInfo startInfo = GetProcessStartInfo(); - - PerformanceLogEventSource.Log.LogMSBuildStart(startInfo.FileName, startInfo.Arguments); exitCode = startInfo.Execute(); - PerformanceLogEventSource.Log.MSBuildStop(exitCode); } else { InitializeRequiredEnvironmentVariables(); string[] arguments = _forwardingAppWithoutLogging.GetAllArguments(); - if (PerformanceLogEventSource.Log.IsEnabled()) - { - PerformanceLogEventSource.Log.LogMSBuildStart( - _forwardingAppWithoutLogging.MSBuildPath, - ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(arguments)); - } exitCode = _forwardingAppWithoutLogging.ExecuteInProc(arguments); - PerformanceLogEventSource.Log.MSBuildStop(exitCode); } return exitCode; diff --git a/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs b/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs index bf770da1c33b..608f4caf5273 100644 --- a/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs +++ b/src/Cli/dotnet/Commands/MSBuild/MSBuildLogger.cs @@ -1,20 +1,16 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Microsoft.Build.Framework; using Microsoft.DotNet.Cli.Telemetry; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Configurer; using Microsoft.DotNet.Utilities; namespace Microsoft.DotNet.Cli.Commands.MSBuild; public sealed class MSBuildLogger : INodeLogger { - private readonly IFirstTimeUseNoticeSentinel _sentinel = - new FirstTimeUseNoticeSentinel(); - private readonly ITelemetry? _telemetry; + private readonly ITelemetryClient? _telemetry; internal const string TargetFrameworkTelemetryEventName = "targetframeworkeval"; internal const string BuildTelemetryEventName = "build"; @@ -66,19 +62,11 @@ public MSBuildLogger() { try { - string? sessionId = - Environment.GetEnvironmentVariable(MSBuildForwardingApp.TelemetrySessionIdEnvironmentVariableName); + string? sessionId = Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_SESSIONID); if (sessionId != null) { - // senderCount: 0 to disable sender. - // When senders in different process running at the same - // time they will read from the same global queue and cause - // sending duplicated events. Disable sender to reduce it. - _telemetry = new Telemetry.Telemetry( - _sentinel, - sessionId, - senderCount: 0); + _telemetry = new TelemetryClient(sessionId); } } catch (Exception) @@ -90,7 +78,7 @@ public MSBuildLogger() /// /// Constructor for testing purposes. /// - internal MSBuildLogger(ITelemetry telemetry) + internal MSBuildLogger(ITelemetryClient telemetry) { _telemetry = telemetry; } @@ -117,8 +105,6 @@ public void Initialize(IEventSource eventSource) { eventSource2.TelemetryLogged += OnTelemetryLogged; } - - eventSource.BuildFinished += OnBuildFinished; } eventSource.BuildFinished += OnBuildFinished; @@ -134,14 +120,14 @@ private void OnBuildFinished(object sender, BuildFinishedEventArgs e) SendAggregatedEventsOnBuildFinished(_telemetry); } - internal void SendAggregatedEventsOnBuildFinished(ITelemetry? telemetry) + internal void SendAggregatedEventsOnBuildFinished(ITelemetryClient? telemetry) { if (telemetry is null) return; if (_aggregatedEvents.TryGetValue(TaskFactoryTelemetryAggregatedEventName, out var taskFactoryData)) { Dictionary taskFactoryProperties = ConvertToStringDictionary(taskFactoryData); - TrackEvent(telemetry, $"msbuild/{TaskFactoryTelemetryAggregatedEventName}", taskFactoryProperties, toBeHashed: [], toBeMeasured: []); + TrackEvent(telemetry, $"msbuild/{TaskFactoryTelemetryAggregatedEventName}", taskFactoryProperties, toBeHashed: []); _aggregatedEvents.Remove(TaskFactoryTelemetryAggregatedEventName); } @@ -149,7 +135,7 @@ internal void SendAggregatedEventsOnBuildFinished(ITelemetry? telemetry) { Dictionary tasksProperties = ConvertToStringDictionary(tasksData); - TrackEvent(telemetry, $"msbuild/{TasksTelemetryAggregatedEventName}", tasksProperties, toBeHashed: [], toBeMeasured: []); + TrackEvent(telemetry, $"msbuild/{TasksTelemetryAggregatedEventName}", tasksProperties, toBeHashed: []); _aggregatedEvents.Remove(TasksTelemetryAggregatedEventName); } } @@ -190,7 +176,7 @@ internal void AggregateEvent(TelemetryEventArgs args) } } - internal static void FormatAndSend(ITelemetry? telemetry, TelemetryEventArgs args) + internal static void FormatAndSend(ITelemetryClient? telemetry, TelemetryEventArgs args) { switch (args.EventName) { @@ -199,14 +185,13 @@ internal static void FormatAndSend(ITelemetry? telemetry, TelemetryEventArgs arg break; case BuildTelemetryEventName: TrackEvent(telemetry, $"msbuild/{BuildTelemetryEventName}", args.Properties, - toBeHashed: ["ProjectPath", "BuildTarget"], - toBeMeasured: ["BuildDurationInMilliseconds", "InnerBuildDurationInMilliseconds"] + toBeHashed: ["ProjectPath", "BuildTarget"] ); break; case LoggingConfigurationTelemetryEventName: TrackEvent(telemetry, $"msbuild/{LoggingConfigurationTelemetryEventName}", args.Properties, - toBeHashed: [], - toBeMeasured: []); + toBeHashed: [] + ); break; case BuildcheckAcquisitionFailureEventName: TrackEvent(telemetry, $"msbuild/{BuildcheckAcquisitionFailureEventName}", args.Properties, @@ -214,14 +199,11 @@ internal static void FormatAndSend(ITelemetry? telemetry, TelemetryEventArgs arg ); break; case BuildcheckRunEventName: - TrackEvent(telemetry, $"msbuild/{BuildcheckRunEventName}", args.Properties, - toBeMeasured: ["TotalRuntimeInMilliseconds"] - ); + TrackEvent(telemetry, $"msbuild/{BuildcheckRunEventName}", args.Properties); break; case BuildcheckRuleStatsEventName: TrackEvent(telemetry, $"msbuild/{BuildcheckRuleStatsEventName}", args.Properties, - toBeHashed: ["RuleId", "CheckFriendlyName"], - toBeMeasured: ["TotalRuntimeInMilliseconds"] + toBeHashed: ["RuleId", "CheckFriendlyName"] ); break; // Pass through events that don't need special handling @@ -240,7 +222,7 @@ internal static void FormatAndSend(ITelemetry? telemetry, TelemetryEventArgs arg } } - private static void TrackEvent(ITelemetry? telemetry, string eventName, IDictionary eventProperties, string[]? toBeHashed = null, string[]? toBeMeasured = null) + private static void TrackEvent(ITelemetryClient? telemetry, string eventName, IDictionary eventProperties, string[]? toBeHashed = null) { if (telemetry == null || !telemetry.Enabled) { @@ -248,7 +230,6 @@ private static void TrackEvent(ITelemetry? telemetry, string eventName, IDiction } Dictionary? properties = null; - Dictionary? measurements = null; if (toBeHashed is not null) { @@ -263,26 +244,7 @@ private static void TrackEvent(ITelemetry? telemetry, string eventName, IDiction } } - if (toBeMeasured is not null) - { - foreach (var propertyToBeMeasured in toBeMeasured) - { - if (eventProperties.TryGetValue(propertyToBeMeasured, out var value)) - { - // Lets lazy allocate in case there is tons of telemetry - properties ??= new(eventProperties); - properties.Remove(propertyToBeMeasured); - if (double.TryParse(value, CultureInfo.InvariantCulture, out double realValue)) - { - // Lets lazy allocate in case there is tons of telemetry - measurements ??= []; - measurements[propertyToBeMeasured] = realValue; - } - } - } - } - - telemetry.TrackEvent(eventName, properties ?? eventProperties, measurements); + telemetry?.TrackEvent(eventName, properties ?? eventProperties); } private void OnTelemetryLogged(object sender, TelemetryEventArgs args) @@ -299,14 +261,6 @@ private void OnTelemetryLogged(object sender, TelemetryEventArgs args) public void Shutdown() { - try - { - _sentinel?.Dispose(); - } - catch (Exception) - { - // Exceptions during telemetry shouldn't cause anything else to fail - } } public LoggerVerbosity Verbosity { get; set; } diff --git a/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs b/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs index 8858145a6c0a..f25795528dd4 100644 --- a/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs +++ b/src/Cli/dotnet/Commands/New/BuiltInTemplatePackageProvider.cs @@ -1,8 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - +using Microsoft.DotNet.Cli.Utils; using Microsoft.TemplateEngine.Abstractions; using Microsoft.TemplateEngine.Abstractions.TemplatePackage; using NuGet.Versioning; @@ -18,14 +17,16 @@ internal sealed class BuiltInTemplatePackageProvider(BuiltInTemplatePackageProvi public ITemplatePackageProviderFactory Factory { get; } = factory; -#pragma warning disable CS0067 /// /// We don't trigger this event, we could complicate our life with FileSystemWatcher. - /// But since "dotnet new" is short lived process is not worth it, plus it would cause - /// some perf hit... + /// But since "dotnet new" is short lived process is not worth it, plus it would cause some perf hit... + /// To avoid warnings about being unused, implement empty add/remove accessors. /// - public event Action TemplatePackagesChanged; -#pragma warning restore CS0067 + public event Action? TemplatePackagesChanged + { + add { } + remove { } + } public Task> GetAllTemplatePackagesAsync(CancellationToken cancellationToken) { @@ -44,14 +45,12 @@ private static IEnumerable GetTemplateFolders(IEngineEnvironmentSettings { var templateFoldersToInstall = new List(); -#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - var sdkDirectory = Path.GetDirectoryName(typeof(Utils.DotnetFiles).Assembly.Location); -#pragma warning restore IL3000 - - var dotnetRootPath = Path.GetDirectoryName(Path.GetDirectoryName(sdkDirectory)); - + var sdksDirectory = new DirectoryInfo(MSBuildForwardingAppWithoutLogging.GetMSBuildSDKsPath()); + var sdkDirectory = sdksDirectory.Parent; + var sdkPath = sdkDirectory?.FullName ?? string.Empty; + var dotnetRootPath = sdkDirectory?.Parent?.Parent?.FullName ?? string.Empty; // First grab templates from dotnet\templates\M.m folders, in ascending order, up to our version - string templatesRootFolder = Path.GetFullPath(Path.Combine(dotnetRootPath, "templates")); + string templatesRootFolder = Path.Combine(dotnetRootPath, "templates"); if (Directory.Exists(templatesRootFolder)) { IReadOnlyDictionary parsedNames = GetVersionDirectoriesInDirectory(templatesRootFolder); @@ -62,7 +61,7 @@ private static IEnumerable GetTemplateFolders(IEngineEnvironmentSettings } // Now grab templates from our base folder, if present. - string templatesDir = Path.Combine(sdkDirectory, "Templates"); + string templatesDir = Path.Combine(sdkPath, "Templates"); if (Directory.Exists(templatesDir)) { templateFoldersToInstall.Add(templatesDir); @@ -79,7 +78,7 @@ private static IReadOnlyDictionary GetVersionDirectorie foreach (string directory in Directory.EnumerateDirectories(fullPath, "*.*", SearchOption.TopDirectoryOnly)) { - if (SemanticVersion.TryParse(Path.GetFileName(directory), out SemanticVersion versionInfo)) + if (SemanticVersion.TryParse(Path.GetFileName(directory), out SemanticVersion? versionInfo) && versionInfo is not null) { versionFileInfo.Add(directory, versionInfo); } @@ -92,7 +91,7 @@ internal static IList GetBestVersionsByMajorMinor(IReadOnlyDictionary bestVersionsByBucket = new Dictionary(); - Version sdkVersion = typeof(NewCommandParser).Assembly.GetName().Version; + Version? sdkVersion = typeof(NewCommandParser).Assembly.GetName().Version; foreach (KeyValuePair dirInfo in versionDirInfo) { var majorMinorDirVersion = new Version(dirInfo.Value.Major, dirInfo.Value.Minor); diff --git a/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs b/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs index 318f1ec64418..3ddb261d7340 100644 --- a/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs +++ b/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs @@ -110,8 +110,6 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin projectPath = _projectFullPath; } - Stopwatch watch = new(); - Stopwatch innerBuildWatch = new(); bool IsSdkStyleProject = false; IReadOnlyList? targetFrameworks = null; string? targetFramework = null; @@ -119,7 +117,6 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin try { - watch.Start(); _logger?.LogDebug("Evaluating project: {0}", projectPath); MSBuildProject evaluatedProject = RunEvaluate(projectPath); @@ -164,13 +161,11 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin //For multi-target project, we need to do additional evaluation for each target framework. Dictionary evaluatedTfmBasedProjects = []; - innerBuildWatch.Start(); foreach (string tfm in targetFrameworks) { _logger?.LogDebug("Evaluating project for target framework: {0}", tfm); evaluatedTfmBasedProjects[tfm] = RunEvaluate(projectPath, tfm); } - innerBuildWatch.Stop(); _logger?.LogDebug("Project is SDK style, multi-target, evaluation succeeded."); return result = MultiTargetEvaluationResult.CreateSuccess(projectPath, evaluatedProject, evaluatedTfmBasedProjects); @@ -182,9 +177,6 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin } finally { - watch.Stop(); - innerBuildWatch.Stop(); - string? targetFrameworksString = null; if (targetFrameworks != null) @@ -204,13 +196,7 @@ private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettin { "TargetFrameworks", targetFrameworksString ?? ""}, }; - Dictionary measurements = new() - { - { "EvaluationTime", watch.ElapsedMilliseconds }, - { "InnerEvaluationTime", innerBuildWatch.ElapsedMilliseconds } - }; - - TelemetryEventEntry.TrackEvent("new/msbuild-eval", properties, measurements); + TelemetryEventEntry.TrackEvent("new/msbuild-eval", properties); } } diff --git a/src/Cli/dotnet/Commands/New/NewCommandParser.cs b/src/Cli/dotnet/Commands/New/NewCommandParser.cs index 4a2f614f2ca8..68b5268c8579 100644 --- a/src/Cli/dotnet/Commands/New/NewCommandParser.cs +++ b/src/Cli/dotnet/Commands/New/NewCommandParser.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Diagnostics; using Microsoft.DotNet.Cli.CommandLine; using Microsoft.DotNet.Cli.Commands.New.MSBuildEvaluation; using Microsoft.DotNet.Cli.Commands.New.PostActions; @@ -16,7 +15,6 @@ using Microsoft.TemplateEngine.Abstractions.Constraints; using Microsoft.TemplateEngine.Abstractions.TemplatePackage; using Microsoft.TemplateEngine.Cli; -using Microsoft.TemplateEngine.Cli.Commands; using Microsoft.TemplateEngine.Cli.PostActionProcessors; using Command = System.CommandLine.Command; diff --git a/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.cs b/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.cs index cf803c32c3c3..77e48fe7b32a 100644 --- a/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.cs +++ b/src/Cli/dotnet/Commands/New/OptionalWorkloadProvider.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Configurer; using Microsoft.TemplateEngine.Abstractions; @@ -22,8 +20,8 @@ internal OptionalWorkloadProvider(ITemplatePackageProviderFactory factory, IEngi public ITemplatePackageProviderFactory Factory { get; } - // To avoid warnings about unused, its implemented via add/remove - event Action ITemplatePackageProvider.TemplatePackagesChanged + // To avoid warnings about being unused, implement empty add/remove accessors. + event Action? ITemplatePackageProvider.TemplatePackagesChanged { add { } remove { } @@ -33,14 +31,13 @@ public Task> GetAllTemplatePackagesAsync(Cancell { var list = new List(); var optionalWorkloadLocator = new TemplateLocator.TemplateLocator(); -#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - var sdkDirectory = Path.GetDirectoryName(typeof(DotnetFiles).Assembly.Location); -#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file - var sdkVersion = Path.GetFileName(sdkDirectory); - var dotnetRootPath = Path.GetDirectoryName(Path.GetDirectoryName(sdkDirectory)); + var sdksDirectory = new DirectoryInfo(MSBuildForwardingAppWithoutLogging.GetMSBuildSDKsPath()); + var sdkDirectory = sdksDirectory?.Parent; + var sdkVersion = sdkDirectory?.Name; + var dotnetRootPath = sdkDirectory?.Parent?.Parent; string userProfileDir = CliFolderPathCalculator.DotnetUserProfileFolderPath; - var packages = optionalWorkloadLocator.GetDotnetSdkTemplatePackages(sdkVersion, dotnetRootPath, userProfileDir); + var packages = optionalWorkloadLocator.GetDotnetSdkTemplatePackages(sdkVersion, dotnetRootPath?.FullName, userProfileDir); var fileSystem = _environmentSettings.Host.FileSystem; foreach (var packageInfo in packages) { diff --git a/src/Cli/dotnet/Commands/Pack/PackCommand.cs b/src/Cli/dotnet/Commands/Pack/PackCommand.cs index ab7d0ba0542b..9300fa1b8310 100644 --- a/src/Cli/dotnet/Commands/Pack/PackCommand.cs +++ b/src/Cli/dotnet/Commands/Pack/PackCommand.cs @@ -5,7 +5,6 @@ using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.CommandLine; -using Microsoft.DotNet.Cli.Commands.Build; using Microsoft.DotNet.Cli.Commands.Restore; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Extensions; @@ -13,7 +12,6 @@ using Microsoft.DotNet.Cli.Utils; using NuGet.Commands; using NuGet.Common; -using NuGet.Packaging; namespace Microsoft.DotNet.Cli.Commands.Pack; @@ -40,7 +38,7 @@ public static CommandBase FromParseResult(ParseResult parseResult, string? msbui bool noRestore = noBuild || parseResult.HasOption(definition.NoRestoreOption); - return CommandFactory.CreateVirtualOrPhysicalCommand( + return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs b/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs index 7c418218f52d..370d35d6c816 100644 --- a/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs +++ b/src/Cli/dotnet/Commands/Pack/PackCommandParser.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CommandLine; using Microsoft.DotNet.Cli.CommandLine; namespace Microsoft.DotNet.Cli.Commands.Pack; diff --git a/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs b/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs index 36d44cee3931..9ec9c4acd7a1 100644 --- a/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs +++ b/src/Cli/dotnet/Commands/Project/ProjectCommandParser.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Project.Convert; using Microsoft.DotNet.Cli.Extensions; diff --git a/src/Cli/dotnet/Commands/Publish/PublishCommand.cs b/src/Cli/dotnet/Commands/Publish/PublishCommand.cs index bb68394da143..6188060bf16d 100644 --- a/src/Cli/dotnet/Commands/Publish/PublishCommand.cs +++ b/src/Cli/dotnet/Commands/Publish/PublishCommand.cs @@ -44,7 +44,7 @@ public static CommandBase FromParseResult(ParseResult parseResult, string? msbui bool noRestore = noBuild || parseResult.HasOption(definition.NoRestoreOption); - return CommandFactory.CreateVirtualOrPhysicalCommand( + return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, (msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand( diff --git a/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs b/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs index 2743d1ffe048..8f2e96fb3f91 100644 --- a/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs +++ b/src/Cli/dotnet/Commands/Restore/RestoreCommand.cs @@ -24,7 +24,7 @@ public static CommandBase FromParseResult(ParseResult result, string? msbuildPat result.HandleDebugSwitch(); result.ShowHelpOrErrorIfAppropriate(); - return CommandFactory.CreateVirtualOrPhysicalCommand( + return DotNetCommandFactory.CreateVirtualOrPhysicalCommand( definition, definition.SlnOrProjectOrFileArgument, static (msbuildArgs, appFilePath) => diff --git a/src/Cli/dotnet/Commands/Run/RunTelemetry.cs b/src/Cli/dotnet/Commands/Run/RunTelemetry.cs index 0d3ab62507b8..4a16683d53f8 100644 --- a/src/Cli/dotnet/Commands/Run/RunTelemetry.cs +++ b/src/Cli/dotnet/Commands/Run/RunTelemetry.cs @@ -48,13 +48,9 @@ public static void TrackRunEvent( { ["app_type"] = isFileBased ? "file_based" : "project_based", ["project_id"] = projectIdentifier, - }; - - var measurements = new Dictionary - { - ["sdk_count"] = sdkCount, - ["package_reference_count"] = packageReferenceCount, - ["project_reference_count"] = projectReferenceCount, + ["sdk_count"] = sdkCount.ToString(), + ["package_reference_count"] = packageReferenceCount.ToString(), + ["project_reference_count"] = projectReferenceCount.ToString(), }; // Launch profile telemetry @@ -80,7 +76,7 @@ public static void TrackRunEvent( // File-based app specific telemetry if (isFileBased) { - measurements["additional_properties_count"] = additionalPropertiesCount; + properties["additional_properties_count"] = additionalPropertiesCount.ToString(); if (usedMSBuild.HasValue) { properties["used_msbuild"] = usedMSBuild.Value ? "true" : "false"; @@ -91,7 +87,7 @@ public static void TrackRunEvent( } } - TelemetryEventEntry.TrackEvent(RunEventName, properties, measurements); + TelemetryEventEntry.TrackEvent(RunEventName, properties); } /// diff --git a/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs b/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs index 61d4b803baa7..708e9d0fa93e 100644 --- a/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs +++ b/src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs @@ -38,7 +38,7 @@ public ToolExecuteCommand(ParseResult result, ToolManifestFinder? toolManifestFi : base(result) { _packageToolIdentityArgument = result.GetValue(Definition.PackageIdentityArgument); - _forwardArguments = result.GetValue(Definition.CommandArgument) ?? Enumerable.Empty(); + _forwardArguments = result.GetValue(Definition.CommandArgument) ?? []; _allowRollForward = result.GetValue(Definition.RollForwardOption); _configFile = result.GetValue(Definition.ConfigOption); _sources = result.GetValue(Definition.SourceOption) ?? []; diff --git a/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs b/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs index 4b42713ae006..72262f1294c0 100644 --- a/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs +++ b/src/Cli/dotnet/Commands/Tool/Run/ToolRunCommand.cs @@ -34,7 +34,7 @@ public override int Execute() public static int ExecuteCommand(LocalToolsCommandResolver commandResolver, string? toolCommandName, IEnumerable? argumentsToForward, bool allowRollForward) { using var _ = Activities.Source.StartActivity("execute-local-tool"); - CommandSpec commandSpec = commandResolver.ResolveStrict(new CommandResolverArguments() + CommandSpec? commandSpec = commandResolver.ResolveStrict(new CommandResolverArguments() { // since LocalToolsCommandResolver is a resolver, and all resolver input have dotnet- CommandName = $"dotnet-{toolCommandName}", @@ -49,4 +49,4 @@ public static int ExecuteCommand(LocalToolsCommandResolver commandResolver, stri var result = CommandFactoryUsingResolver.Create(commandSpec).Execute(); return result.ExitCode; } -} \ No newline at end of file +} diff --git a/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs b/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs index f043eafc93be..84249ba7b5c4 100644 --- a/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs +++ b/src/Cli/dotnet/Commands/Tool/ToolCommandSpecCreator.cs @@ -11,30 +11,20 @@ internal class ToolCommandSpecCreator { public static CommandSpec CreateToolCommandSpec(string toolName, string toolExecutable, string toolRunner, bool allowRollForward, IEnumerable commandArguments) { - if (toolRunner == "dotnet") + var environment = ActivityContextFactory.MakeActivityContextEnvironment(); + switch (toolRunner) { - if (allowRollForward) - { - commandArguments = ["--allow-roll-forward", .. commandArguments]; - } - - return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer( - toolExecutable, - commandArguments); - } - else if (toolRunner == "executable") - { - var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart( - commandArguments); - - return new CommandSpec( - toolExecutable, - escapedArgs); - } - else - { - throw new GracefulException(string.Format(CliStrings.ToolSettingsUnsupportedRunner, - toolName, toolRunner)); + case "dotnet": + if (allowRollForward) + { + commandArguments = ["--allow-roll-forward", .. commandArguments]; + } + return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer(toolExecutable, commandArguments, environment); + case "executable": + var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(commandArguments); + return new CommandSpec(toolExecutable, escapedArgs, environment); + default: + throw new GracefulException(string.Format(CliStrings.ToolSettingsUnsupportedRunner, toolName, toolRunner)); } } } diff --git a/src/Cli/dotnet/DotNetCommandFactory.cs b/src/Cli/dotnet/DotNetCommandFactory.cs deleted file mode 100644 index dcb70b05e6c9..000000000000 --- a/src/Cli/dotnet/DotNetCommandFactory.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.CommandLine.Invocation; -using System.Diagnostics; -using Microsoft.DotNet.Cli.CommandFactory; -using Microsoft.DotNet.Cli.Utils; -using NuGet.Frameworks; - -namespace Microsoft.DotNet.Cli; - -public class DotNetCommandFactory(bool alwaysRunOutOfProc = false, string currentWorkingDirectory = null) : ICommandFactory -{ - private readonly bool _alwaysRunOutOfProc = alwaysRunOutOfProc; - private readonly string _currentWorkingDirectory = currentWorkingDirectory; - - public ICommand Create( - string commandName, - IEnumerable args, - NuGetFramework framework = null, - string configuration = Constants.DefaultConfiguration) - { - if (!_alwaysRunOutOfProc && TryGetBuiltInCommand(commandName, out var builtInCommand)) - { - Debug.Assert(framework == null, "BuiltInCommand doesn't support the 'framework' argument."); - Debug.Assert(configuration == Constants.DefaultConfiguration, "BuiltInCommand doesn't support the 'configuration' argument."); - - return new BuiltInCommand(commandName, args, builtInCommand); - } - - return CommandFactoryUsingResolver.CreateDotNet(commandName, args, framework, configuration, _currentWorkingDirectory); - } - - private static bool TryGetBuiltInCommand(string commandName, out Func commandFunc) - { - var command = Parser.GetBuiltInCommand(commandName); - if (command?.Action is AsynchronousCommandLineAction action) - { - commandFunc = (args) => Parser.Invoke([commandName, .. args]); - return true; - } - commandFunc = null; - return false; - } -} diff --git a/src/Cli/dotnet/Extensions/ActivityExtensions.cs b/src/Cli/dotnet/Extensions/ActivityExtensions.cs new file mode 100644 index 000000000000..b29d0d86de94 --- /dev/null +++ b/src/Cli/dotnet/Extensions/ActivityExtensions.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Diagnostics; + +namespace Microsoft.DotNet.Cli.Extensions; + +internal static class ActivityExtensions +{ + public static void SetDisplayName(this Activity? activity, ParseResult parseResult) + { + if (activity is null) + { + return; + } + + var name = parseResult.GetCommandName(); + activity.DisplayName = name; + activity.SetTag("command.name", name); + } +} diff --git a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs index dc23ee379040..865ae37cdf18 100644 --- a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs +++ b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs @@ -14,25 +14,27 @@ namespace Microsoft.DotNet.Cli.Extensions; public static class ParseResultExtensions { - /// + /// /// Finds the command of the parse result and invokes help for that command. /// If no command is specified, invokes help for the application. - /// - /// + /// + /// /// This is accomplished by finding a set of tokens that should be valid and appending a help token /// to that list, then re-parsing the list of tokens. This is not ideal - either we should have a direct way /// of invoking help for a ParseResult, or we should eliminate this custom, ad-hoc help invocation by moving /// more situations that want to show help into Parsing Errors (which trigger help in the default System.CommandLine pipeline) /// or custom Invocation Middleware, so we can more easily create our version of a HelpResult type. - /// + /// public static void ShowHelp(this ParseResult parseResult) { - // take from the start of the list until we hit an option/--/unparsed token - // since commands can have arguments, we must take those as well in order to get accurate help - Parser.Parse([ - ..parseResult.Tokens.TakeWhile(token => token.Type == TokenType.Argument || token.Type == TokenType.Command || token.Type == TokenType.Directive).Select(t => t.Value), - "-h" - ]).Invoke(); + // Take from the start of the list until we hit an option/--/unparsed token. + // Since commands can have arguments, we must take those as well in order to get accurate help. + var filteredTokenValues = parseResult.Tokens.TakeWhile(token => + token.Type == TokenType.Argument + || token.Type == TokenType.Command + || token.Type == TokenType.Directive) + .Select(t => t.Value); + Parser.Parse([.. filteredTokenValues, "-h"]).Invoke(); } public static void ShowHelpOrErrorIfAppropriate(this ParseResult parseResult) @@ -45,24 +47,26 @@ public static void ShowHelpOrErrorIfAppropriate(this ParseResult parseResult) var rawResourcePartsForThisLocale = DistinctFormatStringParts(CliStrings.UnrecognizedCommandOrArgument); return ErrorContainsAllParts(error.Message, rawResourcePartsForThisLocale); }); - if (parseResult.CommandResult.Command.TreatUnmatchedTokensAsErrors || - parseResult.Errors.Except(unrecognizedTokenErrors).Any()) + + if (parseResult.CommandResult.Command.TreatUnmatchedTokensAsErrors + || parseResult.Errors.Except(unrecognizedTokenErrors).Any()) { throw new CommandParsingException( - message: string.Join(Environment.NewLine, - parseResult.Errors.Select(e => e.Message)), + message: string.Join(Environment.NewLine, parseResult.Errors.Select(e => e.Message)), parseResult: parseResult); } } - ///Splits a .NET format string by the format placeholders (the {N} parts) to get an array of the literal parts, to be used in message-checking - static string[] DistinctFormatStringParts(string formatString) - { - return Regex.Split(formatString, @"{[0-9]+}"); // match the literal '{', followed by any of 0-9 one or more times, followed by the literal '}' - } - + /// + /// Splits a .NET format string by the format placeholders (the {N} parts) to get an array of the literal parts, to be used in message-checking. + /// + static string[] DistinctFormatStringParts(string formatString) => + // Match the literal '{', followed by any of 0-9 one or more times, followed by the literal '}'. + Regex.Split(formatString, @"{[0-9]+}"); - /// given a string and a series of parts, ensures that all parts are present in the string in sequential order + /// + /// Given a string and a series of parts, ensures that all parts are present in the string in sequential order. + /// static bool ErrorContainsAllParts(ReadOnlySpan error, string[] parts) { foreach (var part in parts) @@ -73,39 +77,29 @@ static bool ErrorContainsAllParts(ReadOnlySpan error, string[] parts) error = error.Slice(foundIndex + part.Length); continue; } - else - { - return false; - } + + return false; } + return true; } } - public static string RootSubCommandResult(this ParseResult parseResult) - { - return parseResult.RootCommandResult.Children? - .Select(child => GetSymbolResultValue(parseResult, child)) - .FirstOrDefault(subcommand => !string.IsNullOrEmpty(subcommand)) ?? string.Empty; - } + public static string RootSubCommandResult(this ParseResult parseResult) => parseResult.RootCommandResult.Children? + .Select(child => parseResult.GetSymbolResultValue(child)) + .FirstOrDefault(subcommand => !string.IsNullOrEmpty(subcommand)) ?? string.Empty; - public static bool IsDotnetBuiltInCommand(this ParseResult parseResult) - { - return string.IsNullOrEmpty(parseResult.RootSubCommandResult()) || - Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null; - } + public static bool IsDotnetBuiltInCommand(this ParseResult parseResult) => + string.IsNullOrEmpty(parseResult.RootSubCommandResult()) + || Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null; - public static bool IsTopLevelDotnetCommand(this ParseResult parseResult) - { - return parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult()); - } + public static bool IsTopLevelDotnetCommand(this ParseResult parseResult) => + parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult()); - public static bool CanBeInvoked(this ParseResult parseResult) - { - return Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null || - parseResult.Tokens.Any(token => token.Type == TokenType.Directive) || - (parseResult.IsTopLevelDotnetCommand() && string.IsNullOrEmpty(parseResult.GetValue(Parser.RootCommand.DotnetSubCommand))); - } + public static bool CanBeInvoked(this ParseResult parseResult) => + Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null + || parseResult.Tokens.Any(token => token.Type == TokenType.Directive) + || (parseResult.IsTopLevelDotnetCommand() && string.IsNullOrEmpty(parseResult.GetValue(Parser.RootCommand.DotnetSubCommand))); public static int HandleMissingCommand(this ParseResult parseResult) { @@ -114,12 +108,8 @@ public static int HandleMissingCommand(this ParseResult parseResult) return 1; } - public static string[] GetArguments(this ParseResult parseResult) - { - return parseResult.Tokens.Select(t => t.Value) - .ToArray() - .GetSubArguments(); - } + public static string[] GetArguments(this ParseResult parseResult) => + parseResult.Tokens.Select(t => t.Value).ToArray().GetSubArguments(); public static string[] GetSubArguments(this string[] args) { @@ -131,54 +121,30 @@ public static string[] GetSubArguments(this string[] args) var runArgs = dashDashIndex > -1 ? subargs.GetRange(dashDashIndex, subargs.Count() - dashDashIndex) : []; subargs = dashDashIndex > -1 ? subargs.GetRange(0, dashDashIndex) : subargs; - return - [ - .. subargs - .SkipWhile(arg => Parser.RootCommand.DiagOption.Name.Equals(arg) || Parser.RootCommand.DiagOption.Aliases.Contains(arg) || arg.Equals("dotnet")) - .Skip(1), // remove top level command (ex build or publish) - .. runArgs - ]; - } - - public static bool DiagOptionPrecedesSubcommand(this string[] args, string subCommand) - { - if (string.IsNullOrEmpty(subCommand)) - { - return true; - } - - for (var i = 0; i < args.Length; i++) - { - if (args[i].Equals(subCommand)) - { - return false; - } - else if (Parser.RootCommand.DiagOption.Name.Equals(args) || Parser.RootCommand.DiagOption.Aliases.Contains(args[i])) - { - return true; - } - } + // Remove top level command (ex build or publish). + var subargsFiltered = subargs + .SkipWhile(arg => Parser.RootCommand.DiagOption.Name.Equals(arg) + || Parser.RootCommand.DiagOption.Aliases.Contains(arg) + || arg.Equals("dotnet")) + .Skip(1); - return false; + return [.. subargsFiltered, .. runArgs]; } - private static string? GetSymbolResultValue(ParseResult parseResult, SymbolResult symbolResult) => symbolResult switch + private static string? GetSymbolResultValue(this ParseResult parseResult, SymbolResult symbolResult) => symbolResult switch { CommandResult commandResult => commandResult.Command.Name, ArgumentResult argResult => argResult.Tokens.FirstOrDefault()?.Value, _ => parseResult.GetResult(Parser.RootCommand.DotnetSubCommand)?.GetValueOrDefault() }; - public static IEnumerable? GetRunCommandShorthandProjectValues(this ParseResult parseResult) - { - var properties = GetRunPropertyOptions(parseResult, true); - return properties?.Where(property => !property.Contains("=")); - } + public static IEnumerable? GetRunCommandShorthandProjectValues(this ParseResult parseResult) => + parseResult.GetRunPropertyOptions(true)?.Where(property => !property.Contains("=")); public static IEnumerable GetRunCommandPropertyValues(this ParseResult parseResult) { - var shorthandProperties = GetRunPropertyOptions(parseResult, true)?.Where(property => property.Contains("=")); - var longhandProperties = GetRunPropertyOptions(parseResult, false); + var shorthandProperties = parseResult.GetRunPropertyOptions(true)?.Where(property => property.Contains("=")); + var longhandProperties = parseResult.GetRunPropertyOptions(false); return (shorthandProperties, longhandProperties) switch { (null, null) => Enumerable.Empty(), @@ -188,7 +154,7 @@ public static IEnumerable GetRunCommandPropertyValues(this ParseResult p }; } - private static IEnumerable? GetRunPropertyOptions(ParseResult parseResult, bool shorthand) + private static IEnumerable? GetRunPropertyOptions(this ParseResult parseResult, bool shorthand) { var optionString = shorthand ? "-p" : "--property"; var propertyOptions = parseResult.CommandResult.Children.Where(c => GetOptionTokenOrDefault(c)?.Value.Equals(optionString) ?? false); @@ -214,4 +180,26 @@ public static void HandleDebugSwitch(this ParseResult parseResult) DebugHelper.WaitForDebugger(); } } + + public static string GetCommandName(this ParseResult parseResult) + { + // Walk the parent command tree to find the top-level command name and get the full command name for this ParseResult. + List parentNames = [parseResult.CommandResult.Command.Name]; + var current = parseResult.CommandResult.Parent; + while (current is CommandResult parentCommandResult) + { + parentNames.Add(parentCommandResult.Command.Name); + current = parentCommandResult.Parent; + } + parentNames.Reverse(); + + // Options that perform terminating actions are considered part of the command name as they are essentially subcommands themselves. + // Example: dotnet --version + if (parseResult.Action is InvocableOptionAction { Terminating: true } optionAction) + { + parentNames.Add(optionAction.Option.Name); + } + + return string.Join(' ', parentNames); + } } diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 475d59655a3b..42b129db7067 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -1,10 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; -using System.CommandLine.Invocation; +using System.CommandLine.Help; using System.CommandLine.StaticCompletions; using System.Reflection; using Microsoft.DotNet.Cli.Commands; @@ -47,7 +45,6 @@ using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; using Microsoft.TemplateEngine.Cli; -using Microsoft.TemplateEngine.Cli.Help; using Command = System.CommandLine.Command; namespace Microsoft.DotNet.Cli; @@ -66,19 +63,14 @@ private static DotNetCommandDefinition CreateCommand() { rootCommand.Options.RemoveAt(i); } - else if (option is System.CommandLine.Help.HelpOption helpOption) + else if (option is HelpOption helpOption) { - helpOption.Action = new DotnetHelpAction() - { - Builder = DotnetHelpBuilder.Instance.Value - }; - + helpOption.Action = new PrintHelpAction(helpOption, DotnetHelpBuilder.Instance.Value); option.Description = CliStrings.ShowHelpDescription; } } // Augment the definition of each subcommand with command-specific actions and completions. - AddCommandParser.ConfigureCommand(rootCommand.AddCommand); BuildCommandParser.ConfigureCommand(rootCommand.BuildCommand); BuildServerCommandParser.ConfigureCommand(rootCommand.BuildServerCommand); @@ -122,7 +114,10 @@ private static DotNetCommandDefinition CreateCommand() WorkloadCommandParser.ConfigureCommand(rootCommand.WorkloadCommand); CompletionsCommandParser.ConfigureCommand(rootCommand.CompletionsCommand); - rootCommand.CliSchemaOption.Action = new PrintCliSchemaAction(); + rootCommand.DiagOption.Action = new HandleDiagnosticAction(rootCommand.DiagOption); + rootCommand.VersionOption.Action = new PrintVersionAction(rootCommand.VersionOption); + rootCommand.InfoOption.Action = new PrintInfoAction(rootCommand.InfoOption); + rootCommand.CliSchemaOption.Action = new PrintCliSchemaAction(rootCommand.CliSchemaOption); // TODO: https://github.com/dotnet/sdk/issues/52661 // https://github.com/NuGet/NuGet.Client/blob/bf048eb714eb6b1912ba868edca4c7cfec454841/src/NuGet.Core/NuGet.CommandLine.XPlat/NuGetCommands.cs @@ -133,13 +128,13 @@ private static DotNetCommandDefinition CreateCommand() { if (parseResult.GetValue(rootCommand.DiagOption) && parseResult.Tokens.Count == 1) { - // when user does not specify any args except of diagnostics ("dotnet -d"), we do nothing - // as Program.ProcessArgs already enabled the diagnostic output + // When user does not specify any args except of diagnostics ("dotnet -d"), + // we do nothing as HandleDiagnosticAction already enabled the diagnostic output. return 0; } else { - // when user does not specify any args (just "dotnet"), a usage needs to be printed + // When user does not specify any args (just "dotnet"), a usage needs to be printed. parseResult.InvocationConfiguration.Output.WriteLine(CliUsage.HelpText); return 0; } @@ -148,14 +143,14 @@ private static DotNetCommandDefinition CreateCommand() return rootCommand; } - public static Command GetBuiltInCommand(string commandName) => + public static Command? GetBuiltInCommand(string commandName) => RootCommand.Subcommands.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase)); /// /// Implements token-per-line response file handling for the CLI. We use this instead of the built-in S.CL handling /// to ensure backwards-compatibility with MSBuild. /// - public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList replacementTokens, out string errorMessage) + public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList? replacementTokens, out string? errorMessage) { var filePath = Path.GetFullPath(tokenToReplace); if (File.Exists(filePath)) @@ -215,7 +210,7 @@ public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList public static int Invoke(string[] args) => Invoke(Parse(args)); public static Task InvokeAsync(string[] args, CancellationToken cancellationToken = default) => InvokeAsync(Parse(args), cancellationToken); - internal static int ExceptionHandler(Exception exception, ParseResult parseResult) + internal static int ExceptionHandler(Exception? exception, ParseResult parseResult) { if (exception is TargetInvocationException) { @@ -235,13 +230,13 @@ internal static int ExceptionHandler(Exception exception, ParseResult parseResul exception.Message.Red().Bold()); parseResult.ShowHelp(); } - else if (exception.GetType().Name.Equals("WorkloadManifestCompositionException")) + else if (exception is not null && exception.GetType().Name.Equals("WorkloadManifestCompositionException")) { Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose ? exception.ToString().Red().Bold() : exception.Message.Red().Bold()); } - else + else if (exception is not null) { Reporter.Error.Write("Unhandled exception: ".Red().Bold()); Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose ? @@ -329,7 +324,7 @@ public override void Write(HelpContext context) } else if (command is FormatCommandDefinition format) { - var arguments = context.ParseResult.GetValue(format.Arguments); + var arguments = context.ParseResult.GetValue(format.Arguments) ?? []; new FormatForwardingApp([.. arguments, .. helpArgs]).Execute(); } else if (command is FsiCommandDefinition) @@ -351,14 +346,16 @@ public override void Write(HelpContext context) if (command.Name.Equals(ListReferenceCommandDefinition.Name)) { - Command listCommand = command.Parents.Single() as Command; - - for (int i = 0; i < listCommand.Arguments.Count; i++) + Command? listCommand = command.Parents.Single() as Command; + if (listCommand is not null) { - if (listCommand.Arguments[i].Name == CliStrings.SolutionOrProjectArgumentName) + for (int i = 0; i < listCommand.Arguments.Count; i++) { - // Name is immutable now, so we create a new Argument with the right name.. - listCommand.Arguments[i] = Commands.Hidden.List.ListCommandDefinition.CreateSlnOrProjectArgument(CliStrings.ProjectArgumentName, CliStrings.ProjectArgumentDescription); + if (listCommand.Arguments[i].Name == CliStrings.SolutionOrProjectArgumentName) + { + // Name is immutable now, so we create a new Argument with the right name.. + listCommand.Arguments[i] = ListCommandDefinition.CreateSlnOrProjectArgument(CliStrings.ProjectArgumentName, CliStrings.ProjectArgumentDescription); + } } } } @@ -380,15 +377,4 @@ public override void Write(HelpContext context) } } } - - private class PrintCliSchemaAction : SynchronousCommandLineAction - { - public override bool Terminating => true; - - public override int Invoke(ParseResult parseResult) - { - CliSchema.PrintCliSchema(parseResult.CommandResult, parseResult.InvocationConfiguration.Output, Program.TelemetryClient); - return 0; - } - } } diff --git a/src/Cli/dotnet/ParserOptionActions.cs b/src/Cli/dotnet/ParserOptionActions.cs new file mode 100644 index 000000000000..8186712fa4de --- /dev/null +++ b/src/Cli/dotnet/ParserOptionActions.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Invocation; +using Microsoft.DotNet.Cli.Commands.Workload; +using Microsoft.DotNet.Cli.Extensions; +using Microsoft.DotNet.Cli.Help; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Configurer; +using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; + +namespace Microsoft.DotNet.Cli; + +/// +/// Represents an option that contains an invocable action. +/// These are essentially commands that are only defined as an option. +/// +internal abstract class InvocableOptionAction(Option option) : SynchronousCommandLineAction +{ + /// + /// The option for which this action is bound. + /// + public Option Option { get; } = option; +} + +internal class HandleDiagnosticAction(Option option) : InvocableOptionAction(option) +{ + public override bool Terminating => false; + + public override int Invoke(ParseResult parseResult) + { + // Only set verbose output on built-in commands. + if (!parseResult.IsDotnetBuiltInCommand()) + { + return 0; + } + + // Determine whether the diagnostic option should be attached to the dotnet command or the subcommand. + if (DiagOptionPrecedesSubcommand(parseResult.Tokens.Select(t => t.Value), parseResult.RootSubCommandResult())) + { + Environment.SetEnvironmentVariable(CommandLoggingContext.Variables.Verbose, bool.TrueString); + CommandLoggingContext.SetVerbose(true); + Reporter.Reset(); + + var home = Env.GetEnvironmentVariable(CliFolderPathCalculator.DotnetHomeVariableName); + if (!string.IsNullOrEmpty(home)) + { + // Output DOTNET_CLI_HOME usage when verbosity is enabled. + Reporter.Verbose.WriteLine(string.Format(LocalizableStrings.DotnetCliHomeUsed, home, CliFolderPathCalculator.DotnetHomeVariableName)); + } + } + + return 0; + } + + private static bool DiagOptionPrecedesSubcommand(IEnumerable tokens, string subCommand) + { + if (string.IsNullOrEmpty(subCommand)) + { + return true; + } + + foreach (var token in tokens) + { + if (token == subCommand) + { + return false; + } + + if (Parser.RootCommand.DiagOption.Name == token + || Parser.RootCommand.DiagOption.Aliases.Contains(token)) + { + return true; + } + } + + return false; + } +} + +internal class PrintHelpAction(Option option, HelpBuilder builder) : InvocableOptionAction(option) +{ + public override bool Terminating => true; + public override bool ClearsParseErrors => true; + + private HelpBuilder Builder { get; } = builder; + + public override int Invoke(ParseResult parseResult) + { + var command = parseResult.CommandResult.Command; + var output = parseResult.InvocationConfiguration.Output; + var helpContext = new HelpContext(Builder, command, output, parseResult); + Builder.Write(helpContext); + + return 0; + } +} + +internal class PrintVersionAction(Option option) : InvocableOptionAction(option) +{ + public override bool Terminating => true; + + public override int Invoke(ParseResult parseResult) + { + // Only print for top-level commands. + if (!parseResult.IsTopLevelDotnetCommand()) + { + return 0; + } + + Reporter.Output.WriteLine(Product.Version); + + return 0; + } +} + +internal class PrintInfoAction(Option option) : InvocableOptionAction(option) +{ + public override bool Terminating => true; + + public override int Invoke(ParseResult parseResult) + { + // Only print for top-level commands. + if (!parseResult.IsTopLevelDotnetCommand()) + { + return 0; + } + + DotnetVersionFile versionFile = DotnetFiles.VersionFileObject; + var commitSha = versionFile.CommitSha ?? "N/A"; + Reporter.Output.WriteLine($"{LocalizableStrings.DotNetSdkInfoLabel}"); + Reporter.Output.WriteLine($" Version: {Product.Version}"); + Reporter.Output.WriteLine($" Commit: {commitSha}"); + Reporter.Output.WriteLine($" Workload version: {WorkloadInfoHelper.GetWorkloadsVersion()}"); + Reporter.Output.WriteLine($" MSBuild version: {MSBuildForwardingAppWithoutLogging.MSBuildVersion}"); + Reporter.Output.WriteLine(); + Reporter.Output.WriteLine($"{LocalizableStrings.DotNetRuntimeInfoLabel}"); + Reporter.Output.WriteLine($" OS Name: {RuntimeEnvironment.OperatingSystem}"); + Reporter.Output.WriteLine($" OS Version: {RuntimeEnvironment.OperatingSystemVersion}"); + Reporter.Output.WriteLine($" OS Platform: {RuntimeEnvironment.OperatingSystemPlatform}"); + Reporter.Output.WriteLine($" RID: {GetDisplayRid(versionFile)}"); + Reporter.Output.WriteLine($" Base Path: {AppContext.BaseDirectory}"); + Reporter.Output.WriteLine(); + Reporter.Output.WriteLine($"{LocalizableStrings.DotnetWorkloadInfoLabel}"); + new WorkloadInfoHelper(isInteractive: false).ShowWorkloadsInfo(showVersion: false); + + return 0; + } + + private static string? GetDisplayRid(DotnetVersionFile versionFile) + { + FrameworkDependencyFile fxDepsFile = new(); + string currentRid = RuntimeInformation.RuntimeIdentifier; + // If the current RID isn't supported by the shared framework, display the RID the CLI was built with instead, + // so the user knows which RID they should put in their "runtimes" section. + return fxDepsFile.IsRuntimeSupported(currentRid) ? currentRid : versionFile.BuildRid; + } +} + +internal class PrintCliSchemaAction(Option option) : InvocableOptionAction(option) +{ + public override bool Terminating => true; + + public override int Invoke(ParseResult parseResult) + { + CliSchema.PrintCliSchema(parseResult, parseResult.InvocationConfiguration.Output, Program.TelemetryInstance); + + return 0; + } +} diff --git a/src/Cli/dotnet/PerformanceLogEventListener.cs b/src/Cli/dotnet/PerformanceLogEventListener.cs deleted file mode 100644 index 201006e5c011..000000000000 --- a/src/Cli/dotnet/PerformanceLogEventListener.cs +++ /dev/null @@ -1,160 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Diagnostics.Tracing; -using Microsoft.Extensions.EnvironmentAbstractions; - -namespace Microsoft.DotNet.Cli; - -internal sealed class PerformanceLogEventListener : EventListener -{ - internal struct ProviderConfiguration - { - internal string Name { get; set; } - internal EventKeywords Keywords { get; set; } - internal EventLevel Level { get; set; } - } - - private static readonly ProviderConfiguration[] s_config = - [ - new ProviderConfiguration() - { - Name = "Microsoft-Dotnet-CLI-Performance", - Keywords = EventKeywords.All, - Level = EventLevel.Verbose - } - ]; - - private const char EventDelimiter = '\n'; - private StreamWriter _writer; - - [ThreadStatic] - private static StringBuilder s_builder = new(); - - internal static PerformanceLogEventListener Create(IFileSystem fileSystem, string logDirectory) - { - // Only create a listener if the log directory exists. - if (string.IsNullOrWhiteSpace(logDirectory) || !fileSystem.Directory.Exists(logDirectory)) - { - return null; - } - - PerformanceLogEventListener eventListener = null; - try - { - // Initialization happens as a separate step and not in the constructor to ensure that - // if an exception is thrown during init, we have the opportunity to dispose of the listener, - // which will disable any EventSources that have been enabled. Any EventSources that existed before - // this EventListener will be passed to OnEventSourceCreated before our constructor is called, so - // we if we do this work in the constructor, and don't get an opportunity to call Dispose, the - // EventSources will remain enabled even if there aren't any consuming EventListeners. - eventListener = new PerformanceLogEventListener(); - eventListener.Initialize(fileSystem, logDirectory); - } - catch - { - if (eventListener != null) - { - eventListener.Dispose(); - } - } - - return eventListener; - } - - private PerformanceLogEventListener() - { - } - - internal void Initialize(IFileSystem fileSystem, string logDirectory) - { - // Use a GUID disambiguator to make sure that we have a unique file name. - string logFilePath = Path.Combine(logDirectory, $"perf-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")}.log"); - - Stream outputStream = fileSystem.File.OpenFile( - logFilePath, - FileMode.Create, // Create or overwrite. - FileAccess.Write, // Open for writing. - FileShare.Read, // Allow others to read. - 4096, // Default buffer size. - FileOptions.None); // No hints about how the file will be written. - - _writer = new StreamWriter(outputStream); - } - - public override void Dispose() - { - lock (this) - { - if (_writer != null) - { - _writer.Dispose(); - _writer = null; - } - } - - base.Dispose(); - } - - protected override void OnEventSourceCreated(EventSource eventSource) - { - try - { - // Enable the provider if it matches a requested configuration. - foreach (ProviderConfiguration entry in s_config) - { - if (entry.Name.Equals(eventSource.Name)) - { - EnableEvents(eventSource, entry.Level, entry.Keywords); - } - } - } - catch - { - // If we fail to enable, just skip it and continue. - } - - base.OnEventSourceCreated(eventSource); - } - - protected override void OnEventWritten(EventWrittenEventArgs eventData) - { - try - { - if (s_builder == null) - { - s_builder = new StringBuilder(); - } - else - { - s_builder.Clear(); - } - - s_builder.Append($"[{DateTime.UtcNow.ToString("o")}] Event={eventData.EventSource.Name}/{eventData.EventName} ProcessID={Environment.ProcessId} ThreadID={Thread.CurrentThread.ManagedThreadId}\t "); - for (int i = 0; i < eventData.PayloadNames.Count; i++) - { - s_builder.Append($"{eventData.PayloadNames[i]}=\"{eventData.Payload[i]}\" "); - } - - lock (this) - { - if (_writer != null) - { - foreach (ReadOnlyMemory mem in s_builder.GetChunks()) - { - _writer.Write(mem); - } - _writer.Write(EventDelimiter); - } - } - } - catch - { - // If we fail to log an event, just skip it and continue. - } - - base.OnEventWritten(eventData); - } -} diff --git a/src/Cli/dotnet/PerformanceLogEventSource.cs b/src/Cli/dotnet/PerformanceLogEventSource.cs deleted file mode 100644 index 0d1912db6c72..000000000000 --- a/src/Cli/dotnet/PerformanceLogEventSource.cs +++ /dev/null @@ -1,455 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Diagnostics; -using System.Diagnostics.Tracing; -using System.Reflection; -using Microsoft.DotNet.Cli.Utils; -using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; - -namespace Microsoft.DotNet.Cli; - -[EventSource(Name = "Microsoft-Dotnet-CLI-Performance", Guid = "cbd57d06-3b9f-5374-ed53-cfbcc23cf44f")] -internal sealed partial class PerformanceLogEventSource : EventSource -{ - internal static PerformanceLogEventSource Log = new(); - - [NonEvent] - internal void LogStartUpInformation(PerformanceLogStartupInformation startupInfo) - { - if (!IsEnabled()) - { - return; - } - - DotnetVersionFile versionFile = DotnetFiles.VersionFileObject; - string commitSha = versionFile.CommitSha ?? "N/A"; - - LogMachineConfiguration(); - OSInfo(RuntimeEnvironment.OperatingSystem, RuntimeEnvironment.OperatingSystemVersion, RuntimeEnvironment.OperatingSystemPlatform.ToString()); - SDKInfo(Product.Version, commitSha, RuntimeInformation.RuntimeIdentifier, versionFile.BuildRid, AppContext.BaseDirectory); - EnvironmentInfo(Environment.CommandLine); - LogMemoryConfiguration(); - LogDrives(); - - // It's possible that IsEnabled returns true if an out-of-process collector such as ETW is enabled. - // If the perf log hasn't been enabled, then startupInfo will be null, so protect against nullref here. - if (startupInfo != null) - { - if (startupInfo.TimedAssembly != null) - { - AssemblyLoad(startupInfo.TimedAssembly.GetName().Name, startupInfo.AssemblyLoadTime.TotalMilliseconds); - } - - Process currentProcess = Process.GetCurrentProcess(); - TimeSpan latency = startupInfo.MainTimeStamp - currentProcess.StartTime; - HostLatency(latency.TotalMilliseconds); - } - } - - [Event(1)] - internal void OSInfo(string osname, string osversion, string osplatform) - { - WriteEvent(1, osname, osversion, osplatform); - } - - [Event(2)] - internal void SDKInfo(string version, string commit, string currentRid, string buildRid, string basePath) - { - WriteEvent(2, version, commit, currentRid, buildRid, basePath); - } - - [Event(3)] - internal void EnvironmentInfo(string commandLine) - { - WriteEvent(3, commandLine); - } - - [Event(4)] - internal void HostLatency(double timeInMs) - { - WriteEvent(4, timeInMs); - } - - [Event(5)] - internal void CLIStart() - { - WriteEvent(5); - } - - [Event(6)] - internal void CLIStop() - { - WriteEvent(6); - } - - [Event(7)] - internal void FirstTimeConfigurationStart() - { - WriteEvent(7); - } - - [Event(8)] - internal void FirstTimeConfigurationStop() - { - WriteEvent(8); - } - - [Event(9)] - internal void TelemetryRegistrationStart() - { - WriteEvent(9); - } - - [Event(10)] - internal void TelemetryRegistrationStop() - { - WriteEvent(10); - } - - [Event(11)] - internal void TelemetrySaveIfEnabledStart() - { - WriteEvent(11); - } - - [Event(12)] - internal void TelemetrySaveIfEnabledStop() - { - WriteEvent(12); - } - - [Event(13)] - internal void BuiltInCommandStart() - { - WriteEvent(13); - } - - [Event(14)] - internal void BuiltInCommandStop() - { - WriteEvent(14); - } - - [Event(15)] - internal void BuiltInCommandParserStart() - { - WriteEvent(15); - } - - [Event(16)] - internal void BuiltInCommandParserStop() - { - WriteEvent(16); - } - - [Event(17)] - internal void ExtensibleCommandResolverStart() - { - WriteEvent(17); - } - - [Event(18)] - internal void ExtensibleCommandResolverStop() - { - WriteEvent(18); - } - - [Event(19)] - internal void ExtensibleCommandStart() - { - WriteEvent(19); - } - - [Event(20)] - internal void ExtensibleCommandStop() - { - WriteEvent(20); - } - - [Event(21)] - internal void TelemetryClientFlushStart() - { - WriteEvent(21); - } - - [Event(22)] - internal void TelemetryClientFlushStop() - { - WriteEvent(22); - } - - [NonEvent] - internal void LogMachineConfiguration() - { - if (IsEnabled()) - { - MachineConfiguration(Environment.MachineName, Environment.ProcessorCount); - } - } - - [Event(23)] - internal void MachineConfiguration(string machineName, int processorCount) - { - WriteEvent(23, machineName, processorCount); - } - - [NonEvent] - internal void LogDrives() - { - if (IsEnabled()) - { - foreach (DriveInfo driveInfo in DriveInfo.GetDrives()) - { - try - { - DriveConfiguration(driveInfo.Name, driveInfo.DriveFormat, driveInfo.DriveType.ToString(), - (double)driveInfo.TotalSize / 1024 / 1024, (double)driveInfo.AvailableFreeSpace / 1024 / 1024); - } - catch - { - // If we fail to log a drive, skip it and continue. - } - } - } - } - - [Event(24)] - internal void DriveConfiguration(string name, string format, string type, double totalSizeMB, double availableFreeSpaceMB) - { - WriteEvent(24, name, format, type, totalSizeMB, availableFreeSpaceMB); - } - - [Event(25)] - internal void AssemblyLoad(string assemblyName, double timeInMs) - { - WriteEvent(25, assemblyName, timeInMs); - } - - [NonEvent] - internal void LogMemoryConfiguration() - { - if (IsEnabled()) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Interop.MEMORYSTATUSEX memoryStatusEx = new(); - memoryStatusEx.dwLength = (uint)Marshal.SizeOf(memoryStatusEx); - - if (Interop.GlobalMemoryStatusEx(ref memoryStatusEx)) - { - MemoryConfiguration((int)memoryStatusEx.dwMemoryLoad, (int)(memoryStatusEx.ullAvailPhys / 1024 / 1024), - (int)(memoryStatusEx.ullTotalPhys / 1024 / 1024)); - } - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - ProcMemInfo memInfo = new(); - if (memInfo.Valid) - { - MemoryConfiguration(memInfo.MemoryLoad, memInfo.AvailableMemoryMB, memInfo.TotalMemoryMB); - } - } - } - } - - [Event(26)] - internal void MemoryConfiguration(int memoryLoad, int availablePhysicalMB, int totalPhysicalMB) - { - WriteEvent(26, memoryLoad, availablePhysicalMB, totalPhysicalMB); - } - - [NonEvent] - internal void LogMSBuildStart(string fileName, string arguments) - { - if (IsEnabled()) - { - MSBuildStart($"{fileName} {arguments}"); - } - } - - [Event(27)] - internal void MSBuildStart(string cmdline) - { - WriteEvent(27, cmdline); - } - - [Event(28)] - internal void MSBuildStop(int exitCode) - { - WriteEvent(28, exitCode); - } - - [Event(29)] - internal void CreateBuildCommandStart() - { - WriteEvent(29); - } - - [Event(30)] - internal void CreateBuildCommandStop() - { - WriteEvent(30); - } -} - -internal class PerformanceLogStartupInformation -{ - public PerformanceLogStartupInformation(DateTime mainTimeStamp) - { - // Save the main timestamp. - MainTimeStamp = mainTimeStamp; - - // Attempt to load an assembly. - // Ideally, we've picked one that we'll already need, so we're not adding additional overhead. - MeasureModuleLoad(); - } - - internal DateTime MainTimeStamp { get; private set; } - internal Assembly TimedAssembly { get; private set; } - internal TimeSpan AssemblyLoadTime { get; private set; } - - private void MeasureModuleLoad() - { - // Make sure the assembly hasn't been loaded yet. - string assemblyName = "Microsoft.DotNet.Configurer"; - try - { - foreach (Assembly loadedAssembly in AppDomain.CurrentDomain.GetAssemblies()) - { - if (loadedAssembly.GetName().Name.Equals(assemblyName)) - { - // If the assembly is already loaded, then bail. - return; - } - } - } - catch - { - // If we fail to enumerate, just bail. - return; - } - - Stopwatch stopWatch = Stopwatch.StartNew(); - Assembly assembly; - try - { - assembly = Assembly.Load(assemblyName); - } - catch - { - return; - } - stopWatch.Stop(); - if (assembly != null) - { - // Save the results. - TimedAssembly = assembly; - AssemblyLoadTime = stopWatch.Elapsed; - } - } -} - -/// -/// Global memory statistics on Windows. -/// -internal static class Interop -{ - [StructLayout(LayoutKind.Sequential)] - internal struct MEMORYSTATUSEX - { - // The length field must be set to the size of this data structure. - internal uint dwLength; - internal uint dwMemoryLoad; - internal ulong ullTotalPhys; - internal ulong ullAvailPhys; - internal ulong ullTotalPageFile; - internal ulong ullAvailPageFile; - internal ulong ullTotalVirtual; - internal ulong ullAvailVirtual; - internal ulong ullAvailExtendedVirtual; - } - - [DllImport("kernel32.dll")] - internal static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); -} - -/// -/// Global memory statistics on Linux. -/// -internal sealed class ProcMemInfo -{ - private const string MemTotal = "MemTotal:"; - private const string MemAvailable = "MemAvailable:"; - - private short _matchingLineCount = 0; - - internal ProcMemInfo() - { - Initialize(); - } - - /// - /// The data in this class is valid if we parsed the file, found, and properly parsed the two matching lines. - /// - internal bool Valid - { - get { return _matchingLineCount == 2; } - } - - internal int MemoryLoad - { - get { return (int)((double)(TotalMemoryMB - AvailableMemoryMB) / TotalMemoryMB * 100); } - } - - internal int AvailableMemoryMB - { - get; - private set; - } - - internal int TotalMemoryMB - { - get; - private set; - } - - private void Initialize() - { - try - { - using (StreamReader reader = new(File.OpenRead("/proc/meminfo"))) - { - string line; - while (!Valid && ((line = reader.ReadLine()) != null)) - { - if (line.StartsWith(MemTotal) || line.StartsWith(MemAvailable)) - { - string[] tokens = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length == 3) - { - if (MemTotal.Equals(tokens[0])) - { - TotalMemoryMB = (int)Convert.ToUInt64(tokens[1]) / 1024; - _matchingLineCount++; - } - else if (MemAvailable.Equals(tokens[0])) - { - AvailableMemoryMB = (int)Convert.ToUInt64(tokens[1]) / 1024; - _matchingLineCount++; - } - } - } - } - } - } - catch (Exception ex) when (ex is IOException || ex.InnerException is IOException) - { - // in some environments (restricted docker container, shared hosting etc.), - // procfs is not accessible and we get UnauthorizedAccessException while the - // inner exception is set to IOException. Ignore and continue when that happens. - } - } -} diff --git a/src/Cli/dotnet/PerformanceLogManager.cs b/src/Cli/dotnet/PerformanceLogManager.cs deleted file mode 100644 index 3864adceb84c..000000000000 --- a/src/Cli/dotnet/PerformanceLogManager.cs +++ /dev/null @@ -1,136 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Configurer; -using Microsoft.Extensions.EnvironmentAbstractions; - -namespace Microsoft.DotNet.Cli; - -internal sealed class PerformanceLogManager -{ - internal const string PerfLogDirEnvVar = "DOTNET_PERFLOG_DIR"; - private const string PerfLogRoot = "PerformanceLogs"; - private const int DefaultNumLogsToKeep = 10; - - private readonly IFileSystem _fileSystem; - private string _perfLogRoot; - - internal static PerformanceLogManager Instance - { - get; - private set; - } - - internal static void InitializeAndStartCleanup(IFileSystem fileSystem) - { - if (Instance == null) - { - Instance = new PerformanceLogManager(fileSystem); - - // Check to see if this instance is part of an already running chain of processes. - string perfLogDir = Env.GetEnvironmentVariable(PerfLogDirEnvVar); - if (!string.IsNullOrEmpty(perfLogDir)) - { - // This process has been provided with a log directory, so use it. - Instance.UseExistingLogDirectory(perfLogDir); - } - else - { - // This process was not provided with a log root, so make a new one. - Instance._perfLogRoot = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, PerfLogRoot); - Instance.CreateLogDirectory(); - - Task.Factory.StartNew(() => - { - Instance.CleanupOldLogs(); - }); - } - } - } - - internal PerformanceLogManager(IFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - - internal string CurrentLogDirectory { get; private set; } - - private void CreateLogDirectory() - { - // Ensure the log root directory exists. - if (!_fileSystem.Directory.Exists(_perfLogRoot)) - { - _fileSystem.Directory.CreateDirectory(_perfLogRoot); - } - - // Create a new perf log directory. - CurrentLogDirectory = Path.Combine(_perfLogRoot, Guid.NewGuid().ToString("N")); - _fileSystem.Directory.CreateDirectory(CurrentLogDirectory); - } - - private void UseExistingLogDirectory(string logDirectory) - { - CurrentLogDirectory = logDirectory; - } - - private void CleanupOldLogs() - { - if (_fileSystem.Directory.Exists(_perfLogRoot)) - { - List logDirectories = []; - foreach (string directoryPath in _fileSystem.Directory.EnumerateDirectories(_perfLogRoot)) - { - logDirectories.Add(new DirectoryInfo(directoryPath)); - } - - // Sort the list. - logDirectories.Sort(new LogDirectoryComparer()); - - // Figure out how many logs to keep. - int numLogsToKeep; - string strNumLogsToKeep = Env.GetEnvironmentVariable("DOTNET_PERF_LOG_COUNT"); - if (!int.TryParse(strNumLogsToKeep, out numLogsToKeep)) - { - numLogsToKeep = DefaultNumLogsToKeep; - - // -1 == keep all logs - if (numLogsToKeep == -1) - { - numLogsToKeep = int.MaxValue; - } - } - - // Skip the first numLogsToKeep elements. - if (logDirectories.Count > numLogsToKeep) - { - // Prune the old logs. - for (int i = logDirectories.Count - numLogsToKeep - 1; i >= 0; i--) - { - try - { - logDirectories[i].Delete(true); - } - catch - { - // Do nothing if a log can't be deleted. - // We'll get another chance next time around. - } - } - } - } - } -} - -/// -/// Used to sort log directories when deciding which ones to delete. -/// -internal sealed class LogDirectoryComparer : IComparer -{ - int IComparer.Compare(DirectoryInfo x, DirectoryInfo y) - { - return x.CreationTime.CompareTo(y.CreationTime); - } -} diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index 73a7d7eb6990..f0558ad174f8 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -1,17 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using System.CommandLine.Parsing; using System.Diagnostics; -using System.Runtime.InteropServices; using Microsoft.DotNet.Cli.CommandFactory; using Microsoft.DotNet.Cli.CommandFactory.CommandResolution; -using Microsoft.DotNet.Cli.CommandLine; using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; -using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Workload; using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.ShellShim; @@ -29,380 +24,206 @@ namespace Microsoft.DotNet.Cli; public class Program { - private static readonly string ToolPathSentinelFileName = $"{Product.Version}.toolpath.sentinel"; + private static readonly string s_toolPathSentinelFileName = $"{Product.Version}.toolpath.sentinel"; - public static ITelemetry TelemetryClient; - public static int Main(string[] args) + private static readonly Activity? s_mainActivity; + private static readonly PosixSignalRegistration s_sigIntRegistration; + private static readonly PosixSignalRegistration s_sigQuitRegistration; + private static readonly PosixSignalRegistration s_sigTermRegistration; + private static readonly string? s_globalJsonState; + + public static ITelemetryClient TelemetryInstance { get; private set; } + + static Program() { - // Register a handler for SIGTERM to allow graceful shutdown of the application on Unix. - // See https://github.com/dotnet/docs/issues/46226. - using var termSignalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => Environment.Exit(0)); + var mainTimeStamp = DateTime.Now; + s_sigIntRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, Shutdown); + s_sigQuitRegistration = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, Shutdown); + s_sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, Shutdown); + + // Note: This TelemetryClient instance needs to be created prior to calculating ActivityKind and ParentActivityContext, + // used in the main activity creation below. + TelemetryInstance = new TelemetryClient(); + TelemetryEventEntry.Subscribe(TelemetryInstance.TrackEvent); + TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); + + s_mainActivity = Activities.Source.CreateActivity("main", TelemetryClient.ActivityKind, TelemetryClient.ParentActivityContext) + ?.Start() + ?.SetStartTime(Process.GetCurrentProcess().StartTime) + ?.AddTag("process.pid", Process.GetCurrentProcess().Id) + ?.AddTag("process.executable.name", "dotnet"); - using AutomaticEncodingRestorer _ = new(); + if (CommandLoggingContext.IsVerbose) + { + Console.WriteLine($"Telemetry is: {(TelemetryInstance.Enabled ? "Enabled" : "Disabled")}"); + } - if (Env.GetEnvironmentVariable("DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING") != "1") + // Creates a host-startup activity which includes the global.json state. + using (var hostStartupActivity = Activities.Source.StartActivity("host-startup")) { - // Setting output encoding is not available on those platforms - if (UILanguageOverride.OperatingSystemSupportsUtf8()) + hostStartupActivity?.SetStartTime(Process.GetCurrentProcess().StartTime); + if (TelemetryInstance.Enabled && hostStartupActivity is not null) { - Console.OutputEncoding = Encoding.UTF8; + // Get the global.json state to report in telemetry along with this command invocation. + s_globalJsonState = NativeWrapper.NETCoreSdkResolverNativeWrapper.GetGlobalJsonState(Environment.CurrentDirectory); + hostStartupActivity?.AddTag("dotnet.globalJson", s_globalJsonState); } + hostStartupActivity?.SetEndTime(mainTimeStamp)?.SetStatus(ActivityStatusCode.Ok); } - DebugHelper.HandleDebugSwitch(ref args); - - // Capture the current timestamp to calculate the host overhead. - DateTime mainTimeStamp = DateTime.Now; - TimeSpan startupTime = mainTimeStamp - Process.GetCurrentProcess().StartTime; - - bool perfLogEnabled = Env.GetEnvironmentVariableAsBool("DOTNET_CLI_PERF_LOG", false); - + // We have some behaviors in MSBuild that we want to enforce (either when using MSBuild API or by shelling out to it), + // so we set those ASAP as globally as possible. if (string.IsNullOrEmpty(Env.GetEnvironmentVariable("MSBUILDFAILONDRIVEENUMERATINGWILDCARD"))) { Environment.SetEnvironmentVariable("MSBUILDFAILONDRIVEENUMERATINGWILDCARD", "1"); } + } - // Avoid create temp directory with root permission and later prevent access in non sudo - if (SudoEnvironmentDirectoryOverride.IsRunningUnderSudo()) - { - perfLogEnabled = false; - } - - PerformanceLogStartupInformation startupInfo = null; - if (perfLogEnabled) - { - startupInfo = new PerformanceLogStartupInformation(mainTimeStamp); - PerformanceLogManager.InitializeAndStartCleanup(FileSystemWrapper.Default); - } - - PerformanceLogEventListener perLogEventListener = null; - try - { - if (perfLogEnabled) - { - perLogEventListener = PerformanceLogEventListener.Create(FileSystemWrapper.Default, PerformanceLogManager.Instance.CurrentLogDirectory); - } - - PerformanceLogEventSource.Log.LogStartUpInformation(startupInfo); - PerformanceLogEventSource.Log.CLIStart(); - - InitializeProcess(); - - try - { - return ProcessArgs(args, startupTime); - } - catch (Exception e) when (e.ShouldBeDisplayedAsError()) - { - Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose - ? e.ToString().Red().Bold() - : e.Message.Red().Bold()); - - var commandParsingException = e as CommandParsingException; - if (commandParsingException != null && commandParsingException.ParseResult != null) - { - commandParsingException.ParseResult.ShowHelp(); - } + public static int Main(string[] args) + { + // Register a handler for SIGTERM to allow graceful shutdown of the application on Unix. + // See https://github.com/dotnet/docs/issues/46226. + using var termSignalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => Environment.Exit(0)); - return 1; - } - catch (Exception e) when (!e.ShouldBeDisplayedAsError()) - { - // If telemetry object has not been initialized yet. It cannot be collected - TelemetryEventEntry.SendFiltered(e); - Reporter.Error.WriteLine(e.ToString().Red().Bold()); + using AutomaticEncodingRestorer _ = new(); - return 1; - } - finally - { - PerformanceLogEventSource.Log.CLIStop(); - } - } - finally + if (Env.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING) != "1" + // Setting output encoding is not available on those platforms + && UILanguageOverride.OperatingSystemSupportsUtf8()) { - if (perLogEventListener != null) - { - perLogEventListener.Dispose(); - } + Console.OutputEncoding = Encoding.UTF8; } - } - - internal static int ProcessArgs(string[] args) - { - return ProcessArgs(args, new TimeSpan(0)); - } - internal static int ProcessArgs(string[] args, TimeSpan startupTime) - { - Dictionary performanceData = []; + DebugHelper.HandleDebugSwitch(ref args); + // By default, .NET Core doesn't have all code pages needed for Console apps. + // See the .NET Core Notes: https://docs.microsoft.com/dotnet/api/system.diagnostics.process#-notes + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + UILanguageOverride.Setup(); - PerformanceLogEventSource.Log.BuiltInCommandParserStart(); - ParseResult parseResult; - using (new PerformanceMeasurement(performanceData, "Parse Time")) + var exitCode = 1; + try { - parseResult = Parser.Parse(args); - - // Avoid create temp directory with root permission and later prevent access in non sudo - // This method need to be run very early before temp folder get created - // https://github.com/dotnet/sdk/issues/20195 - SudoEnvironmentDirectoryOverride.OverrideEnvironmentVariableToTmp(parseResult); + exitCode = ProcessArgsAndExecute(args); + s_mainActivity?.AddTag("process.exit.code", exitCode)?.SetStatus(ActivityStatusCode.Ok); + return exitCode; } - PerformanceLogEventSource.Log.BuiltInCommandParserStop(); - - using (IFirstTimeUseNoticeSentinel disposableFirstTimeUseNoticeSentinel = new FirstTimeUseNoticeSentinel()) + catch (Exception e) when (e.ShouldBeDisplayedAsError()) { - IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel = disposableFirstTimeUseNoticeSentinel; - IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel(); - IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, ToolPathSentinelFileName))); - - PerformanceLogEventSource.Log.TelemetryRegistrationStart(); + Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose + ? e.ToString().Red().Bold() + : e.Message.Red().Bold()); - TelemetryClient ??= new Telemetry.Telemetry(firstTimeUseNoticeSentinel); - TelemetryEventEntry.Subscribe(TelemetryClient.TrackEvent); - TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); - - PerformanceLogEventSource.Log.TelemetryRegistrationStop(); - - if (parseResult.GetValue(Parser.RootCommand.DiagOption) && parseResult.IsDotnetBuiltInCommand()) - { - // We found --diagnostic or -d, but we still need to determine whether the option should - // be attached to the dotnet command or the subcommand. - if (args.DiagOptionPrecedesSubcommand(parseResult.RootSubCommandResult())) - { - Environment.SetEnvironmentVariable(CommandLoggingContext.Variables.Verbose, bool.TrueString); - CommandLoggingContext.SetVerbose(true); - Reporter.Reset(); - } - } - if (parseResult.HasOption(Parser.RootCommand.VersionOption) && parseResult.IsTopLevelDotnetCommand()) - { - CommandLineInfo.PrintVersion(); - return 0; - } - else if (parseResult.HasOption(Parser.RootCommand.InfoOption) && parseResult.IsTopLevelDotnetCommand()) + if (e is CommandParsingException { ParseResult: {} exceptionParseResult } ) { - CommandLineInfo.PrintInfo(); - return 0; - } - else - { - PerformanceLogEventSource.Log.FirstTimeConfigurationStart(); - - var environmentProvider = new EnvironmentProvider(); - - bool generateAspNetCertificate = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_GENERATE_ASPNET_CERTIFICATE, defaultValue: true); - bool telemetryOptout = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, defaultValue: CompileOptions.TelemetryOptOutDefault); - bool addGlobalToolsToPath = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_ADD_GLOBAL_TOOLS_TO_PATH, defaultValue: true); - bool nologo = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_NOLOGO, defaultValue: false); - bool skipWorkloadIntegrityCheck = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK, - // Default the workload integrity check skip to true if the command is being ran in CI. Otherwise, false. - defaultValue: new CIEnvironmentDetectorForTelemetry().IsCIEnvironment()); - - ReportDotnetHomeUsage(environmentProvider); - - var isDotnetBeingInvokedFromNativeInstaller = false; - if (parseResult.CommandResult.Command is InternalReportInstallSuccessCommandDefinition) - { - aspNetCertificateSentinel = new NoOpAspNetCertificateSentinel(); - firstTimeUseNoticeSentinel = new NoOpFirstTimeUseNoticeSentinel(); - toolPathSentinel = new NoOpFileSentinel(exists: false); - isDotnetBeingInvokedFromNativeInstaller = true; - } - - var dotnetFirstRunConfiguration = new DotnetFirstRunConfiguration( - generateAspNetCertificate: generateAspNetCertificate, - telemetryOptout: telemetryOptout, - addGlobalToolsToPath: addGlobalToolsToPath, - nologo: nologo, - skipWorkloadIntegrityCheck: skipWorkloadIntegrityCheck); - - string[] getStarOperators = ["getProperty", "getItem", "getTargetResult"]; - char[] switchIndicators = ['-', '/']; - var getStarOptionPassed = parseResult.CommandResult.Tokens.Any(t => - getStarOperators.Any(o => - switchIndicators.Any(i => t.Value.StartsWith(i + o, StringComparison.OrdinalIgnoreCase)))); - - ConfigureDotNetForFirstTimeUse( - firstTimeUseNoticeSentinel, - aspNetCertificateSentinel, - toolPathSentinel, - isDotnetBeingInvokedFromNativeInstaller, - dotnetFirstRunConfiguration, - environmentProvider, - performanceData, - skipFirstTimeUseCheck: getStarOptionPassed); - PerformanceLogEventSource.Log.FirstTimeConfigurationStop(); + exceptionParseResult.ShowHelp(); } + s_mainActivity?.AddTag("process.exit.code", exitCode)?.SetStatus(ActivityStatusCode.Error); + return exitCode; } - - if (CommandLoggingContext.IsVerbose) + catch (Exception e) when (!e.ShouldBeDisplayedAsError()) { - Console.WriteLine($"Telemetry is: {(TelemetryClient.Enabled ? "Enabled" : "Disabled")}"); + TelemetryEventEntry.SendFiltered(e); + Reporter.Error.WriteLine(e.ToString().Red().Bold()); + s_mainActivity?.AddTag("process.exit.code", exitCode)?.SetStatus(ActivityStatusCode.Error); + return exitCode; } - PerformanceLogEventSource.Log.TelemetrySaveIfEnabledStart(); - performanceData.Add("Startup Time", startupTime.TotalMilliseconds); - - string globalJsonState = string.Empty; - if (TelemetryClient.Enabled) + finally { - // Get the global.json state to report in telemetry along with this command invocation. - globalJsonState = NativeWrapper.NETCoreSdkResolverNativeWrapper.GetGlobalJsonState(Environment.CurrentDirectory); + TelemetryInstance.TrackEvent("command/finish", new Dictionary { { "exitCode", exitCode.ToString() } }); + Shutdown(default!); + TelemetryClient.WriteLogIfNecessary(); } + } - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, performanceData, globalJsonState)); - PerformanceLogEventSource.Log.TelemetrySaveIfEnabledStop(); - - int exitCode; - if (parseResult.CanBeInvoked()) + internal static int ProcessArgsAndExecute(string[] args) + { + ParseResult parseResult = ParseArgs(args); + // Options that perform terminating actions are considered to essentially be subcommands. + // These are special as they should not run the first-run setup. + // Example: dotnet --version + if (!(parseResult.Action is InvocableOptionAction { Terminating: true })) { - InvokeBuiltInCommand(parseResult, out exitCode); + SetupFirstRun(parseResult); } - else - { - PerformanceLogEventSource.Log.ExtensibleCommandResolverStart(); - try - { - string commandName = "dotnet-" + parseResult.GetValue(Parser.RootCommand.DotnetSubCommand); - var resolvedCommandSpec = CommandResolver.TryResolveCommandSpec( - new DefaultCommandResolverPolicy(), - commandName, - args.GetSubArguments(), - FrameworkConstants.CommonFrameworks.NetStandardApp15); - - if (resolvedCommandSpec is null && TryRunFileBasedApp(parseResult) is { } fileBasedAppExitCode) - { - exitCode = fileBasedAppExitCode; - } - else - { - var resolvedCommand = CommandFactoryUsingResolver.CreateOrThrow(commandName, resolvedCommandSpec); - PerformanceLogEventSource.Log.ExtensibleCommandResolverStop(); - - PerformanceLogEventSource.Log.ExtensibleCommandStart(); - var result = resolvedCommand.Execute(); - PerformanceLogEventSource.Log.ExtensibleCommandStop(); - exitCode = result.ExitCode; - } - } - catch (CommandUnknownException e) - { - Reporter.Error.WriteLine(e.Message.Red()); - Reporter.Output.WriteLine(e.InstructionMessage); - exitCode = 1; - } + TelemetryEventEntry.SendFiltered(new ParseResultWithGlobalJsonState(parseResult, s_globalJsonState)); + if (parseResult.CanBeInvoked()) + { + return ExecuteInternalCommand(parseResult); } - TelemetryClient.TrackEvent("command/finish", properties: new Dictionary - { - { "exitCode", exitCode.ToString() } - }, - measurements: new Dictionary()); - - PerformanceLogEventSource.Log.TelemetryClientFlushStart(); - TelemetryClient.Flush(); - PerformanceLogEventSource.Log.TelemetryClientFlushStop(); - - TelemetryClient.Dispose(); - - return exitCode; - - static int? TryRunFileBasedApp(ParseResult parseResult) + try { - // If we didn't match any built-in commands, and a C# file path is the first argument, - // parse as `dotnet run --file file.cs ..rest_of_args` instead. - if (parseResult.GetResult(Parser.RootCommand.DotnetSubCommand) is { Tokens: [{ Type: TokenType.Argument, Value: { } } unmatchedCommandOrFile] } - && VirtualProjectBuilder.IsValidEntryPointPath(unmatchedCommandOrFile.Value)) - { - List otherTokens = new(parseResult.Tokens.Count - 1); - foreach (var token in parseResult.Tokens) - { - if (token != unmatchedCommandOrFile) - { - otherTokens.Add(token.Value); - } - } - - parseResult = Parser.Parse(["run", "--file", unmatchedCommandOrFile.Value, .. otherTokens]); - - InvokeBuiltInCommand(parseResult, out var exitCode); - return exitCode; - } - - return null; + return ExecuteExternalCommand(args, parseResult); } - - static void InvokeBuiltInCommand(ParseResult parseResult, out int exitCode) + catch (CommandUnknownException e) { - Debug.Assert(parseResult.CanBeInvoked()); - - PerformanceLogEventSource.Log.BuiltInCommandStart(); - - try - { - exitCode = Parser.Invoke(parseResult); - exitCode = AdjustExitCode(parseResult, exitCode); - } - catch (Exception exception) - { - exitCode = Parser.ExceptionHandler(exception, parseResult); - } - - PerformanceLogEventSource.Log.BuiltInCommandStop(); + Reporter.Error.WriteLine(e.Message.Red()); + Reporter.Output.WriteLine(e.InstructionMessage); + return 1; } - } - private static int AdjustExitCode(ParseResult parseResult, int exitCode) - { - if (parseResult.Errors.Count > 0) + static ParseResult ParseArgs(string[] args) { - var commandResult = parseResult.CommandResult; - - while (commandResult is not null) + ParseResult parseResult; + using (var parseActivity = Activities.Source.StartActivity("parse")) { - if (commandResult.Command.Name == "new") - { - // default parse error exit code is 1 - // for the "new" command and its subcommands it needs to be 127 - return 127; - } + parseResult = Parser.Parse(args); - commandResult = commandResult.Parent as CommandResult; + // Avoid create temp directory with root permission and later prevent access in non sudo + // This method need to be run very early before temp folder get created + // https://github.com/dotnet/sdk/issues/20195 + SudoEnvironmentDirectoryOverride.OverrideEnvironmentVariableToTmp(parseResult); } + s_mainActivity.SetDisplayName(parseResult); + return parseResult; } - - return exitCode; } - private static void ReportDotnetHomeUsage(IEnvironmentProvider provider) + private static void SetupFirstRun(ParseResult parseResult) { - var home = provider.GetEnvironmentVariable(CliFolderPathCalculator.DotnetHomeVariableName); - if (string.IsNullOrEmpty(home)) + using var _ = Activities.Source.StartActivity("first-time-use"); + IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel = new FirstTimeUseNoticeSentinel(); + IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel(); + string toolPath = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, s_toolPathSentinelFileName); + IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(toolPath)); + + var environmentProvider = new EnvironmentProvider(); + bool generateAspNetCertificate = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_GENERATE_ASPNET_CERTIFICATE, defaultValue: true); + bool telemetryOptout = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, defaultValue: CompileOptions.TelemetryOptOutDefault); + bool addGlobalToolsToPath = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_ADD_GLOBAL_TOOLS_TO_PATH, defaultValue: true); + bool nologo = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_NOLOGO, defaultValue: false); + bool skipWorkloadIntegrityCheck = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK, + // Default the workload integrity check skip to true if the command is being ran in CI. Otherwise, false. + defaultValue: new CIEnvironmentDetectorForTelemetry().IsCIEnvironment()); + + var isDotnetBeingInvokedFromNativeInstaller = false; + // Note: This should not be special cased like this. Determine if we can skip first run setup entirely for this command. + if (parseResult.CommandResult.Command is InternalReportInstallSuccessCommandDefinition) { - return; + aspNetCertificateSentinel = new NoOpAspNetCertificateSentinel(); + firstTimeUseNoticeSentinel = new NoOpFirstTimeUseNoticeSentinel(); + toolPathSentinel = new NoOpFileSentinel(exists: false); + isDotnetBeingInvokedFromNativeInstaller = true; } - Reporter.Verbose.WriteLine( - string.Format( - LocalizableStrings.DotnetCliHomeUsed, - home, - CliFolderPathCalculator.DotnetHomeVariableName)); - } + var dotnetFirstRunConfiguration = new DotnetFirstRunConfiguration( + generateAspNetCertificate, + telemetryOptout, + addGlobalToolsToPath, + nologo, + skipWorkloadIntegrityCheck); + + string[] getStarOperators = ["getProperty", "getItem", "getTargetResult"]; + char[] switchIndicators = ['-', '/']; + var skipFirstTimeUseCheck = parseResult.CommandResult.Tokens.Any(t => + getStarOperators.Any(o => + switchIndicators.Any(i => t.Value.StartsWith(i + o, StringComparison.OrdinalIgnoreCase)))); - private static void ConfigureDotNetForFirstTimeUse( - IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel, - IAspNetCertificateSentinel aspNetCertificateSentinel, - IFileSentinel toolPathSentinel, - bool isDotnetBeingInvokedFromNativeInstaller, - DotnetFirstRunConfiguration dotnetFirstRunConfiguration, - IEnvironmentProvider environmentProvider, - Dictionary performanceMeasurements, - bool skipFirstTimeUseCheck) - { var isFirstTimeUse = !firstTimeUseNoticeSentinel.Exists() && !skipFirstTimeUseCheck; var environmentPath = EnvironmentPathFactory.CreateEnvironmentPath(isDotnetBeingInvokedFromNativeInstaller, environmentProvider); - _ = new DotNetCommandFactory(alwaysRunOutOfProc: true); + // Note: Not sure why this unused instance type is created. + var __ = new DotNetCommandFactory(alwaysRunOutOfProc: true); var aspnetCertificateGenerator = new AspNetCoreCertificateGenerator(); var reporter = Reporter.Error; var dotnetConfigurer = new DotnetFirstTimeUseConfigurer( @@ -413,8 +234,7 @@ private static void ConfigureDotNetForFirstTimeUse( dotnetFirstRunConfiguration, reporter, environmentPath, - performanceMeasurements, - skipFirstTimeUseCheck: skipFirstTimeUseCheck); + skipFirstTimeUseCheck); dotnetConfigurer.Configure(); @@ -425,7 +245,7 @@ private static void ConfigureDotNetForFirstTimeUse( } #endif - if (isFirstTimeUse && !dotnetFirstRunConfiguration.SkipWorkloadIntegrityCheck) + if (isFirstTimeUse && !skipWorkloadIntegrityCheck) { try { @@ -439,12 +259,94 @@ private static void ConfigureDotNetForFirstTimeUse( } } - private static void InitializeProcess() + private static int ExecuteInternalCommand(ParseResult parseResult) { - // by default, .NET Core doesn't have all code pages needed for Console apps. - // see the .NET Core Notes in https://docs.microsoft.com/dotnet/api/system.diagnostics.process#-notes - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + Debug.Assert(parseResult.CanBeInvoked()); + int exitCode; + using var _ = Activities.Source.StartActivity("invocation"); + try + { + exitCode = Parser.Invoke(parseResult); + if (parseResult.Errors.Any()) + { + exitCode = AdjustExitCodeForNew(); + } + } + catch (Exception exception) + { + exitCode = Parser.ExceptionHandler(exception, parseResult); + } + return exitCode; - UILanguageOverride.Setup(); + int AdjustExitCodeForNew() + { + var commandResult = parseResult.CommandResult; + while (commandResult is not null) + { + if (commandResult.Command.Name == "new") + { + // Default parse error exit code is 1. + // For the "new" command and its subcommands, it needs to be 127. + return 127; + } + commandResult = commandResult.Parent as CommandResult; + } + return exitCode; + } + } + + private static int ExecuteExternalCommand(string[] args, ParseResult parseResult) + { + string commandName = "dotnet-" + parseResult.GetValue(Parser.RootCommand.DotnetSubCommand); + CommandSpec? resolvedCommandSpec = null; + using (var _ = Activities.Source.StartActivity("lookup-external-command")) + { + resolvedCommandSpec = CommandResolver.TryResolveCommandSpec( + new DefaultCommandResolverPolicy(), + commandName, + args.GetSubArguments(), + FrameworkConstants.CommonFrameworks.NetStandardApp15); + } + + if (resolvedCommandSpec is null && TryRunFileBasedApp(parseResult) is { } fileBasedAppExitCode) + { + return fileBasedAppExitCode; + } + + var resolvedCommand = CommandFactoryUsingResolver.CreateOrThrow(commandName, resolvedCommandSpec); + using var __ = Activities.Source.StartActivity("execute-extensible-command"); + return resolvedCommand.Execute().ExitCode; + } + + private static int? TryRunFileBasedApp(ParseResult parseResult) + { + // If we didn't match any built-in commands, and a C# file path is the first argument, + // parse as `dotnet run file.cs ..rest_of_args` instead. + if (parseResult.GetResult(Parser.RootCommand.DotnetSubCommand) is { Tokens: [{ Type: TokenType.Argument, Value: { } } unmatchedCommandOrFile] } + && VirtualProjectBuilder.IsValidEntryPointPath(unmatchedCommandOrFile.Value)) + { + List otherTokens = new(parseResult.Tokens.Count - 1); + foreach (var token in parseResult.Tokens) + { + if (token.Type != TokenType.Argument || token != unmatchedCommandOrFile) + { + otherTokens.Add(token.Value); + } + } + parseResult = Parser.Parse(["run", "--file", unmatchedCommandOrFile.Value, .. otherTokens]); + return ExecuteInternalCommand(parseResult); + } + + return null; + } + + public static void Shutdown(PosixSignalContext context) + { + s_sigIntRegistration.Dispose(); + s_sigQuitRegistration.Dispose(); + s_sigTermRegistration.Dispose(); + s_mainActivity?.Stop(); + TelemetryClient.FlushProviders(); + Activities.Source.Dispose(); } } diff --git a/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs b/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs index abe2058763ff..86e124e8e668 100644 --- a/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs +++ b/src/Cli/dotnet/Telemetry/AllowListToSendFirstAppliedOptions.cs @@ -14,10 +14,10 @@ internal class AllowListToSendFirstAppliedOptions( { private HashSet _topLevelCommandNameAllowList { get; } = topLevelCommandNameAllowList; - public List AllowList(ParseResult parseResult, Dictionary measurements = null) + public List AllowList(ParseResult parseResult) { var topLevelCommandNameFromParse = parseResult.RootSubCommandResult(); - var result = new List(); + var result = new List(); if (_topLevelCommandNameAllowList.Contains(topLevelCommandNameFromParse)) { var firstOption = parseResult.RootCommandResult.Children @@ -25,14 +25,13 @@ public List AllowList(ParseResult parseResult, D .Children.OfType().FirstOrDefault()?.Command.Name ?? null; if (firstOption != null) { - result.Add(new ApplicationInsightsEntryFormat( + result.Add(new TelemetryEntryFormat( "sublevelparser/command", new Dictionary { - { "verb", topLevelCommandNameFromParse}, + {"verb", topLevelCommandNameFromParse}, {"argument", firstOption} - }, - measurements)); + })); } } return result; diff --git a/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs b/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs index 0b303a14bcd7..4961193ca740 100644 --- a/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs +++ b/src/Cli/dotnet/Telemetry/AllowListToSendFirstArgument.cs @@ -1,22 +1,19 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Cli.Telemetry; -internal class AllowListToSendFirstArgument( - HashSet topLevelCommandNameAllowList) : IParseResultLogRule +internal class AllowListToSendFirstArgument(HashSet topLevelCommandNameAllowList) : IParseResultLogRule { private HashSet _topLevelCommandNameAllowList { get; } = topLevelCommandNameAllowList; - public List AllowList(ParseResult parseResult, Dictionary measurements = null) + public List AllowList(ParseResult parseResult) { - var result = new List(); + var result = new List(); var topLevelCommandNameFromParse = parseResult.RootCommandResult.Children.FirstOrDefault() switch { System.CommandLine.Parsing.CommandResult commandResult => commandResult.Command.Name, @@ -28,17 +25,17 @@ public List AllowList(ParseResult parseResult, D { if (_topLevelCommandNameAllowList.Contains(topLevelCommandNameFromParse)) { - var firstArgument = parseResult.RootCommandResult.Children.FirstOrDefault()?.Tokens.Where(t => t.Type.Equals(TokenType.Argument)).FirstOrDefault()?.Value ?? null; + var firstArgument = parseResult.RootCommandResult.Children.FirstOrDefault()?.Tokens + .Where(t => t.Type.Equals(TokenType.Argument)).FirstOrDefault()?.Value ?? null; if (firstArgument != null) { - result.Add(new ApplicationInsightsEntryFormat( + result.Add(new TelemetryEntryFormat( "sublevelparser/command", - new Dictionary + new Dictionary { {"verb", topLevelCommandNameFromParse}, {"argument", firstArgument} - }, - measurements)); + })); } } } diff --git a/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.cs b/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.cs index 44eff7eb5707..7159b126ac2a 100644 --- a/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.cs +++ b/src/Cli/dotnet/Telemetry/AllowListToSendVerbSecondVerbFirstArgument.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.Extensions; @@ -10,14 +8,13 @@ namespace Microsoft.DotNet.Cli.Telemetry; -internal class AllowListToSendVerbSecondVerbFirstArgument( - HashSet topLevelCommandNameAllowList) : IParseResultLogRule +internal class AllowListToSendVerbSecondVerbFirstArgument(HashSet topLevelCommandNameAllowList) : IParseResultLogRule { private HashSet TopLevelCommandNameAllowList { get; } = topLevelCommandNameAllowList; - public List AllowList(ParseResult parseResult, Dictionary measurements = null) + public List AllowList(ParseResult parseResult) { - var result = new List(); + var result = new List(); var topLevelCommandNameFromParse = parseResult.RootSubCommandResult(); if (topLevelCommandNameFromParse != null) @@ -29,15 +26,14 @@ public List AllowList(ParseResult parseResult, D var firstArgument = parseResult.Tokens.FirstOrDefault(t => t.Type.Equals(TokenType.Argument))?.Value ?? ""; if (secondVerb != null) { - result.Add(new ApplicationInsightsEntryFormat( + result.Add(new TelemetryEntryFormat( "sublevelparser/command", - new Dictionary + new Dictionary { {"verb", topLevelCommandNameFromParse}, {"subcommand", secondVerb}, {"argument", firstArgument} - }, - measurements)); + })); } } } diff --git a/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.cs b/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.cs index a333f4127416..b1de4ad11aa7 100644 --- a/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.cs +++ b/src/Cli/dotnet/Telemetry/ExternalTelemetryProperties.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.Diagnostics; using System.Globalization; using System.Security; @@ -18,7 +16,7 @@ internal static class ExternalTelemetryProperties /// For Windows, returns the OS installation type, eg. "Nano Server", "Server Core", "Server", or "Client". /// For Unix, or on error, currently returns empty string. /// - internal static string GetInstallationType() + internal static string? GetInstallationType() { if (!OperatingSystem.IsWindows()) { @@ -30,7 +28,7 @@ internal static string GetInstallationType() try { - return (string)Registry.GetValue(Key, ValueName, defaultValue: ""); + return (string?)Registry.GetValue(Key, ValueName, defaultValue: ""); } // Catch everything: this is for telemetry only. catch (Exception e) @@ -50,7 +48,7 @@ internal static string GetInstallationType() /// We're not attempting to decode the value on the client side as new Windows releases may add new values. /// For Unix, or on error, returns an empty string. /// - internal static string GetProductType() + internal static string? GetProductType() { if (!OperatingSystem.IsWindows()) { @@ -84,7 +82,7 @@ internal static string GetProductType() /// If the libc is musl, currently returns empty string. /// Otherwise returns empty string. /// - internal static string GetLibcRelease() + internal static string? GetLibcRelease() { if (OperatingSystem.IsWindows()) { @@ -108,7 +106,7 @@ internal static string GetLibcRelease() /// If the libc is musl, currently returns empty string. (In future could run "ldd -version".) /// Otherwise returns empty string. /// - internal static string GetLibcVersion() + internal static string? GetLibcVersion() { if (OperatingSystem.IsWindows()) { diff --git a/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs b/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs index 6384f7dbd59a..7dfe292c6c0c 100644 --- a/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs +++ b/src/Cli/dotnet/Telemetry/IParseResultLogRule.cs @@ -10,5 +10,5 @@ namespace Microsoft.DotNet.Cli.Telemetry; internal interface IParseResultLogRule { - List AllowList(ParseResult parseResult, Dictionary measurements = null); + List AllowList(ParseResult parseResult); } diff --git a/src/Cli/dotnet/Telemetry/ITelemetry.cs b/src/Cli/dotnet/Telemetry/ITelemetryClient.cs similarity index 65% rename from src/Cli/dotnet/Telemetry/ITelemetry.cs rename to src/Cli/dotnet/Telemetry/ITelemetryClient.cs index b8ee7c98e118..f022648a59ff 100644 --- a/src/Cli/dotnet/Telemetry/ITelemetry.cs +++ b/src/Cli/dotnet/Telemetry/ITelemetryClient.cs @@ -1,17 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - namespace Microsoft.DotNet.Cli.Telemetry; -public interface ITelemetry +public interface ITelemetryClient { bool Enabled { get; } - void TrackEvent(string eventName, IDictionary properties, IDictionary measurements); - - void Flush(); - - void Dispose(); + void TrackEvent(string eventName, IDictionary? properties); } diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs deleted file mode 100644 index 2966dfc205a8..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/BaseStorageService.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using Microsoft.ApplicationInsights.Channel; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -internal abstract class BaseStorageService -{ - /// - /// Peeked transmissions dictionary (maps file name to its full path). Holds all the transmissions that were peeked. - /// - /// - /// Note: The value (=file's full path) is not required in the Storage implementation. - /// If there was a concurrent Abstract Data Type Set it would have been used instead. - /// However, since there is no concurrent Set, dictionary is used and the second value is ignored. - /// - protected IDictionary PeekedTransmissions; - - /// - /// Gets or sets the maximum size of the storage in bytes. When limit is reached, the Enqueue method will drop new - /// transmissions. - /// - internal ulong CapacityInBytes { get; set; } - - /// - /// Gets or sets the maximum number of files. When limit is reached, the Enqueue method will drop new transmissions. - /// - internal uint MaxFiles { get; set; } - - internal abstract string StorageDirectoryPath { get; } - - /// - /// Initializes the - /// - /// A folder name. Under this folder all the transmissions will be saved. - internal abstract void Init(string desireStorageDirectoryPath); - - internal abstract StorageTransmission Peek(); - - internal abstract void Delete(StorageTransmission transmission); - - internal abstract Task EnqueueAsync(Transmission transmission); - - protected void OnPeekedItemDisposed(string fileName) - { - try - { - if (PeekedTransmissions.ContainsKey(fileName)) - { - PeekedTransmissions.Remove(fileName); - } - } - catch (Exception e) - { - PersistenceChannelDebugLog.WriteException(e, "Failed to remove the item from storage items."); - } - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs deleted file mode 100644 index 4a80be224e9c..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/FixedSizeQueue.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -/// -/// A light fixed size queue. If Enqueue is called and queue's limit has reached the last item will be removed. -/// This data structure is thread safe. -/// -internal class FixedSizeQueue -{ - private readonly int _maxSize; - private readonly Queue _queue = new(); - private readonly object _queueLockObj = new(); - - internal FixedSizeQueue(int maxSize) - { - _maxSize = maxSize; - } - - internal void Enqueue(T item) - { - lock (_queueLockObj) - { - if (_queue.Count == _maxSize) - { - _queue.Dequeue(); - } - - _queue.Enqueue(item); - } - } - - internal bool Contains(T item) - { - lock (_queueLockObj) - { - return _queue.Contains(item); - } - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs deleted file mode 100644 index 8d14b740cca0..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/FlushManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.Extensibility.Implementation; -using IChannelTelemetry = Microsoft.ApplicationInsights.Channel.ITelemetry; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -/// -/// This class handles all the logic for flushing the In Memory buffer to the persistent storage. -/// -internal class FlushManager -{ - /// - /// The storage that is used to persist all the transmissions. - /// - private readonly BaseStorageService _storage; - - /// - /// Initializes a new instance of the class. - /// - /// The storage that persists the telemetries. - internal FlushManager(BaseStorageService storage) - { - _storage = storage; - } - - /// - /// Gets or sets the service endpoint. - /// - /// - /// Q: Why flushManager knows about the endpoint? - /// A: Storage stores Transmission objects and Transmission objects contain the endpoint address. - /// - internal Uri EndpointAddress { get; set; } - - /// - /// Persist the in-memory telemetry items. - /// - internal void Flush(IChannelTelemetry telemetryItem) - { - if (telemetryItem != null) - { - byte[] data = JsonSerializer.Serialize([telemetryItem]); - Transmission transmission = new( - EndpointAddress, - data, - "application/x-json-stream", - JsonSerializer.CompressionType); - - _storage.EnqueueAsync(transmission).ConfigureAwait(false).GetAwaiter().GetResult(); - } - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs deleted file mode 100644 index 69affdc523a8..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannel.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using Microsoft.ApplicationInsights.Channel; -using IChannelTelemetry = Microsoft.ApplicationInsights.Channel.ITelemetry; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -/// -/// Represents a communication channel for sending telemetry to Application Insights via HTTPS. -/// -internal sealed class PersistenceChannel : ITelemetryChannel -{ - internal const string TelemetryServiceEndpoint = "https://dc.services.visualstudio.com/v2/track"; - - private readonly FlushManager _flushManager; - - private int _disposeCount; - private readonly BaseStorageService _storage; - private readonly PersistenceTransmitter _transmitter; - - /// - /// Initializes a new instance of the class. - /// - /// - /// Full path of a directory name. Under this folder all the transmissions will be saved. - /// Setting this value groups channels, even from different processes. - /// If 2 (or more) channels has the same storageFolderName only one channel will perform the sending even if the - /// channel is in a different process/AppDomain/Thread. - /// - /// - /// Defines the number of senders. A sender is a long-running thread that sends telemetry batches in intervals defined - /// by . - /// So the amount of senders also defined the maximum amount of http channels opened at the same time. - /// - public PersistenceChannel(string storageDirectoryPath = null, int sendersCount = 1) - { - _storage = new StorageService(); - _storage.Init(storageDirectoryPath); - _transmitter = new PersistenceTransmitter(_storage, sendersCount); - _flushManager = new FlushManager(_storage); - EndpointAddress = TelemetryServiceEndpoint; - } - - /// - /// Gets or sets an interval between each successful sending. - /// - /// - /// On error scenario this value is ignored and the interval will be defined using an exponential back-off - /// algorithm. - /// - public TimeSpan? SendingInterval - { - get => _transmitter.SendingInterval; - set => _transmitter.SendingInterval = value; - } - - - /// - /// Gets or sets the maximum amount of files allowed in storage. When the limit is reached telemetries will be dropped. - /// - public uint MaxTransmissionStorageFilesCapacity - { - get => _storage.MaxFiles; - set => _storage.MaxFiles = value; - } - - /// - /// This flag has no effect. But it is required by base class - /// - public bool? DeveloperMode { get; set; } - - /// - /// Gets or sets the HTTP address where the telemetry is sent. - /// - public string EndpointAddress - { - get => _flushManager.EndpointAddress.ToString(); - - set - { - string address = value ?? TelemetryServiceEndpoint; - _flushManager.EndpointAddress = new Uri(address); - } - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - public void Dispose() - { - if (Interlocked.Increment(ref _disposeCount) == 1) - { - _transmitter?.Dispose(); - } - } - - /// - /// Sends an instance of ITelemetry through the channel. - /// - public void Send(IChannelTelemetry item) - { - _flushManager.Flush(item); - } - - /// - /// No operation, send will always flush. So nothing will be in memory - /// - public void Flush() - { - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs deleted file mode 100644 index ff695b79e6c3..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceChannelDebugLog.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Globalization; -using Microsoft.DotNet.Cli.Utils; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -internal static class PersistenceChannelDebugLog -{ - private static readonly bool _isEnabled = IsEnabledByEnvironment(); - - private static bool IsEnabledByEnvironment() - { - var environmentProvider = new EnvironmentProvider(); - return environmentProvider.GetEnvironmentVariableAsBool("DOTNET_ENABLE_PERSISTENCE_CHANNEL_DEBUG_OUTPUT", false); - } - - public static void WriteLine(string message) - { - if (_isEnabled) - { - Reporter.Output.WriteLine(message); - } - } - - internal static void WriteException(Exception exception, string format, params string[] args) - { - var message = string.Format(CultureInfo.InvariantCulture, format, args); - WriteLine(string.Format(CultureInfo.InvariantCulture, "{0} Exception: {1}", message, exception.ToString())); - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs deleted file mode 100644 index 0cede405eb04..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/PersistenceTransmitter.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -/// -/// Implements throttled and persisted transmission of telemetry to Application Insights. -/// -internal class PersistenceTransmitter : IDisposable -{ - /// - /// The number of times this object was disposed. - /// - private int _disposeCount; - - /// - /// A list of senders that sends transmissions. - /// - private readonly List _senders = []; - - /// - /// The storage that is used to persist all the transmissions. - /// - private readonly BaseStorageService _storage; - - /// - /// Initializes a new instance of the class. - /// - /// The transmissions storage. - /// The number of senders to create. - /// - /// A boolean value that indicates if this class should try and create senders. This is a - /// workaround for unit tests purposes only. - /// - internal PersistenceTransmitter(BaseStorageService storage, int sendersCount, bool createSenders = true) - { - _storage = storage; - if (createSenders) - { - for (int i = 0; i < sendersCount; i++) - { - _senders.Add(new Sender(_storage, this)); - } - } - } - - /// - /// Gets or sets the interval between each successful sending. - /// - internal TimeSpan? SendingInterval { get; set; } - - /// - /// Disposes the object. - /// - public void Dispose() - { - if (Interlocked.Increment(ref _disposeCount) == 1) - { - StopSenders(); - } - } - - /// - /// Stops the senders. - /// - /// As long as there is no Start implementation, this method should only be called from Dispose. - private void StopSenders() - { - if (_senders == null) - { - return; - } - - List stoppedTasks = []; - foreach (Sender sender in _senders) - { - stoppedTasks.Add(sender.StopAsync()); - } - - Task.WaitAll([.. stoppedTasks]); - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs deleted file mode 100644 index 6e0f7ceceaa9..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/Sender.cs +++ /dev/null @@ -1,336 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Net; -using System.Net.NetworkInformation; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -/// -/// Fetch transmissions from the storage and sends it. -/// -internal class Sender : IDisposable -{ - /// - /// The default sending interval. - /// - private readonly TimeSpan _defaultSendingInterval; - - /// - /// A wait handle that flags the sender when to start sending again. The type is protected for unit test. - /// - protected readonly AutoResetEvent DelayHandler; - - /// - /// Holds the maximum time for the exponential back-off algorithm. The sending interval will grow on every HTTP - /// Exception until this max value. - /// - private readonly TimeSpan _maxIntervalBetweenRetries = TimeSpan.FromHours(1); - - /// - /// When storage is empty it will be queried again after this interval. - /// Decreasing to 5 sec to send first data (users and sessions). - /// - private readonly TimeSpan _sendingIntervalOnNoData = TimeSpan.FromSeconds(5); - - /// - /// A wait handle that is being set when Sender is no longer sending. - /// - private readonly AutoResetEvent _stoppedHandler; - - /// - /// The number of times this object was disposed. - /// - private int _disposeCount; - - /// - /// The amount of time to wait, in the stop method, until the last transmission is sent. - /// If time expires, the stop method will return even if the transmission hasn't been sent. - /// - private readonly TimeSpan _drainingTimeout; - - /// - /// A boolean value that indicates if the sender should be stopped. The sender's while loop is checking this boolean - /// value. - /// - private bool _stopped; - - /// - /// The transmissions storage. - /// - private readonly BaseStorageService _storage; - - /// - /// Holds the transmitter. - /// - private readonly PersistenceTransmitter _transmitter; - - /// - /// Initializes a new instance of the class. - /// - /// The storage that holds the transmissions to send. - /// - /// The persistence transmitter that manages this Sender. - /// The transmitter will be used as a configuration class, it exposes properties like SendingInterval that will be read - /// by Sender. - /// - /// - /// A boolean value that determines if Sender should start sending immediately. This is only - /// used for unit tests. - /// - internal Sender(BaseStorageService storage, PersistenceTransmitter transmitter, bool startSending = true) - { - _stopped = false; - DelayHandler = new AutoResetEvent(false); - _stoppedHandler = new AutoResetEvent(false); - _drainingTimeout = TimeSpan.FromSeconds(100); - _defaultSendingInterval = TimeSpan.FromSeconds(5); - - _transmitter = transmitter; - _storage = storage; - - if (startSending) - { - // It is currently possible for the long - running task to be executed(and thereby block during WaitOne) on the UI thread when - // called by a task scheduled on the UI thread. Explicitly specifying TaskScheduler.Default - // when calling StartNew guarantees that Sender never blocks the main thread. - Task.Factory.StartNew(SendLoop, CancellationToken.None, TaskCreationOptions.LongRunning, - TaskScheduler.Default) - .ContinueWith( - t => PersistenceChannelDebugLog.WriteException(t.Exception, "Sender: Failure in SendLoop"), - TaskContinuationOptions.OnlyOnFaulted); - } - } - - /// - /// Gets the interval between each successful sending. - /// - private TimeSpan SendingInterval - { - get - { - if (_transmitter.SendingInterval != null) - { - return _transmitter.SendingInterval.Value; - } - - return _defaultSendingInterval; - } - } - - /// - /// Disposes the managed objects. - /// - public void Dispose() - { - if (Interlocked.Increment(ref _disposeCount) == 1) - { - StopAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - - DelayHandler.Dispose(); - _stoppedHandler.Dispose(); - } - } - - /// - /// Stops the sender. - /// - internal Task StopAsync() - { - // After delayHandler is set, a sending iteration will immediately start. - // Setting stopped to true, will cause the iteration to skip the actual sending and stop immediately. - _stopped = true; - DelayHandler.Set(); - - // if delayHandler was set while a transmission was being sent, the return task will wait for it to finish, for an additional second, - // before it will mark the task as completed. - return Task.Run(() => - { - try - { - _stoppedHandler.WaitOne(_drainingTimeout); - } - catch (ObjectDisposedException) - { - } - }); - } - - /// - /// Send transmissions in a loop. - /// - protected void SendLoop() - { - TimeSpan prevSendingInterval = TimeSpan.Zero; - TimeSpan sendingInterval = _sendingIntervalOnNoData; - try - { - while (!_stopped) - { - using (StorageTransmission transmission = _storage.Peek()) - { - if (_stopped) - { - // This second verification is required for cases where 'stopped' was set while peek was happening. - // Once the actual sending starts the design is to wait until it finishes and deletes the transmission. - // So no extra validation is required. - break; - } - - // If there is a transmission to send - send it. - if (transmission != null) - { - bool shouldRetry = Send(transmission, ref sendingInterval); - if (!shouldRetry) - { - // If retry is not required - delete the transmission. - _storage.Delete(transmission); - } - } - else - { - sendingInterval = _sendingIntervalOnNoData; - } - } - - LogInterval(prevSendingInterval, sendingInterval); - DelayHandler.WaitOne(sendingInterval); - prevSendingInterval = sendingInterval; - } - - _stoppedHandler.Set(); - } - catch (ObjectDisposedException) - { - } - } - - /// - /// Sends a transmission and handle errors. - /// - /// The transmission to send. - /// - /// When this value returns it will hold a recommendation for when to start the next sending - /// iteration. - /// - /// True, if there was sent error and we need to retry sending, otherwise false. - protected virtual bool Send(StorageTransmission transmission, ref TimeSpan nextSendInterval) - { - try - { - if (transmission != null) - { - bool isConnected = NetworkInterface.GetIsNetworkAvailable(); - - // there is no internet connection available, return than. - if (!isConnected) - { - PersistenceChannelDebugLog.WriteLine( - "Cannot send data to the server. Internet connection is not available"); - return true; - } - - transmission.SendAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - - // After a successful sending, try immediately to send another transmission. - nextSendInterval = SendingInterval; - } - } - catch (WebException e) - { - int? statusCode = GetStatusCode(e); - nextSendInterval = CalculateNextInterval(statusCode, nextSendInterval, _maxIntervalBetweenRetries); - return IsRetryable(statusCode, e.Status); - } - catch (Exception e) - { - nextSendInterval = CalculateNextInterval(null, nextSendInterval, _maxIntervalBetweenRetries); - PersistenceChannelDebugLog.WriteException(e, "Unknown exception during sending"); - } - - return false; - } - - /// - /// Log next interval. Only log the interval when it changes by more then a minute. So if interval grow by 1 minute or - /// decreased by 1 minute it will be logged. - /// Logging every interval will just make the log noisy. - /// - private static void LogInterval(TimeSpan prevSendInterval, TimeSpan nextSendInterval) - { - if (Math.Abs(nextSendInterval.TotalSeconds - prevSendInterval.TotalSeconds) > 60) - { - PersistenceChannelDebugLog.WriteLine("next sending interval: " + nextSendInterval); - } - } - - /// - /// Return the status code from the web exception or null if no such code exists. - /// - private static int? GetStatusCode(WebException e) - { - if (e.Response is HttpWebResponse httpWebResponse) - { - return (int)httpWebResponse.StatusCode; - } - - return null; - } - - /// - /// Returns true if or are retryable. - /// - private static bool IsRetryable(int? httpStatusCode, WebExceptionStatus webExceptionStatus) - { - switch (webExceptionStatus) - { - case WebExceptionStatus.ProxyNameResolutionFailure: - case WebExceptionStatus.NameResolutionFailure: - case WebExceptionStatus.Timeout: - case WebExceptionStatus.ConnectFailure: - return true; - } - - if (httpStatusCode == null) - { - return false; - } - - switch (httpStatusCode.Value) - { - case 503: // Server in maintenance. - case 408: // invalid request - case 500: // Internal Server Error - case 502: // Bad Gateway, can be common when there is no network. - case 511: // Network Authentication Required - return true; - } - - return false; - } - - /// - /// Calculates the next interval using exponential back-off algorithm (with the exceptions of few error codes that - /// reset the interval to . - /// - private TimeSpan CalculateNextInterval(int? httpStatusCode, TimeSpan currentSendInterval, TimeSpan maxInterval) - { - // if item is expired, no need for exponential back-off - if (httpStatusCode != null && httpStatusCode.Value == 400 /* expired */) - { - return SendingInterval; - } - - // exponential back-off. - if (Math.Abs(currentSendInterval.TotalSeconds) < 1) - { - return TimeSpan.FromSeconds(1); - } - - double nextIntervalInSeconds = Math.Min(currentSendInterval.TotalSeconds * 2, maxInterval.TotalSeconds); - - return TimeSpan.FromSeconds(nextIntervalInSeconds); - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs deleted file mode 100644 index 3e98a0d598a8..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingCollection.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Collections; -using System.Diagnostics; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -internal abstract class SnapshottingCollection : ICollection - where TCollection : class, ICollection -{ - protected readonly TCollection Collection; - protected TCollection snapshot; - - protected SnapshottingCollection(TCollection collection) - { - Debug.Assert(collection != null, "collection"); - Collection = collection; - } - - public int Count => GetSnapshot().Count; - - public bool IsReadOnly => false; - - public void Add(TItem item) - { - lock (Collection) - { - Collection.Add(item); - snapshot = default; - } - } - - public void Clear() - { - lock (Collection) - { - Collection.Clear(); - snapshot = default; - } - } - - public bool Contains(TItem item) - { - return GetSnapshot().Contains(item); - } - - public void CopyTo(TItem[] array, int arrayIndex) - { - GetSnapshot().CopyTo(array, arrayIndex); - } - - public bool Remove(TItem item) - { - lock (Collection) - { - bool removed = Collection.Remove(item); - if (removed) - { - snapshot = default; - } - - return removed; - } - } - - public IEnumerator GetEnumerator() - { - return GetSnapshot().GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - protected abstract TCollection CreateSnapshot(TCollection collection); - - protected TCollection GetSnapshot() - { - TCollection localSnapshot = snapshot; - if (localSnapshot == null) - { - lock (Collection) - { - snapshot = CreateSnapshot(Collection); - localSnapshot = snapshot; - } - } - - return localSnapshot; - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs deleted file mode 100644 index cc6cc51a1f57..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/SnapshottingDictionary.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -internal class SnapshottingDictionary : - SnapshottingCollection, IDictionary>, IDictionary -{ - public SnapshottingDictionary() - : base(new Dictionary()) - { - } - - public ICollection Keys => GetSnapshot().Keys; - - public ICollection Values => GetSnapshot().Values; - - public TValue this[TKey key] - { - get => GetSnapshot()[key]; - - set - { - lock (Collection) - { - Collection[key] = value; - snapshot = null; - } - } - } - - public void Add(TKey key, TValue value) - { - lock (Collection) - { - Collection.Add(key, value); - snapshot = null; - } - } - - public bool ContainsKey(TKey key) - { - return GetSnapshot().ContainsKey(key); - } - - public bool Remove(TKey key) - { - lock (Collection) - { - bool removed = Collection.Remove(key); - if (removed) - { - snapshot = null; - } - - return removed; - } - } - - public bool TryGetValue(TKey key, out TValue value) - { - return GetSnapshot().TryGetValue(key, out value); - } - - protected sealed override IDictionary CreateSnapshot(IDictionary collection) - { - return new Dictionary(collection); - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs deleted file mode 100644 index dc44a0bf3cbb..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageService.cs +++ /dev/null @@ -1,352 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Globalization; -using Microsoft.ApplicationInsights.Channel; -using Microsoft.DotNet.Configurer; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -internal sealed class StorageService : BaseStorageService -{ - private const string DefaultStorageFolderName = "TelemetryStorageService"; - private readonly FixedSizeQueue _deletedFilesQueue = new(10); - - private readonly object _peekLockObj = new(); - private readonly object _storageFolderLock = new(); - private string _storageDirectoryPath; - private string _storageDirectoryPathUsed; - private long _storageCountFiles; - private bool _storageFolderInitialized; - private long _storageSize; - private uint _transmissionsDropped; - - /// - /// Gets the storage's folder name. - /// - internal override string StorageDirectoryPath => _storageDirectoryPath; - - /// - /// Gets the storage folder. If storage folder couldn't be created, null will be returned. - /// - private string StorageFolder - { - get - { - if (!_storageFolderInitialized) - { - lock (_storageFolderLock) - { - if (!_storageFolderInitialized) - { - try - { - _storageDirectoryPathUsed = _storageDirectoryPath; - - if (!Directory.Exists(_storageDirectoryPathUsed)) - { - Directory.CreateDirectory(_storageDirectoryPathUsed); - } - } - catch (Exception e) - { - _storageDirectoryPathUsed = null; - PersistenceChannelDebugLog.WriteException(e, "Failed to create storage folder"); - } - - _storageFolderInitialized = true; - } - } - } - - return _storageDirectoryPathUsed; - } - } - - internal override void Init(string storageDirectoryPath) - { - PeekedTransmissions = new SnapshottingDictionary(); - - VerifyOrSetDefaultStorageDirectoryPath(storageDirectoryPath); - - CapacityInBytes = 10 * 1024 * 1024; // 10 MB - MaxFiles = 100; - - Task.Run(DeleteObsoleteFiles) - .ContinueWith( - task => - { - PersistenceChannelDebugLog.WriteException( - task.Exception, - "Storage: Unhandled exception in DeleteObsoleteFiles"); - }, - TaskContinuationOptions.OnlyOnFaulted); - } - - private void VerifyOrSetDefaultStorageDirectoryPath(string desireStorageDirectoryPath) - { - if (string.IsNullOrEmpty(desireStorageDirectoryPath)) - { - _storageDirectoryPath = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, - DefaultStorageFolderName); - } - else - { - if (!Path.IsPathRooted(desireStorageDirectoryPath)) - { - throw new ArgumentException($"{nameof(desireStorageDirectoryPath)} need to be rooted (full path)"); - } - - _storageDirectoryPath = desireStorageDirectoryPath; - } - } - - /// - /// Reads an item from the storage. Order is Last-In-First-Out. - /// When the Transmission is no longer needed (it was either sent or failed with a non-retryable error) it should be - /// disposed. - /// - internal override StorageTransmission Peek() - { - IEnumerable files = GetFiles("*.trn", 50); - - lock (_peekLockObj) - { - foreach (string file in files) - { - try - { - // if a file was peeked before, skip it (wait until it is disposed). - if (PeekedTransmissions.ContainsKey(file) == false && - _deletedFilesQueue.Contains(file) == false) - { - // Load the transmission from disk. - StorageTransmission storageTransmissionItem = LoadTransmissionFromFileAsync(file) - .ConfigureAwait(false).GetAwaiter().GetResult(); - - // when item is disposed it should be removed from the peeked list. - storageTransmissionItem.Disposing = item => OnPeekedItemDisposed(file); - - // add the transmission to the list. - PeekedTransmissions.Add(file, storageTransmissionItem.FullFilePath); - return storageTransmissionItem; - } - } - catch (Exception e) - { - PersistenceChannelDebugLog.WriteException( - e, - "Failed to load an item from the storage. file: {0}", - file); - } - } - } - - return null; - } - - internal override void Delete(StorageTransmission item) - { - try - { - if (StorageFolder == null) - { - return; - } - - // Initial storage size calculation. - CalculateSize(); - - long fileSize = GetSize(item.FileName); - File.Delete(Path.Combine(StorageFolder, item.FileName)); - - _deletedFilesQueue.Enqueue(item.FileName); - - // calculate size - Interlocked.Add(ref _storageSize, -fileSize); - Interlocked.Decrement(ref _storageCountFiles); - } - catch (IOException e) - { - PersistenceChannelDebugLog.WriteException(e, "Failed to delete a file. file: {0}", item == null ? "null" : item.FullFilePath); - } - } - - internal override async Task EnqueueAsync(Transmission transmission) - { - try - { - if (transmission == null || StorageFolder == null) - { - return; - } - - // Initial storage size calculation. - CalculateSize(); - - if ((ulong)_storageSize >= CapacityInBytes || _storageCountFiles >= MaxFiles) - { - // if max storage capacity has reached, drop the transmission (but log every 100 lost transmissions). - if (_transmissionsDropped++ % 100 == 0) - { - PersistenceChannelDebugLog.WriteLine("Total transmissions dropped: " + _transmissionsDropped); - } - - return; - } - - // Writes content to a temporary file and only then rename to avoid the Peek from reading the file before it is being written. - // Creates the temp file name - string tempFileName = Guid.NewGuid().ToString("N"); - - // Now that the file got created we can increase the files count - Interlocked.Increment(ref _storageCountFiles); - - // Saves transmission to the temp file - await SaveTransmissionToFileAsync(transmission, tempFileName).ConfigureAwait(false); - - // Now that the file is written increase storage size. - long temporaryFileSize = GetSize(tempFileName); - Interlocked.Add(ref _storageSize, temporaryFileSize); - - // Creates a new file name - string now = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); - string newFileName = string.Format(CultureInfo.InvariantCulture, "{0}_{1}.trn", now, tempFileName); - - // Renames the file - File.Move(Path.Combine(StorageFolder, tempFileName), Path.Combine(StorageFolder, newFileName)); - } - catch (Exception e) - { - PersistenceChannelDebugLog.WriteException(e, "EnqueueAsync"); - } - } - - private async Task SaveTransmissionToFileAsync(Transmission transmission, string file) - { - try - { - using (Stream stream = File.OpenWrite(Path.Combine(StorageFolder, file))) - { - await StorageTransmission.SaveAsync(transmission, stream).ConfigureAwait(false); - } - } - catch (UnauthorizedAccessException) - { - string message = - string.Format( - "Failed to save transmission to file. UnauthorizedAccessException. File path: {0}, FileName: {1}", - StorageFolder, file); - PersistenceChannelDebugLog.WriteLine(message); - throw; - } - } - - private async Task LoadTransmissionFromFileAsync(string file) - { - try - { - using (Stream stream = File.OpenRead(Path.Combine(StorageFolder, file))) - { - StorageTransmission storageTransmissionItem = - await StorageTransmission.CreateFromStreamAsync(stream, file).ConfigureAwait(false); - return storageTransmissionItem; - } - } - catch (Exception e) - { - string message = - string.Format( - "Failed to load transmission from file. File path: {0}, FileName: {1}, Exception: {2}", - "storageFolderName", file, e); - PersistenceChannelDebugLog.WriteLine(message); - throw; - } - } - - /// - /// Get files from . - /// - /// Define the logic for sorting the files. - /// Defines a file extension. This method will return only files with this extension. - /// - /// Define how many files to return. This can be useful when the directory has a lot of files, in that case - /// GetFilesAsync will have a performance hit. - /// - private IEnumerable GetFiles(string filterByExtension, int top) - { - try - { - if (StorageFolder != null) - { - return Directory.GetFiles(StorageFolder, filterByExtension).Take(top); - } - } - catch (Exception e) - { - PersistenceChannelDebugLog.WriteException(e, "Peek failed while get files from storage."); - } - - return []; - } - - /// - /// Gets a file's size. - /// - private long GetSize(string file) - { - using (FileStream stream = File.OpenRead(Path.Combine(StorageFolder, file))) - { - return stream.Length; - } - } - - /// - /// Check the storage limits and return true if they reached. - /// Storage limits are defined by the number of files and the total size on disk. - /// - private void CalculateSize() - { - string[] storageFiles = Directory.GetFiles(StorageFolder, "*.*"); - - _storageCountFiles = storageFiles.Count(); - - long storageSizeInBytes = 0; - foreach (string file in storageFiles) - { - storageSizeInBytes += GetSize(file); - } - - _storageSize = storageSizeInBytes; - } - - /// - /// Enqueue is saving a transmission to a file with a guid, and after a successful write operation it renames it to a - /// trn file. - /// A file without a trn extension is ignored by Storage.Peek(), so if a process is taken down before rename - /// happens it will stay on the disk forever. - /// This thread deletes files with the trn extension that exists on disk for more than 5 minutes. - /// - private void DeleteObsoleteFiles() - { - try - { - IEnumerable files = GetFiles("*.trn", 50); - foreach (string file in files) - { - DateTime creationTime = File.GetCreationTimeUtc(Path.Combine(StorageFolder, file)); - // if the file is older then 5 minutes - delete it. - if (DateTime.UtcNow - creationTime >= TimeSpan.FromMinutes(5)) - { - File.Delete(Path.Combine(StorageFolder, file)); - } - } - } - catch (Exception e) - { - PersistenceChannelDebugLog.WriteException(e, "Failed to delete tmp files."); - } - } -} diff --git a/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs b/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs deleted file mode 100644 index 69b6132f1b9f..000000000000 --- a/src/Cli/dotnet/Telemetry/PersistenceChannel/StorageTransmission.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Globalization; -using Microsoft.ApplicationInsights.Channel; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel; - -internal class StorageTransmission : Transmission, IDisposable -{ - internal Action Disposing; - - protected StorageTransmission(string fullPath, Uri address, byte[] content, string contentType, - string contentEncoding) - : base(address, content, contentType, contentEncoding) - { - FullFilePath = fullPath; - FileName = Path.GetFileName(fullPath); - } - - internal string FileName { get; } - - internal string FullFilePath { get; } - - /// - /// Disposing the storage transmission. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Creates a new transmission from the specified . - /// - /// Return transmission loaded from file; return null if the file is corrupted. - internal static async Task CreateFromStreamAsync(Stream stream, string fileName) - { - StreamReader reader = new(stream); - Uri address = await ReadAddressAsync(reader).ConfigureAwait(false); - string contentType = await ReadHeaderAsync(reader, "Content-Type").ConfigureAwait(false); - string contentEncoding = await ReadHeaderAsync(reader, "Content-Encoding").ConfigureAwait(false); - byte[] content = await ReadContentAsync(reader).ConfigureAwait(false); - return new StorageTransmission(fileName, address, content, contentType, contentEncoding); - } - - /// - /// Saves the transmission to the specified . - /// - internal static async Task SaveAsync(Transmission transmission, Stream stream) - { - StreamWriter writer = new(stream); - try - { - await writer.WriteLineAsync(transmission.EndpointAddress.ToString()).ConfigureAwait(false); - await writer.WriteLineAsync("Content-Type" + ":" + transmission.ContentType).ConfigureAwait(false); - await writer.WriteLineAsync("Content-Encoding" + ":" + transmission.ContentEncoding) - .ConfigureAwait(false); - await writer.WriteLineAsync(string.Empty).ConfigureAwait(false); - await writer.WriteAsync(Convert.ToBase64String(transmission.Content)).ConfigureAwait(false); - } - finally - { - writer.Flush(); - } - } - - private static async Task ReadHeaderAsync(TextReader reader, string headerName) - { - string line = await reader.ReadLineAsync().ConfigureAwait(false); - if (string.IsNullOrEmpty(line)) - { - throw new FormatException(string.Format(CultureInfo.InvariantCulture, "{0} header is expected.", - headerName)); - } - - string[] parts = line.Split(':'); - if (parts.Length != 2) - { - throw new FormatException(string.Format(CultureInfo.InvariantCulture, - "Unexpected header format. {0} header is expected. Actual header: {1}", headerName, line)); - } - - if (parts[0] != headerName) - { - throw new FormatException(string.Format(CultureInfo.InvariantCulture, - "{0} header is expected. Actual header: {1}", headerName, line)); - } - - return parts[1].Trim(); - } - - private static async Task ReadAddressAsync(TextReader reader) - { - string addressLine = await reader.ReadLineAsync().ConfigureAwait(false); - if (string.IsNullOrEmpty(addressLine)) - { - throw new FormatException("Transmission address is expected."); - } - - Uri address = new(addressLine); - return address; - } - - private static async Task ReadContentAsync(TextReader reader) - { - string content = await reader.ReadToEndAsync().ConfigureAwait(false); - if (string.IsNullOrEmpty(content) || content == Environment.NewLine) - { - throw new FormatException("Content is expected."); - } - - return Convert.FromBase64String(content); - } - - private void Dispose(bool disposing) - { - if (disposing) - { - Action disposingDelegate = Disposing; - disposingDelegate?.Invoke(this); - } - } -} diff --git a/src/Cli/dotnet/Telemetry/Telemetry.cs b/src/Cli/dotnet/Telemetry/Telemetry.cs deleted file mode 100644 index 38f0d1c7ca19..000000000000 --- a/src/Cli/dotnet/Telemetry/Telemetry.cs +++ /dev/null @@ -1,263 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Frozen; -using System.Diagnostics; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Configurer; -using CLIRuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; - -namespace Microsoft.DotNet.Cli.Telemetry; - -public class Telemetry : ITelemetry -{ - internal static string? CurrentSessionId = null; - internal static bool DisabledForTests = false; - private readonly int _senderCount; - private TelemetryClient? _client = null; - private FrozenDictionary? _commonProperties = null; - private FrozenDictionary? _commonMeasurements = null; - private Task? _trackEventTask = null; - - private const string ConnectionString = "InstrumentationKey=74cc1c9e-3e6e-4d05-b3fc-dde9101d0254"; - - public bool Enabled { get; } - - public Telemetry() : this(null) { } - - public Telemetry(IFirstTimeUseNoticeSentinel? sentinel) : this(sentinel, null) { } - - public Telemetry( - IFirstTimeUseNoticeSentinel? sentinel, - string? sessionId, - bool blockThreadInitialization = false, - IEnvironmentProvider? environmentProvider = null, - int senderCount = 3) - { - - if (DisabledForTests) - { - return; - } - - environmentProvider ??= new EnvironmentProvider(); - - Enabled = !environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, defaultValue: CompileOptions.TelemetryOptOutDefault) - && PermissionExists(sentinel); - - if (!Enabled) - { - return; - } - - // Store the session ID in a static field so that it can be reused - CurrentSessionId = sessionId ?? Guid.NewGuid().ToString(); - _senderCount = senderCount; - if (blockThreadInitialization) - { - InitializeTelemetry(); - } - else - { - //initialize in task to offload to parallel thread - _trackEventTask = Task.Run(() => InitializeTelemetry()); - } - } - - internal static void DisableForTests() - { - DisabledForTests = true; - CurrentSessionId = null; - } - - internal static void EnableForTests() - { - DisabledForTests = false; - } - - private static bool PermissionExists(IFirstTimeUseNoticeSentinel? sentinel) - { - if (sentinel == null) - { - return false; - } - - return sentinel.Exists(); - } - - public void TrackEvent(string eventName, IDictionary properties, - IDictionary measurements) - { - if (!Enabled) - { - return; - } - - //continue the task in different threads - if (_trackEventTask == null) - { - _trackEventTask = Task.Run(() => TrackEventTask(eventName, properties, measurements)); - return; - } - else - { - _trackEventTask = _trackEventTask.ContinueWith( - x => TrackEventTask(eventName, properties, measurements) - ); - } - } - - public void Flush() - { - if (!Enabled || _trackEventTask == null) - { - return; - } - - _trackEventTask.Wait(); - } - - // Adding dispose on graceful shutdown per https://github.com/microsoft/ApplicationInsights-dotnet/issues/1152#issuecomment-518742922 - public void Dispose() - { - if (_client != null) - { - _client.TelemetryConfiguration.Dispose(); - _client = null; - } - } - - public void ThreadBlockingTrackEvent(string eventName, IDictionary properties, IDictionary measurements) - { - if (!Enabled) - { - return; - } - TrackEventTask(eventName, properties, measurements); - } - - private void InitializeTelemetry() - { - try - { - var persistenceChannel = new PersistenceChannel.PersistenceChannel(sendersCount: _senderCount) - { - SendingInterval = TimeSpan.FromMilliseconds(1) - }; - - var config = TelemetryConfiguration.CreateDefault(); - config.TelemetryChannel = persistenceChannel; - config.ConnectionString = ConnectionString; - _client = new TelemetryClient(config); - _client.Context.Session.Id = CurrentSessionId; - _client.Context.Device.OperatingSystem = CLIRuntimeEnvironment.OperatingSystem; - - _commonProperties = new TelemetryCommonProperties().GetTelemetryCommonProperties(CurrentSessionId); - _commonMeasurements = FrozenDictionary.Empty; - } - catch (Exception e) - { - _client = null; - // we dont want to fail the tool if telemetry fails. - Debug.Fail(e.ToString()); - } - } - - private void TrackEventTask( - string eventName, - IDictionary properties, - IDictionary measurements) - { - if (_client == null) - { - return; - } - - try - { - var eventProperties = GetEventProperties(properties); - var eventMeasurements = GetEventMeasures(measurements); - - eventProperties ??= new Dictionary(); - eventProperties.Add("event id", Guid.NewGuid().ToString()); - - _client.TrackEvent(PrependProducerNamespace(eventName), eventProperties, eventMeasurements); - Activity.Current?.AddEvent(CreateActivityEvent(eventName, eventProperties, eventMeasurements)); - } - catch (Exception e) - { - Debug.Fail(e.ToString()); - } - } - - private static ActivityEvent CreateActivityEvent( - string eventName, - IDictionary? properties, - IDictionary? measurements) - { - var tags = MakeTags(properties, measurements); - return new ActivityEvent( - PrependProducerNamespace(eventName), - tags: tags); - } - - private static ActivityTagsCollection? MakeTags( - IDictionary? properties, - IDictionary? measurements) - { - if (properties == null && measurements == null) - { - return null; - } - else if (properties != null && measurements == null) - { - return [.. properties.Select(p => new KeyValuePair(p.Key, p.Value))]; - } - else if (properties == null && measurements != null) - { - return [.. measurements.Select(m => new KeyValuePair(m.Key, m.Value.ToString()))]; - } - else return [ .. properties!.Select(p => new KeyValuePair(p.Key, p.Value)), - .. measurements!.Select(m => new KeyValuePair(m.Key, m.Value.ToString())) ]; - } - - private static string PrependProducerNamespace(string eventName) => $"dotnet/cli/{eventName}"; - - private IDictionary? GetEventMeasures(IDictionary? measurements) - { - return (measurements, _commonMeasurements) switch - { - (null, null) => null, - (null, not null) => _commonMeasurements == FrozenDictionary.Empty ? null : new Dictionary(_commonMeasurements), - (not null, null) => measurements, - (not null, not null) => Combine(_commonMeasurements, measurements), - }; - } - - private IDictionary? GetEventProperties(IDictionary? properties) - { - return (properties, _commonProperties) switch - { - (null, null) => null, - (null, not null) => _commonProperties == FrozenDictionary.Empty ? null : new Dictionary(_commonProperties), - (not null, null) => properties, - (not null, not null) => Combine(_commonProperties, properties), - }; - } - - static IDictionary Combine(IDictionary common, IDictionary specific) where TKey : notnull - { - IDictionary eventMeasurements = new Dictionary(capacity: common.Count + specific.Count); - foreach (KeyValuePair measurement in common) - { - eventMeasurements[measurement.Key] = measurement.Value; - } - foreach (KeyValuePair measurement in specific) - { - eventMeasurements[measurement.Key] = measurement.Value; - } - return eventMeasurements; - } -} diff --git a/src/Cli/dotnet/Telemetry/TelemetryClient.cs b/src/Cli/dotnet/Telemetry/TelemetryClient.cs new file mode 100644 index 000000000000..aaba4e686ef1 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/TelemetryClient.cs @@ -0,0 +1,231 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Frozen; +using System.Diagnostics; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Configurer; + +#if TARGET_WINDOWS +using Azure.Monitor.OpenTelemetry.Exporter; +using OpenTelemetry; +using OpenTelemetry.Context.Propagation; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +#endif + +namespace Microsoft.DotNet.Cli.Telemetry; + +public class TelemetryClient : ITelemetryClient +{ + private static FrozenDictionary s_commonProperties = []; + private Task? _trackEventTask; + +#if TARGET_WINDOWS + private static readonly MeterProviderBuilder s_metricsProviderBuilder; + private static MeterProvider? s_metricsProvider; + private static readonly TracerProviderBuilder s_tracerProviderBuilder; + private static TracerProvider? s_tracerProvider; + private static readonly List s_activities = []; + + private static readonly string s_connectionString = "InstrumentationKey=74cc1c9e-3e6e-4d05-b3fc-dde9101d0254"; + private static readonly string s_defaultStorageDirectory = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, "TelemetryStorageService"); + // Note: The TelemetryClient instance constructor takes in an environment provider. These fields don't use that currently. + private static readonly string? s_environmentStoragePath = Env.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_STORAGE_PATH); + private static readonly string? s_diskLogPath = Env.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_LOG_PATH); + private static readonly bool s_disableTraceExport = Env.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT); + private static readonly int s_flushTimeoutMs = 200; +#endif + + public static string? CurrentSessionId { get; private set; } = null; + public static bool DisabledForTests + { + get => field; + set + { + field = value; + // When disabled, clear the session ID. + if (field) + { + CurrentSessionId = null; + } + } + } = false; + public static ActivityContext ParentActivityContext { get; private set; } + public static ActivityKind ActivityKind { get; private set; } + + public bool Enabled { get; } + + static TelemetryClient() + { +#if TARGET_WINDOWS + s_metricsProviderBuilder = Sdk.CreateMeterProviderBuilder() + .ConfigureResource(r => { r.AddService("dotnet-cli", serviceVersion: Product.Version); }) + .AddMeter(Activities.Source.Name) + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddOtlpExporter(); + + s_tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() + .ConfigureResource(r => { r.AddService("dotnet-cli", serviceVersion: Product.Version); }) + .AddSource(Activities.Source.Name) + .AddHttpClientInstrumentation() + .AddOtlpExporter() + .AddInMemoryExporter(s_activities) + .SetSampler(new AlwaysOnSampler()); + + if (!s_disableTraceExport) + { + var storageDirectory = string.IsNullOrWhiteSpace(s_environmentStoragePath) ? s_defaultStorageDirectory : s_environmentStoragePath; + s_tracerProviderBuilder.AddAzureMonitorTraceExporter(o => + { + o.ConnectionString = s_connectionString; + o.EnableLiveMetrics = false; + o.StorageDirectory = storageDirectory; + }); + } +#endif + + var parentActivityContext = GetParentActivityContext(); + ActivityKind = GetActivityKind(parentActivityContext); + ParentActivityContext = parentActivityContext ?? default; + } + + public TelemetryClient() : this(null) { } + + public TelemetryClient(string? sessionId, IEnvironmentProvider? environmentProvider = null) + { + // This is some kind of special condition for MSBuild-related tests. + if (DisabledForTests) + { + return; + } + + environmentProvider ??= new EnvironmentProvider(); + Enabled = !environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, + // When building in the official CI pipeline, this makes the complier enable telemetry by default. Otherwise, it is disabled. + // It is the reason tests don't send telemetry, because we don't run tests in the official CI pipeline. + defaultValue: CompileOptions.TelemetryOptOutDefault); + if (!Enabled) + { + return; + } + +#if TARGET_WINDOWS + if (s_metricsProvider is null || s_tracerProvider is null) + { + // Create a new OTel meter and tracer provider. + // It is important to keep the provider instances active throughout the process lifetime. + s_metricsProvider ??= s_metricsProviderBuilder.Build(); + s_tracerProvider ??= s_tracerProviderBuilder.Build(); + } +#endif + + CurrentSessionId ??= !string.IsNullOrEmpty(sessionId) ? sessionId : Guid.NewGuid().ToString(); + s_commonProperties = new TelemetryCommonProperties().GetTelemetryCommonProperties(CurrentSessionId); + } + + /// + /// Uses the OpenTelemetry SDK's Propagation API to derive the parent activity context from the DOTNET_CLI_TRACEPARENT and DOTNET_CLI_TRACESTATE environment variables. + /// + private static ActivityContext? GetParentActivityContext() + { + var traceParent = Env.GetEnvironmentVariable(Activities.TRACEPARENT); + if (string.IsNullOrEmpty(traceParent)) + { + return null; + } + + var carrierMap = new Dictionary?> { { "traceparent", [traceParent] } }; + var traceState = Env.GetEnvironmentVariable(Activities.TRACESTATE); + if (!string.IsNullOrEmpty(traceState)) + { + carrierMap.Add("tracestate", [traceState]); + } + + ActivityContext? parentContext = null; +#if TARGET_WINDOWS + // Use the propegator to extract the parent activity context and kind. + // For some reason, this isn't set by the OTel SDK like docs say it should be. + Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator([new TraceContextPropagator(), new BaggagePropagator()])); + parentContext = Propagators.DefaultTextMapPropagator.Extract(default, carrierMap, GetValueFromCarrier).ActivityContext; +#endif + return parentContext; + +#if TARGET_WINDOWS + static IEnumerable? GetValueFromCarrier(Dictionary?> carrier, string key) => + carrier.TryGetValue(key, out var value) ? value : null; +#endif + } + + private static ActivityKind GetActivityKind(ActivityContext? parentActivityContext) => + parentActivityContext is ActivityContext { IsRemote: true } ? ActivityKind.Server : ActivityKind.Internal; + + public static void FlushProviders() + { +#if TARGET_WINDOWS + s_tracerProvider?.ForceFlush(s_flushTimeoutMs); + s_metricsProvider?.ForceFlush(s_flushTimeoutMs); +#endif + } + + public static void WriteLogIfNecessary() + { +#if TARGET_WINDOWS + if (!string.IsNullOrWhiteSpace(s_diskLogPath) && s_activities.Any()) + { + TelemetryDiskLogger.WriteLog(s_diskLogPath, s_activities); + } +#endif + } + + public void TrackEvent(string eventName, IDictionary? properties) + { + if (!Enabled) + { + return; + } + + // Continue the task in different threads. + _trackEventTask = _trackEventTask == null + ? Task.Run(() => TrackEventTask(eventName, properties)) + : _trackEventTask.ContinueWith(_ => TrackEventTask(eventName, properties)); + } + + public void ThreadBlockingTrackEvent(string eventName, IDictionary? properties) + { + if (!Enabled) + { + return; + } + + TrackEventTask(eventName, properties); + } + + private static void TrackEventTask(string eventName, IDictionary? properties) + { + try + { + properties ??= new Dictionary(); + properties.Add("event id", Guid.NewGuid().ToString()); + var @event = new ActivityEvent($"dotnet/cli/{eventName}", tags: MakeTags(properties)); + Activity.Current?.AddEvent(@event); + } + catch (Exception e) + { + Debug.Fail(e.ToString()); + } + } + + private static ActivityTagsCollection MakeTags(IDictionary eventProperties) + { + var common = s_commonProperties + .Select(p => new KeyValuePair(p.Key, p.Value)); + var properties = eventProperties + .Where(p => p.Value is not null) + .Select(p => new KeyValuePair(p.Key, p.Value)) + .OrderBy(p => p.Key); + return [.. common, .. properties]; + } +} diff --git a/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs b/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs index 8c71bff14e7d..12d8395b7688 100644 --- a/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs +++ b/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.Collections.Frozen; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Configurer; @@ -13,23 +11,24 @@ namespace Microsoft.DotNet.Cli.Telemetry; internal class TelemetryCommonProperties( - Func getCurrentDirectory = null, - Func hasher = null, - Func getMACAddress = null, - Func getDeviceId = null, - IDockerContainerDetector dockerContainerDetector = null, - IUserLevelCacheWriter userLevelCacheWriter = null, - ICIEnvironmentDetector ciEnvironmentDetector = null, - ILLMEnvironmentDetector llmEnvironmentDetector = null) + Func? getCurrentDirectory = null, + Func? hasher = null, + Func? getMACAddress = null, + Func? getDeviceId = null, + IDockerContainerDetector? dockerContainerDetector = null, + IUserLevelCacheWriter? userLevelCacheWriter = null, + ICIEnvironmentDetector? ciEnvironmentDetector = null, + ILLMEnvironmentDetector? llmEnvironmentDetector = null) { private readonly IDockerContainerDetector _dockerContainerDetector = dockerContainerDetector ?? new DockerContainerDetectorForTelemetry(); private readonly ICIEnvironmentDetector _ciEnvironmentDetector = ciEnvironmentDetector ?? new CIEnvironmentDetectorForTelemetry(); private readonly ILLMEnvironmentDetector _llmEnvironmentDetector = llmEnvironmentDetector ?? new LLMEnvironmentDetectorForTelemetry(); private readonly Func _getCurrentDirectory = getCurrentDirectory ?? Directory.GetCurrentDirectory; private readonly Func _hasher = hasher ?? Sha256Hasher.Hash; - private readonly Func _getMACAddress = getMACAddress ?? MacAddressGetter.GetMacAddress; + private readonly Func _getMACAddress = getMACAddress ?? MacAddressGetter.GetMacAddress; private readonly Func _getDeviceId = getDeviceId ?? DeviceIdGetter.GetDeviceId; private readonly IUserLevelCacheWriter _userLevelCacheWriter = userLevelCacheWriter ?? new UserLevelCacheWriter(); + private const string OSVersion = "OS Version"; private const string OSPlatform = "OS Platform"; private const string OSArchitecture = "OS Architecture"; @@ -48,63 +47,39 @@ internal class TelemetryCommonProperties( private const string LibcRelease = "Libc Release"; private const string LibcVersion = "Libc Version"; private const string SessionId = "SessionId"; - private const string CI = "Continuous Integration"; private const string LLM = "llm"; - private const string TelemetryProfileEnvironmentVariable = "DOTNET_CLI_TELEMETRY_PROFILE"; - private const string CannotFindMacAddress = "Unknown"; - private const string MachineIdCacheKey = "MachineId"; private const string IsDockerContainerCacheKey = "IsDockerContainer"; - public FrozenDictionary GetTelemetryCommonProperties(string currentSessionId) + public FrozenDictionary GetTelemetryCommonProperties(string? currentSessionId) => new Dictionary { - return new Dictionary - { - {OSVersion, RuntimeEnvironment.OperatingSystemVersion}, - {OSPlatform, RuntimeEnvironment.OperatingSystemPlatform.ToString()}, - {OSArchitecture, RuntimeInformation.OSArchitecture.ToString()}, - {OutputRedirected, Console.IsOutputRedirected.ToString()}, - {RuntimeId, RuntimeInformation.RuntimeIdentifier}, - {ProductVersion, Product.Version}, - {TelemetryProfile, Environment.GetEnvironmentVariable(TelemetryProfileEnvironmentVariable)}, - {DockerContainer, _userLevelCacheWriter.RunWithCache(IsDockerContainerCacheKey, () => _dockerContainerDetector.IsDockerContainer().ToString("G") )}, - {CI, _ciEnvironmentDetector.IsCIEnvironment().ToString() }, - {LLM, _llmEnvironmentDetector.GetLLMEnvironment() }, - {CurrentPathHash, _hasher(_getCurrentDirectory())}, - {MachineIdOld, _userLevelCacheWriter.RunWithCache(MachineIdCacheKey, GetMachineId)}, - // we don't want to recalcuate a new id for every new SDK version. Reuse the same path across versions. - // If we change the format of the cache later. - // We need to rename the cache from v1 to v2 - {MachineId, - _userLevelCacheWriter.RunWithCacheInFilePath( - Path.Combine( - CliFolderPathCalculator.DotnetUserProfileFolderPath, - $"{MachineIdCacheKey}.v1.dotnetUserLevelCache"), - GetMachineId)}, - {DeviceId, _getDeviceId()}, - {KernelVersion, GetKernelVersion()}, - {InstallationType, ExternalTelemetryProperties.GetInstallationType()}, - {ProductType, ExternalTelemetryProperties.GetProductType()}, - {LibcRelease, ExternalTelemetryProperties.GetLibcRelease()}, - {LibcVersion, ExternalTelemetryProperties.GetLibcVersion()}, - {SessionId, currentSessionId} - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - } + { OSVersion, RuntimeEnvironment.OperatingSystemVersion }, + { OSPlatform, RuntimeEnvironment.OperatingSystemPlatform.ToString() }, + { OSArchitecture, RuntimeInformation.OSArchitecture.ToString() }, + { OutputRedirected, Console.IsOutputRedirected.ToString() }, + { RuntimeId, RuntimeInformation.RuntimeIdentifier }, + { ProductVersion, Product.Version }, + { TelemetryProfile, Environment.GetEnvironmentVariable(TelemetryProfileEnvironmentVariable) }, + { DockerContainer, _userLevelCacheWriter.RunWithCache(IsDockerContainerCacheKey, () => _dockerContainerDetector.IsDockerContainer().ToString("G") ) }, + { CI, _ciEnvironmentDetector.IsCIEnvironment().ToString() }, + { LLM, _llmEnvironmentDetector.GetLLMEnvironment() }, + { CurrentPathHash, _hasher(_getCurrentDirectory()) }, + { MachineIdOld, _userLevelCacheWriter.RunWithCache(MachineIdCacheKey, GetMachineId) }, + // We don't want to recalcuate a new id for every new SDK version. Reuse the same path across versions. + // If we change the format of the cache later, we need to rename the cache from v1 to v2. + { MachineId, _userLevelCacheWriter.RunWithCacheInFilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, $"{MachineIdCacheKey}.v1.dotnetUserLevelCache"), GetMachineId) }, + { DeviceId, _getDeviceId() }, + { KernelVersion, GetKernelVersion() }, + { InstallationType, ExternalTelemetryProperties.GetInstallationType() }, + { ProductType, ExternalTelemetryProperties.GetProductType() }, + { LibcRelease, ExternalTelemetryProperties.GetLibcRelease() }, + { LibcVersion, ExternalTelemetryProperties.GetLibcVersion() }, + { SessionId, currentSessionId } + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - private string GetMachineId() - { - var macAddress = _getMACAddress(); - if (macAddress != null) - { - return _hasher(macAddress); - } - else - { - return Guid.NewGuid().ToString(); - } - } + private string GetMachineId() => _getMACAddress() is { } macAddress ? _hasher(macAddress) : Guid.NewGuid().ToString(); /// /// Returns a string identifying the OS kernel. @@ -140,8 +115,5 @@ private string GetMachineId() /// Windows.7 Microsoft Windows 6.1.7601 S /// Windows.81 Microsoft Windows 6.3.9600 /// - private static string GetKernelVersion() - { - return RuntimeInformation.OSDescription; - } + private static string GetKernelVersion() => RuntimeInformation.OSDescription; } diff --git a/src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs b/src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs new file mode 100644 index 000000000000..aa287369406c --- /dev/null +++ b/src/Cli/dotnet/Telemetry/TelemetryDiskLogger.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Cli.Telemetry; + +internal static class TelemetryDiskLogger +{ + private static readonly JsonSerializerOptions s_jsonOptions; + + private static readonly TelemetryDiskLoggerJsonSerializerContext s_jsonContext; + + public record EventModel( + string name, + DateTimeOffset timestamp, + Dictionary tags); + + public record SourceModel( + string name, + string? version, + Dictionary? tags); + + public record IdentifiersModel( + string? id, + string traceId, + string spanId, + string parentSpanId, + string? parentId, + string? rootId); + + public record ActivityModel( + string operationName, + string displayName, + TimeSpan duration, + IdentifiersModel identifiers, + SourceModel source, + Dictionary tags, + EventModel[] events); + + static TelemetryDiskLogger() + { + s_jsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; + s_jsonContext = new(s_jsonOptions); + } + + public static void WriteLog(string logPath, IEnumerable activies) + { + try + { + var jsonText = !File.Exists(logPath) ? """{"activities":[]}""" : File.ReadAllText(logPath); + var root = JsonNode.Parse(jsonText)!; + var activitiesArray = root["activities"]!.AsArray(); + activitiesArray.AddRange(activies.Select(r => JsonNode.Parse(JsonSerializer.Serialize(CreateActivityJsonModel(r), s_jsonContext.ActivityModel)))); + root["activities"] = activitiesArray; + File.WriteAllText(logPath, root.ToJsonString(s_jsonOptions)); + } + catch + { + // Swallow any exceptions to avoid interfering with telemetry shutdown. + } + } + + private static ActivityModel CreateActivityJsonModel(Activity activity) => new( + operationName: activity.OperationName, + displayName: activity.DisplayName, + duration: activity.Duration, + identifiers: new( + id: activity.Id, + traceId: activity.TraceId.ToString(), + spanId: activity.SpanId.ToString(), + parentSpanId: activity.ParentSpanId.ToString(), + parentId: activity.ParentId, + rootId: activity.RootId + ), + source: new( + name: activity.Source.Name, + version: activity.Source.Version, + tags: activity.Source.Tags?.ToDictionary() + ), + tags: activity.Tags.ToDictionary(), + events: [.. activity.Events.Select(e => new EventModel( + name: e.Name, + timestamp: e.Timestamp, + tags: e.Tags.ToDictionary() + ))] + ); +} + +[JsonSerializable(typeof(TelemetryDiskLogger.ActivityModel))] +internal partial class TelemetryDiskLoggerJsonSerializerContext : JsonSerializerContext; diff --git a/src/Cli/dotnet/Telemetry/TelemetryFilter.cs b/src/Cli/dotnet/Telemetry/TelemetryFilter.cs index 36d088115a01..00a758add1de 100644 --- a/src/Cli/dotnet/Telemetry/TelemetryFilter.cs +++ b/src/Cli/dotnet/Telemetry/TelemetryFilter.cs @@ -1,109 +1,97 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using System.Globalization; using Microsoft.DotNet.Cli.CommandLine; -using Microsoft.DotNet.Cli.Commands.Build; -using Microsoft.DotNet.Cli.Commands.Clean; -using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; -using Microsoft.DotNet.Cli.Commands.Pack; -using Microsoft.DotNet.Cli.Commands.Publish; -using Microsoft.DotNet.Cli.Commands.Run; -using Microsoft.DotNet.Cli.Commands.Test; using Microsoft.DotNet.Cli.Commands.VSTest; using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Cli.Telemetry; -internal class TelemetryFilter(Func hash) : ITelemetryFilter +internal class TelemetryFilter(Func? hash) : ITelemetryFilter { private const string ExceptionEventName = "mainCatchException/exception"; private readonly Func _hash = hash ?? throw new ArgumentNullException(nameof(hash)); - public IEnumerable Filter(object objectToFilter) + public IEnumerable Filter(ParseResult parseResult) => + Hash(FilterImpl(parseResult, globalJsonState: null)); + + public IEnumerable Filter(ParseResultWithGlobalJsonState parseData) => + Hash(FilterImpl(parseData.ParseResult, parseData.GlobalJsonState)); + + public IEnumerable Filter(InstallerSuccessReport report) { - var result = new List(); - Dictionary measurements = null; - string globalJsonState = string.Empty; - if (objectToFilter is Tuple> parseResultWithMeasurements) + var reportProperties = new Dictionary { - objectToFilter = parseResultWithMeasurements.Item1; - measurements = parseResultWithMeasurements.Item2; - measurements = RemoveZeroTimes(measurements); - } - else if (objectToFilter is Tuple, string> parseResultWithMeasurementsAndGlobalJsonState) + { "exeName", report.ExeName } + }; + return Hash([new TelemetryEntryFormat("install/reportsuccess", reportProperties)]); + } + + public IEnumerable Filter(Exception exception) + { + var exceptionProperties = new Dictionary { - objectToFilter = parseResultWithMeasurementsAndGlobalJsonState.Item1; - measurements = parseResultWithMeasurementsAndGlobalJsonState.Item2; - measurements = RemoveZeroTimes(measurements); - globalJsonState = parseResultWithMeasurementsAndGlobalJsonState.Item3; - } + { "exceptionType", exception.GetType().ToString() }, + { "detail", ExceptionToStringWithoutMessage(exception) } + }; + return Hash([new TelemetryEntryFormat(ExceptionEventName, exceptionProperties)]); + } - if (objectToFilter is ParseResult parseResult) + private static IEnumerable FilterImpl(ParseResult parseResult, string? globalJsonState) + { + var topLevelCommandName = parseResult.RootSubCommandResult(); + if (topLevelCommandName is null) { - var topLevelCommandName = parseResult.RootSubCommandResult(); - if (topLevelCommandName != null) - { - Dictionary properties = new() - { - ["verb"] = topLevelCommandName - }; - if (!string.IsNullOrEmpty(globalJsonState)) - { - properties["globalJson"] = globalJsonState; - } - - result.Add(new ApplicationInsightsEntryFormat( - "toplevelparser/command", - properties, - measurements - )); - - LogVerbosityForAllTopLevelCommand(result, parseResult, topLevelCommandName, measurements); - LogVulnerableOptionForPackageUpdateCommand(result, parseResult, topLevelCommandName, measurements); - - foreach (IParseResultLogRule rule in ParseResultLogRules) - { - result.AddRange(rule.AllowList(parseResult, measurements)); - } - } + yield break; } - else if (objectToFilter is InstallerSuccessReport installerSuccessReport) + + Dictionary properties = new() { ["verb"] = topLevelCommandName }; + if (!string.IsNullOrEmpty(globalJsonState)) { - result.Add(new ApplicationInsightsEntryFormat( - "install/reportsuccess", - new Dictionary { { "exeName", installerSuccessReport.ExeName } } - )); + properties["globalJson"] = globalJsonState; } - else if (objectToFilter is Exception exception) + + yield return new TelemetryEntryFormat("toplevelparser/command", properties); + + if (parseResult.IsDotnetBuiltInCommand() && + parseResult.SafelyGetValueForOption("--verbosity") is VerbosityOptions verbosity) { - result.Add(new ApplicationInsightsEntryFormat( - ExceptionEventName, - new Dictionary - { - {"exceptionType", exception.GetType().ToString()}, - {"detail", ExceptionToStringWithoutMessage(exception) } - } - )); + var verbosityProperties = new Dictionary() + { + { "verb", topLevelCommandName}, + { "verbosity", Enum.GetName(verbosity)} + }; + yield return new TelemetryEntryFormat("sublevelparser/command", verbosityProperties); } - return [.. result.Select(r => + if (topLevelCommandName == "package" && + parseResult.CommandResult.Command != null && + parseResult.CommandResult.Command.Name == "update") { - if (r.EventName == ExceptionEventName) + var hasVulnerableOption = parseResult.HasOption("--vulnerable"); + var vulnerableProperties = new Dictionary() { - return r; - } - else + { "verb", "package update" }, + { "vulnerable", hasVulnerableOption.ToString()} + }; + yield return new TelemetryEntryFormat("sublevelparser/command", vulnerableProperties); + } + + foreach (IParseResultLogRule rule in ParseResultLogRules) + { + foreach (TelemetryEntryFormat allowList in rule.AllowList(parseResult)) { - return r.WithAppliedToPropertiesValue(_hash); + yield return allowList; } - })]; + } } + public IEnumerable Hash(IEnumerable entries) => + entries.Select(entry => entry.EventName == ExceptionEventName ? entry : entry.WithAppliedToPropertiesValue(_hash)); + private static List ParseResultLogRules => [ new AllowListToSendFirstArgument(["new", "help"]), @@ -136,47 +124,6 @@ public IEnumerable Filter(object objectToFilter) new AllowListToSendVerbSecondVerbFirstArgument(["workload", "tool", "new"]), ]; - private static void LogVulnerableOptionForPackageUpdateCommand( - ICollection result, - ParseResult parseResult, - string topLevelCommandName, - Dictionary measurements = null) - { - if (topLevelCommandName == "package" && parseResult.CommandResult.Command != null && parseResult.CommandResult.Command.Name == "update") - { - var hasVulnerableOption = parseResult.HasOption("--vulnerable"); - - result.Add(new ApplicationInsightsEntryFormat( - "sublevelparser/command", - new Dictionary() - { - { "verb", "package update" }, - { "vulnerable", hasVulnerableOption.ToString()} - }, - measurements)); - } - } - - private static void LogVerbosityForAllTopLevelCommand( - ICollection result, - ParseResult parseResult, - string topLevelCommandName, - Dictionary measurements = null) - { - if (parseResult.IsDotnetBuiltInCommand() && - parseResult.SafelyGetValueForOption("--verbosity") is VerbosityOptions verbosity) - { - result.Add(new ApplicationInsightsEntryFormat( - "sublevelparser/command", - new Dictionary() - { - { "verb", topLevelCommandName}, - { "verbosity", Enum.GetName(verbosity)} - }, - measurements)); - } - } - private static string ExceptionToStringWithoutMessage(Exception e) { const string AggregateException_ToString = "{0}{1}---> (Inner Exception #{2}) {3}{4}{5}"; @@ -209,42 +156,18 @@ private static string NonAggregateExceptionToStringWithoutMessage(Exception e) string s; const string Exception_EndOfInnerExceptionStack = "--- End of inner exception stack trace ---"; - s = e.GetType().ToString(); - if (e.InnerException != null) { s = s + " ---> " + ExceptionToStringWithoutMessage(e.InnerException) + Environment.NewLine + " " + Exception_EndOfInnerExceptionStack; - } var stackTrace = e.StackTrace; - if (stackTrace != null) { s += Environment.NewLine + stackTrace; } - return s; } - - private static Dictionary RemoveZeroTimes(Dictionary measurements) - { - if (measurements != null) - { - foreach (var measurement in measurements) - { - if (measurement.Value == 0) - { - measurements.Remove(measurement.Key); - } - } - if (measurements.Count == 0) - { - measurements = null; - } - } - return measurements; - } } diff --git a/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.cs b/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.cs index 62d9d6e93630..a19e98c315ba 100644 --- a/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.cs +++ b/src/Cli/dotnet/Telemetry/TopLevelCommandNameAndOptionToLog.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.DotNet.Cli.Extensions; @@ -11,17 +9,15 @@ namespace Microsoft.DotNet.Cli.Telemetry; -internal class TopLevelCommandNameAndOptionToLog( - HashSet topLevelCommandName, - HashSet optionsToLog) : IParseResultLogRule +internal class TopLevelCommandNameAndOptionToLog(HashSet topLevelCommandName, HashSet optionsToLog) : IParseResultLogRule { private HashSet _topLevelCommandName { get; } = topLevelCommandName; private HashSet _optionsToLog { get; } = optionsToLog; - public List AllowList(ParseResult parseResult, Dictionary measurements = null) + public List AllowList(ParseResult parseResult) { var topLevelCommandName = parseResult.RootSubCommandResult(); - var result = new List(); + var result = new List(); foreach (var optionName in _optionsToLog) { if (_topLevelCommandName.Contains(topLevelCommandName) @@ -30,14 +26,13 @@ public List AllowList(ParseResult parseResult, D && optionResult.GetValueOrDefault() is object optionValue && optionValue is not null) { - result.Add(new ApplicationInsightsEntryFormat( + result.Add(new TelemetryEntryFormat( "sublevelparser/command", - new Dictionary + new Dictionary { { "verb", topLevelCommandName}, { optionName.RemovePrefix(), Stringify(optionValue) } - }, - measurements)); + })); } } return result; @@ -46,7 +41,7 @@ public List AllowList(ParseResult parseResult, D /// /// We're dealing with untyped payloads here, so we need to handle arrays vs non-array values /// - private static string Stringify(object value) + private static string? Stringify(object value) { if (value is null) { diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index de6c67859456..8dacbda0359c 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -6,7 +6,7 @@ Exe MicrosoftAspNetCore true - true + true dotnet5.4 Microsoft.DotNet.Cli $(DefineConstants);EXCLUDE_ASPNETCORE @@ -84,7 +84,6 @@ - @@ -100,7 +99,15 @@ - + + + + + + + + + diff --git a/src/Common/CompileOptions.cs b/src/Common/CompileOptions.cs index 88e90823d6bf..cb13ce8aa671 100644 --- a/src/Common/CompileOptions.cs +++ b/src/Common/CompileOptions.cs @@ -1,9 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. - #if MICROSOFT_ENABLE_TELEMETRY - [assembly: System.Reflection.AssemblyMetadata("TelemetryOptOutDefault", Microsoft.DotNet.Cli.CompileOptions.TelemetryOptOutDefaultString)] +[assembly: System.Reflection.AssemblyMetadata("TelemetryOptOutDefault", Microsoft.DotNet.Cli.CompileOptions.TelemetryOptOutDefaultString)] #endif namespace Microsoft.DotNet.Cli; diff --git a/src/Common/EnvironmentVariableNames.cs b/src/Common/EnvironmentVariableNames.cs index 6d73b33ab38f..a2dce0704346 100644 --- a/src/Common/EnvironmentVariableNames.cs +++ b/src/Common/EnvironmentVariableNames.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Cli; -static class EnvironmentVariableNames +internal static class EnvironmentVariableNames { public static readonly string ALLOW_TARGETING_PACK_CACHING = "DOTNETSDK_ALLOW_TARGETING_PACK_CACHING"; public static readonly string WORKLOAD_PACK_ROOTS = "DOTNETSDK_WORKLOAD_PACK_ROOTS"; @@ -13,18 +13,24 @@ static class EnvironmentVariableNames public static readonly string WORKLOAD_UPDATE_NOTIFY_INTERVAL_HOURS = "DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_INTERVAL_HOURS"; public static readonly string WORKLOAD_DISABLE_PACK_GROUPS = "DOTNET_CLI_WORKLOAD_DISABLE_PACK_GROUPS"; public static readonly string DISABLE_PUBLISH_AND_PACK_RELEASE = "DOTNET_CLI_DISABLE_PUBLISH_AND_PACK_RELEASE"; - public static readonly string DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS = "DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS"; + public static readonly string DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS = nameof(DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS); public static readonly string DOTNET_CLI_FORCE_UTF8_ENCODING = nameof(DOTNET_CLI_FORCE_UTF8_ENCODING); public static readonly string TELEMETRY_OPTOUT = "DOTNET_CLI_TELEMETRY_OPTOUT"; - public static readonly string DOTNET_ROOT = "DOTNET_ROOT"; - public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG = "DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG"; - public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR = "DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR"; - public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER = "DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER"; - public static readonly string DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT = "DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT"; + public static readonly string DOTNET_ROOT = nameof(DOTNET_ROOT); + public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG = nameof(DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG); + public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR = nameof(DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR); + public static readonly string DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER = nameof(DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER); + public static readonly string DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT = nameof(DOTNET_TOOLS_ALLOW_MANIFEST_IN_ROOT); public static readonly string DOTNET_GENERATE_ASPNET_CERTIFICATE = nameof(DOTNET_GENERATE_ASPNET_CERTIFICATE); public static readonly string DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = nameof(DOTNET_ADD_GLOBAL_TOOLS_TO_PATH); public static readonly string DOTNET_NOLOGO = nameof(DOTNET_NOLOGO); public static readonly string DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK = nameof(DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK); + public static readonly string DOTNET_CLI_TELEMETRY_SESSIONID = nameof(DOTNET_CLI_TELEMETRY_SESSIONID); + public static readonly string DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING = nameof(DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING); + // Telemetry logging/debug/testing. + public static readonly string DOTNET_CLI_TELEMETRY_STORAGE_PATH = nameof(DOTNET_CLI_TELEMETRY_STORAGE_PATH); + public static readonly string DOTNET_CLI_TELEMETRY_LOG_PATH = nameof(DOTNET_CLI_TELEMETRY_LOG_PATH); + public static readonly string DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT = nameof(DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT); #if NET7_0_OR_GREATER private static readonly Version s_version6_0 = new(6, 0); diff --git a/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs b/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs index 7ff2785a7b79..653b5608bafd 100644 --- a/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs +++ b/src/Microsoft.DotNet.TemplateLocator/TemplateLocator.cs @@ -36,8 +36,8 @@ public TemplateLocator(Func getEnvironmentVariable, Func GetDotnetSdkTemplatePackages( - string sdkVersion, - string dotnetRootPath, + string? sdkVersion, + string? dotnetRootPath, string? userProfileDir) { if (string.IsNullOrWhiteSpace(sdkVersion)) @@ -47,8 +47,7 @@ public IReadOnlyCollection GetDotnetSdkTemplate if (string.IsNullOrWhiteSpace(dotnetRootPath)) { - throw new ArgumentException($"'{nameof(dotnetRootPath)}' cannot be null or whitespace", - nameof(dotnetRootPath)); + throw new ArgumentException($"'{nameof(dotnetRootPath)}' cannot be null or whitespace", nameof(dotnetRootPath)); } // Will the current directory correspond to the folder we are creating a project in? If we need diff --git a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs index 9bc513f6ff6c..012d4bb1d797 100644 --- a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs +++ b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs @@ -9,16 +9,11 @@ namespace Microsoft.NET.TestFramework.Commands public class SdkCommandSpec { public string? FileName { get; set; } - public List Arguments { get; set; } = new List(); - - public Dictionary Environment { get; set; } = new(); - - public List EnvironmentToRemove { get; } = new List(); - + public List Arguments { get; set; } = []; + public Dictionary Environment { get; set; } = []; + public List EnvironmentToRemove { get; } = []; public string? WorkingDirectory { get; set; } - public bool RedirectStandardInput { get; set; } - public bool DisableOutputAndErrorRedirection { get; set; } private string EscapeArgs() diff --git a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs index 56cf1002d2ae..f0187fed7b8b 100644 --- a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs +++ b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs @@ -9,19 +9,13 @@ namespace Microsoft.NET.TestFramework.Commands { public abstract class TestCommand { - private Dictionary _environment = new(); + private readonly Dictionary _environment = []; private bool _doNotEscapeArguments; - public ITestOutputHelper Log { get; } - public string? WorkingDirectory { get; set; } - - public List Arguments { get; set; } = new List(); - - public List EnvironmentToRemove { get; } = new List(); - + public List Arguments { get; set; } = []; + public List EnvironmentToRemove { get; } = []; public bool RedirectStandardInput { get; set; } - public bool DisableOutputAndErrorRedirection { get; set; } // These only work via Execute(), not when using GetProcessStartInfo() diff --git a/test/Microsoft.NET.TestFramework/SdkTest.cs b/test/Microsoft.NET.TestFramework/SdkTest.cs index 56c6649e4ae8..b377b9b2be92 100644 --- a/test/Microsoft.NET.TestFramework/SdkTest.cs +++ b/test/Microsoft.NET.TestFramework/SdkTest.cs @@ -3,43 +3,42 @@ using System.Runtime.CompilerServices; -namespace Microsoft.NET.TestFramework +namespace Microsoft.NET.TestFramework; + +public abstract class SdkTest { - public abstract class SdkTest - { - protected bool? UsingFullFrameworkMSBuild => SdkTestContext.Current.ToolsetUnderTest?.ShouldUseFullFrameworkMSBuild; + protected bool? UsingFullFrameworkMSBuild => SdkTestContext.Current.ToolsetUnderTest?.ShouldUseFullFrameworkMSBuild; - protected ITestOutputHelper Log { get; } + protected ITestOutputHelper Log { get; } - protected TestAssetsManager TestAssetsManager { get; } + protected TestAssetsManager TestAssetsManager { get; } - protected SdkTest(ITestOutputHelper log) - { - Log = log; - TestAssetsManager = new TestAssetsManager(log); - } - - protected static void WaitForUtcNowToAdvance() - { - var start = DateTime.UtcNow; + protected SdkTest(ITestOutputHelper log) + { + Log = log; + TestAssetsManager = new TestAssetsManager(log); + } - while (DateTime.UtcNow <= start) - { - Thread.Sleep(millisecondsTimeout: 1); - } - } + protected static void WaitForUtcNowToAdvance() + { + var start = DateTime.UtcNow; - /// - /// Generates a MSBuild binlog argument with a unique name based on the caller and provided parts, and places it in a location that will be collected by Helix if running in that environment. - /// - protected string BinLogArgument(ReadOnlySpan parts, [CallerMemberName] string callerName = "") + while (DateTime.UtcNow <= start) { - // combine the name and parts into a unique binlog - var fileName = $"{callerName}{(parts.Length > 0 ? "-" + string.Join("-", parts.ToArray()) : "")}-{{}}.binlog"; - var binlogDestPath = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot && Environment.GetEnvironmentVariable("HELIX_WORKITEM_ID") is { } helixGuid ? - Path.Combine(ciOutputRoot, "binlog", helixGuid, fileName) : - $"./{fileName}"; - return $"/bl:{binlogDestPath}"; + Thread.Sleep(millisecondsTimeout: 1); } } + + /// + /// Generates a MSBuild binlog argument with a unique name based on the caller and provided parts, and places it in a location that will be collected by Helix if running in that environment. + /// + protected string BinLogArgument(ReadOnlySpan parts, [CallerMemberName] string callerName = "") + { + // combine the name and parts into a unique binlog + var fileName = $"{callerName}{(parts.Length > 0 ? "-" + string.Join("-", parts.ToArray()) : "")}-{{}}.binlog"; + var binlogDestPath = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot && Environment.GetEnvironmentVariable("HELIX_WORKITEM_ID") is { } helixGuid ? + Path.Combine(ciOutputRoot, "binlog", helixGuid, fileName) : + $"./{fileName}"; + return $"/bl:{binlogDestPath}"; + } } diff --git a/test/dotnet.Tests/CliSchemaTests.cs b/test/dotnet.Tests/CliSchemaTests.cs index 8478d3c0d706..71a507d3ebb9 100644 --- a/test/dotnet.Tests/CliSchemaTests.cs +++ b/test/dotnet.Tests/CliSchemaTests.cs @@ -1208,7 +1208,7 @@ public void PrintCliSchema_WritesExpectedJson(string[] commandArgs, string json) { var stream = new MemoryStream(); var writer = new StreamWriter(stream); - CliSchema.PrintCliSchema(Parser.Parse(commandArgs).CommandResult, writer, null); + CliSchema.PrintCliSchema(Parser.Parse(commandArgs), writer, null); stream.Position = 0; var reader = new StreamReader(stream); var output = reader.ReadToEnd(); diff --git a/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs b/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs index b0ff2d4f0bd4..d526082c400d 100644 --- a/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs +++ b/test/dotnet.Tests/CommandFactoryTests/GivenALocalToolsCommandResolver.cs @@ -214,15 +214,23 @@ [new RestoredCommandIdentifier( _localToolsResolverCache, _fileSystem); - localToolsCommandResolver.Resolve(new CommandResolverArguments() + var commandSpecA = localToolsCommandResolver.Resolve(new CommandResolverArguments() { CommandName = "dotnet-a", - }).Args!.Trim('"').Should().Be(fakeExecutableA.Value); + }); + commandSpecA.Should().NotBeNull(); + var argsA = commandSpecA.Args; + argsA.Should().NotBeNull(); + argsA.Trim('"').Should().Be(fakeExecutableA.Value); - localToolsCommandResolver.Resolve(new CommandResolverArguments() + var commandSpecDotnetA = localToolsCommandResolver.Resolve(new CommandResolverArguments() { CommandName = "dotnet-dotnet-a", - }).Args!.Trim('"').Should().Be(fakeExecutableDotnetA.Value); + }); + commandSpecDotnetA.Should().NotBeNull(); + var argsDotnetA = commandSpecDotnetA.Args; + argsDotnetA.Should().NotBeNull(); + argsDotnetA.Trim('"').Should().Be(fakeExecutableDotnetA.Value); } private string _jsonContent = diff --git a/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs b/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs index 360342b83c30..9f82d05313e5 100644 --- a/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs +++ b/test/dotnet.Tests/CommandTests/CommandDirectoryContextExtensions.cs @@ -22,7 +22,6 @@ public static void PerformActionWithBasePath(string basePath, Action action) } CommandDirectoryContext.CurrentBaseDirectory_TestOnly = basePath; - Telemetry.Telemetry.CurrentSessionId = null; try { action(); diff --git a/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs b/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs index a2a26316e3d8..433b5b5cf2de 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/DotnetMsbuildInProcTests.cs @@ -5,6 +5,7 @@ using System.Reflection; using Microsoft.DotNet.Cli.Commands.MSBuild; +using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Configurer; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -19,8 +20,7 @@ public DotnetMsbuildInProcTests(ITestOutputHelper log) : base(log) [Fact] public void WhenTelemetryIsEnabledTheLoggerIsAddedToTheCommandLine() { - Telemetry.Telemetry telemetry; - string[] allArgs = GetArgsForMSBuild(() => true, out telemetry); + string[] allArgs = GetArgsForMSBuild(() => true, out TelemetryClient telemetry); // telemetry will still be disabled if environment variable is set if (telemetry.Enabled) { @@ -46,15 +46,13 @@ public void WhenTelemetryIsDisabledTheLoggerIsNotAddedToTheCommandLine() private string[] GetArgsForMSBuild(Func sentinelExists) { - Telemetry.Telemetry telemetry; - return GetArgsForMSBuild(sentinelExists, out telemetry); + return GetArgsForMSBuild(sentinelExists, out TelemetryClient telemetry); } - private string[] GetArgsForMSBuild(Func sentinelExists, out Telemetry.Telemetry telemetry) + private string[] GetArgsForMSBuild(Func sentinelExists, out TelemetryClient telemetry) { - - Telemetry.Telemetry.DisableForTests(); // reset static session id modified by telemetry constructor - telemetry = new Telemetry.Telemetry(new MockFirstTimeUseNoticeSentinel(sentinelExists)); + TelemetryClient.DisabledForTests = true; // reset static session id modified by telemetry constructor + telemetry = new TelemetryClient(); MSBuildForwardingApp msBuildForwardingApp = new(Enumerable.Empty()); diff --git a/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs b/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs index bcb6a6dbfde1..5bf3435c507f 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/FakeTelemetry.cs @@ -1,34 +1,23 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using Microsoft.DotNet.Cli.Telemetry; -namespace Microsoft.DotNet.Cli.MSBuild.Tests -{ - public class FakeTelemetry : ITelemetry - { - public bool Enabled { get; set; } = true; - - private readonly List _logEntries = new List(); +namespace Microsoft.DotNet.Cli.MSBuild.Tests; - public void TrackEvent(string eventName, IDictionary properties, IDictionary measurements) - { - var entry = new LogEntry { EventName = eventName, Properties = properties, Measurement = measurements }; - _logEntries.Add(entry); - } +public class FakeTelemetry : ITelemetryClient +{ + public bool Enabled { get; set; } = true; - public void Flush() - { - } + private readonly List _logEntries = new List(); - public void Dispose() - { - } + public void TrackEvent(string eventName, IDictionary? properties) + { + var entry = new LogEntry { EventName = eventName, Properties = properties }; + _logEntries.Add(entry); + } - public LogEntry LogEntry => _logEntries.Count > 0 ? _logEntries[_logEntries.Count - 1] : null; + public LogEntry? LogEntry => _logEntries.Count > 0 ? _logEntries[_logEntries.Count - 1] : null; - public IReadOnlyList LogEntries => _logEntries.AsReadOnly(); - } + public IReadOnlyList LogEntries => _logEntries.AsReadOnly(); } diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs index ff6543518696..812ec22b2c8f 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetBuildInvocation.cs @@ -3,7 +3,7 @@ using Microsoft.DotNet.Cli.Commands.Restore; using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Tests; +using Microsoft.DotNet.Tests.TelemetryTests; using BuildCommand = Microsoft.DotNet.Cli.Commands.Build.BuildCommand; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -122,7 +122,7 @@ public void MsbuildInvocationIsCorrectForSeparateRestore( } [Theory] - [MemberData(memberName: nameof(TelemetryCommonPropertiesTests.LLMTelemetryTestCases), MemberType =typeof(TelemetryCommonPropertiesTests))] + [MemberData(memberName: nameof(TelemetryCommonPropertiesTests.LLMTelemetryTestCases), MemberType = typeof(TelemetryCommonPropertiesTests))] public void WhenLLMIsDetectedTLLiveUpdateIsDisabled(Dictionary? llmEnvVarsToSet, string? expectedLLMName) { CommandDirectoryContext.PerformActionWithBasePath(WorkingDirectory, () => diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs index e995a09ae611..beeac7148703 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetMSBuildBuildsProjects.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. - - -// There are tests which modify static Telemetry.CurrentSessionId and they cannot run in parallel +// There are tests which modify static TelemetryClient.CurrentSessionId. They cannot run in parallel. [assembly: CollectionBehavior(DisableTestParallelization = true)] namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -106,11 +104,5 @@ public void WhenDotnetRunHelpIsInvokedAppArgumentsTextIsIncludedInOutput() result.ExitCode.Should().Be(0); result.StdOut.Should().Contain(AppArgumentsText); } - - - - } - - } diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs index 9b4dca228442..db007ec0cdc9 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetRestoreInvocation.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.DotNet.Cli.Commands.MSBuild; +using Microsoft.DotNet.Cli.Telemetry; using RestoreCommand = Microsoft.DotNet.Cli.Commands.Restore.RestoreCommand; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -41,7 +42,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona { CommandDirectoryContext.PerformActionWithBasePath(WorkingDirectory, () => { - Telemetry.Telemetry.DisableForTests(); + TelemetryClient.DisabledForTests = true; expectedAdditionalArgs = expectedAdditionalArgs .Select(arg => arg.Replace("", WorkingDirectory)) diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.cs index c017646949b0..d089f147ff6f 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetTestInvocation.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 Microsoft.DotNet.Cli.Telemetry; using TestCommand = Microsoft.DotNet.Cli.Commands.Test.TestCommand; namespace Microsoft.DotNet.Cli.MSBuild.Tests @@ -26,7 +27,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona { CommandDirectoryContext.PerformActionWithBasePath(WorkingDirectory, () => { - Telemetry.Telemetry.DisableForTests(); + TelemetryClient.DisabledForTests = true; expectedAdditionalArgs = expectedAdditionalArgs .Select(arg => arg.Replace("", WorkingDirectory)) diff --git a/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs b/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs index b2974fba1006..54ddd2256b19 100644 --- a/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs +++ b/test/dotnet.Tests/CommandTests/MSBuild/NullCurrentSessionIdFixture.cs @@ -1,19 +1,20 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Cli.MSBuild.Tests +using Microsoft.DotNet.Cli.Telemetry; + +namespace Microsoft.DotNet.Cli.MSBuild.Tests; + +public class NullCurrentSessionIdFixture { - public class NullCurrentSessionIdFixture + public NullCurrentSessionIdFixture() { - public NullCurrentSessionIdFixture() - { - // We need to set this to guarantee that the telemetry logging - // information will not be added to the msbuild generated parameters - // when testing the translation between CLI params and msbuild params. - // This is now needed because before we set SKIP FIRST RUN in the CLI - // build scripts, but now we don't and we don't want to rely on scripts - // to make our build/tests work. - Telemetry.Telemetry.DisableForTests(); - } + // We need to set this to guarantee that the telemetry logging + // information will not be added to the msbuild generated parameters + // when testing the translation between CLI params and msbuild params. + // This is now needed because before we set SKIP FIRST RUN in the CLI + // build scripts, but now we don't and we don't want to rely on scripts + // to make our build/tests work. + TelemetryClient.DisabledForTests = true; } } diff --git a/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs b/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs index ae6425892777..0bfaa370f9cf 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs @@ -144,9 +144,9 @@ public void CountAdditionalProperties_CountsPropertyDirectives() public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() { // Arrange - var events = new List<(string? eventName, IDictionary? properties, IDictionary? measurements)>(); + var events = new List<(string? eventName, IDictionary? properties)>(); - void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties, args.Measurements)); + void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties)); TelemetryEventEntry.EntryPosted += handler; @@ -171,7 +171,6 @@ public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() var eventData = events[0]; eventData.eventName.Should().Be("run"); eventData.properties.Should().NotBeNull(); - eventData.measurements.Should().NotBeNull(); var props = eventData.properties!; props["app_type"].Should().Be("file_based"); @@ -180,12 +179,6 @@ public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() props["used_roslyn_compiler"].Should().Be("false"); props["launch_profile_requested"].Should().Be("explicit"); props["launch_profile_is_default"].Should().Be("true"); - - var measurements = eventData.measurements!; - measurements["sdk_count"].Should().Be(2); - measurements["package_reference_count"].Should().Be(3); - measurements["project_reference_count"].Should().Be(1); - measurements["additional_properties_count"].Should().Be(2); } finally { @@ -198,9 +191,9 @@ public void TrackRunEvent_FileBasedApp_SendsCorrectTelemetry() public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() { // Arrange - var events = new List<(string? eventName, IDictionary? properties, IDictionary? measurements)>(); + var events = new List<(string? eventName, IDictionary? properties)>(); - void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties, args.Measurements)); + void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties)); TelemetryEventEntry.EntryPosted += handler; @@ -222,7 +215,6 @@ public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() var eventData = events[0]; eventData.eventName.Should().Be("run"); eventData.properties.Should().NotBeNull(); - eventData.measurements.Should().NotBeNull(); var props = eventData.properties!; props["app_type"].Should().Be("project_based"); @@ -230,12 +222,6 @@ public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() props["launch_profile_requested"].Should().Be("none"); props.Should().NotContainKey("used_msbuild"); props.Should().NotContainKey("used_roslyn_compiler"); - - var measurements = eventData.measurements!; - measurements["sdk_count"].Should().Be(1); - measurements["package_reference_count"].Should().Be(5); - measurements["project_reference_count"].Should().Be(2); - measurements.Should().NotContainKey("additional_properties_count"); } finally { @@ -248,9 +234,9 @@ public void TrackRunEvent_ProjectBasedApp_SendsCorrectTelemetry() public void TrackRunEvent_WithDefaultLaunchProfile_MarksTelemetryCorrectly() { // Arrange - var events = new List<(string? eventName, IDictionary? properties, IDictionary? measurements)>(); + var events = new List<(string? eventName, IDictionary? properties)>(); - void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties, args.Measurements)); + void handler(object? sender, InstrumentationEventArgs args) => events.Add((args.EventName, args.Properties)); TelemetryEventEntry.EntryPosted += handler; diff --git a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs index f5b87dadeee8..756766a8aa5f 100644 --- a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs +++ b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurer.cs @@ -259,77 +259,5 @@ public void It_does_not_add_the_tool_path_to_the_environment_if_addGlobalToolsTo _pathAdderMock.Verify(p => p.AddPackageExecutablePathToUserPath(), Times.Never); } - - [Fact] - public void It_does_add_telemetry_when_all_firsttimeuse_values_run() - { - - _firstTimeUseNoticeSentinelMock.Setup(n => n.Exists()).Returns(false); - - Dictionary measurements = new(); - var dotnetFirstTimeUseConfigurer = new DotnetFirstTimeUseConfigurer( - _firstTimeUseNoticeSentinelMock.Object, - _aspNetCertificateSentinelMock.Object, - _aspNetCoreCertificateGeneratorMock.Object, - _toolPathSentinelMock.Object, - new DotnetFirstRunConfiguration - ( - generateAspNetCertificate: true, - telemetryOptout: false, - addGlobalToolsToPath: true, - nologo: false, - skipWorkloadIntegrityCheck: false - ), - _reporterMock.Object, - _pathAdderMock.Object, - measurements); - - DateTime beforeConfigure = DateTime.Now; - dotnetFirstTimeUseConfigurer.Configure(); - double configureTime = (DateTime.Now - beforeConfigure).TotalMilliseconds; - - measurements.Should().HaveCount(3); - measurements.Should().ContainKey("AddPackageExecutablePath Time"); - measurements.Should().ContainKey("FirstTimeUseNotice Time"); - measurements.Should().ContainKey("GenerateAspNetCertificate Time"); - measurements["AddPackageExecutablePath Time"].Should().BeGreaterThan(0); - measurements["FirstTimeUseNotice Time"].Should().BeGreaterThan(0); - measurements["GenerateAspNetCertificate Time"].Should().BeGreaterThan(0); - measurements["AddPackageExecutablePath Time"].Should().BeLessThan(configureTime); - measurements["FirstTimeUseNotice Time"].Should().BeLessThan(configureTime); - measurements["GenerateAspNetCertificate Time"].Should().BeLessThan(configureTime); - } - - [Fact] - public void It_does_add_telemetry_when_no_firsttimeuse_values_run() - { - - _firstTimeUseNoticeSentinelMock.Setup(n => n.Exists()).Returns(true); - - Dictionary measurements = new(); - var dotnetFirstTimeUseConfigurer = new DotnetFirstTimeUseConfigurer( - _firstTimeUseNoticeSentinelMock.Object, - _aspNetCertificateSentinelMock.Object, - _aspNetCoreCertificateGeneratorMock.Object, - _toolPathSentinelMock.Object, - new DotnetFirstRunConfiguration - ( - generateAspNetCertificate: false, - telemetryOptout: false, - addGlobalToolsToPath: false, - nologo: false, - skipWorkloadIntegrityCheck: false - ), - _reporterMock.Object, - _pathAdderMock.Object, - measurements); - - dotnetFirstTimeUseConfigurer.Configure(); - - measurements.Should().HaveCount(0); - measurements.Should().NotContainKey("AddPackageExecutablePath Time"); - measurements.Should().NotContainKey("FirstTimeUseNotice Time"); - measurements.Should().NotContainKey("GenerateAspNetCertificate Time"); - } } } diff --git a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs index d694240d865b..89cc75f779b1 100644 --- a/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs +++ b/test/dotnet.Tests/ConfigurerTests/GivenADotnetFirstTimeUseConfigurerWIthStateSetup.cs @@ -28,7 +28,7 @@ public GivenADotnetFirstTimeUseConfigurerWithStateSetup(ITestOutputHelper output private void ResetObjectState() { - Telemetry.EnableForTests(); + TelemetryClient.DisabledForTests = false; _firstTimeUseNoticeSentinelMock = new MockBasicSentinel(); _aspNetCertificateSentinelMock = new MockBasicSentinel(); _aspNetCoreCertificateGeneratorMock = new Mock(MockBehavior.Strict); @@ -183,16 +183,6 @@ public void Assert(ActionCalledTime expectedActionCalledTime) } } - private static ActionCalledTime GetCalledTime(bool predicate, ActionCalledTime actionCalledTime) - { - if (actionCalledTime != FirstRun && predicate) - { - actionCalledTime = SecondRun; - } - - return actionCalledTime; - } - public enum ActionCalledTime { Never, @@ -200,7 +190,7 @@ public enum ActionCalledTime SecondRun } - private Telemetry RunConfigUsingMocks(bool isInstallerRun) + private TelemetryClient RunConfigUsingMocks(bool isInstallerRun) { // Assume the following objects set up are in sync with production behavior. // subject to future refactoring to de-dup with production code. @@ -252,10 +242,7 @@ private Telemetry RunConfigUsingMocks(bool isInstallerRun) configurer.Configure(); - return new Telemetry(firstTimeUseNoticeSentinel, - "test", - environmentProvider: _environmentProviderObject, - senderCount: 0); + return new TelemetryClient("test", environmentProvider: _environmentProviderObject); } private class MockBasicSentinel : IFileSentinel, IFirstTimeUseNoticeSentinel, IAspNetCertificateSentinel diff --git a/test/dotnet.Tests/FakeRecordEventNameTelemetry.cs b/test/dotnet.Tests/FakeRecordEventNameTelemetry.cs deleted file mode 100644 index 4ce548e97621..000000000000 --- a/test/dotnet.Tests/FakeRecordEventNameTelemetry.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Collections.Concurrent; -using Microsoft.DotNet.Cli.Telemetry; - -namespace Microsoft.DotNet.Tests -{ - public class FakeRecordEventNameTelemetry : ITelemetry - { - public bool Enabled { get; set; } - - public string EventName { get; set; } - - public void TrackEvent(string eventName, - IDictionary properties, - IDictionary measurements) - { - LogEntries.Add( - new LogEntry - { - EventName = eventName, - Measurement = measurements, - Properties = properties - }); - } - - public void Flush() - { - } - - public void Dispose() - { - } - - public ConcurrentBag LogEntries { get; set; } = new ConcurrentBag(); - - public class LogEntry - { - public string EventName { get; set; } - public IDictionary Properties { get; set; } - public IDictionary Measurement { get; set; } - } - } -} diff --git a/test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs b/test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs deleted file mode 100644 index 06a8418a6acb..000000000000 --- a/test/dotnet.Tests/GivenThatTheUserEnablesThePerfLog.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.Tracing; - -namespace Microsoft.DotNet.Tests -{ - public class GivenThatTheUserEnablesThePerfLog : SdkTest - { - public GivenThatTheUserEnablesThePerfLog(ITestOutputHelper log) : base(log) - { - } - - [Fact] - public void WhenPerfLogDisabledDotNetDoesNotWriteToThePerfLog() - { - var dir = TestAssetsManager.CreateTestDirectory(); - - var result = new DotnetCommand(Log, "--help") - .WithEnvironmentVariable("DOTNET_PERFLOG_DIR", dir.Path) - .Execute(); - - result.ExitCode.Should().Be(0); - Assert.Empty(new DirectoryInfo(dir.Path).GetFiles()); - } - - [Fact] - public void WhenPerfLogEnabledDotNetWritesToThePerfLog() - { - var dir = TestAssetsManager.CreateTestDirectory(); - - var result = new DotnetCommand(Log, "--help") - .WithEnvironmentVariable("DOTNET_CLI_PERF_LOG", "1") - .WithEnvironmentVariable("DOTNET_PERFLOG_DIR", dir.Path) - .Execute(); - - result.ExitCode.Should().Be(0); - - DirectoryInfo logDir = new(dir.Path); - FileInfo[] logFiles = logDir.GetFiles(); - Assert.NotEmpty(logFiles); - Assert.All(logFiles, f => Assert.StartsWith("perf-", f.Name)); - Assert.All(logFiles, f => Assert.NotEqual(0, f.Length)); - } - - [Fact] - public void WhenPerfLogEnabledDotNetBuildWritesAPerfLog() - { - using (PerfLogTestEventListener listener = new()) - { - int exitCode = Cli.Program.Main(new string[] { "--help" }); - Assert.Equal(0, exitCode); - Assert.NotEqual(0, listener.EventCount); - } - } - } - - internal sealed class PerfLogTestEventListener : EventListener - { - private const string PerfLogEventSourceName = "Microsoft-Dotnet-CLI-Performance"; - - public int EventCount - { - get; private set; - } - - protected override void OnEventSourceCreated(EventSource eventSource) - { - if (eventSource.Name.Equals(PerfLogEventSourceName)) - { - EnableEvents(eventSource, EventLevel.Verbose); - } - } - - protected override void OnEventWritten(EventWrittenEventArgs eventData) - { - Assert.Equal(PerfLogEventSourceName, eventData.EventSource.Name); - EventCount++; - } - } -} diff --git a/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs b/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs index 5e9191d076f0..f0950fe9b9fd 100644 --- a/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs +++ b/test/dotnet.Tests/GivenThatTheUserIsRunningDotNetForTheFirstTime.cs @@ -165,8 +165,7 @@ public void ItDoesNotCreateAFirstUseSentinelFileNorAnAspNetCertificateSentinelFi var command = dotnetFirstTime.Setup(Log, TestAssetsManager); - // Disable telemetry to prevent the creation of the .dotnet folder - // for machineid and docker cache files + // Disable telemetry to prevent the creation of the .dotnet folder for machineid and docker cache files. command = command.WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", "true"); command.Execute("internal-reportinstallsuccess", "test").Should().Pass(); diff --git a/test/dotnet.Tests/TelemetryCommandTest.cs b/test/dotnet.Tests/TelemetryCommandTest.cs deleted file mode 100644 index aa180fe14930..000000000000 --- a/test/dotnet.Tests/TelemetryCommandTest.cs +++ /dev/null @@ -1,416 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; -using Microsoft.DotNet.Cli.Telemetry; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Utilities; - -namespace Microsoft.DotNet.Tests -{ - [Collection(TestConstants.UsesStaticTelemetryState)] - public class TelemetryCommandTests : SdkTest - { - private readonly FakeRecordEventNameTelemetry _fakeTelemetry; - - public string EventName { get; set; } - - public IDictionary Properties { get; set; } - - public TelemetryCommandTests(ITestOutputHelper log) : base(log) - { - _fakeTelemetry = new FakeRecordEventNameTelemetry(); - TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); - TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); - } - - [Fact] - public void NoTelemetryIfCommandIsInvalid() - { - string[] args = { "publish", "-r" }; - Action a = () => { Cli.Program.ProcessArgs(args); }; - a.Should().NotThrow(); - } - - [Fact] - public void NoTelemetryIfCommandIsInvalid2() - { - string[] args = { "restore", "-v" }; - Action a = () => { Cli.Program.ProcessArgs(args); }; - a.Should().NotThrow(); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetry() - { - string[] args = { "help" }; - Cli.Program.ProcessArgs(args); - - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("HELP")); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithPerformanceData() - { - string[] args = { "help" }; - Cli.Program.ProcessArgs(args, new TimeSpan(12345)); - - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("HELP") && - e.Measurement.ContainsKey("Startup Time") && - e.Measurement["Startup Time"] == 1.2345 && - e.Measurement.ContainsKey("Parse Time") && - e.Measurement["Parse Time"] > 0); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithoutStartupTime() - { - string[] args = { "help" }; - Cli.Program.ProcessArgs(args); - - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("HELP") && - !e.Measurement.ContainsKey("Startup Time") && - e.Measurement.ContainsKey("Parse Time") && - e.Measurement["Parse Time"] > 0); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryZeroStartupTime() - { - string[] args = { "help" }; - Cli.Program.ProcessArgs(args, new TimeSpan(0)); - - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("HELP") && - !e.Measurement.ContainsKey("Startup Time") && - e.Measurement.ContainsKey("Parse Time") && - e.Measurement["Parse Time"] > 0); - } - - [Fact] - public void DotnetNewCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "console"; - string[] args = { "new", argumentToSend }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW")); - } - - [Fact(Skip = "https://github.com/dotnet/sdk/issues/24190")] - public void DotnetNewCommandFirstArgumentShouldBeSentToTelemetryWithPerformanceData() - { - const string argumentToSend = "console"; - string[] args = { "new", argumentToSend }; - Cli.Program.ProcessArgs(args, new TimeSpan(23456)); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW") && - e.Measurement.ContainsKey("Startup Time") && - e.Measurement["Startup Time"] == 2.3456 && - e.Measurement.ContainsKey("Parse Time") && - e.Measurement["Parse Time"] > 0); - } - - [Fact] - public void DotnetHelpCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "something"; - string[] args = { "help", argumentToSend }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("HELP")); - } - - [Fact] - public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "package"; - string[] args = { "add", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("ADD")); - } - - [Fact] - public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry2() - { - const string argumentToSend = "reference"; - string[] args = { "add", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("ADD")); - } - - [Fact] - public void DotnetRemoveCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "package"; - string[] args = { "remove", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("REMOVE")); - } - - [Fact] - public void DotnetListCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "reference"; - string[] args = { "list", argumentToSend, "aPackageName" }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("LIST")); - } - - [Fact] - public void DotnetSlnCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "list"; - string[] args = { "sln", "aSolution", argumentToSend }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("SOLUTION")); - } - - [Fact] - public void DotnetNugetCommandFirstArgumentShouldBeSentToTelemetry() - { - const string argumentToSend = "push"; - - string[] args = { "nuget", argumentToSend, "path" }; - - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NUGET")); - } - - [Fact(Skip = "https://github.com/dotnet/sdk/issues/47862")] - public void DotnetNewCommandLanguageOpinionShouldBeSentToTelemetry() - { - const string optionKey = "language"; - const string optionValueToSend = "c#"; - string[] args = { "new", "console", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW")); - } - - [Fact] - public void AnyDotnetCommandVerbosityOpinionShouldBeSentToTelemetry() - { - const string optionKey = "verbosity"; - const string optionValueToSend = "minimal"; - string[] args = { "restore", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("RESTORE")); - } - - [Fact] - public void AnyDotnetCommandVerbosityOpinionShouldBeSentToTelemetryWithPerformanceData() - { - const string optionKey = "verbosity"; - const string optionValueToSend = "minimal"; - string[] args = { "restore", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgs(args, new TimeSpan(34567)); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("RESTORE") && - e.Measurement.ContainsKey("Startup Time") && - e.Measurement["Startup Time"] == 3.4567 && - e.Measurement.ContainsKey("Parse Time") && - e.Measurement["Parse Time"] > 0); - } - - [Fact] - public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetry() - { - const string optionKey = "configuration"; - const string optionValueToSend = "Debug"; - string[] args = { "build", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); - } - - [Fact] - public void DotnetPublishCommandRuntimeOpinionsShouldBeSentToTelemetry() - { - const string optionKey = "runtime"; - const string optionValueToSend = $"{ToolsetInfo.LatestWinRuntimeIdentifier}-x64"; - string[] args = { "publish", "--" + optionKey, optionValueToSend }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("PUBLISH")); - } - - [Fact] - public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() - { - string[] args = { "build", "--configuration", "Debug", "--runtime", $"{ToolsetInfo.LatestMacRuntimeIdentifier}-x64" }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && - e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); - - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("runtime") && - e.Properties["runtime"] == Sha256Hasher.Hash($"{ToolsetInfo.LatestMacRuntimeIdentifier.ToUpper()}-X64") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); - } - - [Fact] - public void DotnetRunCleanTestCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() - { - string[] args = { "clean", "--configuration", "Debug", "--framework", ToolsetInfo.CurrentTargetFramework }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && - e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); - - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("framework") && - e.Properties["framework"] == Sha256Hasher.Hash(ToolsetInfo.CurrentTargetFramework.ToUpper()) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); - } - - [Fact] - public void DotnetUpdatePackageVulnerableOptionShouldBeSentToTelemetry() - { - const string optionKey = "vulnerable"; - string[] args = { "package", "update", "--vulnerable" }; - Cli.Program.ProcessArgs(args); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey(optionKey) && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("PACKAGE UPDATE")); - } - - [WindowsOnlyFact] - public void InternalreportinstallsuccessCommandCollectExeNameWithEventname() - { - FakeRecordEventNameTelemetry fakeTelemetry = new(); - string[] args = { "c:\\mypath\\dotnet-sdk-latest-win-x64.exe" }; - - InternalReportInstallSuccessCommand.ProcessInputAndSendTelemetry(args, fakeTelemetry); - - fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "install/reportsuccess" && e.Properties.ContainsKey("exeName") && - e.Properties["exeName"] == Sha256Hasher.Hash("DOTNET-SDK-LATEST-WIN-X64.EXE")); - } - - [Fact] - public void ExceptionShouldBeSentToTelemetry() - { - Exception caughtException = null; - try - { - string[] args = { "build" }; - Cli.Program.ProcessArgs(args); - throw new ArgumentException("test exception"); - } - catch (Exception ex) - { - caughtException = ex; - TelemetryEventEntry.SendFiltered(ex); - } - - var exception = new Exception(); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "mainCatchException/exception" && - e.Properties.ContainsKey("exceptionType") && - e.Properties["exceptionType"] == "System.ArgumentException" && - e.Properties.ContainsKey("detail") && - e.Properties["detail"].Contains(caughtException.StackTrace)); - } - } -} diff --git a/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs b/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs deleted file mode 100644 index 5939d8ffeb2c..000000000000 --- a/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs +++ /dev/null @@ -1,313 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.Cli.Telemetry; -using Microsoft.DotNet.Configurer; - -namespace Microsoft.DotNet.Tests -{ - public class TelemetryCommonPropertiesTests : SdkTest - { - public TelemetryCommonPropertiesTests(ITestOutputHelper log) : base(log) - { - } - - [Fact] - public void TelemetryCommonPropertiesShouldContainIfItIsInDockerOrNot() - { - var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId").Should().ContainKey("Docker Container"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnHashedPath() - { - var unitUnderTest = new TelemetryCommonProperties(() => "ADirectory", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Current Path Hash"].Should().NotBe("ADirectory"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnHashedMachineId() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"].Should().NotBe("plaintext"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnDevDeviceId() - { - var unitUnderTest = new TelemetryCommonProperties(getDeviceId: () => "plaintext", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"].Should().Be("plaintext"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddress() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"]; - - Guid.TryParse(assignedMachineId, out var _).Should().BeTrue("it should be a guid"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldEnsureDevDeviceIDIsCached() - { - var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); - var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; - - Guid.TryParse(assignedMachineId, out var _).Should().BeTrue("it should be a guid"); - var secondAssignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; - - Guid.TryParse(secondAssignedMachineId, out var _).Should().BeTrue("it should be a guid"); - secondAssignedMachineId.Should().Be(assignedMachineId, "it should match the previously assigned guid"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnHashedMachineIdOld() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"].Should().NotBe("plaintext"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddressOld() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"]; - - Guid.TryParse(assignedMachineId, out var _).Should().BeTrue("it should be a guid"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnIsOutputRedirected() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Output Redirected"].Should().BeOneOf("True", "False"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnIsCIDetection() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Continuous Integration"].Should().BeOneOf("True", "False"); - } - - [Fact] - public void TelemetryCommonPropertiesShouldContainKernelVersion() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Kernel Version"].Should().Be(RuntimeInformation.OSDescription); - } - - [Fact] - public void TelemetryCommonPropertiesShouldContainArchitectureInformation() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["OS Architecture"].Should().Be(RuntimeInformation.OSArchitecture.ToString()); - } - - [WindowsOnlyFact] - public void TelemetryCommonPropertiesShouldContainWindowsInstallType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().NotBeEmpty(); - } - - [UnixOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyWindowsInstallType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().BeEmpty(); - } - - [WindowsOnlyFact] - public void TelemetryCommonPropertiesShouldContainWindowsProductType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().NotBeEmpty(); - } - - [UnixOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyWindowsProductType() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().BeEmpty(); - } - - [WindowsOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); - } - - [MacOsOnlyFact] - public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion2() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); - } - - [LinuxOnlyFact] - public void TelemetryCommonPropertiesShouldContainLibcReleaseAndVersion() - { - if (!RuntimeInformation.RuntimeIdentifier.Contains("alpine", StringComparison.OrdinalIgnoreCase)) - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().NotBeEmpty(); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().NotBeEmpty(); - } - } - - [Fact] - public void TelemetryCommonPropertiesShouldReturnIsLLMDetection() - { - var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); - unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["llm"].Should().BeOneOf("claude", null); - } - - [Theory] - [MemberData(nameof(CITelemetryTestCases))] - public void CanDetectCIStatusForEnvVars(Dictionary envVars, bool expected) - { - try - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, value); - } - new CIEnvironmentDetectorForTelemetry().IsCIEnvironment().Should().Be(expected); - } - finally - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, null); - } - } - } - - [Theory] - [MemberData(nameof(LLMTelemetryTestCases))] - public void CanDetectLLMStatusForEnvVars(Dictionary? envVars, string? expected) - { - try - { - if (envVars is not null) - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, value); - } - } - new LLMEnvironmentDetectorForTelemetry().GetLLMEnvironment().Should().Be(expected); - } - finally - { - if (envVars is not null) - { - foreach (var (key, value) in envVars) - { - Environment.SetEnvironmentVariable(key, null); - } - } - } - } - - [Theory] - [InlineData("dummySessionId")] - [InlineData(null)] - public void TelemetryCommonPropertiesShouldContainSessionId(string? sessionId) - { - var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); - var commonProperties = unitUnderTest.GetTelemetryCommonProperties(sessionId); - - commonProperties.Should().ContainKey("SessionId"); - commonProperties["SessionId"].Should().Be(sessionId); - } - - - public static TheoryData?, string?> LLMTelemetryTestCases => new() - { - { new Dictionary { {"CLAUDECODE", "1" } }, "claude" }, - { new Dictionary { {"CLAUDE_CODE_ENTRYPOINT", "some_value" } }, "claude" }, - { new Dictionary { { "CURSOR_EDITOR", "1" } }, "cursor" }, - { new Dictionary { { "CURSOR_AI", "1" } }, "cursor" }, - { new Dictionary { { "GEMINI_CLI", "true" } }, "gemini" }, - { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "true" } }, "copilot" }, - { new Dictionary { { "GH_COPILOT_WORKING_DIRECTORY", "/repo" } }, "copilot" }, - { new Dictionary { { "CODEX_CLI", "1" } }, "codex" }, - { new Dictionary { { "CODEX_SANDBOX", "1" } }, "codex" }, - { new Dictionary { { "OR_APP_NAME", "Aider" } }, "aider" }, - { new Dictionary { { "OR_APP_NAME", "aider" } }, "aider" }, - { new Dictionary { { "OR_APP_NAME", "plandex" } }, "plandex" }, - { new Dictionary { { "OR_APP_NAME", "Plandex" } }, "plandex" }, - { new Dictionary { { "AMP_HOME", "/path/to/amp" } }, "amp" }, - { new Dictionary { { "QWEN_CODE", "1" } }, "qwen" }, - { new Dictionary { { "DROID_CLI", "true" } }, "droid" }, - { new Dictionary { { "OPENCODE_AI", "1" } }, "opencode" }, - { new Dictionary { { "ZED_ENVIRONMENT", "1" } }, "zed" }, - { new Dictionary { { "ZED_TERM", "1" } }, "zed" }, - { new Dictionary { { "KIMI_CLI", "true" } }, "kimi" }, - { new Dictionary { { "OR_APP_NAME", "OpenHands" } }, "openhands" }, - { new Dictionary { { "OR_APP_NAME", "openhands" } }, "openhands" }, - { new Dictionary { { "GOOSE_TERMINAL", "1" } }, "goose" }, - { new Dictionary { { "CLINE_TASK_ID", "task123" } }, "cline" }, - { new Dictionary { { "ROO_CODE_TASK_ID", "task456" } }, "roo" }, - { new Dictionary { { "WINDSURF_SESSION", "session789" } }, "windsurf" }, - { new Dictionary { { "AGENT_CLI", "true" } }, "generic_agent" }, - // Test combinations of older tools - { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" }, - { new Dictionary { { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" } }, "gemini, copilot" }, - { new Dictionary { { "CLAUDECODE", "1" }, { "GEMINI_CLI", "true" }, { "AGENT_CLI", "true" } }, "claude, gemini, generic_agent" }, - { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" }, { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" }, { "AGENT_CLI", "true" } }, "claude, cursor, gemini, copilot, generic_agent" }, - // Test combinations of newer tools - { new Dictionary { { "OR_APP_NAME", "Aider" }, { "CLINE_TASK_ID", "task123" } }, "aider, cline" }, - { new Dictionary { { "CODEX_CLI", "1" }, { "WINDSURF_SESSION", "session789" } }, "codex, windsurf" }, - { new Dictionary { { "GOOSE_TERMINAL", "1" }, { "ROO_CODE_TASK_ID", "task456" } }, "goose, roo" }, - { new Dictionary { { "GEMINI_CLI", "false" } }, null }, - { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "false" } }, null }, - { new Dictionary { { "AGENT_CLI", "false" } }, null }, - { new Dictionary { { "DROID_CLI", "false" } }, null }, - { new Dictionary { { "KIMI_CLI", "false" } }, null }, - { new Dictionary { { "OR_APP_NAME", "SomeOtherApp" } }, null }, - { new Dictionary(), null }, - }; - - public static TheoryData, bool> CITelemetryTestCases => new() - { - { new Dictionary { { "TF_BUILD", "true" } }, true }, - { new Dictionary { { "GITHUB_ACTIONS", "true" } }, true }, - { new Dictionary { { "APPVEYOR", "true"} }, true }, - { new Dictionary { { "CI", "true"} }, true }, - { new Dictionary { { "TRAVIS", "true"} }, true }, - { new Dictionary { { "CIRCLECI", "true"} }, true }, - { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" }, { "AWS_REGION", "hi" } }, true }, - { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" } }, false }, - { new Dictionary { { "BUILD_ID", "hi" }, { "BUILD_URL", "hi" } }, true }, - { new Dictionary { { "BUILD_ID", "hi" } }, false }, - { new Dictionary { { "BUILD_ID", "hi" }, { "PROJECT_ID", "hi" } }, true }, - { new Dictionary { { "BUILD_ID", "hi" } }, false }, - { new Dictionary { { "TEAMCITY_VERSION", "hi" } }, true }, - { new Dictionary { { "TEAMCITY_VERSION", "" } }, false }, - { new Dictionary { { "JB_SPACE_API_URL", "hi" } }, true }, - { new Dictionary { { "JB_SPACE_API_URL", "" } }, false }, - { new Dictionary { { "SomethingElse", "hi" } }, false }, - }; - - private class NothingCache : IUserLevelCacheWriter - { - public string RunWithCache(string cacheKey, Func getValueToCache) - { - return getValueToCache(); - } - - public string RunWithCacheInFilePath(string cacheFilepath, Func getValueToCache) - { - return getValueToCache(); - } - } - } -} diff --git a/test/dotnet.Tests/TelemetryFilterTest.cs b/test/dotnet.Tests/TelemetryFilterTest.cs deleted file mode 100644 index db5f835e791d..000000000000 --- a/test/dotnet.Tests/TelemetryFilterTest.cs +++ /dev/null @@ -1,211 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using Microsoft.DotNet.Cli.Telemetry; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Utilities; -using Parser = Microsoft.DotNet.Cli.Parser; - -namespace Microsoft.DotNet.Tests -{ - /// - /// Only adding the performance data tests for now as the TelemetryCommandTests cover most other scenarios already - /// - public class TelemetryFilterTests : SdkTest - { - private readonly FakeRecordEventNameTelemetry _fakeTelemetry; - - public string EventName { get; set; } - - public IDictionary Properties { get; set; } - - public TelemetryFilterTests(ITestOutputHelper log) : base(log) - { - _fakeTelemetry = new FakeRecordEventNameTelemetry(); - TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); - TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithoutPerformanceData() - { - var parseResult = Parser.Parse(["build"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && - e.Measurement == null); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithPerformanceData() - { - var parseResult = Parser.Parse(["build"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 12345 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && - e.Measurement.ContainsKey("Startup Time") && - e.Measurement["Startup Time"] == 12345); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithGlobalJsonState() - { - string globalJsonState = "invalid_data"; - var parseResult = Parser.Parse(["build"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary(), globalJsonState)); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && - e.Measurement == null && - e.Properties.ContainsKey("globalJson") && - e.Properties["globalJson"] == Sha256Hasher.HashWithNormalizedCasing(globalJsonState)); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithZeroPerformanceData() - { - var parseResult = Parser.Parse(["build"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && - e.Measurement == null); - } - - [Fact] - public void TopLevelCommandNameShouldBeSentToTelemetryWithSomeZeroPerformanceData() - { - var parseResult = Parser.Parse(["build"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && - !e.Measurement.ContainsKey("Startup Time") && - e.Measurement.ContainsKey("Parse Time") && - e.Measurement["Parse Time"] == 23456); - } - - [Fact] - public void SubLevelCommandNameShouldBeSentToTelemetryWithoutPerformanceData() - { - var parseResult = Parser.Parse(["new", "console"]); - TelemetryEventEntry.SendFiltered(parseResult); - _fakeTelemetry - .LogEntries.Should() - .Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW") && - e.Measurement == null); - } - - [Fact] - public void SubLevelCommandNameShouldBeSentToTelemetryWithPerformanceData() - { - var parseResult = Parser.Parse(["new", "console"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 34567 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW") && - e.Measurement.ContainsKey("Startup Time") && - e.Measurement["Startup Time"] == 34567); - } - - [Fact] - public void SubLevelCommandNameShouldBeSentToTelemetryWithZeroPerformanceData() - { - var parseResult = Parser.Parse(["new", "console"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW") && - e.Measurement == null); - } - - [Fact] - public void SubLevelCommandNameShouldBeSentToTelemetryWithSomeZeroPerformanceData() - { - var parseResult = Parser.Parse(["new", "console"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 45678 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("argument") && - e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("NEW") && - !e.Measurement.ContainsKey("Startup Time") && - e.Measurement.ContainsKey("Parse Time") && - e.Measurement["Parse Time"] == 45678); - } - - [Fact] - public void WorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["workload", "install", "microsoft-ios-sdk-full"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, - new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL") && - e.Properties["argument"] == - Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); - } - - [Fact] - public void ToolsSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["tool", "install", "dotnet-format"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, - new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("TOOL") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL") && - e.Properties["argument"] == - Sha256Hasher.Hash("DOTNET-FORMAT")); - } - - [Fact] - public void WhenCalledWithDiagnosticWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["-d", "workload", "install", "microsoft-ios-sdk-full"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, - new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL") && - e.Properties["argument"] == - Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); - } - - [Fact] - public void WhenCalledWithMissingArgumentWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() - { - var parseResult = - Parser.Parse(["-d", "workload", "install"]); - TelemetryEventEntry.SendFiltered(Tuple.Create(parseResult, - new Dictionary() { { "Startup Time", 0 }, { "Parse Time", 23456 } })); - _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && - e.Properties.ContainsKey("verb") && - e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && - e.Properties["subcommand"] == - Sha256Hasher.Hash("INSTALL")); - } - } -} diff --git a/test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs b/test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs new file mode 100644 index 000000000000..277e89826a8d --- /dev/null +++ b/test/dotnet.Tests/TelemetryTests/FakeRecordEventNameTelemetry.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using Microsoft.DotNet.Cli.Telemetry; + +namespace Microsoft.DotNet.Tests.TelemetryTests; + +public class FakeRecordEventNameTelemetry : ITelemetryClient +{ + public bool Enabled { get; set; } + + public string? EventName { get; set; } + + public void TrackEvent(string eventName, IDictionary? properties) + { + LogEntries.Add(new LogEntry + { + EventName = eventName, + Properties = properties ?? new Dictionary() + }); + } + + public ConcurrentBag LogEntries { get; set; } = []; + + public class LogEntry + { + public string? EventName { get; set; } + public IDictionary Properties { get; set; } = new Dictionary(); + } +} diff --git a/test/dotnet.Tests/TelemetryTests/SenderTests.cs b/test/dotnet.Tests/TelemetryTests/SenderTests.cs deleted file mode 100644 index 6d6a4983ea23..000000000000 --- a/test/dotnet.Tests/TelemetryTests/SenderTests.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Net; -using System.Runtime.CompilerServices; -using Moq; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel.Tests -{ - public class SenderTests : SdkTest - { - private int _deleteCount; - - private Mock TransmissionMock { get; } - - private Mock StorageBaseMock { get; } - - public SenderTests(ITestOutputHelper log) : base(log) - { - StorageBaseMock = new Mock(); - TransmissionMock = new Mock(string.Empty, new Uri("http://some/url"), new byte[] { }, - string.Empty, string.Empty); - _deleteCount = 0; - StorageBaseMock.Setup(storage => storage.Delete(It.IsAny())) - .Callback(() => _deleteCount++); - } - - [Fact] - public void WhenServerReturn503TransmissionWillBeRetried() - { - var Sender = GetSenderUnderTest(); - int peekCounts = 0; - - // Setup transmission.SendAsync() to throw WebException that has 503 status Code - TransmissionMock.Setup(transmission => transmission.SendAsync()) - .Throws(GenerateWebException((HttpStatusCode)503)); - - // Setup Storage.Peek() to return the mocked transmission, and stop the loop after 10 peeks. - StorageBaseMock.Setup(storage => storage.Peek()) - .Returns(TransmissionMock.Object) - .Callback(() => - { - if (peekCounts++ == 10) - { - Sender.StopAsync(); - } - }); - - // Act - Sender.SendLoop(); - _deleteCount.Should().Be(0, - "delete is not expected to be called on 503, request is expected to be send forever."); - } - - [Fact] - public void WhenServerReturn400IntervalWillBe10Seconds() - { - var Sender = GetSenderUnderTest(); - int peekCounts = 0; - - // Setup transmission.SendAsync() to throw WebException that has 400 status Code - TransmissionMock.Setup(transmission => transmission.SendAsync()) - .Throws(GenerateWebException((HttpStatusCode)400)); - - // Setup Storage.Peek() to return the mocked transmission, and stop the loop after 10 peeks. - StorageBaseMock.Setup(storage => storage.Peek()) - .Returns(TransmissionMock.Object) - .Callback(() => - { - if (peekCounts++ == 10) - { - Sender.StopAsync(); - } - }); - - // Cache the interval (it is a parameter passed to the Send method). - TimeSpan intervalOnSixIteration = TimeSpan.Zero; - Sender.OnSend = interval => intervalOnSixIteration = interval; - - // Act - Sender.SendLoop(); - - intervalOnSixIteration.TotalSeconds.Should().Be(5); - _deleteCount.Should().Be(10, "400 should not be retried so delete should always be called."); - } - - [Fact] - public void DisposeDoesNotThrow() - { - new Sender(StorageBaseMock.Object, - new PersistenceTransmitter( - CreateStorageService(), - 3)) - .Dispose(); - } - - [Fact] - public void WhenServerReturnDnsErrorRequestWillBeRetried() - { - var Sender = GetSenderUnderTest(); - int peekCounts = 0; - - // Setup transmission.SendAsync() to throw WebException with ProxyNameResolutionFailure failure - WebException webException = new( - string.Empty, - new Exception(), - WebExceptionStatus.ProxyNameResolutionFailure, - null); - TransmissionMock.Setup(transmission => transmission.SendAsync()).Throws(webException); - - // Setup Storage.Peek() to return the mocked transmission, and stop the loop after 10 peeks. - StorageBaseMock.Setup(storage => storage.Peek()) - .Returns(TransmissionMock.Object) - .Callback(() => - { - if (peekCounts++ == 10) - { - Sender.StopAsync(); - } - }); - - // Act - Sender.SendLoop(); - - _deleteCount.Should().Be(0, - "delete is not expected to be called on Dns errors since it , request is expected to be retried forever."); - } - - private WebException GenerateWebException(HttpStatusCode httpStatusCode) - { - Mock httpWebResponse = new(); - httpWebResponse.SetupGet(webResponse => webResponse.StatusCode).Returns(httpStatusCode); - - WebException webException = new(string.Empty, new Exception(), WebExceptionStatus.SendFailure, - httpWebResponse.Object); - - return webException; - } - - /// - /// A class that inherits from Sender, to expose its protected methods. - /// - internal class SenderUnderTest : Sender - { - internal Action OnSend = nextSendInterval => { }; - - internal SenderUnderTest(BaseStorageService storage, PersistenceTransmitter transmitter) - : base(storage, transmitter, false) - { - } - - internal AutoResetEvent IntervalAutoResetEvent => DelayHandler; - - internal new void SendLoop() - { - base.SendLoop(); - } - - protected override bool Send(StorageTransmission transmission, ref TimeSpan nextSendInterval) - { - OnSend(nextSendInterval); - DelayHandler.Set(); - return base.Send(transmission, ref nextSendInterval); - } - } - - private StorageService CreateStorageService([CallerMemberName] string testName = null) - { - string tempPath = Path.Combine(TestAssetsManager.CreateTestDirectory("TestStorageService", identifier: testName).Path, Path.GetTempFileName()); - StorageService storageService = new(); - storageService.Init(tempPath); - return storageService; - } - - private SenderUnderTest GetSenderUnderTest([CallerMemberName] string testName = null) - { - StorageService storageService = CreateStorageService(testName); - PersistenceTransmitter transmitter = new(storageService, 0); - return new SenderUnderTest(StorageBaseMock.Object, transmitter); - } - } -} diff --git a/test/dotnet.Tests/TelemetryTests/StorageTests.cs b/test/dotnet.Tests/TelemetryTests/StorageTests.cs deleted file mode 100644 index bd56db879b8d..000000000000 --- a/test/dotnet.Tests/TelemetryTests/StorageTests.cs +++ /dev/null @@ -1,202 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using System.Runtime.CompilerServices; -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.DataContracts; -using Microsoft.ApplicationInsights.Extensibility.Implementation; -using IChannelTelemetry = Microsoft.ApplicationInsights.Channel.ITelemetry; - -namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel.Tests -{ - /// - /// Tests for Storage. - /// - /// - /// To reduce complexity, there was a design decision to make Storage the file system abstraction layer. - /// That means that Storage knows about the file system types (e.g. IStorageFile or FileInfo). - /// Those types are not easy to mock (even IStorageFile is using extension methods that makes it very hard to mock). - /// Therefore those UnitTests just doesn't mock the file system. Every unit test in - /// reads and writes files to/from the disk. - /// - public class StorageTests : SdkTest - { - public StorageTests(ITestOutputHelper log) : base(log) - { - } - - [Fact] - public async Task EnqueuedContentIsEqualToPeekedContent() - { - // Setup - StorageService storage = new(); - storage.Init(GetTemporaryPath()); - Transmission transmissionToEnqueue = CreateTransmission(new TraceTelemetry("mock_item")); - - // Act - await storage.EnqueueAsync(transmissionToEnqueue); - StorageTransmission peekedTransmission = storage.Peek(); - - // Asserts - string enqueuedContent = - Encoding.UTF8.GetString(transmissionToEnqueue.Content, 0, transmissionToEnqueue.Content.Length); - string peekedContent = - Encoding.UTF8.GetString(peekedTransmission.Content, 0, peekedTransmission.Content.Length); - enqueuedContent.Should().Be(peekedContent); - } - - [Fact] - public void DeletedItemIsNotReturnedInCallsToPeek() - { - // Setup - create a storage with one item - StorageService storage = new(); - storage.Init(GetTemporaryPath()); - Transmission transmissionToEnqueue = CreateTransmissionAndEnqueueIt(storage); - - // Act - StorageTransmission firstPeekedTransmission; - - // if item is not disposed,peek will not return it (regardless of the call to delete). - // So for this test to actually test something, using 'using' is required. - using (firstPeekedTransmission = storage.Peek()) - { - storage.Delete(firstPeekedTransmission); - } - - StorageTransmission secondPeekedTransmission = storage.Peek(); - - // Asserts - firstPeekedTransmission.Should().NotBeNull(); - secondPeekedTransmission.Should().BeNull(); - } - - [Fact] - public void PeekedItemIsOnlyReturnedOnce() - { - // Setup - create a storage with one item - StorageService storage = new(); - storage.Init(GetTemporaryPath()); - - Transmission transmissionToEnqueue = CreateTransmissionAndEnqueueIt(storage); - - // Act - StorageTransmission firstPeekedTransmission = storage.Peek(); - StorageTransmission secondPeekedTransmission = storage.Peek(); - - // Asserts - firstPeekedTransmission.Should().NotBeNull(); - secondPeekedTransmission.Should().BeNull(); - } - - [Fact] - public async Task PeekedItemIsReturnedAgainAfterTheItemInTheFirstCallToPeekIsDisposed() - { - // Setup - create a storage with one item - StorageService storage = new(); - storage.Init(GetTemporaryPath()); - - Transmission transmissionToEnqueue = CreateTransmission(new TraceTelemetry("mock_item")); - await storage.EnqueueAsync(transmissionToEnqueue); - - // Act - StorageTransmission firstPeekedTransmission; - using (firstPeekedTransmission = storage.Peek()) - { - } - - StorageTransmission secondPeekedTransmission = storage.Peek(); - - // Asserts - firstPeekedTransmission.Should().NotBeNull(); - secondPeekedTransmission.Should().NotBeNull(); - } - - [Fact] - public void WhenStorageHasTwoItemsThenTwoCallsToPeekReturns2DifferentItems() - { - // Setup - create a storage with 2 items - StorageService storage = new(); - storage.Init(GetTemporaryPath()); - - Transmission firstTransmission = CreateTransmissionAndEnqueueIt(storage); - Transmission secondTransmission = CreateTransmissionAndEnqueueIt(storage); - - // Act - StorageTransmission firstPeekedTransmission = storage.Peek(); - StorageTransmission secondPeekedTransmission = storage.Peek(); - - // Asserts - firstPeekedTransmission.Should().NotBeNull(); - secondPeekedTransmission.Should().NotBeNull(); - - string first = Encoding.UTF8.GetString(firstPeekedTransmission.Content, 0, - firstPeekedTransmission.Content.Length); - string second = Encoding.UTF8.GetString(secondPeekedTransmission.Content, 0, - secondPeekedTransmission.Content.Length); - first.Should().NotBe(second); - } - - [Fact] - public void WhenMaxFilesIsOneThenSecondTransmissionIsDropped() - { - // Setup - StorageService storage = new(); - storage.Init(GetTemporaryPath()); - - storage.MaxFiles = 1; - - // Act - Enqueue twice - CreateTransmissionAndEnqueueIt(storage); - CreateTransmissionAndEnqueueIt(storage); - - // Asserts - Second Peek should be null - storage.Peek().Should().NotBeNull(); - storage.Peek().Should().BeNull(); - } - - [Fact] - public void WhenMaxSizeIsReachedThenEnqueuedTransmissionsAreDropped() - { - // Setup - create a storage with 2 items - StorageService storage = new(); - storage.Init(GetTemporaryPath()); - - storage.CapacityInBytes = 200; // Each file enqueued in CreateTransmissionAndEnqueueIt is ~300 bytes. - - // Act - Enqueue twice - CreateTransmissionAndEnqueueIt(storage); - CreateTransmissionAndEnqueueIt(storage); - - // Asserts - Second Peek should be null - storage.Peek().Should().NotBeNull(); - storage.Peek().Should().BeNull(); - } - - private static Transmission CreateTransmission(IChannelTelemetry telemetry) - { - byte[] data = JsonSerializer.Serialize(new[] { telemetry }); - Transmission transmission = new( - new Uri(@"http://some.url"), - data, - "application/x-json-stream", - JsonSerializer.CompressionType); - - return transmission; - } - - private static Transmission CreateTransmissionAndEnqueueIt(StorageService storage) - { - Transmission firstTransmission = CreateTransmission(new TraceTelemetry(Guid.NewGuid().ToString())); - storage.EnqueueAsync(firstTransmission).ConfigureAwait(false).GetAwaiter().GetResult(); - - return firstTransmission; - } - - private string GetTemporaryPath([CallerMemberName] string callingMethod = null) - { - return TestAssetsManager.CreateTestDirectory(callingMethod).Path; - } - } -} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs b/test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs new file mode 100644 index 000000000000..eb64306bfa69 --- /dev/null +++ b/test/dotnet.Tests/TelemetryTests/TelemetryClientTests.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; + +namespace Microsoft.DotNet.Tests.TelemetryTests; + +public class TelemetryClientTests(ITestOutputHelper log) : SdkTest(log) +{ + public static TheoryData CommandsWithExitCode => new() + { + { new[] { "--help" }, "0" }, + { new[] { "--info" }, "0" }, + { new[] { "workload", "list" }, "0" }, + { new[] { "sdk", "check" }, "0" }, + { new[] { "build-server", "shutdown" }, "0" }, + { new[] { "solution", "list" }, "1" }, + { new[] { "clean" }, "1" }, + { new[] { "run" }, "1" }, + { new[] { "new", "details" }, "127" } + }; + + // Only runs on Windows because OTel libraries are only referenced on Windows builds. + // Thus, this test that writes telemetry logs will not work on other platforms. + [PlatformSpecificTheory(TestPlatforms.Windows)] + [MemberData(nameof(CommandsWithExitCode))] + public void ItProcessesTelemetryData(string[] commandArgs, string exitCodeExpected) + { + var testDir = TestAssetsManager.CreateTestDirectory().Path; + var commandString = string.Join(' ', commandArgs); + var logFile = Path.Combine(testDir, $"TelemLog_{commandString}.json"); + + new DotnetCommand(Log, commandArgs) + .WithWorkingDirectory(testDir) + .WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", "false") + .WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT", "true") + .WithEnvironmentVariable("DOTNET_CLI_TELEMETRY_LOG_PATH", logFile) + .Execute(); + + var logFileInfo = new FileInfo(logFile); + logFileInfo.Should().Exist(); + + var telemetryJson = JsonNode.Parse(logFileInfo.ReadAllText()); + telemetryJson.Should().NotBeNull(); + + var activities = telemetryJson["activities"]?.AsArray(); + activities.Should().NotBeNull(); + + var mainOperation = activities.FirstOrDefault(n => n?["operationName"]?.GetValue() == "main"); + mainOperation.Should().NotBeNull(); + + var displayName = mainOperation["displayName"]?.GetValue(); + displayName.Should().Be($"dotnet {commandString}"); + + var events = mainOperation["events"]?.AsArray(); + events.Should().NotBeNull(); + + var finishEvent = events.FirstOrDefault(n => n?["name"]?.GetValue() == "dotnet/cli/command/finish"); + finishEvent.Should().NotBeNull(); + + var tags = finishEvent["tags"]; + tags.Should().NotBeNull(); + + var exitCode = tags["exitCode"]?.GetValue(); + exitCode.Should().Be(exitCodeExpected); + } +} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs b/test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs new file mode 100644 index 000000000000..cef125282576 --- /dev/null +++ b/test/dotnet.Tests/TelemetryTests/TelemetryCommandTest.cs @@ -0,0 +1,332 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess; +using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Utilities; + +namespace Microsoft.DotNet.Tests.TelemetryTests; + +[Collection(TestConstants.UsesStaticTelemetryState)] +public class TelemetryCommandTests : SdkTest +{ + private readonly FakeRecordEventNameTelemetry _fakeTelemetry; + + public string? EventName { get; set; } + + public IDictionary Properties { get; set; } = new Dictionary(); + + public TelemetryCommandTests(ITestOutputHelper log) : base(log) + { + _fakeTelemetry = new FakeRecordEventNameTelemetry(); + TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); + TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); + } + + [Fact] + public void NoTelemetryIfCommandIsInvalid() + { + string[] args = { "publish", "-r" }; + Action a = () => { Cli.Program.ProcessArgsAndExecute(args); }; + a.Should().NotThrow(); + } + + [Fact] + public void NoTelemetryIfCommandIsInvalid2() + { + string[] args = { "restore", "-v" }; + Action a = () => { Cli.Program.ProcessArgsAndExecute(args); }; + a.Should().NotThrow(); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetry() + { + string[] args = { "help" }; + Cli.Program.ProcessArgsAndExecute(args); + + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("HELP")); + } + + [Fact] + public void DotnetNewCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "console"; + string[] args = { "new", argumentToSend }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW")); + } + + [Fact] + public void DotnetHelpCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "something"; + string[] args = { "help", argumentToSend }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("HELP")); + } + + [Fact] + public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "package"; + string[] args = { "add", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("ADD")); + } + + [Fact] + public void DotnetAddCommandFirstArgumentShouldBeSentToTelemetry2() + { + const string argumentToSend = "reference"; + string[] args = { "add", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("ADD")); + } + + [Fact] + public void DotnetRemoveCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "package"; + string[] args = { "remove", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("REMOVE")); + } + + [Fact] + public void DotnetListCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "reference"; + string[] args = { "list", argumentToSend, "aPackageName" }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("LIST")); + } + + [Fact] + public void DotnetSlnCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "list"; + string[] args = { "sln", "aSolution", argumentToSend }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("SOLUTION")); + } + + [Fact] + public void DotnetNugetCommandFirstArgumentShouldBeSentToTelemetry() + { + const string argumentToSend = "push"; + + string[] args = { "nuget", argumentToSend, "path" }; + + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash(argumentToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NUGET")); + } + + [Fact(Skip = "https://github.com/dotnet/sdk/issues/47862")] + public void DotnetNewCommandLanguageOpinionShouldBeSentToTelemetry() + { + const string optionKey = "language"; + const string optionValueToSend = "c#"; + string[] args = { "new", "console", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW")); + } + + [Fact] + public void AnyDotnetCommandVerbosityOpinionShouldBeSentToTelemetry() + { + const string optionKey = "verbosity"; + const string optionValueToSend = "minimal"; + string[] args = { "restore", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("RESTORE")); + } + + [Fact] + public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetry() + { + const string optionKey = "configuration"; + const string optionValueToSend = "Debug"; + string[] args = { "build", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); + } + + [Fact] + public void DotnetPublishCommandRuntimeOpinionsShouldBeSentToTelemetry() + { + const string optionKey = "runtime"; + const string optionValueToSend = $"{ToolsetInfo.LatestWinRuntimeIdentifier}-x64"; + string[] args = { "publish", "--" + optionKey, optionValueToSend }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties[optionKey] == Sha256Hasher.Hash(optionValueToSend.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("PUBLISH")); + } + + [Fact] + public void DotnetBuildAndPublishCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() + { + string[] args = { "build", "--configuration", "Debug", "--runtime", $"{ToolsetInfo.LatestMacRuntimeIdentifier}-x64" }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && + e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); + + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("runtime") && + e.Properties["runtime"] == Sha256Hasher.Hash($"{ToolsetInfo.LatestMacRuntimeIdentifier.ToUpper()}-X64") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); + } + + [Fact] + public void DotnetRunCleanTestCommandOpinionsShouldBeSentToTelemetryWhenThereIsMultipleOption() + { + string[] args = { "clean", "--configuration", "Debug", "--framework", ToolsetInfo.CurrentTargetFramework }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("configuration") && + e.Properties["configuration"] == Sha256Hasher.Hash("DEBUG") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); + + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && e.Properties.ContainsKey("framework") && + e.Properties["framework"] == Sha256Hasher.Hash(ToolsetInfo.CurrentTargetFramework.ToUpper()) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("CLEAN")); + } + + [Fact] + public void DotnetUpdatePackageVulnerableOptionShouldBeSentToTelemetry() + { + const string optionKey = "vulnerable"; + string[] args = { "package", "update", "--vulnerable" }; + Cli.Program.ProcessArgsAndExecute(args); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey(optionKey) && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("PACKAGE UPDATE")); + } + + [WindowsOnlyFact] + public void InternalreportinstallsuccessCommandCollectExeNameWithEventname() + { + FakeRecordEventNameTelemetry fakeTelemetry = new(); + string[] args = { "c:\\mypath\\dotnet-sdk-latest-win-x64.exe" }; + + InternalReportInstallSuccessCommand.ProcessInputAndSendTelemetry(args, fakeTelemetry); + + fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "install/reportsuccess" && e.Properties.ContainsKey("exeName") && + e.Properties["exeName"] == Sha256Hasher.Hash("DOTNET-SDK-LATEST-WIN-X64.EXE")); + } + + [Fact] + public void ExceptionShouldBeSentToTelemetry() + { + Exception? caughtException = null; + try + { + string[] args = { "build" }; + Cli.Program.ProcessArgsAndExecute(args); + throw new ArgumentException("test exception"); + } + catch (Exception ex) + { + caughtException = ex; + TelemetryEventEntry.SendFiltered(ex); + } + + var stackTrace = caughtException?.StackTrace ?? string.Empty; + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "mainCatchException/exception" && + e.Properties.ContainsKey("exceptionType") && + e.Properties["exceptionType"] == "System.ArgumentException" && + e.Properties.ContainsKey("detail") && + e.Properties["detail"] != null && + e.Properties["detail"]!.Contains(stackTrace)); + } +} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs b/test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs new file mode 100644 index 000000000000..fb4382e0ae04 --- /dev/null +++ b/test/dotnet.Tests/TelemetryTests/TelemetryCommonPropertiesTests.cs @@ -0,0 +1,311 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Configurer; + +namespace Microsoft.DotNet.Tests.TelemetryTests; + +public class TelemetryCommonPropertiesTests : SdkTest +{ + public TelemetryCommonPropertiesTests(ITestOutputHelper log) : base(log) + { + } + + [Fact] + public void TelemetryCommonPropertiesShouldContainIfItIsInDockerOrNot() + { + var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId").Should().ContainKey("Docker Container"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnHashedPath() + { + var unitUnderTest = new TelemetryCommonProperties(() => "ADirectory", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Current Path Hash"].Should().NotBe("ADirectory"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnHashedMachineId() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"].Should().NotBe("plaintext"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnDevDeviceId() + { + var unitUnderTest = new TelemetryCommonProperties(getDeviceId: () => "plaintext", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"].Should().Be("plaintext"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddress() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID"]; + + Guid.TryParse((string?)assignedMachineId, out var _).Should().BeTrue("it should be a guid"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldEnsureDevDeviceIDIsCached() + { + var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); + var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; + + Guid.TryParse((string?)assignedMachineId, out var _).Should().BeTrue("it should be a guid"); + var secondAssignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["devdeviceid"]; + + Guid.TryParse((string?)secondAssignedMachineId, out var _).Should().BeTrue("it should be a guid"); + secondAssignedMachineId.Should().Be(assignedMachineId, "it should match the previously assigned guid"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnHashedMachineIdOld() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => "plaintext", userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"].Should().NotBe("plaintext"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnNewGuidWhenCannotGetMacAddressOld() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + var assignedMachineId = unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Machine ID Old"]; + + Guid.TryParse((string?)assignedMachineId, out var _).Should().BeTrue("it should be a guid"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnIsOutputRedirected() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Output Redirected"].Should().BeOneOf("True", "False"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnIsCIDetection() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Continuous Integration"].Should().BeOneOf("True", "False"); + } + + [Fact] + public void TelemetryCommonPropertiesShouldContainKernelVersion() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Kernel Version"].Should().Be(RuntimeInformation.OSDescription); + } + + [Fact] + public void TelemetryCommonPropertiesShouldContainArchitectureInformation() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["OS Architecture"].Should().Be(RuntimeInformation.OSArchitecture.ToString()); + } + + [WindowsOnlyFact] + public void TelemetryCommonPropertiesShouldContainWindowsInstallType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().NotBeEmpty(); + } + + [UnixOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyWindowsInstallType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Installation Type"].Should().BeEmpty(); + } + + [WindowsOnlyFact] + public void TelemetryCommonPropertiesShouldContainWindowsProductType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().NotBeEmpty(); + } + + [UnixOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyWindowsProductType() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Product Type"].Should().BeEmpty(); + } + + [WindowsOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); + } + + [MacOsOnlyFact] + public void TelemetryCommonPropertiesShouldContainEmptyLibcReleaseAndVersion2() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().BeEmpty(); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().BeEmpty(); + } + + [LinuxOnlyFact] + public void TelemetryCommonPropertiesShouldContainLibcReleaseAndVersion() + { + if (!RuntimeInformation.RuntimeIdentifier.Contains("alpine", StringComparison.OrdinalIgnoreCase)) + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Release"].Should().NotBeEmpty(); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["Libc Version"].Should().NotBeEmpty(); + } + } + + [Fact] + public void TelemetryCommonPropertiesShouldReturnIsLLMDetection() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["llm"].Should().BeOneOf("claude", null); + } + + [Theory] + [MemberData(nameof(CITelemetryTestCases))] + public void CanDetectCIStatusForEnvVars(Dictionary envVars, bool expected) + { + try + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, value); + } + new CIEnvironmentDetectorForTelemetry().IsCIEnvironment().Should().Be(expected); + } + finally + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, null); + } + } + } + + [Theory] + [MemberData(nameof(LLMTelemetryTestCases))] + public void CanDetectLLMStatusForEnvVars(Dictionary? envVars, string? expected) + { + try + { + if (envVars is not null) + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, value); + } + } + new LLMEnvironmentDetectorForTelemetry().GetLLMEnvironment().Should().Be(expected); + } + finally + { + if (envVars is not null) + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, null); + } + } + } + } + + [Theory] + [InlineData("dummySessionId")] + [InlineData(null)] + public void TelemetryCommonPropertiesShouldContainSessionId(string? sessionId) + { + var unitUnderTest = new TelemetryCommonProperties(userLevelCacheWriter: new NothingCache()); + var commonProperties = unitUnderTest.GetTelemetryCommonProperties(sessionId); + + commonProperties.Should().ContainKey("SessionId"); + commonProperties["SessionId"].Should().Be(sessionId); + } + + public static TheoryData?, string?> LLMTelemetryTestCases => new() + { + { new Dictionary { {"CLAUDECODE", "1" } }, "claude" }, + { new Dictionary { {"CLAUDE_CODE_ENTRYPOINT", "some_value" } }, "claude" }, + { new Dictionary { { "CURSOR_EDITOR", "1" } }, "cursor" }, + { new Dictionary { { "CURSOR_AI", "1" } }, "cursor" }, + { new Dictionary { { "GEMINI_CLI", "true" } }, "gemini" }, + { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "true" } }, "copilot" }, + { new Dictionary { { "GH_COPILOT_WORKING_DIRECTORY", "/repo" } }, "copilot" }, + { new Dictionary { { "CODEX_CLI", "1" } }, "codex" }, + { new Dictionary { { "CODEX_SANDBOX", "1" } }, "codex" }, + { new Dictionary { { "OR_APP_NAME", "Aider" } }, "aider" }, + { new Dictionary { { "OR_APP_NAME", "aider" } }, "aider" }, + { new Dictionary { { "OR_APP_NAME", "plandex" } }, "plandex" }, + { new Dictionary { { "OR_APP_NAME", "Plandex" } }, "plandex" }, + { new Dictionary { { "AMP_HOME", "/path/to/amp" } }, "amp" }, + { new Dictionary { { "QWEN_CODE", "1" } }, "qwen" }, + { new Dictionary { { "DROID_CLI", "true" } }, "droid" }, + { new Dictionary { { "OPENCODE_AI", "1" } }, "opencode" }, + { new Dictionary { { "ZED_ENVIRONMENT", "1" } }, "zed" }, + { new Dictionary { { "ZED_TERM", "1" } }, "zed" }, + { new Dictionary { { "KIMI_CLI", "true" } }, "kimi" }, + { new Dictionary { { "OR_APP_NAME", "OpenHands" } }, "openhands" }, + { new Dictionary { { "OR_APP_NAME", "openhands" } }, "openhands" }, + { new Dictionary { { "GOOSE_TERMINAL", "1" } }, "goose" }, + { new Dictionary { { "CLINE_TASK_ID", "task123" } }, "cline" }, + { new Dictionary { { "ROO_CODE_TASK_ID", "task456" } }, "roo" }, + { new Dictionary { { "WINDSURF_SESSION", "session789" } }, "windsurf" }, + { new Dictionary { { "AGENT_CLI", "true" } }, "generic_agent" }, + // Test combinations of older tools + { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" }, + { new Dictionary { { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" } }, "gemini, copilot" }, + { new Dictionary { { "CLAUDECODE", "1" }, { "GEMINI_CLI", "true" }, { "AGENT_CLI", "true" } }, "claude, gemini, generic_agent" }, + { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" }, { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" }, { "AGENT_CLI", "true" } }, "claude, cursor, gemini, copilot, generic_agent" }, + // Test combinations of newer tools + { new Dictionary { { "OR_APP_NAME", "Aider" }, { "CLINE_TASK_ID", "task123" } }, "aider, cline" }, + { new Dictionary { { "CODEX_CLI", "1" }, { "WINDSURF_SESSION", "session789" } }, "codex, windsurf" }, + { new Dictionary { { "GOOSE_TERMINAL", "1" }, { "ROO_CODE_TASK_ID", "task456" } }, "goose, roo" }, + { new Dictionary { { "GEMINI_CLI", "false" } }, null }, + { new Dictionary { { "GITHUB_COPILOT_CLI_MODE", "false" } }, null }, + { new Dictionary { { "AGENT_CLI", "false" } }, null }, + { new Dictionary { { "DROID_CLI", "false" } }, null }, + { new Dictionary { { "KIMI_CLI", "false" } }, null }, + { new Dictionary { { "OR_APP_NAME", "SomeOtherApp" } }, null }, + { new Dictionary(), null }, + }; + + public static TheoryData, bool> CITelemetryTestCases => new() + { + { new Dictionary { { "TF_BUILD", "true" } }, true }, + { new Dictionary { { "GITHUB_ACTIONS", "true" } }, true }, + { new Dictionary { { "APPVEYOR", "true"} }, true }, + { new Dictionary { { "CI", "true"} }, true }, + { new Dictionary { { "TRAVIS", "true"} }, true }, + { new Dictionary { { "CIRCLECI", "true"} }, true }, + { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" }, { "AWS_REGION", "hi" } }, true }, + { new Dictionary { { "CODEBUILD_BUILD_ID", "hi" } }, false }, + { new Dictionary { { "BUILD_ID", "hi" }, { "BUILD_URL", "hi" } }, true }, + { new Dictionary { { "BUILD_ID", "hi" } }, false }, + { new Dictionary { { "BUILD_ID", "hi" }, { "PROJECT_ID", "hi" } }, true }, + { new Dictionary { { "BUILD_ID", "hi" } }, false }, + { new Dictionary { { "TEAMCITY_VERSION", "hi" } }, true }, + { new Dictionary { { "TEAMCITY_VERSION", "" } }, false }, + { new Dictionary { { "JB_SPACE_API_URL", "hi" } }, true }, + { new Dictionary { { "JB_SPACE_API_URL", "" } }, false }, + { new Dictionary { { "SomethingElse", "hi" } }, false }, + }; + + private class NothingCache : IUserLevelCacheWriter + { + public string RunWithCache(string cacheKey, Func getValueToCache) + { + return getValueToCache(); + } + + public string RunWithCacheInFilePath(string cacheFilepath, Func getValueToCache) + { + return getValueToCache(); + } + } +} diff --git a/test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs b/test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs new file mode 100644 index 000000000000..300f1f90e0e1 --- /dev/null +++ b/test/dotnet.Tests/TelemetryTests/TelemetryFilterTest.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Utilities; +using Parser = Microsoft.DotNet.Cli.Parser; + +namespace Microsoft.DotNet.Tests.TelemetryTests; + +/// +/// Only adding the performance data tests for now as the TelemetryCommandTests cover most other scenarios already +/// +public class TelemetryFilterTests : SdkTest +{ + private readonly FakeRecordEventNameTelemetry _fakeTelemetry; + + public string? EventName { get; set; } + + public IDictionary Properties { get; set; } = new Dictionary(); + + public TelemetryFilterTests(ITestOutputHelper log) : base(log) + { + _fakeTelemetry = new FakeRecordEventNameTelemetry(); + TelemetryEventEntry.Subscribe(_fakeTelemetry.TrackEvent); + TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetry() + { + var parseResult = Parser.Parse(["build"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD")); + } + + [Fact] + public void TopLevelCommandNameShouldBeSentToTelemetryWithGlobalJsonState() + { + string globalJsonState = "invalid_data"; + var parseResult = Parser.Parse(["build"]); + TelemetryEventEntry.SendFiltered(new ParseResultWithGlobalJsonState(parseResult, globalJsonState)); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "toplevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("BUILD") && + e.Properties.ContainsKey("globalJson") && + e.Properties["globalJson"] == Sha256Hasher.HashWithNormalizedCasing(globalJsonState)); + } + + [Fact] + public void SubLevelCommandNameShouldBeSentToTelemetry() + { + var parseResult = Parser.Parse(["new", "console"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry + .LogEntries.Should() + .Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("argument") && + e.Properties["argument"] == Sha256Hasher.Hash("CONSOLE") && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("NEW")); + } + + [Fact] + public void WorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["workload", "install", "microsoft-ios-sdk-full"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL") && + e.Properties["argument"] == + Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); + } + + [Fact] + public void ToolsSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["tool", "install", "dotnet-format"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("TOOL") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL") && + e.Properties["argument"] == + Sha256Hasher.Hash("DOTNET-FORMAT")); + } + + [Fact] + public void WhenCalledWithDiagnosticWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["-d", "workload", "install", "microsoft-ios-sdk-full"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL") && + e.Properties["argument"] == + Sha256Hasher.Hash("MICROSOFT-IOS-SDK-FULL")); + } + + [Fact] + public void WhenCalledWithMissingArgumentWorkloadSubLevelCommandNameAndArgumentShouldBeSentToTelemetry() + { + var parseResult = + Parser.Parse(["-d", "workload", "install"]); + TelemetryEventEntry.SendFiltered(parseResult); + _fakeTelemetry.LogEntries.Should().Contain(e => e.EventName == "sublevelparser/command" && + e.Properties.ContainsKey("verb") && + e.Properties["verb"] == Sha256Hasher.Hash("WORKLOAD") && + e.Properties["subcommand"] == + Sha256Hasher.Hash("INSTALL")); + } +}