File: Agents\Hooks\AgentTelemetryHook.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.Globalization;
using System.Text.Json;
using Aspire.Cli.Agents.AspireSkills;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
 
namespace Aspire.Cli.Agents.Hooks;
 
/// <summary>
/// Classifies hook input before telemetry providers and enrichment are initialized.
/// </summary>
internal sealed class AgentTelemetryHook(IEnvironment environment)
{
    internal const string PayloadLimitEnvironmentVariable = "ASPIRE_AGENT_TELEMETRY_MAX_PAYLOAD_CHARACTERS";
    // Bound memory before JSON parsing; TextReader counts UTF-16 characters, not bytes.
    internal const int DefaultMaxPayloadCharacters = 64 * 1024;
    internal const int MaximumPayloadCharacters = 1024 * 1024;
    private const string ContinueResponse = """{"continue":true}""";
    private static readonly string[] s_mcpPrefixes = ["aspire-", "mcp__aspire__", "mcp_aspire_"];
 
    internal static (string Command, string[] Args) GetCommand(string mode)
    {
        var command = Environment.ProcessPath ?? throw new InvalidOperationException("Could not resolve the CLI executable.");
        string[] args = [AgentTelemetryProtocol.AgentCommandName, AgentTelemetryProtocol.TelemetryCommandName, mode];
        if (Path.GetFileNameWithoutExtension(command).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
        {
            args = [Path.Combine(AppContext.BaseDirectory, "aspire.dll"), .. args];
        }
        return (command, args);
    }
 
    internal async Task<int> RunAsync(TextReader input, TextWriter output, TextWriter error, Func<string[], Task<int>> execute)
    {
        try
        {
            if (environment.IsFlagEnabled(AspireCliTelemetry.TelemetryOptOutConfigKey))
            {
                return 0;
            }
 
            int maxPayloadCharacters;
            try
            {
                maxPayloadCharacters = GetMaxPayloadCharacters(environment.GetEnvironmentVariable(PayloadLimitEnvironmentVariable));
            }
            catch (ArgumentException ex)
            {
                await error.WriteLineAsync(ex.Message).ConfigureAwait(false);
                return 0;
            }
 
            // One sentinel distinguishes a payload exactly at the limit from an oversized one.
            var buffer = new char[maxPayloadCharacters + 1];
            var length = await input.ReadBlockAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
            if (length == buffer.Length)
            {
                while (await input.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false) > 0)
                {
                }
                return 0;
            }
 
            var args = Classify(new string(buffer, 0, length), environment.IsFlagEnabled("COPILOT_CLI"), maxPayloadCharacters);
            if (args is not null)
            {
                await execute(args).ConfigureAwait(false);
            }
        }
        catch (Exception ex)
        {
            // Hooks must not interrupt the tool loop, including on malformed input or CLI failure.
            // Do not include the payload in diagnostics.
            await error.WriteLineAsync($"Agent telemetry hook failed ({ex.GetType().Name}).").ConfigureAwait(false);
        }
        finally
        {
            await output.WriteLineAsync(ContinueResponse).ConfigureAwait(false);
        }
 
        return 0;
    }
 
    internal static int GetMaxPayloadCharacters(string? configuredValue)
    {
        if (configuredValue is null)
        {
            return DefaultMaxPayloadCharacters;
        }
        if (int.TryParse(configuredValue, NumberStyles.None, CultureInfo.InvariantCulture, out var limit)
            && limit is > 0 and <= MaximumPayloadCharacters)
        {
            return limit;
        }
 
        throw new ArgumentException($"{PayloadLimitEnvironmentVariable} must be an integer between 1 and {MaximumPayloadCharacters} UTF-16 characters.");
    }
 
    internal static string[]? Classify(string payload, bool isCopilotCli, int maxPayloadCharacters)
    {
        if (payload.Length == 0 || payload.Length > maxPayloadCharacters)
        {
            return null;
        }
 
        try
        {
            using var document = JsonDocument.Parse(payload);
            var data = document.RootElement;
            var tool = Text(data, "toolName") ?? Text(data, "tool_name");
            if (tool is null)
            {
                return null;
            }
 
            // Copilot: {"toolName":"skill","toolArgs":"{\"skill\":\"aspire\"}"};
            // Claude: {"tool_name":"Skill","tool_input":{"skill":"aspire:aspire"}}.
            var input = Property(data, "toolArgs") ?? Property(data, "tool_input") ?? default;
            using var nested = ParseInput(input);
            input = nested?.RootElement ?? input;
            (string EventType, string Dimension, string Value)? trackedEvent = null;
            if (tool.Equals("skill", StringComparison.OrdinalIgnoreCase))
            {
                var skill = Text(input, "skill") ?? "";
                if (skill.StartsWith("aspire:", StringComparison.Ordinal))
                {
                    skill = skill[7..];
                }
                if (AgentTelemetryCatalog.Bundled.Skills.Contains(skill))
                {
                    trackedEvent = (AgentTelemetryProtocol.SkillInvocationEventType, AgentTelemetryProtocol.SkillNameOptionName, skill);
                }
            }
            else if (tool.Equals("view", StringComparison.OrdinalIgnoreCase)
                || tool.Equals("Read", StringComparison.OrdinalIgnoreCase)
                || tool.Equals("read_file", StringComparison.OrdinalIgnoreCase))
            {
                var path = Text(input, "path") ?? Text(input, "filePath") ?? Text(input, "file_path") ?? "";
                var segments = path.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
                for (var i = segments.Length - 3; i >= 0; i--)
                {
                    if (!segments[i].Equals(AspireSkillsBundleLayout.SkillsDirectoryName, StringComparison.OrdinalIgnoreCase))
                    {
                        continue;
                    }
                    var skill = segments[i + 1];
                    var relativePath = string.Join('/', segments[(i + 1)..]);
                    if (AgentTelemetryCatalog.Bundled.Skills.Contains(skill))
                    {
                        if (segments[^1].Equals(AspireSkillsBundleLayout.SkillFileName, StringComparison.OrdinalIgnoreCase))
                        {
                            trackedEvent = (AgentTelemetryProtocol.SkillInvocationEventType, AgentTelemetryProtocol.SkillNameOptionName, skill);
                        }
                        else if (AgentTelemetryCatalog.Bundled.References.Contains(relativePath))
                        {
                            trackedEvent = (AgentTelemetryProtocol.ReferenceFileReadEventType, AgentTelemetryProtocol.FileReferenceOptionName, relativePath);
                        }
                    }
                    break;
                }
            }
            else
            {
                foreach (var prefix in s_mcpPrefixes)
                {
                    if (tool.StartsWith(prefix, StringComparison.Ordinal) && AgentTelemetryCatalog.Bundled.Tools.Contains(tool[prefix.Length..]))
                    {
                        trackedEvent = (AgentTelemetryProtocol.ToolInvocationEventType, AgentTelemetryProtocol.ToolNameOptionName, tool);
                        break;
                    }
                }
            }
 
            if (trackedEvent is not { } telemetryEvent)
            {
                return null;
            }
 
            var client = isCopilotCli ? "copilot-cli"
                : Property(data, "hook_event_name") is not null
                    ? IsVsCode(data) ? "vscode" : "claude-code"
                    : Property(data, "toolArgs") is not null ? "copilot-cli" : "unknown";
            var args = new List<string>
            {
                AgentTelemetryProtocol.AgentCommandName, AgentTelemetryProtocol.TelemetryCommandName,
                AgentTelemetryProtocol.EventTypeOptionName, telemetryEvent.EventType,
                AgentTelemetryProtocol.ClientNameOptionName, client,
                AgentTelemetryProtocol.TimestampOptionName, DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture),
                telemetryEvent.Dimension, telemetryEvent.Value
            };
            var session = Text(data, "sessionId") ?? Text(data, "session_id");
            if (Guid.TryParseExact(session, "D", out _))
            {
                args.AddRange([AgentTelemetryProtocol.SessionIdOptionName, session]);
            }
            return [.. args];
        }
        catch (JsonException)
        {
            return null;
        }
    }
 
    private static bool IsVsCode(JsonElement data)
    {
        var path = Text(data, "transcript_path")?.Replace('\\', '/') ?? "";
        return (Text(data, "tool_use_id") ?? "").Contains("__vscode", StringComparison.OrdinalIgnoreCase)
            || path.Contains("/Code/", StringComparison.OrdinalIgnoreCase)
            || path.Contains("/Code - Insiders/", StringComparison.OrdinalIgnoreCase);
    }
 
    private static JsonDocument? ParseInput(JsonElement input)
    {
        if (input.ValueKind != JsonValueKind.String)
        {
            return null;
        }
        try
        {
            return JsonDocument.Parse(input.GetString()!);
        }
        catch (JsonException)
        {
            // An MCP tool invocation is still classifiable when only its arguments are malformed.
            return null;
        }
    }
 
    private static JsonElement? Property(JsonElement element, string name)
        => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) ? value : null;
 
    private static string? Text(JsonElement element, string name)
        => Property(element, name) is { ValueKind: JsonValueKind.String } value && !string.IsNullOrEmpty(value.GetString()) ? value.GetString() : null;
}