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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 131 additions & 167 deletions .github/skills/startup-perf/SKILL.md

Large diffs are not rendered by default.

431 changes: 431 additions & 0 deletions eng/scripts/verify-startup-otel.ps1

Large diffs are not rendered by default.

788 changes: 788 additions & 0 deletions eng/scripts/verify-startup-otel.sh

Large diffs are not rendered by default.

27 changes: 21 additions & 6 deletions src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
Expand All @@ -22,6 +23,7 @@ internal sealed class AppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackchannel
private JsonRpc? _rpc;
private bool _disposed;
private readonly ImmutableHashSet<string> _capabilities;
private readonly ProfilingTelemetry? _profilingTelemetry;

/// <summary>
/// Private constructor - use factory methods to create instances.
Expand All @@ -33,7 +35,8 @@ private AppHostAuxiliaryBackchannel(
AppHostInformation? appHostInfo,
bool isInScope,
ImmutableHashSet<string> capabilities,
ILogger? logger)
ILogger? logger,
ProfilingTelemetry? profilingTelemetry)
{
Hash = hash;
SocketPath = socketPath;
Expand All @@ -43,6 +46,7 @@ private AppHostAuxiliaryBackchannel(
_capabilities = capabilities;
ConnectedAt = DateTimeOffset.UtcNow;
_logger = logger;
_profilingTelemetry = profilingTelemetry;
}

/// <summary>
Expand All @@ -54,7 +58,7 @@ internal AppHostAuxiliaryBackchannel(
JsonRpc rpc,
AppHostInformation? appHostInfo,
bool isInScope)
: this(hash, socketPath, rpc, appHostInfo, isInScope, ImmutableHashSet<string>.Empty, null)
: this(hash, socketPath, rpc, appHostInfo, isInScope, ImmutableHashSet<string>.Empty, null, null)
{
}

Expand Down Expand Up @@ -102,14 +106,16 @@ private JsonRpc EnsureConnected()
/// <param name="socketPath">The path to the Unix domain socket.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="profilingTelemetry">Optional profiling service.</param>
/// <returns>A connected AppHostAuxiliaryBackchannel instance.</returns>
public static Task<AppHostAuxiliaryBackchannel> ConnectAsync(
string socketPath,
ILogger? logger = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
ProfilingTelemetry? profilingTelemetry = null)
{
var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath) ?? string.Empty;
return CreateFromSocketAsync(hash, socketPath, isInScope: true, socket: null, logger, cancellationToken);
return CreateFromSocketAsync(hash, socketPath, isInScope: true, socket: null, logger, cancellationToken, profilingTelemetry);
}

/// <summary>
Expand All @@ -123,14 +129,16 @@ public static Task<AppHostAuxiliaryBackchannel> ConnectAsync(
/// <param name="socket">Optional already-connected socket. If null, a new connection will be established.</param>
/// <param name="logger">Optional logger.</param>
/// <param name="cancellationToken">Cancellation token (only used when socket is null).</param>
/// <param name="profilingTelemetry">Optional profiling service.</param>
/// <returns>A connected AppHostAuxiliaryBackchannel instance.</returns>
internal static async Task<AppHostAuxiliaryBackchannel> CreateFromSocketAsync(
string hash,
string socketPath,
bool isInScope,
Socket? socket = null,
ILogger? logger = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
ProfilingTelemetry? profilingTelemetry = null)
{
// Connect if no socket provided
if (socket is null)
Expand All @@ -155,7 +163,7 @@ internal static async Task<AppHostAuxiliaryBackchannel> CreateFromSocketAsync(

var capabilitiesSet = capabilities?.ToImmutableHashSet() ?? ImmutableHashSet.Create(AuxiliaryBackchannelCapabilities.V1);

return new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, appHostInfo, isInScope, capabilitiesSet, logger);
return new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, appHostInfo, isInScope, capabilitiesSet, logger, profilingTelemetry);
}

/// <summary>
Expand Down Expand Up @@ -237,6 +245,9 @@ await rpc.InvokeWithCancellationAsync(
var rpc = EnsureConnected();

_logger?.LogDebug("Requesting Dashboard URLs");
// This method runs inside whichever activity is current, so avoid adding
// profiling-only events to reported telemetry unless profiling is on.
var activity = _profilingTelemetry?.StartAuxiliaryBackchannelGetDashboardUrls() ?? default;

try
{
Expand All @@ -245,12 +256,16 @@ await rpc.InvokeWithCancellationAsync(
[],
cancellationToken).ConfigureAwait(false);

activity.SetAppHostDashboardUrls(dashboardUrls);
activity.AddAuxBackchannelGetDashboardUrlsResponseEvent();

return dashboardUrls;
}
catch (RemoteMethodNotFoundException ex)
{
// The RPC method may not be available on older AppHost versions.
_logger?.LogDebug(ex, "GetDashboardUrlsAsync RPC method not available on the remote AppHost. The AppHost may be running an older version.");
activity.AddAuxBackchannelGetDashboardUrlsNotFoundEvent();
return null;
}
}
Expand Down
21 changes: 17 additions & 4 deletions src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ internal interface IAppHostCliBackchannel
Task<GetPipelineStepsResponse> GetPipelineStepsAsync(string? step, CancellationToken cancellationToken);
}

internal sealed class AppHostCliBackchannel(ILogger<AppHostCliBackchannel> logger, AspireCliTelemetry telemetry) : IAppHostCliBackchannel
internal sealed class AppHostCliBackchannel(
ILogger<AppHostCliBackchannel> logger,
AspireCliTelemetry telemetry,
ProfilingTelemetry profilingTelemetry) : IAppHostCliBackchannel
{
private const string BaselineCapability = "baseline.v2";
private TaskCompletionSource<JsonRpc> _rpcTaskCompletionSource = new();
Expand Down Expand Up @@ -67,15 +70,20 @@ await rpc.InvokeWithCancellationAsync(

public async Task<DashboardUrlsState> GetDashboardUrlsAsync(CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
using var activity = profilingTelemetry.StartBackchannelGetDashboardUrls();
activity.AddBackchannelWaitForRpcEvent();
var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
activity.AddBackchannelRpcReadyEvent();

logger.LogDebug("Requesting dashboard URL");

activity.AddBackchannelGetDashboardUrlsInvokeEvent();
var state = await rpc.InvokeWithCancellationAsync<DashboardUrlsState>(
"GetDashboardUrlsAsync",
[],
cancellationToken);
activity.SetAppHostDashboardUrls(state);
activity.AddBackchannelGetDashboardUrlsResponseEvent();
return state;
}

Expand Down Expand Up @@ -253,7 +261,7 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC
{
try
{
using var activity = telemetry.StartDiagnosticActivity();
using var activity = profilingTelemetry.StartBackchannelConnect(socketPath, autoReconnect, retryCount);

lock (_lock)
{
Expand All @@ -271,7 +279,9 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC
logger.Log(connectingLogLevel, "Connecting to AppHost backchannel at {SocketPath} (autoReconnect={AutoReconnect}, retryCount={RetryCount})", socketPath, autoReconnect, retryCount);
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
var endpoint = new UnixDomainSocketEndPoint(socketPath);
activity.AddBackchannelSocketConnectStartEvent();
await socket.ConnectAsync(endpoint, cancellationToken);
activity.AddBackchannelSocketConnectedEvent();
logger.LogDebug("Connected to AppHost backchannel at {SocketPath} (retryCount={RetryCount})", socketPath, retryCount);

var stream = new NetworkStream(socket, true);
Expand All @@ -280,11 +290,15 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC
{
rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
rpc.StartListening();
activity.AddBackchannelRpcListeningEvent();

activity.AddBackchannelGetCapabilitiesStartEvent();
var capabilities = await rpc.InvokeWithCancellationAsync<string[]>(
"GetCapabilitiesAsync",
[],
cancellationToken);
activity.SetBackchannelCapabilitySummary(capabilities, BaselineCapability);
activity.AddBackchannelGetCapabilitiesResponseEvent();

if (!capabilities.Any(s => s == BaselineCapability))
{
Expand Down Expand Up @@ -499,4 +513,3 @@ public async Task<GetPipelineStepsResponse> GetPipelineStepsAsync(string? step,
}

}

6 changes: 4 additions & 2 deletions src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
Expand Down Expand Up @@ -42,7 +43,8 @@ internal sealed class AppHostConnectionResolver(
IInteractionService interactionService,
IProjectLocator projectLocator,
CliExecutionContext executionContext,
ILogger logger)
ILogger logger,
ProfilingTelemetry? profilingTelemetry = null)
{
/// <summary>
/// Resolves all running AppHost connections using socket-first discovery.
Expand Down Expand Up @@ -143,7 +145,7 @@ public async Task<AppHostConnectionResult> ResolveConnectionAsync(
try
{
var connection = await AppHostAuxiliaryBackchannel.ConnectAsync(
socketPath, logger, cancellationToken).ConfigureAwait(false);
socketPath, logger, cancellationToken, profilingTelemetry).ConfigureAwait(false);
if (connection is not null)
{
return new AppHostConnectionResult { Connection = connection };
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using Aspire.Cli.Commands;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
Expand All @@ -20,7 +21,8 @@ namespace Aspire.Cli.Backchannel;
internal sealed class AuxiliaryBackchannelMonitor(
ILogger<AuxiliaryBackchannelMonitor> logger,
CliExecutionContext executionContext,
TimeProvider timeProvider) : BackgroundService, IAuxiliaryBackchannelMonitor
TimeProvider timeProvider,
ProfilingTelemetry profilingTelemetry) : BackgroundService, IAuxiliaryBackchannelMonitor
{
private static readonly TimeSpan s_maxRetryElapsed = TimeSpan.FromSeconds(3);
private static readonly TimeSpan s_maxRetryDelay = TimeSpan.FromSeconds(1);
Expand Down Expand Up @@ -406,7 +408,7 @@ private async Task TryConnectToSocketAsync(string socketPath, ConcurrentBag<stri

// Use the centralized factory to create the connection
// This ensures capabilities are always fetched
var connection = await AppHostAuxiliaryBackchannel.CreateFromSocketAsync(hash, socketPath, isInScope, socket, logger, cancellationToken).ConfigureAwait(false);
var connection = await AppHostAuxiliaryBackchannel.CreateFromSocketAsync(hash, socketPath, isInScope, socket, logger, cancellationToken, profilingTelemetry).ConfigureAwait(false);

// Update isInScope based on actual appHostInfo now that we have it
connection.IsInScope = IsAppHostInScope(connection.AppHostInfo?.AppHostPath);
Expand Down
63 changes: 45 additions & 18 deletions src/Aspire.Cli/Commands/AppHostLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal sealed class AppHostLauncher(
IAuxiliaryBackchannelMonitor backchannelMonitor,
ICliHostEnvironment hostEnvironment,
AspireCliTelemetry telemetry,
ProfilingTelemetry profilingTelemetry,
ILogger<AppHostLauncher> logger,
TimeProvider timeProvider)
{
Expand Down Expand Up @@ -245,6 +246,13 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can
internal static bool IsExtensionEnvironmentVariable(string name) =>
name.StartsWith(ExtensionEnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase);

internal static Dictionary<string, string> CreateDetachedChildEnvironment(Activity? activity)
{
var environment = new Dictionary<string, string> { [KnownConfigNames.CliRunDetached] = "true" };
ProfilingTelemetry.AddActivityContextToEnvironment(activity, environment);
return environment;
}

private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode);

private async Task<LaunchResult> LaunchAndWaitForBackchannelAsync(
Expand All @@ -256,25 +264,32 @@ private async Task<LaunchResult> LaunchAndWaitForBackchannelAsync(
{
Process childProcess;

try
using (var spawnActivity = profilingTelemetry.StartDetachedSpawnChild(executablePath, childArgs.Count, "run"))
{
childProcess = DetachedProcessLauncher.Start(
executablePath,
childArgs,
executionContext.WorkingDirectory.FullName,
IsExtensionEnvironmentVariable,
new Dictionary<string, string> { [KnownConfigNames.CliRunDetached] = "true" });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to start child CLI process");
return new LaunchResult(null, null, null, false, 0);
try
{
childProcess = DetachedProcessLauncher.Start(
executablePath,
childArgs,
executionContext.WorkingDirectory.FullName,
IsExtensionEnvironmentVariable,
CreateDetachedChildEnvironment(Activity.Current));
spawnActivity.SetProcessId(childProcess.Id);
}
catch (Exception ex)
{
spawnActivity.SetError(ex.Message);
logger.LogError(ex, "Failed to start child CLI process");
return new LaunchResult(null, null, null, false, 0);
}
}

logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.Id);

var startTime = timeProvider.GetUtcNow();
var timeout = TimeSpan.FromSeconds(120);
using var waitForBackchannelActivity = profilingTelemetry.StartDetachedWaitForBackchannel(childProcess.Id, expectedHash, legacyHash is not null);
var scanCount = 0;

while (timeProvider.GetUtcNow() - startTime < timeout)
{
Expand All @@ -283,24 +298,34 @@ private async Task<LaunchResult> LaunchAndWaitForBackchannelAsync(
if (childProcess.HasExited)
{
var exitCode = childProcess.ExitCode;
waitForBackchannelActivity.SetProcessExitCode(exitCode);
waitForBackchannelActivity.SetError($"Child CLI exited with code {exitCode}.");
logger.LogWarning("Child CLI process exited with code {ExitCode}", exitCode);
return new LaunchResult(childProcess, null, null, true, exitCode);
}

await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
scanCount++;

var connection = backchannelMonitor.GetConnectionsByHash(expectedHash).FirstOrDefault()
?? (legacyHash is not null ? backchannelMonitor.GetConnectionsByHash(legacyHash).FirstOrDefault() : null);
if (connection is not null)
{
waitForBackchannelActivity.SetBackchannelScanCount(scanCount);
waitForBackchannelActivity.AddStartAppHostBackchannelConnectedEvent();
DashboardUrlsState? dashboardUrls = null;
try
{
dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
using (var getDashboardUrlsActivity = profilingTelemetry.StartDetachedGetDashboardUrls())
{
logger.LogDebug(ex, "Failed to retrieve dashboard URLs from backchannel connection. Continuing without dashboard URLs.");
try
{
dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
getDashboardUrlsActivity.SetAppHostDashboardUrls(dashboardUrls);
}
catch (Exception ex)
{
getDashboardUrlsActivity.SetError(ex.Message);
logger.LogDebug(ex, "Failed to retrieve dashboard URLs from backchannel connection. Continuing without dashboard URLs.");
}
}

return new LaunchResult(childProcess, connection, dashboardUrls, false, 0);
Expand All @@ -316,6 +341,8 @@ private async Task<LaunchResult> LaunchAndWaitForBackchannelAsync(
}
}

waitForBackchannelActivity.SetBackchannelScanCount(scanCount);
waitForBackchannelActivity.SetError("Timed out waiting for AppHost backchannel.");
return new LaunchResult(childProcess, null, null, false, 0);
}

Expand Down
Loading
Loading