Skip to content

Commit 4576fff

Browse files
Copilotbaronfel
andcommitted
Implement dotnet run telemetry feature with tests
Co-authored-by: baronfel <[email protected]>
1 parent c935e7d commit 4576fff

File tree

3 files changed

+664
-0
lines changed

3 files changed

+664
-0
lines changed

src/Cli/dotnet/Commands/Run/RunCommand.cs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ public int Execute()
163163
targetCommand.EnvironmentVariable(name, value);
164164
}
165165

166+
// Send telemetry about the run operation
167+
SendRunTelemetry(launchSettings, projectFactory, cachedRunProperties);
168+
166169
// Ignore Ctrl-C for the remainder of the command's execution
167170
Console.CancelKeyPress += (sender, e) => { e.Cancel = true; };
168171

@@ -755,4 +758,132 @@ public static ParseResult ModifyParseResultForShorthandProjectOption(ParseResult
755758
var newParseResult = Parser.Parse(tokensToParse);
756759
return newParseResult;
757760
}
761+
762+
/// <summary>
763+
/// Sends telemetry about the run operation.
764+
/// </summary>
765+
/// <param name="launchSettings">Applied launch settings if any</param>
766+
/// <param name="projectFactory">Project factory for file-based apps using MSBuild</param>
767+
/// <param name="cachedRunProperties">Cached run properties if available</param>
768+
private void SendRunTelemetry(
769+
ProjectLaunchSettingsModel? launchSettings,
770+
Func<ProjectCollection, ProjectInstance>? projectFactory,
771+
RunProperties? cachedRunProperties)
772+
{
773+
try
774+
{
775+
bool isFileBased = EntryPointFileFullPath is not null;
776+
string projectIdentifier;
777+
int sdkCount = 1; // Default assumption
778+
int packageReferenceCount = 0;
779+
int projectReferenceCount = 0;
780+
int additionalPropertiesCount = 0;
781+
bool? usedMSBuild = null;
782+
bool? usedRoslynCompiler = null;
783+
784+
if (isFileBased)
785+
{
786+
// File-based app telemetry
787+
projectIdentifier = RunTelemetry.GetFileBasedIdentifier(EntryPointFileFullPath!);
788+
789+
var virtualCommand = CreateVirtualCommand();
790+
var directives = virtualCommand.Directives;
791+
792+
sdkCount = RunTelemetry.CountSdks(directives: directives);
793+
packageReferenceCount = RunTelemetry.CountPackageReferences(directives: directives);
794+
projectReferenceCount = RunTelemetry.CountProjectReferences(directives: directives);
795+
additionalPropertiesCount = RunTelemetry.CountAdditionalProperties(directives);
796+
797+
// Determine if MSBuild or Roslyn compiler was used
798+
if (cachedRunProperties != null)
799+
{
800+
// If we have cached properties, we used the optimized path
801+
usedRoslynCompiler = projectFactory == null;
802+
usedMSBuild = projectFactory != null;
803+
}
804+
else if (ShouldBuild)
805+
{
806+
// Fresh build - check if we used MSBuild optimization
807+
usedMSBuild = !directives.IsDefaultOrEmpty || projectFactory != null;
808+
usedRoslynCompiler = directives.IsDefaultOrEmpty && projectFactory == null;
809+
}
810+
}
811+
else
812+
{
813+
// Project-based app telemetry
814+
projectIdentifier = RunTelemetry.GetProjectBasedIdentifier(ProjectFileFullPath!, GetRepositoryRoot());
815+
816+
// Try to get project information for telemetry
817+
// We need to evaluate the project to get accurate counts
818+
if (ShouldBuild)
819+
{
820+
// We built the project, so we can evaluate it for telemetry
821+
try
822+
{
823+
var globalProperties = MSBuildArgs.GlobalProperties?.ToDictionary() ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
824+
globalProperties[Constants.EnableDefaultItems] = "false";
825+
globalProperties[Constants.MSBuildExtensionsPath] = AppContext.BaseDirectory;
826+
827+
using var collection = new ProjectCollection(globalProperties: globalProperties);
828+
var project = collection.LoadProject(ProjectFileFullPath!).CreateProjectInstance();
829+
830+
sdkCount = RunTelemetry.CountSdks(project);
831+
packageReferenceCount = RunTelemetry.CountPackageReferences(project);
832+
projectReferenceCount = RunTelemetry.CountProjectReferences(project);
833+
}
834+
catch
835+
{
836+
// If project evaluation fails for telemetry, use defaults
837+
// We don't want telemetry collection to affect the run operation
838+
}
839+
}
840+
}
841+
842+
RunTelemetry.TrackRunEvent(
843+
isFileBased: isFileBased,
844+
launchProfile: LaunchProfile,
845+
noLaunchProfile: NoLaunchProfile,
846+
launchSettings: launchSettings,
847+
projectIdentifier: projectIdentifier,
848+
sdkCount: sdkCount,
849+
packageReferenceCount: packageReferenceCount,
850+
projectReferenceCount: projectReferenceCount,
851+
additionalPropertiesCount: additionalPropertiesCount,
852+
usedMSBuild: usedMSBuild,
853+
usedRoslynCompiler: usedRoslynCompiler);
854+
}
855+
catch
856+
{
857+
// Silently ignore telemetry errors to not affect the run operation
858+
}
859+
}
860+
861+
/// <summary>
862+
/// Attempts to find the repository root directory.
863+
/// </summary>
864+
/// <returns>Repository root path if found, null otherwise</returns>
865+
private string? GetRepositoryRoot()
866+
{
867+
try
868+
{
869+
var currentDir = ProjectFileFullPath != null
870+
? Path.GetDirectoryName(ProjectFileFullPath)
871+
: Directory.GetCurrentDirectory();
872+
873+
while (currentDir != null)
874+
{
875+
if (Directory.Exists(Path.Combine(currentDir, ".git")))
876+
{
877+
return currentDir;
878+
}
879+
currentDir = Directory.GetParent(currentDir)?.FullName;
880+
}
881+
}
882+
catch
883+
{
884+
// Ignore errors when trying to find repo root
885+
}
886+
887+
return null;
888+
}
758889
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Immutable;
5+
using System.Security.Cryptography;
6+
using System.Text;
7+
using Microsoft.Build.Evaluation;
8+
using Microsoft.Build.Execution;
9+
using Microsoft.DotNet.Cli.Commands.Run.LaunchSettings;
10+
using Microsoft.DotNet.Cli.Utils;
11+
12+
namespace Microsoft.DotNet.Cli.Commands.Run;
13+
14+
/// <summary>
15+
/// Provides telemetry functionality for dotnet run command.
16+
/// </summary>
17+
internal static class RunTelemetry
18+
{
19+
private const string RunEventName = "run";
20+
21+
/// <summary>
22+
/// Sends telemetry for a dotnet run operation.
23+
/// </summary>
24+
/// <param name="isFileBased">True if this is a file-based app run, false for project-based</param>
25+
/// <param name="launchProfile">The launch profile name if specified</param>
26+
/// <param name="noLaunchProfile">True if --no-launch-profile was specified</param>
27+
/// <param name="launchSettings">The applied launch settings model if any</param>
28+
/// <param name="projectIdentifier">Obfuscated unique project identifier</param>
29+
/// <param name="sdkCount">Number of SDKs used</param>
30+
/// <param name="packageReferenceCount">Number of PackageReferences</param>
31+
/// <param name="projectReferenceCount">Number of ProjectReferences</param>
32+
/// <param name="additionalPropertiesCount">Number of additional properties (file-based only)</param>
33+
/// <param name="usedMSBuild">Whether MSBuild was used (file-based only)</param>
34+
/// <param name="usedRoslynCompiler">Whether Roslyn compiler was used directly (file-based only)</param>
35+
public static void TrackRunEvent(
36+
bool isFileBased,
37+
string? launchProfile,
38+
bool noLaunchProfile,
39+
ProjectLaunchSettingsModel? launchSettings,
40+
string projectIdentifier,
41+
int sdkCount,
42+
int packageReferenceCount,
43+
int projectReferenceCount,
44+
int additionalPropertiesCount = 0,
45+
bool? usedMSBuild = null,
46+
bool? usedRoslynCompiler = null)
47+
{
48+
var properties = new Dictionary<string, string?>
49+
{
50+
["app_type"] = isFileBased ? "file_based" : "project_based",
51+
["project_id"] = projectIdentifier,
52+
["sdk_count"] = sdkCount.ToString(),
53+
["package_reference_count"] = packageReferenceCount.ToString(),
54+
["project_reference_count"] = projectReferenceCount.ToString(),
55+
};
56+
57+
// Launch profile telemetry
58+
if (noLaunchProfile)
59+
{
60+
properties["launch_profile_requested"] = "none";
61+
}
62+
else if (!string.IsNullOrEmpty(launchProfile))
63+
{
64+
properties["launch_profile_requested"] = "explicit";
65+
properties["launch_profile_is_default"] = IsDefaultProfile(launchProfile) ? "true" : "false";
66+
}
67+
else if (launchSettings != null)
68+
{
69+
properties["launch_profile_requested"] = "default_used";
70+
properties["launch_profile_is_default"] = IsDefaultProfile(launchSettings.LaunchProfileName) ? "true" : "false";
71+
}
72+
else
73+
{
74+
properties["launch_profile_requested"] = "false";
75+
}
76+
77+
// File-based app specific telemetry
78+
if (isFileBased)
79+
{
80+
properties["additional_properties_count"] = additionalPropertiesCount.ToString();
81+
if (usedMSBuild.HasValue)
82+
{
83+
properties["used_msbuild"] = usedMSBuild.Value ? "true" : "false";
84+
}
85+
if (usedRoslynCompiler.HasValue)
86+
{
87+
properties["used_roslyn_compiler"] = usedRoslynCompiler.Value ? "true" : "false";
88+
}
89+
}
90+
91+
TelemetryEventEntry.TrackEvent(RunEventName, properties, measurements: null);
92+
}
93+
94+
/// <summary>
95+
/// Generates an obfuscated unique identifier for a project-based app.
96+
/// </summary>
97+
/// <param name="projectFilePath">Full path to the project file</param>
98+
/// <param name="repoRoot">Repository root path if available</param>
99+
/// <returns>Hashed project identifier</returns>
100+
public static string GetProjectBasedIdentifier(string projectFilePath, string? repoRoot = null)
101+
{
102+
// Use relative path from repo root if available, otherwise use full path
103+
string pathToHash = repoRoot != null && projectFilePath.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase)
104+
? Path.GetRelativePath(repoRoot, projectFilePath)
105+
: projectFilePath;
106+
107+
return ComputeHash(pathToHash);
108+
}
109+
110+
/// <summary>
111+
/// Generates an obfuscated unique identifier for a file-based app.
112+
/// This leverages the same caching infrastructure identifier already used.
113+
/// </summary>
114+
/// <param name="entryPointFilePath">Full path to the entry point file</param>
115+
/// <returns>Hashed file identifier</returns>
116+
public static string GetFileBasedIdentifier(string entryPointFilePath)
117+
{
118+
// Use the same hash computation as the caching infrastructure
119+
return ComputeHash(entryPointFilePath);
120+
}
121+
122+
/// <summary>
123+
/// Counts the number of SDKs used in a project.
124+
/// </summary>
125+
/// <param name="project">Project instance for project-based apps</param>
126+
/// <param name="directives">Directives for file-based apps</param>
127+
/// <returns>Number of SDKs</returns>
128+
public static int CountSdks(ProjectInstance? project = null, ImmutableArray<CSharpDirective> directives = default)
129+
{
130+
if (!directives.IsDefaultOrEmpty)
131+
{
132+
// File-based: count SDK directives
133+
var sdkDirectives = directives.OfType<CSharpDirective.Sdk>().Count();
134+
// If no explicit SDK directives, there's still the default Microsoft.NET.Sdk
135+
return sdkDirectives > 0 ? sdkDirectives : 1;
136+
}
137+
138+
if (project != null)
139+
{
140+
// Project-based: look for Sdk attributes and Import nodes
141+
var sdkCount = 0;
142+
143+
// Count main project SDK
144+
var projectSdk = project.GetPropertyValue("MSBuildProjectSdk");
145+
if (!string.IsNullOrEmpty(projectSdk))
146+
{
147+
sdkCount++;
148+
}
149+
150+
// Count additional SDK imports
151+
var imports = project.GetItems("_SdkImport");
152+
sdkCount += imports.Count;
153+
154+
return Math.Max(1, sdkCount); // At least 1 for Microsoft.NET.Sdk
155+
}
156+
157+
return 1; // Default assumption for project-based apps
158+
}
159+
160+
/// <summary>
161+
/// Counts the number of PackageReferences in a project.
162+
/// </summary>
163+
/// <param name="project">Project instance for project-based apps</param>
164+
/// <param name="directives">Directives for file-based apps</param>
165+
/// <returns>Number of package references</returns>
166+
public static int CountPackageReferences(ProjectInstance? project = null, ImmutableArray<CSharpDirective> directives = default)
167+
{
168+
if (!directives.IsDefaultOrEmpty)
169+
{
170+
// File-based: count package directives
171+
return directives.OfType<CSharpDirective.Package>().Count();
172+
}
173+
174+
if (project != null)
175+
{
176+
// Project-based: count PackageReference items
177+
return project.GetItems("PackageReference").Count;
178+
}
179+
180+
return 0;
181+
}
182+
183+
/// <summary>
184+
/// Counts the number of direct ProjectReferences in a project.
185+
/// </summary>
186+
/// <param name="project">Project instance for project-based apps</param>
187+
/// <param name="directives">Directives for file-based apps</param>
188+
/// <returns>Number of project references</returns>
189+
public static int CountProjectReferences(ProjectInstance? project = null, ImmutableArray<CSharpDirective> directives = default)
190+
{
191+
if (!directives.IsDefaultOrEmpty)
192+
{
193+
// File-based: count project directives
194+
return directives.OfType<CSharpDirective.Project>().Count();
195+
}
196+
197+
if (project != null)
198+
{
199+
// Project-based: count ProjectReference items
200+
return project.GetItems("ProjectReference").Count;
201+
}
202+
203+
return 0;
204+
}
205+
206+
/// <summary>
207+
/// Counts the number of additional properties for file-based apps.
208+
/// </summary>
209+
/// <param name="directives">Directives for file-based apps</param>
210+
/// <returns>Number of additional properties</returns>
211+
public static int CountAdditionalProperties(ImmutableArray<CSharpDirective> directives)
212+
{
213+
if (directives.IsDefaultOrEmpty)
214+
{
215+
return 0;
216+
}
217+
218+
return directives.OfType<CSharpDirective.Property>().Count();
219+
}
220+
221+
/// <summary>
222+
/// Determines if a launch profile name is one of the default ones.
223+
/// </summary>
224+
/// <param name="profileName">The profile name to check</param>
225+
/// <returns>True if it's a default profile name</returns>
226+
private static bool IsDefaultProfile(string? profileName)
227+
{
228+
if (string.IsNullOrEmpty(profileName))
229+
{
230+
return false;
231+
}
232+
233+
// Common default profile names
234+
return profileName.Equals("Default", StringComparison.OrdinalIgnoreCase) ||
235+
profileName.Equals("Development", StringComparison.OrdinalIgnoreCase) ||
236+
profileName.Equals("Production", StringComparison.OrdinalIgnoreCase) ||
237+
profileName.Equals("Staging", StringComparison.OrdinalIgnoreCase);
238+
}
239+
240+
/// <summary>
241+
/// Computes a hash for the given input string.
242+
/// </summary>
243+
/// <param name="input">String to hash</param>
244+
/// <returns>Hex-encoded hash</returns>
245+
private static string ComputeHash(string input)
246+
{
247+
var inputBytes = Encoding.UTF8.GetBytes(input.ToLowerInvariant());
248+
var hashBytes = SHA256.HashData(inputBytes);
249+
return Convert.ToHexString(hashBytes).ToLowerInvariant();
250+
}
251+
}

0 commit comments

Comments
 (0)