diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index fa4d3683a6f..8d8ed84f6f3 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -75,6 +75,7 @@ + @@ -84,6 +85,7 @@ + diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index 36df5b2a4dd..bb239d4ee48 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Globalization; using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; @@ -18,7 +17,6 @@ using Aspire.Shared.Mcp; using Microsoft.Extensions.Logging; using ModelContextProtocol; -using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -56,6 +54,7 @@ public AgentMcpCommand( IEnvironmentChecker environmentChecker, IDocsSearchService docsSearchService, IDocsIndexService docsIndexService, + IHttpClientFactory httpClientFactory, AspireCliTelemetry telemetry) : base("mcp", AgentCommandStrings.McpCommand_Description, features, updateNotifier, executionContext, interactionService, telemetry) { @@ -69,9 +68,9 @@ public AgentMcpCommand( [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(), - [KnownMcpTools.ListTraces] = new ListTracesTool(), - [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(), + [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), + [KnownMcpTools.ListTraces] = new ListTracesTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), + [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), [KnownMcpTools.SelectAppHost] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), [KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), [KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor), @@ -176,33 +175,17 @@ private async ValueTask HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext CallDashboardToolAsync( - string toolName, - CliMcpTool tool, - ProgressToken? progressToken, - IReadOnlyDictionary? arguments, - CancellationToken cancellationToken) - { - var connection = await GetSelectedConnectionAsync(cancellationToken).ConfigureAwait(false); - if (connection is null) - { - _logger.LogWarning("No Aspire AppHost is currently running"); - throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); - } - - if (connection.McpInfo is null) - { - _logger.LogWarning("Dashboard is not available in the running AppHost"); - throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError); - } - - _logger.LogInformation( - "Connecting to dashboard MCP server. " + - "Dashboard URL: {EndpointUrl}, " + - "AppHost Path: {AppHostPath}, " + - "AppHost PID: {AppHostPid}, " + - "CLI PID: {CliPid}", - connection.McpInfo.EndpointUrl, - connection.AppHostInfo?.AppHostPath ?? "N/A", - connection.AppHostInfo?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "N/A", - connection.AppHostInfo?.CliProcessId?.ToString(CultureInfo.InvariantCulture) ?? "N/A"); - - var transportOptions = new HttpClientTransportOptions - { - Endpoint = new Uri(connection.McpInfo.EndpointUrl), - AdditionalHeaders = new Dictionary - { - ["x-mcp-api-key"] = connection.McpInfo.ApiToken - } - }; - - using var httpClient = new HttpClient(); - await using var transport = new HttpClientTransport(transportOptions, httpClient, _loggerFactory, ownsHttpClient: true); - - // Create MCP client to communicate with the dashboard - await using var mcpClient = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken); - - _logger.LogDebug("Calling tool {ToolName} on dashboard MCP server", toolName); - - try - { - _logger.LogDebug("Invoking CallToolAsync for tool {ToolName} with arguments: {Arguments}", toolName, arguments); - var context = new CallToolContext - { - Notifier = new McpServerNotifier(_server!), - McpClient = mcpClient, - Arguments = arguments, - ProgressToken = progressToken - }; - var result = await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Tool {ToolName} completed successfully", toolName); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while calling tool {ToolName}", toolName); - throw; - } - } - /// /// Gets the appropriate AppHost connection based on the selection logic. /// diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 9d4e8241e53..27287908a2b 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -3,7 +3,7 @@ using System.CommandLine; using System.Globalization; -using System.Text.Json; +using System.Net.Http.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; using Aspire.Cli.Otlp; @@ -165,61 +165,58 @@ public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiT return client; } - /// - /// Fetches available resources from the Dashboard API and resolves a resource name to specific instances. - /// If the resource name matches a base name with multiple replicas, returns all matching replica names. - /// - /// The HTTP client configured for Dashboard API access. - /// The Dashboard API base URL. - /// The resource name to resolve (can be base name or full instance name). - /// Cancellation token. - /// A list of resolved resource display names to query, or null if resource not found. - public static async Task?> ResolveResourceNamesAsync( - HttpClient client, - string baseUrl, + public static bool TryResolveResourceNames( string? resourceName, - CancellationToken cancellationToken) + IList resources, + out List? resolvedResources) { if (string.IsNullOrEmpty(resourceName)) { - // No filter - return null to indicate no resource filter - return null; + // No filter - return true to indicate success + resolvedResources = null; + return true; } - // Fetch available resources - var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl); - var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var resources = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); - - if (resources is null || resources.Length == 0) + if (resources is null || resources.Count == 0) { - return null; + resolvedResources = null; + return false; } // First, try exact match on display name (full instance name like "catalogservice-abc123") var exactMatch = resources.FirstOrDefault(r => - string.Equals(r.DisplayName, resourceName, StringComparison.OrdinalIgnoreCase)); + string.Equals(r.GetCompositeName(), resourceName, StringComparison.OrdinalIgnoreCase)); if (exactMatch is not null) { - return [exactMatch.DisplayName]; + resolvedResources = [exactMatch.GetCompositeName()]; + return true; } // Then, try matching by base name to find all replicas var matchingReplicas = resources .Where(r => string.Equals(r.Name, resourceName, StringComparison.OrdinalIgnoreCase)) - .Select(r => r.DisplayName) + .Select(r => r.GetCompositeName()) .ToList(); if (matchingReplicas.Count > 0) { - return matchingReplicas; + resolvedResources = matchingReplicas; + return true; } // No match found - return []; + resolvedResources = null; + return false; + } + + public static async Task GetAllResourcesAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken) + { + var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl); + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var resources = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false); + return resources!; } /// diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index 46afd08b45b..c363581dbb7 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -111,11 +111,10 @@ private async Task FetchLogsAsync( using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); // Resolve resource name to specific instances (handles replicas) - var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( - client, baseUrl, resource, cancellationToken); + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); - // If a resource was specified but not found, show error - if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) { _interactionService.DisplayError($"Resource '{resource}' not found."); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 4f884ee9a65..a82e6163976 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -107,11 +107,10 @@ private async Task FetchSpansAsync( using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); // Resolve resource name to specific instances (handles replicas) - var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( - client, baseUrl, resource, cancellationToken); + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); - // If a resource was specified but not found, show error - if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) { _interactionService.DisplayError($"Resource '{resource}' not found."); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index efadfc7efcc..442d664a383 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -160,11 +160,10 @@ private async Task FetchTracesAsync( using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); // Resolve resource name to specific instances (handles replicas) - var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( - client, baseUrl, resource, cancellationToken); + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); - // If a resource was specified but not found, show error - if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) { _interactionService.DisplayError($"Resource '{resource}' not found."); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs index de9276b63c0..346211b7846 100644 --- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs +++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs @@ -14,7 +14,6 @@ internal static class KnownMcpTools internal const string ListStructuredLogs = "list_structured_logs"; internal const string ListTraces = "list_traces"; internal const string ListTraceStructuredLogs = "list_trace_structured_logs"; - internal const string SelectAppHost = "select_apphost"; internal const string ListAppHosts = "list_apphosts"; internal const string ListIntegrations = "list_integrations"; @@ -45,21 +44,4 @@ internal static class KnownMcpTools GetDoc ]; - public static bool IsLocalTool(string toolName) => toolName is - SelectAppHost or - ListAppHosts or - ListIntegrations or - Doctor or - RefreshTools or - ListDocs or - SearchDocs or - GetDoc or - ListResources or - ListConsoleLogs or - ExecuteResourceCommand; - - public static bool IsDashboardTool(string toolName) => toolName is - ListStructuredLogs or - ListTraces or - ListTraceStructuredLogs; } diff --git a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs index 846fbea7715..3b09c3e0583 100644 --- a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs @@ -30,9 +30,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client or arguments - _ = context; - try { // Run all environment checks diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index b369d343c56..4e8ec36df79 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -44,7 +44,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - if (arguments is null || !arguments.TryGetValue("resourceName", out var resourceNameElement) || !arguments.TryGetValue("commandName", out var commandNameElement)) diff --git a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs index 0e21d6b3e11..aa6cfda5e32 100644 --- a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs @@ -44,9 +44,7 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync( - CallToolContext context, - CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; diff --git a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs index 14ac11b57a9..6e906ae8f96 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs @@ -37,9 +37,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = context; - // Trigger an immediate scan to ensure we have the latest AppHost connections await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs index 56fa8aade4f..e5e2cc06975 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs @@ -72,15 +72,15 @@ public override async ValueTask CallToolAsync(CallToolContext co var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, SharedAIHelpers.ConsoleLogsLimit, "console log", "console logs", SharedAIHelpers.SerializeLogEntry, - logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry)); - var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + SharedAIHelpers.EstimateTokenCount); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs index 6969f0925cd..14387d20a05 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs @@ -38,9 +38,7 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync( - CallToolContext context, - CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs index 443702cb971..9e74b3d46b3 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs @@ -69,9 +69,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = context; - try { // Get all channels diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 8e579c8d115..1a55f716492 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -57,9 +57,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates via backchannel - _ = context; - var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); if (connection is null) { diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index 17eab72cfe7..ffe2e8163e2 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -1,13 +1,25 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListStructuredLogsTool : CliMcpTool +/// +/// MCP tool for listing structured logs. +/// Gets log data directly from the Dashboard telemetry API. +/// +internal sealed class ListStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListStructuredLogs; @@ -30,22 +42,66 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (context.Arguments != null) + var arguments = context.Arguments; + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + + // Extract resourceName from arguments + string? resourceName = null; + if (arguments?.TryGetValue("resourceName", out var resourceNameElement) == true && + resourceNameElement.ValueKind == JsonValueKind.String) { - convertedArgs = new Dictionary(); - foreach (var kvp in context.Arguments) + resourceName = resourceNameElement.GetString(); + } + + try + { + using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken); + + // Resolve resource name to specific instances (handles replicas) + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources)) { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Resource '{resourceName}' not found." }], + IsError = true + }; } - } - // Forward the call to the dashboard's MCP server - return await context.McpClient!.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resolvedResources); + + logger.LogDebug("Fetching structured logs from {Url}", url); + + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); + var resourceLogs = apiResponse?.Data?.ResourceLogs; + + var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson( + resourceLogs, + getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), + dashboardBaseUrl: dashboardBaseUrl); + + var text = $""" + {limitMessage} + + # STRUCTURED LOGS DATA + + {logsData} + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = text }] + }; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch structured logs from Dashboard API"); + throw new McpProtocolException($"Failed to fetch structured logs: {ex.Message}", McpErrorCode.InternalError); + } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 7127c546758..05e55e55b2e 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -1,13 +1,25 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListTraceStructuredLogsTool : CliMcpTool +/// +/// MCP tool for listing structured logs for a specific distributed trace. +/// Gets log data directly from the Dashboard telemetry API. +/// +internal sealed class ListTraceStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraceStructuredLogs; @@ -31,22 +43,65 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (context.Arguments != null) + var arguments = context.Arguments; + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + + // Extract traceId from arguments (required) + string? traceId = null; + if (arguments?.TryGetValue("traceId", out var traceIdElement) == true && + traceIdElement.ValueKind == JsonValueKind.String) { - convertedArgs = new Dictionary(); - foreach (var kvp in context.Arguments) + traceId = traceIdElement.GetString(); + } + + if (string.IsNullOrEmpty(traceId)) + { + return new CallToolResult { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } + Content = [new TextContentBlock { Text = "The 'traceId' parameter is required." }], + IsError = true + }; } - // Forward the call to the dashboard's MCP server - return await context.McpClient!.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + try + { + using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken); + + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + + // Build the logs API URL with traceId filter + var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resources: null, ("traceId", traceId)); + + logger.LogDebug("Fetching structured logs from {Url}", url); + + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); + var resourceLogs = apiResponse?.Data?.ResourceLogs; + + var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson( + resourceLogs, + getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), + dashboardBaseUrl: dashboardBaseUrl); + + var text = $""" + {limitMessage} + + # STRUCTURED LOGS DATA + + {logsData} + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = text }] + }; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch structured logs for trace from Dashboard API"); + throw new McpProtocolException($"Failed to fetch structured logs for trace: {ex.Message}", McpErrorCode.InternalError); + } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 4e589bcffba..c9b032366c5 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -1,13 +1,25 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListTracesTool : CliMcpTool +/// +/// MCP tool for listing distributed traces. +/// Gets trace data directly from the Dashboard telemetry API. +/// +internal sealed class ListTracesTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraces; @@ -30,22 +42,66 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (context.Arguments != null) + var arguments = context.Arguments; + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + + // Extract resourceName from arguments + string? resourceName = null; + if (arguments?.TryGetValue("resourceName", out var resourceNameElement) == true && + resourceNameElement.ValueKind == JsonValueKind.String) { - convertedArgs = new Dictionary(); - foreach (var kvp in context.Arguments) + resourceName = resourceNameElement.GetString(); + } + + try + { + using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken); + + // Resolve resource name to specific instances (handles replicas) + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources)) { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Resource '{resourceName}' not found." }], + IsError = true + }; } - } - // Forward the call to the dashboard's MCP server - return await context.McpClient!.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + var url = DashboardUrls.TelemetryTracesApiUrl(apiBaseUrl, resolvedResources); + + logger.LogDebug("Fetching traces from {Url}", url); + + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); + var resourceSpans = apiResponse?.Data?.ResourceSpans; + + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson( + resourceSpans, + getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), + dashboardBaseUrl: dashboardBaseUrl); + + var text = $""" + {limitMessage} + + # TRACES DATA + + {tracesData} + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = text }] + }; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch traces from Dashboard API"); + throw new McpProtocolException($"Failed to fetch traces: {ex.Message}", McpErrorCode.InternalError); + } } } diff --git a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs new file mode 100644 index 00000000000..20f520e173b --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs @@ -0,0 +1,52 @@ +// 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.Backchannel; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; + +namespace Aspire.Cli.Mcp.Tools; + +internal static class McpToolHelpers +{ + public static async Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger, CancellationToken cancellationToken) + { + var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + if (connection is null) + { + logger.LogWarning("No Aspire AppHost is currently running"); + throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); + } + + var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken).ConfigureAwait(false); + if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null) + { + logger.LogWarning("Dashboard API is not available"); + throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError); + } + + var dashboardBaseUrl = GetBaseUrl(dashboardInfo.DashboardUrls.FirstOrDefault()); + + return (dashboardInfo.ApiToken, dashboardInfo.ApiBaseUrl, dashboardBaseUrl); + } + + /// + /// Extracts the base URL (scheme, host, and port) from a URL, removing any path and query string. + /// + /// The full URL that may contain path and query string. + /// The base URL with only scheme, host, and port, or null if the input is null or invalid. + internal static string? GetBaseUrl(string? url) + { + if (url is null) + { + return null; + } + + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return $"{uri.Scheme}://{uri.Authority}"; + } + + return url; + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs index 502f00251a3..c8d3d5c4fef 100644 --- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs @@ -19,8 +19,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - _ = context; - var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false); await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs index 8b1524bf9c7..0dd23d75ccf 100644 --- a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs @@ -48,12 +48,9 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync( - CallToolContext context, - CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - if (arguments is null || !arguments.TryGetValue("query", out var queryElement)) { return new CallToolResult diff --git a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs index 1024bcc8331..a151b4b8cf7 100644 --- a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs @@ -34,9 +34,6 @@ public override JsonElement GetInputSchema() public override ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = cancellationToken; - var arguments = context.Arguments; if (arguments == null || !arguments.TryGetValue("appHostPath", out var appHostPathElement)) diff --git a/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs index 4f5a76f7bbd..ed8cfc4e697 100644 --- a/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs +++ b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs @@ -47,18 +47,11 @@ internal sealed class ResourceInfoJson public string Name { get; set; } = ""; /// - /// The instance ID if this is a replica (e.g., "abc123"), or null if single instance. + /// The instance ID (e.g., "abc123"). /// [JsonPropertyName("instanceId")] public string? InstanceId { get; set; } - /// - /// The full display name including instance ID (e.g., "catalogservice-abc123" or "catalogservice"). - /// Use this when querying the telemetry API. - /// - [JsonPropertyName("displayName")] - public string DisplayName { get; set; } = ""; - /// /// Whether this resource has structured logs. /// @@ -76,6 +69,20 @@ internal sealed class ResourceInfoJson /// [JsonPropertyName("hasMetrics")] public bool HasMetrics { get; set; } + + /// + /// Gets the full display name by combining Name and InstanceId. + /// + /// The full display name (e.g., "catalogservice-abc123" or "catalogservice"). + public string GetCompositeName() + { + if (InstanceId is null) + { + return Name; + } + + return $"{Name}-{InstanceId}"; + } } /// diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index 38fd9574f01..4f636ff25ef 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -16,12 +16,15 @@ namespace Aspire.Dashboard.Api; /// Handles telemetry API requests, returning data in OTLP JSON format. /// internal sealed class TelemetryApiService( - TelemetryRepository telemetryRepository) + TelemetryRepository telemetryRepository, + IEnumerable outgoingPeerResolvers) { private const int DefaultLimit = 200; private const int DefaultTraceLimit = 100; private const int MaxQueryCount = 10000; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); + /// /// Gets spans in OTLP JSON format. /// Returns null if resource filter is specified but not found. @@ -83,7 +86,7 @@ internal sealed class TelemetryApiService( spans = spans.Skip(spans.Count - effectiveLimit).ToList(); } - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); return new TelemetryApiResponse { @@ -148,7 +151,7 @@ internal sealed class TelemetryApiService( // Get all spans from filtered traces var spans = traces.SelectMany(t => t.Spans).ToList(); - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); return new TelemetryApiResponse { @@ -181,7 +184,7 @@ internal sealed class TelemetryApiService( var spans = trace.Spans.ToList(); - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); return new TelemetryApiResponse { @@ -315,7 +318,7 @@ public async IAsyncEnumerable FollowSpansAsync( } // Use compact JSON for NDJSON streaming (no indentation) - yield return TelemetryExportService.ConvertSpanToJson(span, logs: null, indent: false); + yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false); } } diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 79511ee4e99..fa1447d8f10 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -300,6 +300,7 @@ + @@ -309,6 +310,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs index fe541a121bb..96a92c20eec 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs @@ -341,7 +341,7 @@ private void OnToggleExpand(ResourceDataRow resourceRow) private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName); - private string GetOtlpResourceName(OtlpResource resource) => OtlpResource.GetResourceName(resource, TelemetryRepository.GetResources()); + private string GetOtlpResourceName(OtlpResource resource) => OtlpHelpers.GetResourceName(resource, TelemetryRepository.GetResources()); private string GetDataTypeDisplayName(AspireDataType dataType) => dataType switch { diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index 151b4ac1d62..d287a8f7c92 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -414,7 +414,7 @@ private async Task HandleAfterFilterBindAsync() await ClearSelectedLogEntryAsync(); } - private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app.Resource, _resources); + private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources); private string GetRowClass(OtlpLogEntry entry) { diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index c9dafd543c6..6e48f33524e 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -248,8 +248,8 @@ private async Task HandleAfterFilterBindAsync() await InvokeAsync(_dataGrid.SafeRefreshDataAsync); } - private string GetResourceName(OtlpResource app) => OtlpResource.GetResourceName(app, _resources); - private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app, _resources); + private string GetResourceName(OtlpResource app) => OtlpHelpers.GetResourceName(app, _resources); + private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources); private static string GetRowClass(OtlpTrace entry) { diff --git a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs index 5e81a195940..b38155710be 100644 --- a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs @@ -115,15 +115,15 @@ public async Task ListConsoleLogsAsync( var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, AIHelpers.ConsoleLogsLimit, "console log", "console logs", SharedAIHelpers.SerializeLogEntry, - logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry)); - var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + SharedAIHelpers.EstimateTokenCount); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs index f3cef5d4b83..b94a37944c4 100644 --- a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.Options; using ModelContextProtocol.Server; @@ -20,7 +21,7 @@ namespace Aspire.Dashboard.Mcp; internal sealed class AspireTelemetryMcpTools { private readonly TelemetryRepository _telemetryRepository; - private readonly IEnumerable _outgoingPeerResolvers; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; private readonly IOptionsMonitor _dashboardOptions; private readonly IDashboardClient _dashboardClient; private readonly ILogger _logger; @@ -32,7 +33,7 @@ public AspireTelemetryMcpTools(TelemetryRepository telemetryRepository, ILogger logger) { _telemetryRepository = telemetryRepository; - _outgoingPeerResolvers = outgoingPeerResolvers; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); _dashboardOptions = dashboardOptions; _dashboardClient = dashboardClient; _logger = logger; @@ -70,13 +71,13 @@ public string ListStructuredLogs( } } + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs); var resources = _telemetryRepository.GetResources(); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson( - logs, + otlpData, _dashboardOptions.CurrentValue, includeDashboardUrl: true, - getResourceName: r => OtlpResource.GetResourceName(r, resources)); + getResourceName: r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" Always format log_id in the response as code like this: `log_id: 123`. @@ -123,12 +124,10 @@ public string ListTraces( var resources = _telemetryRepository.GetResources(); - var (tracesData, limitMessage) = AIHelpers.GetTracesJson( - traces, - _outgoingPeerResolvers, - _dashboardOptions.CurrentValue, - includeDashboardUrl: true, - getResourceName: r => OtlpResource.GetResourceName(r, resources)); + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson( + TelemetryExportService.ConvertTracesToOtlpJson(traces, _outgoingPeerResolvers).ResourceSpans, + getResourceName: r => OtlpHelpers.GetResourceName(r, resources), + AIHelpers.GetDashboardUrl(_dashboardOptions.CurrentValue)); var response = $""" {limitMessage} @@ -165,13 +164,13 @@ public string ListTraceStructuredLogs( Filters = [traceIdFilter] }); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); var resources = _telemetryRepository.GetResources(); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson( - logs.Items, + otlpData, _dashboardOptions.CurrentValue, includeDashboardUrl: true, - getResourceName: r => OtlpResource.GetResourceName(r, resources)); + getResourceName: r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index 47cc68b30a8..434b10965ba 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs @@ -3,12 +3,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Utils; @@ -22,16 +23,16 @@ internal static class AIHelpers { public const int TracesLimit = 200; public const int StructuredLogsLimit = 200; - public const int ConsoleLogsLimit = SharedAIHelpers.ConsoleLogsLimit; + public const int ConsoleLogsLimit = 500; // There is currently a 64K token limit in VS. // Limit the result from individual token calls to a smaller number so multiple results can live inside the context. - public const int MaximumListTokenLength = SharedAIHelpers.MaximumListTokenLength; + public const int MaximumListTokenLength = 8192; // This value is chosen to balance: // - Providing enough data to the model for it to provide accurate answers. // - Providing too much data and exceeding length limits. - public const int MaximumStringLength = SharedAIHelpers.MaximumStringLength; + public const int MaximumStringLength = 2048; // Always pass English translations to AI private static readonly IStringLocalizer s_columnsLoc = new InvariantStringLocalizer(); @@ -41,177 +42,101 @@ internal static class AIHelpers private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - internal static object GetTraceDto(OtlpTrace trace, IEnumerable outgoingPeerResolvers, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) + internal static string GetResponseGraphJson(List resources, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false) { - var spanData = trace.Spans.Select(s => new - { - span_id = OtlpHelpers.ToShortenedId(s.SpanId), - parent_span_id = s.ParentSpanId is { } id ? OtlpHelpers.ToShortenedId(id) : null, - kind = s.Kind.ToString(), - name = context.AddValue(s.Name, id => $@"Duplicate of ""name"" for span {OtlpHelpers.ToShortenedId(id)}", s.SpanId), - status = s.Status != OtlpSpanStatusCode.Unset ? s.Status.ToString() : null, - status_message = context.AddValue(s.StatusMessage, id => $@"Duplicate of ""status_message"" for span {OtlpHelpers.ToShortenedId(id)}", s.SpanId), - source = getResourceName?.Invoke(s.Source.Resource) ?? s.Source.ResourceKey.GetCompositeName(), - destination = GetDestination(s, outgoingPeerResolvers), - duration_ms = ConvertToMilliseconds(s.Duration), - attributes = s.Attributes - .ToDictionary(a => a.Key, a => context.AddValue(MapOtelAttributeValue(a), id => $@"Duplicate of attribute ""{id.Key}"" for span {OtlpHelpers.ToShortenedId(id.SpanId)}", (s.SpanId, a.Key))), - links = s.Links.Select(l => new { trace_id = OtlpHelpers.ToShortenedId(l.TraceId), span_id = OtlpHelpers.ToShortenedId(l.SpanId) }).ToList(), - back_links = s.BackLinks.Select(l => new { source_trace_id = OtlpHelpers.ToShortenedId(l.SourceTraceId), source_span_id = OtlpHelpers.ToShortenedId(l.SourceSpanId) }).ToList() - }).ToList(); - - var traceId = OtlpHelpers.ToShortenedId(trace.TraceId); - var traceData = new Dictionary - { - ["trace_id"] = traceId, - ["duration_ms"] = ConvertToMilliseconds(trace.Duration), - ["title"] = trace.RootOrFirstSpan.Name, - ["spans"] = spanData, - ["has_error"] = trace.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error), - ["timestamp"] = trace.TimeStamp, - }; - - if (includeDashboardUrl) - { - traceData["dashboard_link"] = GetDashboardLink(options, DashboardUrls.TraceDetailUrl(traceId), traceId); - } - - return traceData; + var dashboardBaseUrl = includeDashboardUrl ? GetDashboardUrl(options) : null; + return GetResponseGraphJson(resources, dashboardBaseUrl, includeDashboardUrl, getResourceName, includeEnvironmentVariables); } - private static string MapOtelAttributeValue(KeyValuePair attribute) + internal static string GetResponseGraphJson(List resources, string? dashboardBaseUrl, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false) { - switch (attribute.Key) + var dataArray = new JsonArray(); + + foreach (var resource in resources.Where(resource => !resource.IsResourceHidden(false))) { - case "http.response.status_code": + var resourceName = getResourceName?.Invoke(resource) ?? resource.Name; + + var endpointUrlsArray = new JsonArray(); + foreach (var u in resource.Urls.Where(u => !u.IsInternal)) + { + var urlObj = new JsonObject { - if (int.TryParse(attribute.Value, CultureInfo.InvariantCulture, out var value)) - { - return OtelAttributeHelpers.GetHttpStatusName(value); - } - goto default; - } - case "rpc.grpc.status_code": + ["name"] = u.EndpointName, + ["url"] = u.Url.ToString() + }; + if (!string.IsNullOrEmpty(u.DisplayProperties.DisplayName)) { - if (int.TryParse(attribute.Value, CultureInfo.InvariantCulture, out var value)) - { - return OtelAttributeHelpers.GetGrpcStatusName(value); - } - goto default; + urlObj["display_name"] = u.DisplayProperties.DisplayName; } - default: - return attribute.Value; - } - } - - private static int ConvertToMilliseconds(TimeSpan duration) - { - return (int)Math.Round(duration.TotalMilliseconds, 0, MidpointRounding.AwayFromZero); - } - - public static (string json, string limitMessage) GetTracesJson(List traces, IEnumerable outgoingPeerResolvers, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) - { - var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( - traces, - TracesLimit, - "trace", - "traces", - trace => GetTraceDto(trace, outgoingPeerResolvers, promptContext, options, includeDashboardUrl, getResourceName), - EstimateSerializedJsonTokenSize); - var tracesData = SerializeJson(trimmedItems); - - return (tracesData, limitMessage); - } - - internal static string GetTraceJson(OtlpTrace trace, IEnumerable outgoingPeerResolvers, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) - { - var dto = GetTraceDto(trace, outgoingPeerResolvers, context, options, includeDashboardUrl, getResourceName); - - var json = SerializeJson(dto); - return json; - } - - private static string? GetDestination(OtlpSpan s, IEnumerable outgoingPeerResolvers) - { - return ResolveUninstrumentedPeerName(s, outgoingPeerResolvers); - } + endpointUrlsArray.Add(urlObj); + } - private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IEnumerable outgoingPeerResolvers) - { - // Attempt to resolve uninstrumented peer to a friendly name from the span. - foreach (var resolver in outgoingPeerResolvers) - { - if (resolver.TryResolvePeer(span.Attributes, out var name, out _)) + var healthReportsArray = new JsonArray(); + foreach (var report in resource.HealthReports) { - return name; + healthReportsArray.Add(new JsonObject + { + ["name"] = report.Name, + ["health_status"] = GetReportHealthStatus(resource, report), + ["exception"] = report.ExceptionText + }); } - } - // Fallback to the peer address. - return span.Attributes.GetPeerAddress(); - } + var healthObj = new JsonObject + { + ["resource_health_status"] = GetResourceHealthStatus(resource), + ["health_reports"] = healthReportsArray + }; - internal static string GetResponseGraphJson(List resources, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false) - { - var data = resources.Where(resource => !resource.IsResourceHidden(false)).Select(resource => - { - var resourceName = getResourceName?.Invoke(resource) ?? resource.Name; + var commandsArray = new JsonArray(); + foreach (var cmd in resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled)) + { + commandsArray.Add(new JsonObject + { + ["name"] = cmd.Name, + ["description"] = cmd.GetDisplayDescription() + }); + } - var resourceObj = new Dictionary + var resourceObj = new JsonObject { ["resource_name"] = resourceName, ["type"] = resource.ResourceType, ["state"] = resource.State, ["state_description"] = ResourceStateViewModel.GetResourceStateTooltip(resource, s_columnsLoc), - ["relationships"] = GetResourceRelationships(resources, resource, getResourceName), - ["endpoint_urls"] = resource.Urls.Where(u => !u.IsInternal).Select(u => new - { - name = u.EndpointName, - url = u.Url, - display_name = !string.IsNullOrEmpty(u.DisplayProperties.DisplayName) ? u.DisplayProperties.DisplayName : null, - }).ToList(), - ["health"] = new - { - resource_health_status = GetResourceHealthStatus(resource), - health_reports = resource.HealthReports.Select(report => new - { - name = report.Name, - health_status = GetReportHealthStatus(resource, report), - exception = report.ExceptionText - }).ToList() - }, + ["relationships"] = GetResourceRelationshipsJson(resources, resource, getResourceName), + ["endpoint_urls"] = endpointUrlsArray, + ["health"] = healthObj, ["source"] = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value, - ["commands"] = resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled).Select(cmd => new - { - name = cmd.Name, - description = cmd.GetDisplayDescription() - }).ToList() + ["commands"] = commandsArray }; - if (includeDashboardUrl) + if (includeDashboardUrl && dashboardBaseUrl != null) { - resourceObj["dashboard_link"] = GetDashboardLink(options, DashboardUrls.ResourcesUrl(resource: resource.Name), resourceName); + resourceObj["dashboard_link"] = SharedAIHelpers.GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.ResourcesUrl(resource: resource.Name), resourceName); } if (includeEnvironmentVariables) { - resourceObj["environment_variables"] = resource.Environment.Where(e => e.FromSpec).Select(e => e.Name).ToList(); + var envVarsArray = new JsonArray(); + foreach (var e in resource.Environment.Where(e => e.FromSpec)) + { + envVarsArray.Add(JsonValue.Create(e.Name)); + } + resourceObj["environment_variables"] = envVarsArray; } - return resourceObj; - }).ToList(); + dataArray.Add(resourceObj); + } - var resourceGraphData = SerializeJson(data); - return resourceGraphData; + return dataArray.ToJsonString(s_jsonSerializerOptions); - static List GetResourceRelationships(List allResources, ResourceViewModel resourceViewModel, Func? getResourceName) + static JsonArray GetResourceRelationshipsJson(List allResources, ResourceViewModel resourceViewModel, Func? getResourceName) { - var relationships = new List(); + var relationships = new JsonArray(); foreach (var relationship in resourceViewModel.Relationships) { @@ -222,10 +147,10 @@ static List GetResourceRelationships(List allResource foreach (var match in matches) { - relationships.Add(new + relationships.Add(new JsonObject { - resource_name = getResourceName?.Invoke(match) ?? match.Name, - Types = relationship.Type + ["resource_name"] = getResourceName?.Invoke(match) ?? match.Name, + ["Types"] = relationship.Type }); } } @@ -259,22 +184,7 @@ static List GetResourceRelationships(List allResource } } - public static object? GetDashboardLink(DashboardOptions options, string path, string text) - { - var url = GetDashboardUrl(options, path); - if (string.IsNullOrEmpty(url)) - { - return null; - } - - return new - { - url = url, - text = text - }; - } - - public static string? GetDashboardUrl(DashboardOptions options, string path) + public static string? GetDashboardUrl(DashboardOptions options) { var frontendEndpoints = options.Frontend.GetEndpointAddresses(); @@ -282,73 +192,17 @@ static List GetResourceRelationships(List allResource ?? frontendEndpoints.FirstOrDefault(e => string.Equals(e.Scheme, "https", StringComparison.Ordinal))?.ToString() ?? frontendEndpoints.FirstOrDefault(e => string.Equals(e.Scheme, "http", StringComparison.Ordinal))?.ToString(); - if (frontendUrl == null) - { - return null; - } - - return new Uri(new Uri(frontendUrl), path).ToString(); - } - - public static int EstimateSerializedJsonTokenSize(T value) - { - var json = SerializeJson(value); - return SharedAIHelpers.EstimateTokenCount(json); - } - - private static string SerializeJson(T value) - { - return JsonSerializer.Serialize(value, s_jsonSerializerOptions); - } - - public static (string json, string limitMessage) GetStructuredLogsJson(List errorLogs, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) - { - var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( - errorLogs, - StructuredLogsLimit, - "log entry", - "log entries", - i => GetLogEntryDto(i, promptContext, options, includeDashboardUrl, getResourceName), - EstimateSerializedJsonTokenSize); - var logsData = SerializeJson(trimmedItems); - - return (logsData, limitMessage); + return frontendUrl; } - internal static string GetStructuredLogJson(OtlpLogEntry l, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) + public static (string json, string limitMessage) GetStructuredLogsJson(OtlpTelemetryDataJson otlpData, DashboardOptions options, Func getResourceName, bool includeDashboardUrl = false) { - var dto = GetLogEntryDto(l, new PromptContext(), options, includeDashboardUrl, getResourceName); - - var json = SerializeJson(dto); - return json; + return SharedAIHelpers.GetStructuredLogsJson(otlpData.ResourceLogs, getResourceName, includeDashboardUrl ? GetDashboardUrl(options) : null); } - public static object GetLogEntryDto(OtlpLogEntry l, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) + internal static string GetStructuredLogJson(OtlpTelemetryDataJson otlpData, DashboardOptions options, Func getResourceName, bool includeDashboardUrl = false) { - var exceptionText = OtlpLogEntry.GetExceptionText(l); - - var log = new Dictionary - { - ["log_id"] = l.InternalId, - ["span_id"] = OtlpHelpers.ToShortenedId(l.SpanId), - ["trace_id"] = OtlpHelpers.ToShortenedId(l.TraceId), - ["message"] = context.AddValue(l.Message, id => $@"Duplicate of ""message"" for log entry {id.InternalId}", l), - ["severity"] = l.Severity.ToString(), - ["resource_name"] = getResourceName?.Invoke(l.ResourceView.Resource) ?? l.ResourceView.Resource.ResourceKey.GetCompositeName(), - ["attributes"] = l.Attributes - .Where(l => l.Key is not (OtlpLogEntry.ExceptionStackTraceField or OtlpLogEntry.ExceptionMessageField or OtlpLogEntry.ExceptionTypeField)) - .ToDictionary(a => a.Key, a => context.AddValue(MapOtelAttributeValue(a), id => $@"Duplicate of attribute ""{id.Key}"" for log entry {id.InternalId}", (l.InternalId, a.Key))), - ["exception"] = context.AddValue(exceptionText, id => $@"Duplicate of ""exception"" for log entry {id.InternalId}", l), - ["source"] = l.Scope.Name - }; - - if (includeDashboardUrl) - { - log["dashboard_link"] = GetDashboardLink(options, DashboardUrls.StructuredLogsUrl(logEntryId: l.InternalId), $"log_id: {l.InternalId}"); - } - - return log; + return SharedAIHelpers.GetStructuredLogJson(otlpData.ResourceLogs, getResourceName, includeDashboardUrl ? GetDashboardUrl(options) : null); } public static bool TryGetSingleResult(IEnumerable source, Func predicate, [NotNullWhen(true)] out T? result) diff --git a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs index 012ddbe4fed..a12198b516e 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs @@ -96,7 +96,10 @@ public async Task GetTraceAsync( _referencedTraces.TryAdd(trace.TraceId, trace); - return AIHelpers.GetTraceJson(trace, _outgoingPeerResolvers, new PromptContext(), _dashboardOptions.CurrentValue); + var spans = TelemetryExportService.ConvertTracesToOtlpJson([trace], _outgoingPeerResolvers.ToArray()).ResourceSpans; + var resources = TelemetryRepository.GetResources(); + + return SharedAIHelpers.GetTraceJson(spans, r => OtlpHelpers.GetResourceName(r, resources), AIHelpers.GetDashboardUrl(_dashboardOptions.CurrentValue)); } [Description("Get structured logs for resources.")] @@ -128,7 +131,9 @@ public async Task GetStructuredLogsAsync( Filters = [] }); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(logs.Items, _dashboardOptions.CurrentValue); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + var resources = TelemetryRepository.GetResources(); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, _dashboardOptions.CurrentValue, r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" Always format log_id in the response as code like this: `log_id: 123`. @@ -170,7 +175,9 @@ public async Task GetTracesAsync( FilterText = string.Empty }); - var (tracesData, limitMessage) = AIHelpers.GetTracesJson(traces.PagedResult.Items, _outgoingPeerResolvers, _dashboardOptions.CurrentValue); + var spans = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, _outgoingPeerResolvers.ToArray()).ResourceSpans; + var resources = TelemetryRepository.GetResources(); + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson(spans, r => OtlpHelpers.GetResourceName(r, resources), AIHelpers.GetDashboardUrl(_dashboardOptions.CurrentValue)); var response = $""" {limitMessage} @@ -207,7 +214,9 @@ public async Task GetTraceStructuredLogsAsync( await InvokeToolCallbackAsync(nameof(GetTraceStructuredLogsAsync), _loc.GetString(nameof(AIAssistant.ToolNotificationTraceStructuredLogs), OtlpHelpers.ToShortenedId(traceId)), cancellationToken).ConfigureAwait(false); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(logs.Items, _dashboardOptions.CurrentValue); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + var resources = TelemetryRepository.GetResources(); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, _dashboardOptions.CurrentValue, r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" {limitMessage} @@ -264,15 +273,15 @@ public async Task GetConsoleLogsAsync( var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, AIHelpers.ConsoleLogsLimit, "console log", "console logs", SharedAIHelpers.SerializeLogEntry, - logEntry => SharedAIHelpers.EstimateTokenCount((string) logEntry)); - var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + SharedAIHelpers.EstimateTokenCount); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs index 5b14dc3aa5d..b5aef2f6af2 100644 --- a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs +++ b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs @@ -5,6 +5,7 @@ using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.AI; namespace Aspire.Dashboard.Model.Assistant.Prompts; @@ -176,9 +177,10 @@ public static ChatMessage CreateHelpMessage() public static class StructuredLogs { - public static ChatMessage CreateErrorStructuredLogsMessage(List errorLogs, DashboardOptions options) + public static ChatMessage CreateErrorStructuredLogsMessage(List errorLogs, DashboardOptions options, Func getResourceName) { - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(errorLogs, options); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(errorLogs); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, options, getResourceName); var prompt = $""" @@ -194,8 +196,10 @@ Explain the errors in the following log entries. Provide a summary of the errors return new(ChatRole.User, prompt); } - public static ChatMessage CreateAnalyzeLogEntryMessage(OtlpLogEntry logEntry, DashboardOptions options) + public static ChatMessage CreateAnalyzeLogEntryMessage(OtlpLogEntry logEntry, DashboardOptions options, Func getResourceName) { + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([logEntry]); + var prompt = $""" My application has written a log entry. Provide context about the state of the app when the log entry was written and why. @@ -204,7 +208,7 @@ Investigate the root cause of any errors in the log entry. # LOG ENTRY DATA - {AIHelpers.GetStructuredLogJson(logEntry, options)} + {AIHelpers.GetStructuredLogJson(otlpData, options, getResourceName)} """; return new(ChatRole.User, prompt); @@ -280,9 +284,13 @@ public static ChatMessage CreateResourceTracesMessage(OtlpResource resource) return new ChatMessage(ChatRole.User, message); } - public static ChatMessage CreateAnalyzeTraceMessage(OtlpTrace trace, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options) + public static ChatMessage CreateAnalyzeTraceMessage(OtlpTrace trace, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options, Func getResourceName) { - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(traceLogEntries, options); + var spans = TelemetryExportService.ConvertTracesToOtlpJson([trace], outgoingPeerResolvers.ToArray()).ResourceSpans; + var (tracesData, _) = SharedAIHelpers.GetTracesJson(spans, getResourceName, AIHelpers.GetDashboardUrl(options)); + + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(traceLogEntries); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, options, getResourceName); var prompt = $""" @@ -291,7 +299,7 @@ Summarize the distributed trace. Focus on errors. # DISTRIBUTED TRACE DATA - {AIHelpers.GetTraceJson(trace, outgoingPeerResolvers, new PromptContext(), options)} + {tracesData} # STRUCTURED LOGS DATA @@ -303,9 +311,13 @@ Summarize the distributed trace. Focus on errors. return new(ChatRole.User, prompt); } - public static ChatMessage CreateAnalyzeSpanMessage(OtlpSpan span, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options) + public static ChatMessage CreateAnalyzeSpanMessage(OtlpSpan span, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options, Func getResourceName) { - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(traceLogEntries, options); + var spans = TelemetryExportService.ConvertTracesToOtlpJson([span.Trace], outgoingPeerResolvers.ToArray()).ResourceSpans; + var (tracesData, _) = SharedAIHelpers.GetTracesJson(spans, getResourceName, AIHelpers.GetDashboardUrl(options)); + + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(traceLogEntries); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, options, getResourceName); var prompt = $""" @@ -314,7 +326,7 @@ public static ChatMessage CreateAnalyzeSpanMessage(OtlpSpan span, List errorTraces, IEnumerable outgoingPeerResolvers, DashboardOptions options) + public static ChatMessage CreateErrorTracesMessage(List errorTraces, IEnumerable outgoingPeerResolvers, DashboardOptions options, Func getResourceName) { - var (tracesData, limitMessage) = AIHelpers.GetTracesJson(errorTraces, outgoingPeerResolvers, options); + var spans = TelemetryExportService.ConvertTracesToOtlpJson(errorTraces, outgoingPeerResolvers.ToArray()).ResourceSpans; + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson(spans, getResourceName, AIHelpers.GetDashboardUrl(options)); var prompt = $""" diff --git a/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs b/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs index 702bc2ed170..abfc836f865 100644 --- a/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs +++ b/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs @@ -11,6 +11,8 @@ internal static class PromptContextsBuilder public static Task ErrorTraces(InitializePromptContext promptContext, string displayText, Func> getErrorTraces) { var outgoingPeerResolvers = promptContext.ServiceProvider.GetRequiredService>(); + var repository = promptContext.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var errorTraces = getErrorTraces(); foreach (var trace in errorTraces.Items) { @@ -19,13 +21,15 @@ public static Task ErrorTraces(InitializePromptContext promptContext, string dis promptContext.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.Traces.CreateErrorTracesMessage(errorTraces.Items, outgoingPeerResolvers, promptContext.DashboardOptions).Text); + KnownChatMessages.Traces.CreateErrorTracesMessage(errorTraces.Items, outgoingPeerResolvers, promptContext.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } public static Task ErrorStructuredLogs(InitializePromptContext promptContext, string displayText, Func> getErrorLogs) { + var repository = promptContext.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var errorLogs = getErrorLogs(); foreach (var log in errorLogs.Items) { @@ -34,7 +38,7 @@ public static Task ErrorStructuredLogs(InitializePromptContext promptContext, st promptContext.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.StructuredLogs.CreateErrorStructuredLogsMessage(errorLogs.Items, promptContext.DashboardOptions).Text); + KnownChatMessages.StructuredLogs.CreateErrorStructuredLogsMessage(errorLogs.Items, promptContext.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } @@ -50,10 +54,12 @@ public static Task AnalyzeResource(InitializePromptContext promptContext, string public static Task AnalyzeLogEntry(InitializePromptContext promptContext, string displayText, OtlpLogEntry logEntry) { + var repository = promptContext.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); promptContext.DataContext.AddReferencedLogEntry(logEntry); promptContext.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.StructuredLogs.CreateAnalyzeLogEntryMessage(logEntry, promptContext.DashboardOptions).Text); + KnownChatMessages.StructuredLogs.CreateAnalyzeLogEntryMessage(logEntry, promptContext.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } @@ -64,6 +70,7 @@ public static Task AnalyzeTrace(InitializePromptContext context, string displayT var outgoingPeerResolvers = context.ServiceProvider.GetRequiredService>(); var repository = context.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var traceLogs = repository.GetLogsForTrace(trace.TraceId); foreach (var log in traceLogs) { @@ -72,7 +79,7 @@ public static Task AnalyzeTrace(InitializePromptContext context, string displayT context.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.Traces.CreateAnalyzeTraceMessage(trace, traceLogs, outgoingPeerResolvers, context.DashboardOptions).Text); + KnownChatMessages.Traces.CreateAnalyzeTraceMessage(trace, traceLogs, outgoingPeerResolvers, context.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } @@ -83,6 +90,7 @@ public static Task AnalyzeSpan(InitializePromptContext context, string displayTe var outgoingPeerResolvers = context.ServiceProvider.GetRequiredService>(); var repository = context.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var traceLogs = repository.GetLogsForTrace(span.Trace.TraceId); foreach (var log in traceLogs) { @@ -91,7 +99,7 @@ public static Task AnalyzeSpan(InitializePromptContext context, string displayTe context.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.Traces.CreateAnalyzeSpanMessage(span, traceLogs, outgoingPeerResolvers, context.DashboardOptions).Text); + KnownChatMessages.Traces.CreateAnalyzeSpanMessage(span, traceLogs, outgoingPeerResolvers, context.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } diff --git a/src/Aspire.Dashboard/Model/ExportHelpers.cs b/src/Aspire.Dashboard/Model/ExportHelpers.cs index 12d5e277cab..46f5475c68e 100644 --- a/src/Aspire.Dashboard/Model/ExportHelpers.cs +++ b/src/Aspire.Dashboard/Model/ExportHelpers.cs @@ -21,13 +21,10 @@ internal static class ExportHelpers /// /// Gets a span as a JSON export result, including associated log entries. /// - /// The span to convert. - /// The telemetry repository to fetch logs from. - /// A result containing the JSON representation and suggested file name. - public static ExportResult GetSpanAsJson(OtlpSpan span, TelemetryRepository telemetryRepository) + public static ExportResult GetSpanAsJson(OtlpSpan span, TelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) { var logs = telemetryRepository.GetLogsForSpan(span.TraceId, span.SpanId); - var json = TelemetryExportService.ConvertSpanToJson(span, logs); + var json = TelemetryExportService.ConvertSpanToJson(span, outgoingPeerResolvers, logs); var fileName = $"span-{OtlpHelpers.ToShortenedId(span.SpanId)}.json"; return new ExportResult(json, fileName); } @@ -47,13 +44,10 @@ public static ExportResult GetLogEntryAsJson(OtlpLogEntry logEntry) /// /// Gets all spans in a trace as a JSON export result, including associated log entries. /// - /// The trace to convert. - /// The telemetry repository to fetch logs from. - /// A result containing the JSON representation and suggested file name. - public static ExportResult GetTraceAsJson(OtlpTrace trace, TelemetryRepository telemetryRepository) + public static ExportResult GetTraceAsJson(OtlpTrace trace, TelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) { var logs = telemetryRepository.GetLogsForTrace(trace.TraceId); - var json = TelemetryExportService.ConvertTraceToJson(trace, logs); + var json = TelemetryExportService.ConvertTraceToJson(trace, outgoingPeerResolvers, logs); var fileName = $"trace-{OtlpHelpers.ToShortenedId(trace.TraceId)}.json"; return new ExportResult(json, fileName); } diff --git a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs index ac701dd5f03..c3c47a25703 100644 --- a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs +++ b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs @@ -63,9 +63,9 @@ public static GenAIVisualizerDialogViewModel Create( SpanDetailsViewModel = spanDetailsViewModel, SelectedLogEntryId = selectedLogEntryId, GetContextGenAISpans = getContextGenAISpans, - SourceName = OtlpResource.GetResourceName(spanDetailsViewModel.Span.Source, resources), + SourceName = OtlpHelpers.GetResourceName(spanDetailsViewModel.Span.Source.Resource, resources), PeerName = telemetryRepository.GetPeerResource(spanDetailsViewModel.Span) is { } peerResource - ? OtlpResource.GetResourceName(peerResource, resources) + ? OtlpHelpers.GetResourceName(peerResource, resources) : OtlpHelpers.GetPeerAddress(spanDetailsViewModel.Span.Attributes) ?? UnknownPeerName }; diff --git a/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs b/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs index 680acafbccb..1df031b5f6b 100644 --- a/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs +++ b/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs @@ -111,7 +111,7 @@ public static List> CreateResources(List { Id = ResourceTypeDetails.CreateReplicaInstance(replica.ResourceKey.ToString(), resourceName), - Name = OtlpResource.GetResourceName(replica, resources) + Name = OtlpHelpers.GetResourceName(replica, resources) })); } diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs index 4f967a96385..3a852359f3c 100644 --- a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -220,7 +220,7 @@ static double CalculatePercent(double value, double total) // If the span has a peer name, use it. Note that when the peer is a resource with replicas, it's possible the uninstrumented peer name returned here isn't the real replica. // We are matching an address to replicas which share the same address. There isn't a way to know exactly which replica was called. The first replica instance will be chosen. // This shouldn't be a big issue because typically project replicas will have OTEL setup, and so a child span is recorded. - return OtlpResource.GetResourceName(span.UninstrumentedPeer, allResources); + return OtlpHelpers.GetResourceName(span.UninstrumentedPeer, allResources); } // Attempt to resolve uninstrumented peer to a friendly name from the span. diff --git a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs index 5bb0cbafa96..faeac64969f 100644 --- a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs +++ b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs @@ -32,7 +32,7 @@ public static SpanDetailsViewModel Create(OtlpSpan span, TelemetryRepository tel { Name = "Destination", Key = KnownTraceFields.DestinationField, - Value = OtlpResource.GetResourceName(destination, resources) + Value = OtlpHelpers.GetResourceName(destination, resources) }); } entryProperties.AddRange(span.GetAttributeProperties().Select(CreateTelemetryProperty)); diff --git a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs index fb721adc7df..4ad20a1189a 100644 --- a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs @@ -35,6 +35,7 @@ public sealed class SpanMenuBuilder private readonly IAIContextProvider _aiContextProvider; private readonly DashboardDialogService _dialogService; private readonly TelemetryRepository _telemetryRepository; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// /// Initializes a new instance of the class. @@ -46,7 +47,8 @@ public SpanMenuBuilder( NavigationManager navigationManager, IAIContextProvider aiContextProvider, DashboardDialogService dialogService, - TelemetryRepository telemetryRepository) + TelemetryRepository telemetryRepository, + IEnumerable outgoingPeerResolvers) { _controlsLoc = controlsLoc; _aiAssistantLoc = aiAssistantLoc; @@ -55,6 +57,7 @@ public SpanMenuBuilder( _aiContextProvider = aiContextProvider; _dialogService = dialogService; _telemetryRepository = telemetryRepository; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); } /// @@ -109,7 +112,7 @@ public void AddMenuItems( Icon = s_bracesIcon, OnClick = async () => { - var result = ExportHelpers.GetSpanAsJson(span, _telemetryRepository); + var result = ExportHelpers.GetSpanAsJson(span, _telemetryRepository, _outgoingPeerResolvers); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index a3598f6f103..20584c2d8b7 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -25,6 +25,7 @@ public sealed class TelemetryExportService private readonly TelemetryRepository _telemetryRepository; private readonly ConsoleLogsFetcher _consoleLogsFetcher; private readonly IDashboardClient _dashboardClient; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// /// Initializes a new instance of the class. @@ -32,11 +33,13 @@ public sealed class TelemetryExportService /// The telemetry repository. /// The console log fetcher. /// The dashboard client for fetching resources. - public TelemetryExportService(TelemetryRepository telemetryRepository, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient) + /// The outgoing peer resolvers for destination name resolution. + public TelemetryExportService(TelemetryRepository telemetryRepository, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) { _telemetryRepository = telemetryRepository; _consoleLogsFetcher = consoleLogsFetcher; _dashboardClient = dashboardClient; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); } /// @@ -163,7 +166,7 @@ private void ExportStructuredLogs(ZipArchive archive, List resourc continue; } - var resourceName = OtlpResource.GetResourceName(resource, resources); + var resourceName = OtlpHelpers.GetResourceName(resource, resources); var logsJson = ConvertLogsToOtlpJson(logs.Items); WriteJsonToArchive(archive, $"structuredlogs/{SanitizeFileName(resourceName)}.json", logsJson); } @@ -180,8 +183,8 @@ private void ExportTraces(ZipArchive archive, List resources) continue; } - var resourceName = OtlpResource.GetResourceName(resource, resources); - var tracesJson = ConvertTracesToOtlpJson(tracesResponse.PagedResult.Items); + var resourceName = OtlpHelpers.GetResourceName(resource, resources); + var tracesJson = ConvertTracesToOtlpJson(tracesResponse.PagedResult.Items, _outgoingPeerResolvers); WriteJsonToArchive(archive, $"traces/{SanitizeFileName(resourceName)}.json", tracesJson); } } @@ -221,7 +224,7 @@ private void ExportMetrics(ZipArchive archive, List resources) continue; } - var resourceName = OtlpResource.GetResourceName(resource, resources); + var resourceName = OtlpHelpers.GetResourceName(resource, resources); var metricsJson = ConvertMetricsToOtlpJson(resource, instrumentsData); WriteJsonToArchive(archive, $"metrics/{SanitizeFileName(resourceName)}.json", metricsJson); } @@ -262,7 +265,10 @@ private static OtlpLogRecordJson ConvertLogEntry(OtlpLogEntry log) SeverityNumber = log.SeverityNumber, SeverityText = log.Severity.ToString(), Body = new OtlpAnyValueJson { StringValue = log.Message }, - Attributes = ConvertAttributes(log.Attributes), + Attributes = ConvertAttributes(log.Attributes, () => + [ + new KeyValuePair(OtlpHelpers.AspireLogIdAttribute, log.InternalId.ToString(CultureInfo.InvariantCulture)) + ]), TraceId = string.IsNullOrEmpty(log.TraceId) ? null : log.TraceId, SpanId = string.IsNullOrEmpty(log.SpanId) ? null : log.SpanId, Flags = log.Flags, @@ -270,7 +276,7 @@ private static OtlpLogRecordJson ConvertLogEntry(OtlpLogEntry log) }; } - internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList spans) + internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList spans, IOutgoingPeerResolver[] outgoingPeerResolvers) { // Group spans by resource and scope var resourceSpans = spans @@ -286,7 +292,7 @@ internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList new OtlpScopeSpansJson { Scope = ConvertScope(scopeGroup.Key), - Spans = scopeGroup.Select(ConvertSpan).ToArray() + Spans = scopeGroup.Select(s => ConvertSpan(s, outgoingPeerResolvers)).ToArray() }).ToArray() }; }).ToArray(); @@ -297,14 +303,14 @@ internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList traces) + internal static OtlpTelemetryDataJson ConvertTracesToOtlpJson(IReadOnlyList traces, IOutgoingPeerResolver[] outgoingPeerResolvers) { // Group spans by resource and scope var allSpans = traces.SelectMany(t => t.Spans).ToList(); - return ConvertSpansToOtlpJson(allSpans); + return ConvertSpansToOtlpJson(allSpans, outgoingPeerResolvers); } - internal static string ConvertSpanToJson(OtlpSpan span, List? logs = null, bool indent = true) + internal static string ConvertSpanToJson(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers, List? logs = null, bool indent = true) { var data = new OtlpTelemetryDataJson { @@ -318,7 +324,7 @@ internal static string ConvertSpanToJson(OtlpSpan span, List? logs new OtlpScopeSpansJson { Scope = ConvertScope(span.Scope), - Spans = [ConvertSpan(span)] + Spans = [ConvertSpan(span, outgoingPeerResolvers)] } ] } @@ -329,7 +335,7 @@ internal static string ConvertSpanToJson(OtlpSpan span, List? logs return JsonSerializer.Serialize(data, options); } - internal static string ConvertTraceToJson(OtlpTrace trace, List? logs = null) + internal static string ConvertTraceToJson(OtlpTrace trace, IOutgoingPeerResolver[] outgoingPeerResolvers, List? logs = null) { // Group spans by resource and scope var spansByResourceAndScope = trace.Spans @@ -345,7 +351,7 @@ internal static string ConvertTraceToJson(OtlpTrace trace, List? l .Select(scopeGroup => new OtlpScopeSpansJson { Scope = ConvertScope(scopeGroup.Key), - Spans = scopeGroup.Select(ConvertSpan).ToArray() + Spans = scopeGroup.Select(s => ConvertSpan(s, outgoingPeerResolvers)).ToArray() }).ToArray() }; }).ToArray(); @@ -381,8 +387,12 @@ internal static string ConvertLogEntryToJson(OtlpLogEntry logEntry) return JsonSerializer.Serialize(data, OtlpJsonSerializerContext.IndentedOptions); } - private static OtlpSpanJson ConvertSpan(OtlpSpan span) + private static OtlpSpanJson ConvertSpan(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers) { + var destinationName = outgoingPeerResolvers.Length > 0 + ? GetDestination(span, outgoingPeerResolvers) + : null; + return new OtlpSpanJson { TraceId = span.TraceId, @@ -392,7 +402,9 @@ private static OtlpSpanJson ConvertSpan(OtlpSpan span) Kind = (int)span.Kind, StartTimeUnixNano = OtlpHelpers.DateTimeToUnixNanoseconds(span.StartTime), EndTimeUnixNano = OtlpHelpers.DateTimeToUnixNanoseconds(span.EndTime), - Attributes = ConvertAttributes(span.Attributes), + Attributes = ConvertAttributes(span.Attributes, destinationName is not null + ? () => [new KeyValuePair(OtlpHelpers.AspireDestinationNameAttribute, destinationName)] + : null), Status = ConvertSpanStatus(span.Status, span.StatusMessage), Events = span.Events.Count > 0 ? span.Events.Select(ConvertSpanEvent).ToArray() : null, Links = span.Links.Count > 0 ? span.Links.Select(ConvertSpanLink).ToArray() : null, @@ -643,18 +655,40 @@ private static OtlpInstrumentationScopeJson ConvertScope(OtlpScope scope) }; } - private static OtlpKeyValueJson[]? ConvertAttributes(KeyValuePair[] attributes) + private static OtlpKeyValueJson[]? ConvertAttributes(KeyValuePair[] attributes, Func[]>? getAdditionalAttributes = null) { - if (attributes.Length == 0) + var additionalAttributes = getAdditionalAttributes?.Invoke(); + var additionalCount = additionalAttributes?.Length ?? 0; + + if (attributes.Length == 0 && additionalCount == 0) { return null; } - return attributes.Select(a => new OtlpKeyValueJson + var result = new OtlpKeyValueJson[attributes.Length + additionalCount]; + + for (var i = 0; i < attributes.Length; i++) { - Key = a.Key, - Value = new OtlpAnyValueJson { StringValue = a.Value } - }).ToArray(); + result[i] = new OtlpKeyValueJson + { + Key = attributes[i].Key, + Value = new OtlpAnyValueJson { StringValue = attributes[i].Value } + }; + } + + if (additionalAttributes is not null) + { + for (var i = 0; i < additionalAttributes.Length; i++) + { + result[attributes.Length + i] = new OtlpKeyValueJson + { + Key = additionalAttributes[i].Key, + Value = new OtlpAnyValueJson { StringValue = additionalAttributes[i].Value } + }; + } + } + + return result; } private static void WriteJsonToArchive(ZipArchive archive, string path, T data) @@ -769,4 +803,22 @@ internal static string ConvertResourceToJson(ResourceViewModel resource, IReadOn return JsonSerializer.Serialize(resourceJson, ResourceJsonSerializerContext.IndentedOptions); } + + /// + /// Gets the destination name for a span by resolving uninstrumented peer names. + /// + private static string? GetDestination(OtlpSpan span, IEnumerable outgoingPeerResolvers) + { + // Attempt to resolve uninstrumented peer to a friendly name from the span. + foreach (var resolver in outgoingPeerResolvers) + { + if (resolver.TryResolvePeer(span.Attributes, out var name, out _)) + { + return name; + } + } + + // Fallback to the peer address. + return span.Attributes.GetPeerAddress(); + } } diff --git a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs index 141cfcea3f7..1596af60a24 100644 --- a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs @@ -33,6 +33,7 @@ public sealed class TraceMenuBuilder private readonly IAIContextProvider _aiContextProvider; private readonly DashboardDialogService _dialogService; private readonly TelemetryRepository _telemetryRepository; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// /// Initializes a new instance of the class. @@ -44,7 +45,8 @@ public TraceMenuBuilder( NavigationManager navigationManager, IAIContextProvider aiContextProvider, DashboardDialogService dialogService, - TelemetryRepository telemetryRepository) + TelemetryRepository telemetryRepository, + IEnumerable outgoingPeerResolvers) { _controlsLoc = controlsLoc; _aiAssistantLoc = aiAssistantLoc; @@ -53,6 +55,7 @@ public TraceMenuBuilder( _aiContextProvider = aiContextProvider; _dialogService = dialogService; _telemetryRepository = telemetryRepository; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); } /// @@ -97,7 +100,7 @@ public void AddMenuItems( Icon = s_bracesIcon, OnClick = async () => { - var result = ExportHelpers.GetTraceAsJson(trace, _telemetryRepository); + var result = ExportHelpers.GetTraceAsJson(trace, _telemetryRepository, _outgoingPeerResolvers); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs index d2ed293037f..80af7ef0f0f 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs @@ -50,6 +50,7 @@ public OtlpLogEntry(LogRecord record, OtlpResourceView resourceView, OtlpScope s return false; case "SpanId": case "TraceId": + case OtlpHelpers.AspireLogIdAttribute: // Explicitly ignore these return false; case "logrecord.event.name": diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs index b3f9dc4cda9..c2c058901e4 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs @@ -13,7 +13,7 @@ namespace Aspire.Dashboard.Otlp.Model; [DebuggerDisplay("ResourceName = {ResourceName}, InstanceId = {InstanceId}")] -public class OtlpResource +public class OtlpResource : IOtlpResource { public const string SERVICE_NAME = "service.name"; public const string SERVICE_INSTANCE_ID = "service.instance.id"; @@ -281,45 +281,8 @@ public static Dictionary> GetReplicasByResourceName(I .ToDictionary(grouping => grouping.Key, grouping => grouping.ToList()); } - public static string GetResourceName(OtlpResourceView resource, List allResources) => - GetResourceName(resource.Resource, allResources); - - public static string GetResourceName(OtlpResource resource, List allResources) - { - var count = 0; - foreach (var item in allResources) - { - if (string.Equals(item.ResourceName, resource.ResourceName, StringComparisons.ResourceName)) - { - count++; - if (count >= 2) - { - var instanceId = resource.InstanceId; - - // Convert long GUID into a shorter, more human friendly format. - // Before: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee - // After: aaaaaaaa - if (instanceId != null && Guid.TryParse(instanceId, out var guid)) - { - Span chars = stackalloc char[32]; - var result = guid.TryFormat(chars, charsWritten: out _, format: "N"); - Debug.Assert(result, "Guid.TryFormat not successful."); - - instanceId = chars.Slice(0, 8).ToString(); - } - - if (instanceId == null) - { - return item.ResourceName; - } - - return $"{item.ResourceName}-{instanceId}"; - } - } - } - - return resource.ResourceName; - } + public static string GetResourceName(OtlpResourceView resource, IReadOnlyList allResources) => + OtlpHelpers.GetResourceName(resource.Resource, allResources); internal List GetViews() => _resourceViews.Values.ToList(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index b11b3985b9c..0d46579ccd3 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -1449,7 +1449,7 @@ private static OtlpSpan CreateSpan(OtlpResourceView resourceView, Span span, Otl EndTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.EndTimeUnixNano), Status = ConvertStatus(span.Status), StatusMessage = span.Status?.Message, - Attributes = span.Attributes.ToKeyValuePairs(context), + Attributes = span.Attributes.ToKeyValuePairs(context, filter: attribute => attribute.Key != OtlpHelpers.AspireDestinationNameAttribute), State = !string.IsNullOrEmpty(span.TraceState) ? span.TraceState : null, Events = events, Links = links, diff --git a/src/Aspire.Dashboard/Model/Assistant/PromptContext.cs b/src/Shared/ConsoleLogs/PromptContext.cs similarity index 95% rename from src/Aspire.Dashboard/Model/Assistant/PromptContext.cs rename to src/Shared/ConsoleLogs/PromptContext.cs index 1840d140ea8..7d0858592e1 100644 --- a/src/Aspire.Dashboard/Model/Assistant/PromptContext.cs +++ b/src/Shared/ConsoleLogs/PromptContext.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Shared.ConsoleLogs; - -namespace Aspire.Dashboard.Model.Assistant; +namespace Aspire.Shared.ConsoleLogs; internal sealed class PromptContext { diff --git a/src/Shared/ConsoleLogs/SharedAIHelpers.cs b/src/Shared/ConsoleLogs/SharedAIHelpers.cs index 1716d428312..13d8be72688 100644 --- a/src/Shared/ConsoleLogs/SharedAIHelpers.cs +++ b/src/Shared/ConsoleLogs/SharedAIHelpers.cs @@ -4,6 +4,12 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; namespace Aspire.Shared.ConsoleLogs; @@ -13,10 +19,17 @@ namespace Aspire.Shared.ConsoleLogs; /// internal static class SharedAIHelpers { + public const int TracesLimit = 200; + public const int StructuredLogsLimit = 200; public const int ConsoleLogsLimit = 500; public const int MaximumListTokenLength = 8192; public const int MaximumStringLength = 2048; + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + /// /// Estimates the token count for a string. /// This is a rough estimate - use a library for exact calculation. @@ -26,6 +39,586 @@ public static int EstimateTokenCount(string text) return text.Length / 4; } + /// + /// Estimates the serialized JSON token size for a JsonNode. + /// + public static int EstimateSerializedJsonTokenSize(JsonNode node) + { + var json = node.ToJsonString(s_jsonSerializerOptions); + return EstimateTokenCount(json); + } + + /// + /// Converts OTLP resource logs to structured logs JSON for AI processing. + /// + /// The OTLP resource logs containing log records. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A tuple containing the JSON string and a limit message. + public static (string json, string limitMessage) GetStructuredLogsJson( + IList? resourceLogs, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var logRecords = GetLogRecordsFromOtlpData(resourceLogs); + var promptContext = new PromptContext(); + + var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( + logRecords, + StructuredLogsLimit, + "log entry", + "log entries", + i => GetLogEntryDto(i, promptContext, getResourceName, dashboardBaseUrl), + EstimateSerializedJsonTokenSize); + + var jsonArray = new JsonArray(trimmedItems.ToArray()); + var logsData = jsonArray.ToJsonString(s_jsonSerializerOptions); + + return (logsData, limitMessage); + } + + /// + /// Converts OTLP resource logs to a single structured log JSON for AI processing. + /// + /// The OTLP resource logs containing log records. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// The JSON string for the first log entry. + public static string GetStructuredLogJson( + IList? resourceLogs, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var logRecords = GetLogRecordsFromOtlpData(resourceLogs); + var logEntry = logRecords.FirstOrDefault() ?? throw new InvalidOperationException("No log entry found in OTLP data."); + var promptContext = new PromptContext(); + var dto = GetLogEntryDto(logEntry, promptContext, getResourceName, dashboardBaseUrl); + + return dto.ToJsonString(s_jsonSerializerOptions); + } + + /// + /// Converts OTLP resource spans to traces JSON for AI processing. + /// + /// The OTLP resource spans containing trace data. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A tuple containing the JSON string and a limit message. + public static (string json, string limitMessage) GetTracesJson( + IList? resourceSpans, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var traces = GetTracesFromOtlpData(resourceSpans); + var promptContext = new PromptContext(); + + var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( + traces, + TracesLimit, + "trace", + "traces", + t => GetTraceDto(t, promptContext, getResourceName, dashboardBaseUrl), + EstimateSerializedJsonTokenSize); + + var jsonArray = new JsonArray(trimmedItems.ToArray()); + var tracesData = jsonArray.ToJsonString(s_jsonSerializerOptions); + + return (tracesData, limitMessage); + } + + /// + /// Converts OTLP resource spans to a single trace JSON for AI processing. + /// + /// The OTLP resource spans containing trace data. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// The JSON string for the first trace. + public static string GetTraceJson( + IList? resourceSpans, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var traces = GetTracesFromOtlpData(resourceSpans); + var trace = traces.FirstOrDefault() ?? throw new InvalidOperationException("No trace found in OTLP data."); + var promptContext = new PromptContext(); + var dto = GetTraceDto(trace, promptContext, getResourceName, dashboardBaseUrl); + + return dto.ToJsonString(s_jsonSerializerOptions); + } + + /// + /// Extracts traces from OTLP resource spans, grouping spans by trace ID. + /// + public static List GetTracesFromOtlpData(IList? resourceSpans) + { + var spansByTraceId = new Dictionary>(StringComparer.Ordinal); + + if (resourceSpans is null) + { + return []; + } + + foreach (var resourceSpan in resourceSpans) + { + var resource = CreateResourceFromOtlpJson(resourceSpan.Resource); + + if (resourceSpan.ScopeSpans is null) + { + continue; + } + + foreach (var scopeSpan in resourceSpan.ScopeSpans) + { + var scopeName = scopeSpan.Scope?.Name; + + if (scopeSpan.Spans is null) + { + continue; + } + + foreach (var span in scopeSpan.Spans) + { + var traceId = span.TraceId ?? string.Empty; + if (!spansByTraceId.TryGetValue(traceId, out var spanList)) + { + spanList = []; + spansByTraceId[traceId] = spanList; + } + + spanList.Add(new OtlpSpanDto(span, resource, scopeName)); + } + } + } + + return spansByTraceId + .Select(kvp => new OtlpTraceDto(kvp.Key, kvp.Value)) + .ToList(); + } + + /// + /// Creates a JsonObject representing a trace for AI processing. + /// + /// The trace DTO to convert. + /// The prompt context for tracking duplicate values. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A JsonObject containing the trace data. + public static JsonObject GetTraceDto( + OtlpTraceDto trace, + PromptContext context, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var spanObjects = new List(); + foreach (var s in trace.Spans) + { + var span = s.Span; + var spanId = span.SpanId ?? string.Empty; + + var attributesObj = new JsonObject(); + if (span.Attributes is not null) + { + foreach (var attr in span.Attributes.Where(a => a.Key != OtlpHelpers.AspireDestinationNameAttribute)) + { + var attrValue = MapOtelAttributeValue(attr); + attributesObj[attr.Key!] = context.AddValue(attrValue, id => $@"Duplicate of attribute ""{id.Key}"" for span {OtlpHelpers.ToShortenedId(id.SpanId)}", (SpanId: spanId, attr.Key)); + } + } + + JsonArray? linksArray = null; + if (span.Links is { Length: > 0 }) + { + var linkObjects = span.Links.Select(link => (JsonNode)new JsonObject + { + ["trace_id"] = OtlpHelpers.ToShortenedId(link.TraceId ?? string.Empty), + ["span_id"] = OtlpHelpers.ToShortenedId(link.SpanId ?? string.Empty) + }).ToArray(); + linksArray = new JsonArray(linkObjects); + } + + var resourceName = getResourceName?.Invoke(s.Resource) ?? s.Resource.ResourceName; + var destination = GetAttributeStringValue(span.Attributes, OtlpHelpers.AspireDestinationNameAttribute); + var statusCode = span.Status?.Code; + var statusText = statusCode switch + { + 1 => "Ok", + 2 => "Error", + _ => null + }; + + var spanObj = new JsonObject + { + ["span_id"] = OtlpHelpers.ToShortenedId(spanId), + ["parent_span_id"] = span.ParentSpanId is { } id ? OtlpHelpers.ToShortenedId(id) : null, + ["kind"] = GetSpanKindName(span.Kind), + ["name"] = context.AddValue(span.Name, sId => $@"Duplicate of ""name"" for span {OtlpHelpers.ToShortenedId(sId)}", spanId), + ["status"] = statusText, + ["status_message"] = context.AddValue(span.Status?.Message, sId => $@"Duplicate of ""status_message"" for span {OtlpHelpers.ToShortenedId(sId)}", spanId), + ["source"] = resourceName, + ["destination"] = destination, + ["duration_ms"] = CalculateDurationMs(span.StartTimeUnixNano, span.EndTimeUnixNano), + ["attributes"] = attributesObj, + ["links"] = linksArray + }; + spanObjects.Add(spanObj); + } + + var spanArray = new JsonArray(spanObjects.ToArray()); + var traceId = OtlpHelpers.ToShortenedId(trace.TraceId); + var rootSpan = trace.Spans.FirstOrDefault(s => string.IsNullOrEmpty(s.Span.ParentSpanId)) ?? trace.Spans.FirstOrDefault(); + var hasError = trace.Spans.Any(s => s.Span.Status?.Code == 2); + var timestamp = rootSpan?.Span.StartTimeUnixNano is { } startNano + ? OtlpHelpers.UnixNanoSecondsToDateTime(startNano) + : (DateTime?)null; + + var traceData = new JsonObject + { + ["trace_id"] = traceId, + ["duration_ms"] = CalculateTraceDurationMs(trace.Spans), + ["title"] = rootSpan?.Span.Name, + ["spans"] = spanArray, + ["has_error"] = hasError, + ["timestamp"] = timestamp + }; + + if (dashboardBaseUrl is not null) + { + traceData["dashboard_link"] = GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.TraceDetailUrl(traceId), traceId); + } + + return traceData; + } + + private static string MapOtelAttributeValue(OtlpKeyValueJson attribute) + { + var key = attribute.Key; + var value = GetAttributeValue(attribute); + + switch (key) + { + case "http.response.status_code": + { + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) + { + return GetHttpStatusName(intValue); + } + goto default; + } + case "rpc.grpc.status_code": + { + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) + { + return GetGrpcStatusName(intValue); + } + goto default; + } + default: + return value; + } + } + + private static string GetHttpStatusName(int statusCode) + { + return statusCode switch + { + 200 => "200 OK", + 201 => "201 Created", + 204 => "204 No Content", + 301 => "301 Moved Permanently", + 302 => "302 Found", + 304 => "304 Not Modified", + 400 => "400 Bad Request", + 401 => "401 Unauthorized", + 403 => "403 Forbidden", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 408 => "408 Request Timeout", + 409 => "409 Conflict", + 422 => "422 Unprocessable Entity", + 429 => "429 Too Many Requests", + 500 => "500 Internal Server Error", + 501 => "501 Not Implemented", + 502 => "502 Bad Gateway", + 503 => "503 Service Unavailable", + 504 => "504 Gateway Timeout", + _ => statusCode.ToString(CultureInfo.InvariantCulture) + }; + } + + private static string GetGrpcStatusName(int statusCode) + { + return statusCode switch + { + 0 => "OK", + 1 => "CANCELLED", + 2 => "UNKNOWN", + 3 => "INVALID_ARGUMENT", + 4 => "DEADLINE_EXCEEDED", + 5 => "NOT_FOUND", + 6 => "ALREADY_EXISTS", + 7 => "PERMISSION_DENIED", + 8 => "RESOURCE_EXHAUSTED", + 9 => "FAILED_PRECONDITION", + 10 => "ABORTED", + 11 => "OUT_OF_RANGE", + 12 => "UNIMPLEMENTED", + 13 => "INTERNAL", + 14 => "UNAVAILABLE", + 15 => "DATA_LOSS", + 16 => "UNAUTHENTICATED", + _ => statusCode.ToString(CultureInfo.InvariantCulture) + }; + } + + private static string? GetSpanKindName(int? kind) + { + return kind switch + { + 1 => "Internal", + 2 => "Server", + 3 => "Client", + 4 => "Producer", + 5 => "Consumer", + _ => null + }; + } + + private static int? CalculateDurationMs(ulong? startTimeUnixNano, ulong? endTimeUnixNano) + { + if (startTimeUnixNano is null || endTimeUnixNano is null) + { + return null; + } + + var durationNano = endTimeUnixNano.Value - startTimeUnixNano.Value; + return (int)Math.Round(durationNano / 1_000_000.0, 0, MidpointRounding.AwayFromZero); + } + + private static int? CalculateTraceDurationMs(List spans) + { + if (spans.Count == 0) + { + return null; + } + + ulong? minStart = null; + ulong? maxEnd = null; + + foreach (var s in spans) + { + if (s.Span.StartTimeUnixNano is { } start) + { + minStart = minStart is null ? start : Math.Min(minStart.Value, start); + } + if (s.Span.EndTimeUnixNano is { } end) + { + maxEnd = maxEnd is null ? end : Math.Max(maxEnd.Value, end); + } + } + + return CalculateDurationMs(minStart, maxEnd); + } + + /// + /// Extracts log records from OTLP resource logs. + /// + public static List GetLogRecordsFromOtlpData(IList? resourceLogs) + { + var logRecords = new List(); + + if (resourceLogs is null) + { + return logRecords; + } + + foreach (var resourceLog in resourceLogs) + { + var resource = CreateResourceFromOtlpJson(resourceLog.Resource); + + if (resourceLog.ScopeLogs is null) + { + continue; + } + + foreach (var scopeLogs in resourceLog.ScopeLogs) + { + var scopeName = scopeLogs.Scope?.Name; + + if (scopeLogs.LogRecords is null) + { + continue; + } + + foreach (var logRecord in scopeLogs.LogRecords) + { + logRecords.Add(new OtlpLogEntryDto(logRecord, resource, scopeName)); + } + } + } + + return logRecords; + } + + /// + /// Gets the message from a log record. + /// + public static string? GetLogMessage(OtlpLogRecordJson logRecord) + { + return logRecord.Body?.StringValue; + } + + /// + /// Gets the attribute value as a string. + /// + public static string GetAttributeValue(OtlpKeyValueJson attribute) + { + if (attribute.Value is null) + { + return string.Empty; + } + + return attribute.Value.StringValue + ?? attribute.Value.IntValue?.ToString(CultureInfo.InvariantCulture) + ?? attribute.Value.DoubleValue?.ToString(CultureInfo.InvariantCulture) + ?? attribute.Value.BoolValue?.ToString(CultureInfo.InvariantCulture) + ?? string.Empty; + } + + /// + /// Gets the value of an attribute by key as a string, or null if not found. + /// + public static string? GetAttributeStringValue(OtlpKeyValueJson[]? attributes, string key) + { + if (attributes is null) + { + return null; + } + + foreach (var attr in attributes) + { + if (attr.Key == key) + { + var value = GetAttributeValue(attr); + return string.IsNullOrEmpty(value) ? null : value; + } + } + + return null; + } + + /// + /// Creates a SimpleOtlpResource from OTLP resource JSON. + /// + /// The OTLP resource JSON, or null. + /// A SimpleOtlpResource with the service name and instance ID extracted from attributes. + private static SimpleOtlpResource CreateResourceFromOtlpJson(OtlpResourceJson? resource) + { + var serviceName = GetAttributeStringValue(resource?.Attributes, "service.name"); + var serviceInstanceId = GetAttributeStringValue(resource?.Attributes, "service.instance.id"); + var resourceName = serviceName ?? "Unknown"; + return new SimpleOtlpResource(resourceName, serviceInstanceId); + } + + private const string ExceptionStackTraceField = "exception.stacktrace"; + private const string ExceptionMessageField = "exception.message"; + private const string ExceptionTypeField = "exception.type"; + + /// + /// Filters out exception-related attributes and internal Aspire attributes from the attributes list. + /// + public static IEnumerable GetFilteredAttributes(OtlpKeyValueJson[]? attributes) + { + if (attributes is null) + { + return []; + } + + return attributes.Where(a => a.Key is not (ExceptionStackTraceField or ExceptionMessageField or ExceptionTypeField or OtlpHelpers.AspireLogIdAttribute)); + } + + /// + /// Gets the exception text from a log entry's attributes. + /// + public static string? GetExceptionText(OtlpLogEntryDto logEntry) + { + var stackTrace = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionStackTraceField); + if (!string.IsNullOrEmpty(stackTrace)) + { + return stackTrace; + } + + var message = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionMessageField); + if (!string.IsNullOrEmpty(message)) + { + var type = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionTypeField); + if (!string.IsNullOrEmpty(type)) + { + return $"{type}: {message}"; + } + + return message; + } + + return null; + } + + /// + /// Creates a JsonObject representing a log entry for AI processing. + /// + /// The log entry to convert. + /// The prompt context for tracking duplicate values. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A JsonObject containing the log entry data. + public static JsonObject GetLogEntryDto( + OtlpLogEntryDto logEntry, + PromptContext context, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var exceptionText = GetExceptionText(logEntry); + var logIdString = GetAttributeStringValue(logEntry.LogRecord.Attributes, OtlpHelpers.AspireLogIdAttribute); + var logId = long.TryParse(logIdString, CultureInfo.InvariantCulture, out var parsedLogId) ? parsedLogId : (long?)null; + var resourceName = getResourceName?.Invoke(logEntry.Resource) ?? logEntry.Resource.ResourceName; + + var attributesObject = new JsonObject(); + foreach (var attr in GetFilteredAttributes(logEntry.LogRecord.Attributes)) + { + var attrValue = GetAttributeValue(attr); + attributesObject[attr.Key!] = context.AddValue(attrValue, id => $@"Duplicate of attribute ""{id.Key}"" for log entry {id.LogId}", (LogId: logId, attr.Key)); + } + + var message = GetLogMessage(logEntry.LogRecord) ?? string.Empty; + var log = new JsonObject + { + ["log_id"] = logId, + ["span_id"] = OtlpHelpers.ToShortenedId(logEntry.LogRecord.SpanId ?? string.Empty), + ["trace_id"] = OtlpHelpers.ToShortenedId(logEntry.LogRecord.TraceId ?? string.Empty), + ["message"] = context.AddValue(message, id => $@"Duplicate of ""message"" for log entry {id}", logId), + ["severity"] = logEntry.LogRecord.SeverityText ?? "Unknown", + ["resource_name"] = resourceName, + ["attributes"] = attributesObject, + ["exception"] = context.AddValue(exceptionText, id => $@"Duplicate of ""exception"" for log entry {id}", logId), + ["source"] = logEntry.ScopeName + }; + + if (dashboardBaseUrl is not null && logId is not null) + { + log["dashboard_link"] = GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.StructuredLogsUrl(logEntryId: logId), $"log_id: {logId}"); + } + + return log; + } + + public static JsonObject? GetDashboardLinkObject(string dashboardBaseUrl, string path, string text) + { + return new JsonObject + { + ["url"] = DashboardUrls.CombineUrl(dashboardBaseUrl, path), + ["text"] = text + }; + } + /// /// Serializes a log entry to a string, stripping timestamps and ANSI control sequences. /// @@ -81,13 +674,13 @@ public static string LimitLength(string value) /// /// Gets items from the end of a list with a summary message, applying count and token limits. /// - public static (List items, string message) GetLimitFromEndWithSummary( + public static (List items, string message) GetLimitFromEndWithSummary( List values, int limit, string itemName, string pluralItemName, - Func convertToDto, - Func estimateTokenSize) + Func convertToDto, + Func estimateTokenSize) { return GetLimitFromEndWithSummary(values, values.Count, limit, itemName, pluralItemName, convertToDto, estimateTokenSize); } @@ -95,14 +688,14 @@ public static (List items, string message) GetLimitFromEndWithSummary /// /// Gets items from the end of a list with a summary message, applying count and token limits. /// - public static (List items, string message) GetLimitFromEndWithSummary( + public static (List items, string message) GetLimitFromEndWithSummary( List values, int totalValues, int limit, string itemName, string pluralItemName, - Func convertToDto, - Func estimateTokenSize) + Func convertToDto, + Func estimateTokenSize) { Debug.Assert(totalValues >= values.Count, "Total values should be large or equal to the values passed into the method."); @@ -157,3 +750,26 @@ private static string ToQuantity(int count, string itemName, string pluralItemNa return string.Create(CultureInfo.InvariantCulture, $"{count} {name}"); } } + +/// +/// Represents a log entry extracted from OTLP JSON format for AI processing. +/// +/// The OTLP log record JSON data. +/// The resource information from the resource attributes. +/// The instrumentation scope name. +internal sealed record OtlpLogEntryDto(OtlpLogRecordJson LogRecord, IOtlpResource Resource, string? ScopeName); + +/// +/// Represents a trace (collection of spans with the same trace ID) extracted from OTLP JSON format. +/// +/// The trace ID. +/// The spans belonging to this trace. +internal sealed record OtlpTraceDto(string TraceId, List Spans); + +/// +/// Represents a span extracted from OTLP JSON format for AI processing. +/// +/// The OTLP span JSON data. +/// The resource information from the resource attributes. +/// The instrumentation scope name. +internal sealed record OtlpSpanDto(OtlpSpanJson Span, IOtlpResource Resource, string? ScopeName); diff --git a/src/Shared/Otlp/IOtlpResource.cs b/src/Shared/Otlp/IOtlpResource.cs new file mode 100644 index 00000000000..303afe54d56 --- /dev/null +++ b/src/Shared/Otlp/IOtlpResource.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Otlp.Model; + +/// +/// Interface for OTLP resource data. +/// Used by both Dashboard and CLI. +/// +public interface IOtlpResource +{ + /// + /// Gets the resource name (typically the service.name attribute). + /// + string ResourceName { get; } + + /// + /// Gets the instance ID (typically the service.instance.id attribute). + /// + string? InstanceId { get; } +} + +/// +/// Simple implementation of for cases where only the name and instance ID are needed. +/// +/// The resource name (typically the service.name attribute). +/// The instance ID (typically the service.instance.id attribute). +public sealed record SimpleOtlpResource(string ResourceName, string? InstanceId) : IOtlpResource; diff --git a/src/Shared/Otlp/OtlpHelpers.cs b/src/Shared/Otlp/OtlpHelpers.cs index 0f7803e1384..e9899f87cd0 100644 --- a/src/Shared/Otlp/OtlpHelpers.cs +++ b/src/Shared/Otlp/OtlpHelpers.cs @@ -1,6 +1,7 @@ // 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 System.Globalization; namespace Aspire.Dashboard.Otlp.Model; @@ -11,6 +12,16 @@ namespace Aspire.Dashboard.Otlp.Model; /// public static partial class OtlpHelpers { + /// + /// The attribute name for Aspire's log entry ID. + /// + public const string AspireLogIdAttribute = "aspire.log_id"; + + /// + /// The attribute name for the resolved destination name of a span. + /// + public const string AspireDestinationNameAttribute = "aspire.destination"; + /// /// The standard length for shortened trace/span IDs. /// @@ -88,4 +99,41 @@ public static string FormatNanoTimestamp(ulong? nanos) } return ""; } + + public static string GetResourceName(IOtlpResource resource, IReadOnlyList allResources) + { + var count = 0; + foreach (var item in allResources) + { + if (string.Equals(item.ResourceName, resource.ResourceName, StringComparisons.ResourceName)) + { + count++; + if (count >= 2) + { + var instanceId = resource.InstanceId; + + // Convert long GUID into a shorter, more human friendly format. + // Before: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + // After: aaaaaaaa + if (instanceId != null && Guid.TryParse(instanceId, out var guid)) + { + Span chars = stackalloc char[32]; + var result = guid.TryFormat(chars, charsWritten: out _, format: "N"); + Debug.Assert(result, "Guid.TryFormat not successful."); + + instanceId = chars.Slice(0, 8).ToString(); + } + + if (instanceId == null) + { + return item.ResourceName; + } + + return $"{item.ResourceName}-{instanceId}"; + } + } + } + + return resource.ResourceName; + } } diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs index 217d6c89759..6a10a091588 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs @@ -3,6 +3,7 @@ using System.Net; using Aspire.Cli.Mcp.Docs; +using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Mcp.Docs; @@ -377,55 +378,6 @@ protected override Task SendAsync( } } - private sealed class MockHttpMessageHandler : HttpMessageHandler - { - private readonly Func? _responseFactory; - private readonly HttpResponseMessage? _response; - private readonly Exception? _exception; - private readonly Action? _requestValidator; - - public bool RequestValidated { get; private set; } - - public MockHttpMessageHandler(HttpResponseMessage response, Action? requestValidator = null) - { - _response = response; - _requestValidator = requestValidator; - } - - public MockHttpMessageHandler(Func responseFactory) - { - _responseFactory = responseFactory; - } - - public MockHttpMessageHandler(Exception exception) - { - _exception = exception; - } - - protected override Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - if (_exception is not null) - { - throw _exception; - } - - if (_requestValidator is not null) - { - _requestValidator(request); - RequestValidated = true; - } - - if (_responseFactory is not null) - { - return Task.FromResult(_responseFactory(request)); - } - - return Task.FromResult(_response!); - } - } - private sealed class MockDocsCache : IDocsCache { private readonly Dictionary _content = []; diff --git a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs new file mode 100644 index 00000000000..0024b7849ba --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs @@ -0,0 +1,429 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Mcp; +using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListStructuredLogsToolTests +{ + private static readonly TestHttpClientFactory s_httpClientFactory = new(); + + [Fact] + public async Task ListStructuredLogsTool_ThrowsException_WhenNoAppHostRunning() + { + var tool = CreateTool(); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("No Aspire AppHost", exception.Message); + } + + [Fact] + public async Task ListStructuredLogsTool_ThrowsException_WhenDashboardApiNotAvailable() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = null + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = CreateTool(monitor); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("Dashboard is not available", exception.Message); + } + + [Fact] + public async Task ListStructuredLogsTool_ReturnsFormattedLogs_WhenApiReturnsData() + { + // Local function to create OtlpResourceLogsJson with service name and instance ID + static OtlpResourceLogsJson CreateResourceLogs(string serviceName, string? serviceInstanceId, params OtlpLogRecordJson[] logRecords) + { + var attributes = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } } + }; + if (serviceInstanceId is not null) + { + attributes.Add(new OtlpKeyValueJson { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = serviceInstanceId } }); + } + + return new OtlpResourceLogsJson + { + Resource = new OtlpResourceJson + { + Attributes = [.. attributes] + }, + ScopeLogs = + [ + new OtlpScopeLogsJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "Microsoft.Extensions.Logging" }, + LogRecords = logRecords + } + ] + }; + } + + // Arrange - Create mock HTTP handler with sample structured logs response + // Include aspire.log_id attribute to verify it's extracted to log_id field and filtered from attributes + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson + { + ResourceLogs = + [ + CreateResourceLogs("api-service", "instance-1", + new OtlpLogRecordJson + { + TimeUnixNano = 1706540400000000000, + SeverityNumber = 9, + SeverityText = "Information", + Body = new OtlpAnyValueJson { StringValue = "Application started successfully" }, + TraceId = "abc123", + SpanId = "def456", + Attributes = + [ + new OtlpKeyValueJson { Key = OtlpHelpers.AspireLogIdAttribute, Value = new OtlpAnyValueJson { StringValue = "42" } }, + new OtlpKeyValueJson { Key = "custom.attr", Value = new OtlpAnyValueJson { StringValue = "custom-value" } } + ] + }), + CreateResourceLogs("api-service", "instance-2", + new OtlpLogRecordJson + { + TimeUnixNano = 1706540401000000000, + SeverityNumber = 13, + SeverityText = "Warning", + Body = new OtlpAnyValueJson { StringValue = "Connection timeout warning" }, + TraceId = "abc123", + SpanId = "ghi789", + Attributes = + [ + new OtlpKeyValueJson { Key = OtlpHelpers.AspireLogIdAttribute, Value = new OtlpAnyValueJson { IntValue = 43 } } + ] + }), + CreateResourceLogs("worker-service", "instance-1", + new OtlpLogRecordJson + { + TimeUnixNano = 1706540402000000000, + SeverityNumber = 17, + SeverityText = "Error", + Body = new OtlpAnyValueJson { StringValue = "Worker failed to process message" }, + TraceId = "xyz789", + SpanId = "uvw123", + Attributes = + [ + new OtlpKeyValueJson { Key = OtlpHelpers.AspireLogIdAttribute, Value = new OtlpAnyValueJson { IntValue = 44 } } + ] + }) + ] + }, + TotalCount = 3, + ReturnedCount = 3 + }; + + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + // Create resources that match the OtlpResourceLogsJson entries + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "api-service", InstanceId = "instance-2", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "worker-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For logs endpoint, return the structured logs response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + // Use a dashboard URL with path and query string to test that only the base URL is used + var monitor = CreateMonitorWithDashboard(dashboardUrls: ["http://localhost:18888/login?t=authtoken123"]); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + + // Parse the JSON array from the response to verify log_id extraction and attribute filtering + var jsonStartIndex = textContent.Text.IndexOf('['); + var jsonEndIndex = textContent.Text.LastIndexOf(']') + 1; + var jsonText = textContent.Text[jsonStartIndex..jsonEndIndex]; + var logsArray = JsonNode.Parse(jsonText)?.AsArray(); + + Assert.NotNull(logsArray); + Assert.Equal(3, logsArray.Count); + + // Verify first log entry has correct resource_name, log_id extracted, and aspire.log_id not in attributes + var firstLog = logsArray[0]?.AsObject(); + Assert.NotNull(firstLog); + Assert.Equal("api-service-instance-1", firstLog["resource_name"]?.GetValue()); + Assert.Equal(42, firstLog["log_id"]?.GetValue()); + var firstLogAttributes = firstLog["attributes"]?.AsObject(); + Assert.NotNull(firstLogAttributes); + Assert.False(firstLogAttributes.ContainsKey(OtlpHelpers.AspireLogIdAttribute), "aspire.log_id should be filtered from attributes"); + Assert.True(firstLogAttributes.ContainsKey("custom.attr"), "custom.attr should be present in attributes"); + + // Verify dashboard_link is included for each log entry with correct URLs + var firstDashboardLink = firstLog["dashboard_link"]?.AsObject(); + Assert.NotNull(firstDashboardLink); + Assert.Equal("http://localhost:18888/structuredlogs?logEntryId=42", firstDashboardLink["url"]?.GetValue()); + Assert.Equal("log_id: 42", firstDashboardLink["text"]?.GetValue()); + + // Verify second log entry has correct resource_name (different instance), log_id extracted (from intValue) + var secondLog = logsArray[1]?.AsObject(); + Assert.NotNull(secondLog); + Assert.Equal("api-service-instance-2", secondLog["resource_name"]?.GetValue()); + Assert.Equal(43, secondLog["log_id"]?.GetValue()); + var secondLogAttributes = secondLog["attributes"]?.AsObject(); + Assert.NotNull(secondLogAttributes); + Assert.False(secondLogAttributes.ContainsKey(OtlpHelpers.AspireLogIdAttribute), "aspire.log_id should be filtered from attributes"); + + var secondDashboardLink = secondLog["dashboard_link"]?.AsObject(); + Assert.NotNull(secondDashboardLink); + Assert.Equal("http://localhost:18888/structuredlogs?logEntryId=43", secondDashboardLink["url"]?.GetValue()); + Assert.Equal("log_id: 43", secondDashboardLink["text"]?.GetValue()); + + // Verify third log entry has correct resource_name (no instance ID) + var thirdLog = logsArray[2]?.AsObject(); + Assert.NotNull(thirdLog); + Assert.Equal("worker-service", thirdLog["resource_name"]?.GetValue()); + Assert.Equal(44, thirdLog["log_id"]?.GetValue()); + + var thirdDashboardLink = thirdLog["dashboard_link"]?.AsObject(); + Assert.NotNull(thirdDashboardLink); + Assert.Equal("http://localhost:18888/structuredlogs?logEntryId=44", thirdDashboardLink["url"]?.GetValue()); + Assert.Equal("log_id: 44", thirdDashboardLink["text"]?.GetValue()); + } + + [Fact] + public async Task ListStructuredLogsTool_ReturnsEmptyLogs_WhenApiReturnsNoData() + { + // Arrange - Create mock HTTP handler with empty logs response + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceLogs = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For logs endpoint, return empty logs response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("STRUCTURED LOGS DATA", textContent.Text); + // Empty array should be returned + Assert.Contains("[]", textContent.Text); + } + + [Fact] + public async Task ListStructuredLogsTool_ReturnsResourceNotFound_WhenResourceDoesNotExist() + { + // Arrange - Create mock HTTP handler that returns resources that don't match the requested name + var resources = new ResourceInfoJson[] + { + new() { Name = "other-resource", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + var emptyLogsResponse = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceLogs = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var emptyLogsJson = JsonSerializer.Serialize(emptyLogsResponse, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Check if this is the resources lookup request + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For any other request, return empty logs response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(emptyLogsJson, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"non-existent-resource\"").RootElement + }; + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Resource 'non-existent-resource' not found", textContent.Text); + } + + [Fact] + public void ListStructuredLogsTool_HasCorrectName() + { + var tool = CreateTool(); + + Assert.Equal(KnownMcpTools.ListStructuredLogs, tool.Name); + } + + [Fact] + public void ListStructuredLogsTool_HasCorrectDescription() + { + var tool = CreateTool(); + + Assert.Equal("List structured logs for resources.", tool.Description); + } + + [Fact] + public void ListStructuredLogsTool_InputSchema_HasResourceNameProperty() + { + var tool = CreateTool(); + + var schema = tool.GetInputSchema(); + + Assert.Equal(JsonValueKind.Object, schema.ValueKind); + Assert.True(schema.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("resourceName", out var resourceName)); + Assert.True(resourceName.TryGetProperty("type", out var type)); + Assert.Equal("string", type.GetString()); + } + + [Fact] + public void ListStructuredLogsTool_InputSchema_ResourceNameIsOptional() + { + var tool = CreateTool(); + + var schema = tool.GetInputSchema(); + + // Check that there's no "required" array or it doesn't include resourceName + if (schema.TryGetProperty("required", out var required)) + { + var requiredArray = required.EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.DoesNotContain("resourceName", requiredArray); + } + // If no required property, that's also fine - means nothing is required + } + + /// + /// Creates a ListStructuredLogsTool instance for testing with optional custom dependencies. + /// + private static ListStructuredLogsTool CreateTool( + TestAuxiliaryBackchannelMonitor? monitor = null, + IHttpClientFactory? httpClientFactory = null) + { + return new ListStructuredLogsTool( + monitor ?? new TestAuxiliaryBackchannelMonitor(), + httpClientFactory ?? s_httpClientFactory, + NullLogger.Instance); + } + + /// + /// Creates a TestAuxiliaryBackchannelMonitor with a connection configured with dashboard info. + /// + private static TestAuxiliaryBackchannelMonitor CreateMonitorWithDashboard( + string apiBaseUrl = "http://localhost:5000", + string apiToken = "test-token", + string[]? dashboardUrls = null) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = apiBaseUrl, + ApiToken = apiToken, + DashboardUrls = dashboardUrls ?? ["http://localhost:18888"] + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + return monitor; + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs new file mode 100644 index 00000000000..5a6d104297c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs @@ -0,0 +1,468 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListTracesToolTests +{ + private static readonly TestHttpClientFactory s_httpClientFactory = new(); + + [Fact] + public async Task ListTracesTool_ReturnsFormattedTraces_WhenApiReturnsData() + { + // Local function to create OtlpResourceSpansJson with service name and instance ID + static OtlpResourceSpansJson CreateResourceSpans(string serviceName, string? serviceInstanceId, params OtlpSpanJson[] spans) + { + var attributes = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } } + }; + if (serviceInstanceId is not null) + { + attributes.Add(new OtlpKeyValueJson { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = serviceInstanceId } }); + } + + return new OtlpResourceSpansJson + { + Resource = new OtlpResourceJson + { + Attributes = [.. attributes] + }, + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "OpenTelemetry" }, + Spans = spans + } + ] + }; + } + + // Arrange - Create mock HTTP handler with sample traces response + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson + { + ResourceSpans = + [ + CreateResourceSpans("api-service", "instance-1", + new OtlpSpanJson + { + TraceId = "abc123def456789012345678901234567890", + SpanId = "span123456789012", + Name = "GET /api/products", + Kind = 2, // Server + StartTimeUnixNano = 1706540400000000000, + EndTimeUnixNano = 1706540400100000000, + Status = new OtlpSpanStatusJson { Code = 1 }, // Ok + Attributes = + [ + new OtlpKeyValueJson { Key = "http.method", Value = new OtlpAnyValueJson { StringValue = "GET" } }, + new OtlpKeyValueJson { Key = "http.url", Value = new OtlpAnyValueJson { StringValue = "/api/products" } } + ] + }), + CreateResourceSpans("api-service", "instance-2", + new OtlpSpanJson + { + TraceId = "abc123def456789012345678901234567890", + SpanId = "span234567890123", + ParentSpanId = "span123456789012", + Name = "GET /api/catalog", + Kind = 3, // Client + StartTimeUnixNano = 1706540400010000000, + EndTimeUnixNano = 1706540400090000000, + Status = new OtlpSpanStatusJson { Code = 1 }, + Attributes = + [ + new OtlpKeyValueJson { Key = "aspire.destination", Value = new OtlpAnyValueJson { StringValue = "catalog-service" } } + ] + }), + CreateResourceSpans("worker-service", "instance-1", + new OtlpSpanJson + { + TraceId = "xyz789abc123456789012345678901234567890", + SpanId = "span345678901234", + Name = "ProcessMessage", + Kind = 1, // Internal + StartTimeUnixNano = 1706540401000000000, + EndTimeUnixNano = 1706540401500000000, + Status = new OtlpSpanStatusJson { Code = 2 }, // Error + Attributes = + [ + new OtlpKeyValueJson { Key = "error", Value = new OtlpAnyValueJson { StringValue = "Processing failed" } } + ] + }) + ] + }, + TotalCount = 3, + ReturnedCount = 3 + }; + + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + // Create resources that match the OtlpResourceSpansJson entries + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "api-service", InstanceId = "instance-2", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "worker-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For traces endpoint, return the traces response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + // Use a dashboard URL with path and query string to test that only the base URL is used + var monitor = CreateMonitorWithDashboard(dashboardUrls: ["http://localhost:18888/login?t=authtoken123"]); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + + // Parse the JSON array from the response + var jsonStartIndex = textContent.Text.IndexOf('['); + var jsonEndIndex = textContent.Text.LastIndexOf(']') + 1; + var jsonText = textContent.Text[jsonStartIndex..jsonEndIndex]; + var tracesArray = JsonNode.Parse(jsonText)?.AsArray(); + + Assert.NotNull(tracesArray); + // Should have 2 traces (grouped by trace_id) + Assert.Equal(2, tracesArray.Count); + + // Verify first trace (trace_id is shortened to 7 characters) + var firstTrace = tracesArray[0]?.AsObject(); + Assert.NotNull(firstTrace); + Assert.Equal("abc123d", firstTrace["trace_id"]?.GetValue()); + + // Verify spans in first trace have correct source and destination + var spans = firstTrace["spans"]?.AsArray(); + Assert.NotNull(spans); + Assert.Equal(2, spans.Count); + + // Verify dashboard_link is included for each trace with correct URLs (trace_id is shortened to 7 chars in the URL) + var firstDashboardLink = firstTrace["dashboard_link"]?.AsObject(); + Assert.NotNull(firstDashboardLink); + Assert.Equal("http://localhost:18888/traces/detail/abc123d", firstDashboardLink["url"]?.GetValue()); + Assert.Equal("abc123d", firstDashboardLink["text"]?.GetValue()); + + // First span (server) should have source from resource name, no destination + var serverSpan = spans.FirstOrDefault(s => s?["kind"]?.GetValue() == "Server")?.AsObject(); + Assert.NotNull(serverSpan); + Assert.Equal("api-service-instance-1", serverSpan["source"]?.GetValue()); + Assert.Null(serverSpan["destination"]); + + // Second span (client) should have source from resource name and destination from aspire.destination + var clientSpan = spans.FirstOrDefault(s => s?["kind"]?.GetValue() == "Client")?.AsObject(); + Assert.NotNull(clientSpan); + Assert.Equal("api-service-instance-2", clientSpan["source"]?.GetValue()); + Assert.Equal("catalog-service", clientSpan["destination"]?.GetValue()); + + // Verify second trace + var secondTrace = tracesArray[1]?.AsObject(); + Assert.NotNull(secondTrace); + Assert.Equal("xyz789a", secondTrace["trace_id"]?.GetValue()); + + var secondDashboardLink = secondTrace["dashboard_link"]?.AsObject(); + Assert.NotNull(secondDashboardLink); + Assert.Equal("http://localhost:18888/traces/detail/xyz789a", secondDashboardLink["url"]?.GetValue()); + Assert.Equal("xyz789a", secondDashboardLink["text"]?.GetValue()); + + // Verify spans in second trace have correct source and destination + var secondTraceSpans = secondTrace["spans"]?.AsArray(); + Assert.NotNull(secondTraceSpans); + Assert.Single(secondTraceSpans); + + // Internal span should have source from resource name (worker-service has no instance ID), no destination + var internalSpan = secondTraceSpans[0]?.AsObject(); + Assert.NotNull(internalSpan); + Assert.Equal("Internal", internalSpan["kind"]?.GetValue()); + Assert.Equal("worker-service", internalSpan["source"]?.GetValue()); + Assert.Null(internalSpan["destination"]); + } + + [Fact] + public async Task ListTracesTool_ReturnsEmptyTraces_WhenApiReturnsNoData() + { + // Arrange - Create mock HTTP handler with empty traces response + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For traces endpoint, return empty traces response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("TRACES DATA", textContent.Text); + // Empty array should be returned + Assert.Contains("[]", textContent.Text); + } + + [Fact] + public async Task ListTracesTool_ReturnsResourceNotFound_WhenResourceDoesNotExist() + { + // Arrange - Create mock HTTP handler that returns resources that don't match the requested name + var resources = new ResourceInfoJson[] + { + new() { Name = "other-resource", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + var emptyTracesResponse = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var emptyTracesJson = JsonSerializer.Serialize(emptyTracesResponse, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Check if this is the resources lookup request + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For any other request, return empty traces response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(emptyTracesJson, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"non-existent-resource\"").RootElement + }; + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Resource 'non-existent-resource' not found", textContent.Text); + } + + [Fact] + public async Task ListTracesTool_FiltersTracesByResource_WhenResourceNameProvided() + { + // Arrange - Create mock HTTP handler with traces from multiple resources + static OtlpResourceSpansJson CreateResourceSpans(string serviceName, string? serviceInstanceId, params OtlpSpanJson[] spans) + { + var attributes = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } } + }; + if (serviceInstanceId is not null) + { + attributes.Add(new OtlpKeyValueJson { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = serviceInstanceId } }); + } + + return new OtlpResourceSpansJson + { + Resource = new OtlpResourceJson + { + Attributes = [.. attributes] + }, + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "OpenTelemetry" }, + Spans = spans + } + ] + }; + } + + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson + { + ResourceSpans = + [ + CreateResourceSpans("api-service", null, + new OtlpSpanJson + { + TraceId = "trace123", + SpanId = "span123", + Name = "GET /api/products", + Kind = 2, + StartTimeUnixNano = 1706540400000000000, + EndTimeUnixNano = 1706540400100000000, + Status = new OtlpSpanStatusJson { Code = 1 } + }) + ] + }, + TotalCount = 1, + ReturnedCount = 1 + }; + + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "worker-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + string? capturedUrl = null; + using var mockHandler = new MockHttpMessageHandler(request => + { + // Capture the URL for assertions + capturedUrl = request.RequestUri?.ToString(); + + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement + }; + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(capturedUrl); + // Verify the URL contains the resource name filter + Assert.Contains("api-service", capturedUrl); + } + + /// + /// Creates a ListTracesTool instance for testing with optional custom dependencies. + /// + private static ListTracesTool CreateTool( + TestAuxiliaryBackchannelMonitor? monitor = null, + IHttpClientFactory? httpClientFactory = null) + { + return new ListTracesTool( + monitor ?? new TestAuxiliaryBackchannelMonitor(), + httpClientFactory ?? s_httpClientFactory, + NullLogger.Instance); + } + + /// + /// Creates a TestAuxiliaryBackchannelMonitor with a connection configured with dashboard info. + /// + private static TestAuxiliaryBackchannelMonitor CreateMonitorWithDashboard( + string apiBaseUrl = "http://localhost:5000", + string apiToken = "test-token", + string[]? dashboardUrls = null) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = apiBaseUrl, + ApiToken = apiToken, + DashboardUrls = dashboardUrls ?? ["http://localhost:18888"] + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + return monitor; + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs new file mode 100644 index 00000000000..0480f5d1a21 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs @@ -0,0 +1,24 @@ +// 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.Mcp.Tools; + +namespace Aspire.Cli.Tests.Mcp; + +public class McpToolHelpersTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("http://localhost:18888", "http://localhost:18888")] + [InlineData("http://localhost:18888/", "http://localhost:18888")] + [InlineData("http://localhost:18888/login", "http://localhost:18888")] + [InlineData("http://localhost:18888/login?t=authtoken123", "http://localhost:18888")] + [InlineData("https://localhost:16319/login?t=d8d8255df4c79aebcb5b7325828ccb20", "https://localhost:16319")] + [InlineData("https://example.com:8080/path/to/resource?param=value", "https://example.com:8080")] + [InlineData("invalid-url", "invalid-url")] // Falls back to returning the original string + public void GetBaseUrl_ExtractsBaseUrl_RemovingPathAndQueryString(string? input, string? expected) + { + var result = McpToolHelpers.GetBaseUrl(input); + Assert.Equal(expected, result); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/MockHttpClientFactory.cs b/tests/Aspire.Cli.Tests/Utils/MockHttpClientFactory.cs new file mode 100644 index 00000000000..6e0274398f1 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/MockHttpClientFactory.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.Utils; + +/// +/// Mock HTTP client factory that creates clients using the specified handler. +/// Useful for testing code that depends on IHttpClientFactory. +/// +internal sealed class MockHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory +{ + /// + /// Creates an HTTP client using the configured handler. + /// + /// The name of the client (ignored). + /// An HTTP client configured with the mock handler. + public HttpClient CreateClient(string name) + { + return new HttpClient(handler, disposeHandler: false); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/MockHttpMessageHandler.cs b/tests/Aspire.Cli.Tests/Utils/MockHttpMessageHandler.cs new file mode 100644 index 00000000000..44cc4eae0a2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/MockHttpMessageHandler.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.Utils; + +/// +/// Mock HTTP message handler for testing HTTP client interactions. +/// Supports returning fixed responses, dynamic responses via factory, throwing exceptions, and request validation. +/// +internal sealed class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly Func? _responseFactory; + private readonly HttpResponseMessage? _response; + private readonly Exception? _exception; + private readonly Action? _requestValidator; + + /// + /// Gets a value indicating whether the request validator was invoked. + /// + public bool RequestValidated { get; private set; } + + /// + /// Creates a handler that returns the specified response. + /// + /// The HTTP response to return. + /// Optional action to validate the request. + public MockHttpMessageHandler(HttpResponseMessage response, Action? requestValidator = null) + { + _response = response; + _requestValidator = requestValidator; + } + + /// + /// Creates a handler that generates responses dynamically using the provided factory. + /// + /// A function that creates responses based on the request. + public MockHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory; + } + + /// + /// Creates a handler that throws the specified exception. + /// + /// The exception to throw. + public MockHttpMessageHandler(Exception exception) + { + _exception = exception; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (_exception is not null) + { + throw _exception; + } + + if (_requestValidator is not null) + { + _requestValidator(request); + RequestValidated = true; + } + + if (_responseFactory is not null) + { + return Task.FromResult(_responseFactory(request)); + } + + return Task.FromResult(_response!); + } +} diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs index 438e150d0ea..521b4b5d4bd 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs @@ -187,10 +187,10 @@ public void GetDashboardUrl_PublicUrl() Assert.True(options.Frontend.TryParseOptions(out _)); // Act - var url = AIHelpers.GetDashboardUrl(options, "/path"); + var url = AIHelpers.GetDashboardUrl(options); // Assert - Assert.Equal("https://localhost:1234/path", url); + Assert.Equal("https://localhost:1234", url); } [Fact] @@ -202,10 +202,10 @@ public void GetDashboardUrl_HttpsAndHttpEndpointUrls() Assert.True(options.Frontend.TryParseOptions(out _)); // Act - var url = AIHelpers.GetDashboardUrl(options, "/path"); + var url = AIHelpers.GetDashboardUrl(options); // Assert - Assert.Equal("https://localhost:1234/path", url); + Assert.Equal("https://localhost:1234", url); } [Fact] @@ -217,9 +217,9 @@ public void GetDashboardUrl_HttpEndpointUrl() Assert.True(options.Frontend.TryParseOptions(out _)); // Act - var url = AIHelpers.GetDashboardUrl(options, "/path"); + var url = AIHelpers.GetDashboardUrl(options); // Assert - Assert.Equal("http://localhost:5000/path", url); + Assert.Equal("http://localhost:5000", url); } } diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs index 2220e964a75..bf57c341dac 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs @@ -128,12 +128,21 @@ public async Task GetConsoleLogs_ExceedTokenLimit_ReturnMostRecentItems() internal static AssistantChatDataContext CreateAssistantChatDataContext(TelemetryRepository? telemetryRepository = null, IDashboardClient? dashboardClient = null) { + var dashboardOptions = new DashboardOptions + { + Frontend = new FrontendOptions + { + EndpointUrls = "http://localhost:5000" + } + }; + Assert.True(dashboardOptions.Frontend.TryParseOptions(out _)); + var context = new AssistantChatDataContext( telemetryRepository ?? CreateRepository(), dashboardClient ?? new MockDashboardClient(), [], new TestStringLocalizer(), - new TestOptionsMonitor(new DashboardOptions())); + new TestOptionsMonitor(dashboardOptions)); return context; } diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index e3f7c282a81..e9220633c4f 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -1,6 +1,7 @@ // 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.IO.Compression; using System.Text.Json; using Aspire.Dashboard.Model; @@ -9,6 +10,7 @@ using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Shared; +using Aspire.Dashboard.Tests.TelemetryRepositoryTests; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; @@ -90,6 +92,44 @@ public void ConvertLogsToOtlpJson_SingleLog_ReturnsCorrectStructure() Assert.Contains(logRecord.Attributes, a => a.Key == "custom.attr" && a.Value?.StringValue == "custom-value"); } + [Fact] + public void ConvertLogsToOtlpJson_AddsAspireLogIdAttribute() + { + // Arrange + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddLogs(addContext, new RepeatedField() + { + new ResourceLogs + { + Resource = CreateResource(name: "TestService", instanceId: "instance-1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { CreateLogRecord(time: s_testTime, message: "Test log message") } + } + } + } + }); + + var resources = repository.GetResources(); + var resource = resources[0]; + var logs = repository.GetLogs(GetLogsContext.ForResourceKey(resource.ResourceKey)); + + // Act + var result = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + + // Assert + var logRecord = result.ResourceLogs![0].ScopeLogs![0].LogRecords![0]; + Assert.NotNull(logRecord.Attributes); + + // Verify aspire.log_id attribute is added with the InternalId value + var logIdAttribute = Assert.Single(logRecord.Attributes, a => a.Key == OtlpHelpers.AspireLogIdAttribute); + Assert.Equal(logs.Items[0].InternalId.ToString(CultureInfo.InvariantCulture), logIdAttribute.Value?.StringValue); + } + [Fact] public void ConvertLogsToOtlpJson_MultipleLogs_GroupsByScope() { @@ -252,7 +292,7 @@ public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() }); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); // Assert Assert.NotNull(result.ResourceSpans); @@ -318,7 +358,7 @@ public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); // Assert var spans = result.ResourceSpans![0].ScopeSpans![0].Spans!; @@ -330,6 +370,98 @@ public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() Assert.NotNull(childSpan.ParentSpanId); } + [Fact] + public void ConvertTracesToOtlpJson_WithPeerResolvers_AddsDestinationNameAttribute() + { + // Arrange + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "trace123456789012", + spanId: "span1234", + startTime: s_testTime, + endTime: s_testTime.AddSeconds(5), + attributes: [new KeyValuePair("peer.service", "target-service")]) + } + } + } + } + }); + + var resources = repository.GetResources(); + var resource = resources[0]; + var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); + + var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => + { + var peerService = attributes.FirstOrDefault(a => a.Key == "peer.service"); + return (peerService.Value, null); + }); + + // Act + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, [outgoingPeerResolver]); + + // Assert + var span = result.ResourceSpans![0].ScopeSpans![0].Spans![0]; + Assert.NotNull(span.Attributes); + Assert.Contains(span.Attributes, a => a.Key == OtlpHelpers.AspireDestinationNameAttribute && a.Value?.StringValue == "target-service"); + } + + [Fact] + public void ConvertTracesToOtlpJson_WithoutPeerResolvers_DoesNotAddDestinationNameAttribute() + { + // Arrange + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "trace123456789012", + spanId: "span1234", + startTime: s_testTime, + endTime: s_testTime.AddSeconds(5), + attributes: [new KeyValuePair("peer.service", "target-service")]) + } + } + } + } + }); + + var resources = repository.GetResources(); + var resource = resources[0]; + var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); + + // Act + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); + + // Assert + var span = result.ResourceSpans![0].ScopeSpans![0].Spans![0]; + Assert.NotNull(span.Attributes); + Assert.DoesNotContain(span.Attributes, a => a.Key == OtlpHelpers.AspireDestinationNameAttribute); + } + [Fact] public void ConvertMetricsToOtlpJson_SingleInstrument_ReturnsCorrectStructure() { @@ -758,7 +890,7 @@ public void ConvertSpanToJson_ReturnsValidOtlpTelemetryDataJson() var span = repository.GetTraces(GetTracesRequest.ForResourceKey(repository.GetResources()[0].ResourceKey)).PagedResult.Items[0].Spans[0]; // Act - var json = TelemetryExportService.ConvertSpanToJson(span); + var json = TelemetryExportService.ConvertSpanToJson(span, []); // Assert - deserialize back to verify OtlpTelemetryDataJson structure var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -811,7 +943,7 @@ public void ConvertSpanToJson_WithLogs_IncludesLogsInOutput() var logs = repository.GetLogs(GetLogsContext.ForResourceKey(repository.GetResources()[0].ResourceKey)).Items; // Act - var json = TelemetryExportService.ConvertSpanToJson(span, logs); + var json = TelemetryExportService.ConvertSpanToJson(span, [], logs); // Assert - verify both spans and logs are in the output var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -871,7 +1003,7 @@ public void ConvertTraceToJson_WithLogs_IncludesLogsInOutput() var logs = repository.GetLogs(GetLogsContext.ForResourceKey(repository.GetResources()[0].ResourceKey)).Items; // Act - var json = TelemetryExportService.ConvertTraceToJson(trace, logs); + var json = TelemetryExportService.ConvertTraceToJson(trace, [], logs); // Assert - verify both spans and logs are in the output var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -911,7 +1043,7 @@ public void ConvertTraceToJson_ReturnsValidOtlpTelemetryDataJson() var trace = repository.GetTraces(GetTracesRequest.ForResourceKey(repository.GetResources()[0].ResourceKey)).PagedResult.Items[0]; // Act - var json = TelemetryExportService.ConvertTraceToJson(trace); + var json = TelemetryExportService.ConvertTraceToJson(trace, []); // Assert - deserialize back to verify OtlpTelemetryDataJson structure var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -965,7 +1097,7 @@ private static async Task CreateExportServiceAsync(Telem var consoleLogsManager = new ConsoleLogsManager(sessionStorage); await consoleLogsManager.EnsureInitializedAsync(); var consoleLogsFetcher = new ConsoleLogsFetcher(dashboardClient, consoleLogsManager); - return new TelemetryExportService(repository, consoleLogsFetcher, dashboardClient); + return new TelemetryExportService(repository, consoleLogsFetcher, dashboardClient, Array.Empty()); } private static Dictionary> BuildAllResourcesSelection(TelemetryRepository repository) diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index eb26ae7ebdb..5059a958c36 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs @@ -1,6 +1,7 @@ // 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.IO.Compression; using System.Text; using System.Text.Json; @@ -313,6 +314,67 @@ public async Task ImportAsync_RoundTrip_LogsExportAndImport_PreservesData() Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Warning, importedLogs.Items[0].Severity); } + [Fact] + public async Task ImportAsync_RoundTrip_LogsWithAspireLogId_FiltersOutAttribute() + { + // Arrange - Export logs (which adds aspire.log_id attribute) + var sourceRepository = CreateRepository(); + var addContext = new AddContext(); + + sourceRepository.AddLogs(addContext, new RepeatedField() + { + new ResourceLogs + { + Resource = CreateResource(name: "TestService", instanceId: "test-1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { CreateLogRecord(time: s_testTime, message: "Test message", attributes: [new KeyValuePair("custom.attr", "custom-value")]) } + } + } + } + }); + + var resources = sourceRepository.GetResources(); + var logs = sourceRepository.GetLogs(GetLogsContext.ForResourceKey(resources[0].ResourceKey)); + var originalInternalId = logs.Items[0].InternalId; + + // Export adds aspire.log_id attribute + var exportedJson = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + + // Verify aspire.log_id was added during export + var exportedLogRecord = exportedJson.ResourceLogs![0].ScopeLogs![0].LogRecords![0]; + Assert.Contains(exportedLogRecord.Attributes!, a => a.Key == OtlpHelpers.AspireLogIdAttribute && a.Value?.StringValue == originalInternalId.ToString(CultureInfo.InvariantCulture)); + + var jsonString = JsonSerializer.Serialize(exportedJson, OtlpJsonSerializerContext.DefaultOptions); + + // Import + var targetRepository = CreateRepository(); + var importService = CreateImportService(targetRepository); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonString)); + + // Act + await importService.ImportAsync("logs.json", stream, CancellationToken.None); + + // Assert + var importedResources = targetRepository.GetResources(); + Assert.Single(importedResources); + + var importedLogs = targetRepository.GetLogs(GetLogsContext.ForResourceKey(importedResources[0].ResourceKey)); + Assert.Single(importedLogs.Items); + + // Verify aspire.log_id is NOT in the imported log's attributes (it should be filtered out) + Assert.DoesNotContain(importedLogs.Items[0].Attributes, a => a.Key == OtlpHelpers.AspireLogIdAttribute); + + // Verify the imported log gets a new InternalId (not the original one) + Assert.NotEqual(originalInternalId, importedLogs.Items[0].InternalId); + + // Verify other attributes are preserved + Assert.Contains(importedLogs.Items[0].Attributes, a => a.Key == "custom.attr" && a.Value == "custom-value"); + } + [Fact] public async Task ImportAsync_RoundTrip_TracesExportAndImport_PreservesData() { @@ -340,7 +402,7 @@ public async Task ImportAsync_RoundTrip_TracesExportAndImport_PreservesData() var traces = sourceRepository.GetTraces(GetTracesRequest.ForResourceKey(resources[0].ResourceKey)); // Export - var exportedJson = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); + var exportedJson = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); var jsonString = JsonSerializer.Serialize(exportedJson, OtlpJsonSerializerContext.DefaultOptions); // Import diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 69e505bb5ce..08be66566a8 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Api; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Trace.V1; @@ -44,7 +46,7 @@ public async Task FollowSpansAsync_StreamsAllSpans() }); } - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Act - stream spans @@ -91,7 +93,7 @@ public async Task FollowLogsAsync_StreamsAllLogs() }); } - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Act - stream logs @@ -136,7 +138,7 @@ public void GetSpans_HasErrorFalse_ExcludesErrorSpans() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get spans with hasError=false var result = service.GetSpans(resourceNames: null, traceId: null, hasError: false, limit: null); @@ -178,7 +180,7 @@ public void GetSpans_HasErrorTrue_OnlyReturnsErrorSpans() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get spans with hasError=true var result = service.GetSpans(resourceNames: null, traceId: null, hasError: true, limit: null); @@ -237,7 +239,7 @@ public void GetTraces_HasErrorFalse_ExcludesTracesWithErrors() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get traces with hasError=false (no error, should exclude the error trace) var result = service.GetTraces(resourceNames: null, hasError: false, limit: null); @@ -297,7 +299,7 @@ public void GetTraces_HasErrorTrue_OnlyReturnsTracesWithErrors() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get traces with hasError=true (error only) var result = service.GetTraces(resourceNames: null, hasError: true, limit: null); @@ -338,7 +340,7 @@ public async Task FollowSpansAsync_WithInvalidResourceName_ReturnsNoSpans() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); // Act - stream spans for a non-existent resource @@ -385,7 +387,7 @@ public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); // Act - stream logs for a non-existent resource @@ -405,4 +407,16 @@ public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() // Assert - should receive NO items because the resource doesn't exist Assert.Empty(receivedItems); } + + /// + /// Creates a TelemetryApiService instance for testing with optional custom dependencies. + /// + private static TelemetryApiService CreateService( + TelemetryRepository? repository = null, + IOutgoingPeerResolver[]? peerResolvers = null) + { + return new TelemetryApiService( + repository ?? CreateRepository(), + peerResolvers ?? []); + } } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs index ec80ac7b521..8d01fd21581 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs @@ -138,8 +138,8 @@ public void GetResourceName_GuidInstanceId_Shorten() // Act var resources = repository.GetResources(); - var instance1Name = OtlpResource.GetResourceName(resources[0], resources); - var instance2Name = OtlpResource.GetResourceName(resources[1], resources); + var instance1Name = OtlpHelpers.GetResourceName(resources[0], resources); + var instance2Name = OtlpHelpers.GetResourceName(resources[1], resources); // Assert Assert.Equal("app1-19572b19", instance1Name); diff --git a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs index 65affd93b20..bc7a802c394 100644 --- a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs @@ -538,8 +538,6 @@ public async Task HttpsCertificateExecutionConfigurationGatherer_WithCallback_Ex #endregion - #region Helper Methods - private static X509Certificate2 CreateTestCertificate() { using var rsa = RSA.Create(2048); @@ -589,6 +587,4 @@ public TestDeveloperCertificateService(X509Certificate2? certificate = null) public bool UseForHttps => true; } - - #endregion }