File: Telemetry\AgentTelemetryUploader.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 Aspire.Cli.Agents.Hooks;
using Aspire.Cli.Bundles;
using Aspire.Cli.Layout;
using Aspire.Cli.Processes;
using Aspire.Shared;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Cli.Telemetry;
 
/// <summary>
/// Keeps the existing exporter alive until its durable backlog has been delivered.
/// </summary>
internal static class AgentTelemetryUploader
{
    private const string UploaderName = "agent-telemetry";
    private const string LockFileName = UploaderName + ".lock";
 
    internal static string LockPath => Path.Combine(Path.GetDirectoryName(TelemetryManager.GetTelemetryStoragePath())!, LockFileName);
 
    internal static bool HasPendingTelemetry(string storagePath)
        => Directory.Exists(storagePath) && Directory.EnumerateFiles(storagePath, "*", SearchOption.AllDirectories).Any();
 
    internal static async Task EnsureRunningAsync(IServiceProvider services)
    {
        if (!HasPendingTelemetry(TelemetryManager.GetTelemetryStoragePath()))
        {
            return;
        }
 
        // Probe without waiting. The child takes the same lock; concurrent launches are harmless.
        using (var probe = FileLock.TryAcquire(LockPath))
        {
            if (probe is null)
            {
                return;
            }
        }
 
        var (command, args) = AgentTelemetryHook.GetCommand(AgentTelemetryProtocol.DrainOptionName);
        var startInfo = new IsolatedProcessStartInfo
        {
            FileName = command,
            WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            Detached = true,
            IsolateConsole = false
        };
        foreach (var arg in args)
        {
            startInfo.ArgumentList.Add(arg);
        }
 
        // A drainer must neither attach to an IDE session nor export profiling data.
        foreach (var key in startInfo.Environment.Keys.Where(key => key.StartsWith("ASPIRE_EXTENSION_", StringComparison.OrdinalIgnoreCase)
            || key.StartsWith("OTEL_", StringComparison.OrdinalIgnoreCase)).ToArray())
        {
            startInfo.Environment.Remove(key);
        }
 
        // Windows launches the self-contained CLI without needing any bundle component. Unix uses
        // the existing DCP detach helper; retain its layout while handing the lease to the child.
        using var dcp = OperatingSystem.IsWindows() ? null : await DcpExecutableResolver.TryGetDcpExecutableAsync(
            services.GetRequiredService<ILayoutDiscovery>(), services.GetRequiredService<IBundleService>(),
            services.GetRequiredService<CliExecutionContext>(), UploaderName, CancellationToken.None).ConfigureAwait(false);
        if (!OperatingSystem.IsWindows())
        {
            startInfo.DetachedUnixLauncherPath = dcp?.ExecutablePath
                ?? throw new InvalidOperationException("Could not resolve DCP for the telemetry uploader.");
        }
        if (dcp?.LayoutLease is { } layoutLease)
        {
            var childEnvironment = new Dictionary<string, string>();
            layoutLease.AddEnvironment(childEnvironment);
            foreach (var (key, value) in childEnvironment)
            {
                startInfo.Environment[key] = value;
            }
        }
 
        // IsolatedProcess disposal releases launch handles, not the independent process.
        await using var process = await IsolatedProcess.StartAsync(startInfo, CancellationToken.None).ConfigureAwait(false);
    }
 
    internal static async Task DrainAsync(string storagePath, string lockPath, CancellationToken cancellationToken)
    {
        do
        {
            using (var lease = FileLock.TryAcquire(lockPath))
            {
                if (lease is null)
                {
                    return;
                }
                while (HasPendingTelemetry(storagePath))
                {
                    // The exporter owns batching, retries, lease recovery and retention. We only keep
                    // its process alive; no private storage formats or retry algorithms are duplicated.
                    await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false);
                }
            }
            // Release before rechecking: a producer racing idle shutdown either starts a successor
            // or leaves work we see here. It cannot strand an event behind a departing worker's lock.
        }
        while (HasPendingTelemetry(storagePath));
    }
}