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 System.Diagnostics;
using Aspire.Cli.Agents.Hooks;
using Aspire.Shared;
using Aspire.Shared.Telemetry;
 
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(AspireTelemetryExporter.GetTelemetryStoragePath("cli"))!, LockFileName);
 
    internal static bool HasPendingTelemetry(string storagePath)
        => Directory.Exists(storagePath) && Directory.EnumerateFiles(storagePath, "*", SearchOption.AllDirectories).Any();
 
    internal static void EnsureRunning()
    {
        if (!HasPendingTelemetry(AspireTelemetryExporter.GetTelemetryStoragePath("cli")))
        {
            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 ProcessStartInfo(command, args)
        {
            WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        };
 
        if (OperatingSystem.IsWindows())
        {
            // Share the hook's console instead of StartDetached (DETACHED_PROCESS), matching the
            // other detached CLI children. Inherit no handles so the hook host (which may be
            // waiting for its pipes to close) is not held open by the drainer.
            startInfo.InheritedHandles = [];
        }
        else
        {
            // setsid() so the drainer outlives the hook's session and terminal.
            startInfo.StartDetached = true;
        }
 
        // 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);
        }
 
        // StartAndForget connects stdio to the null device and releases the process handle, so the
        // drainer is fully independent of this short-lived hook invocation.
        Process.StartAndForget(startInfo);
    }
 
    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));
    }
}