| File: Profiling\ProfileCaptureOptions.cs | Web Access |
| Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire) |
// 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.Globalization; using System.Net; using System.Net.Sockets; using AspireRootCommand = Aspire.Cli.Commands.RootCommand; using BootstrapRootCommand = System.CommandLine.RootCommand; namespace Aspire.Cli.Profiling; // Captures the small amount of state that must be decided before normal CLI startup. // The hidden --capture-profile flag is parsed before DI so telemetry can be configured // early, and the selected output path, session ID, and private dashboard/OTLP endpoints // are then reused by the later profile collector and AppHost launch paths. internal sealed record ProfileCaptureOptions( string OutputPath, string DashboardUrl, string OtlpGrpcUrl, string OtlpHttpUrl, string SessionId, TimeSpan StartupDelay) { public static ProfileCaptureOptions? TryCreate( IReadOnlyList<string> args, TimeProvider timeProvider, DirectoryInfo workingDirectory, Func<int>? portProvider = null) { ArgumentNullException.ThrowIfNull(args); ArgumentNullException.ThrowIfNull(timeProvider); ArgumentNullException.ThrowIfNull(workingDirectory); // This deliberately uses a tiny bootstrap command instead of the real DI-backed root // command. Profiling must be enabled before DI creates telemetry, and anything after "--" // belongs to the wrapped command/AppHost rather than to the root Aspire invocation: // aspire run --capture-profile -- --capture-profile var captureProfileOption = new Option<bool>(AspireRootCommand.CaptureProfileOption.Name); var captureProfileOutputOption = new Option<string?>(AspireRootCommand.CaptureProfileOutputOption.Name); var captureProfileDelayOption = new Option<int>(AspireRootCommand.CaptureProfileDelayOption.Name) { DefaultValueFactory = _ => AspireRootCommand.DefaultCaptureProfileDelaySeconds }; var bootstrapCommand = new BootstrapRootCommand { TreatUnmatchedTokensAsErrors = false }; bootstrapCommand.Options.Add(captureProfileOption); bootstrapCommand.Options.Add(captureProfileOutputOption); bootstrapCommand.Options.Add(captureProfileDelayOption); var parseResult = bootstrapCommand.Parse([.. args]); if (!parseResult.GetValue(captureProfileOption)) { return null; } var outputPath = parseResult.GetValue(captureProfileOutputOption); var startupDelaySeconds = Math.Max(0, parseResult.GetValue(captureProfileDelayOption)); var getPort = portProvider ?? GetFreeTcpPort; // Use loopback ephemeral ports so concurrent captures do not fight over fixed dashboard // or OTLP endpoints. The private dashboard binds these same values shortly after parsing. var dashboardPort = getPort(); var otlpGrpcPort = getPort(); var otlpHttpPort = getPort(); var sessionId = Guid.NewGuid().ToString("N"); if (string.IsNullOrWhiteSpace(outputPath)) { outputPath = Path.Combine(workingDirectory.FullName, CreateDefaultOutputFileName(timeProvider, sessionId)); } else { if (!Path.IsPathRooted(outputPath)) { outputPath = Path.Combine(workingDirectory.FullName, outputPath); } // Treat an existing directory, or a path with a trailing directory separator, as an // output directory and generate the same default archive name inside it. A non-existent // path without a trailing separator is preserved as the requested archive file path. if (Directory.Exists(outputPath) || Path.EndsInDirectorySeparator(outputPath)) { outputPath = Path.Combine(outputPath, CreateDefaultOutputFileName(timeProvider, sessionId)); } } return new ProfileCaptureOptions( Path.GetFullPath(outputPath), $"http://127.0.0.1:{dashboardPort.ToString(CultureInfo.InvariantCulture)}", $"http://127.0.0.1:{otlpGrpcPort.ToString(CultureInfo.InvariantCulture)}", $"http://127.0.0.1:{otlpHttpPort.ToString(CultureInfo.InvariantCulture)}", sessionId, TimeSpan.FromSeconds(startupDelaySeconds)); } private static string CreateDefaultOutputFileName(TimeProvider timeProvider, string sessionId) { var timestamp = timeProvider.GetLocalNow().ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); return $"aspire-profile-{timestamp}-{sessionId[..8]}.zip"; } private static int GetFreeTcpPort() { using var listener = new TcpListener(IPAddress.Loopback, port: 0); listener.Start(); return ((IPEndPoint)listener.LocalEndpoint).Port; } }