diff --git a/.vscode/cspell.json b/.vscode/cspell.json index ab3cee428c..dc66c4adb2 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -268,9 +268,9 @@ "words": [ "1espt", "aarch", - "accesspolicy", "acaenvironment", "activitylog", + "accesspolicy", "adminprovider", "agentic", "aieval", @@ -280,11 +280,12 @@ "alcoop", "amlfs", "aoai", - "apos", "apim", + "apos", "appconfig", "applens", "appservice", + "apptrace", "aspnetcore", "australiacentral", "australiaeast", @@ -387,6 +388,7 @@ "datasources", "dataverse", "dbforpostgresql", + "dcount", "deallocate", "debugtelemetry", "deregistering", @@ -435,6 +437,8 @@ "hostpools", "idempotency", "idtyp", + "ifexists", + "IKQL", "indonesiacentral", "infile", "intelli", @@ -453,6 +457,7 @@ "kusto", "kvps", "lakehouse", + "leftouter", "liftr", "ligar", "linkedservices", @@ -500,8 +505,8 @@ "northeurope", "norwayeast", "norwaywest", - "npmjs", "npgsql", + "npmjs", "nupkg", "nuxt", "occured", @@ -518,8 +523,8 @@ "paygo", "persistable", "pgrep", - "piechart", "pids", + "piechart", "polandcentral", "portalsettings", "predeploy", @@ -538,6 +543,7 @@ "resourcehealth", "RESTAPI", "rhvm", + "rightouter", "rulesets", "runtimes", "searchdocs", @@ -581,8 +587,11 @@ "tfvars", "timechart", "timespan", + "todouble", + "toint", "toolset", "toolsets", + "toscalar", "typespec", "uaenorth", "uksouth", diff --git a/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs index f72ff516c8..56e31cade0 100644 --- a/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs +++ b/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs @@ -106,6 +106,9 @@ public static bool TryGetValue(this CommandResult commandResult, string optio return TryGetValue(commandResult, option, out value); } + /// + /// Gets the value of the specified option, returning default if not found or not set. + /// public static T? GetValueOrDefault(this CommandResult commandResult, Option option) { ArgumentNullException.ThrowIfNull(commandResult); @@ -140,6 +143,9 @@ public static bool TryGetValue(this CommandResult commandResult, string optio return optionResult.GetValueOrDefault(); } + /// + /// Gets the value of the option with the first matched option name, returning default if not found or not set. + /// public static T? GetValueOrDefault(this CommandResult commandResult, string optionName) { // Find the option by name in the command diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/IResourceResolverService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Resource/IResourceResolverService.cs similarity index 95% rename from tools/Azure.Mcp.Tools.Monitor/src/Services/IResourceResolverService.cs rename to core/Azure.Mcp.Core/src/Services/Azure/Resource/IResourceResolverService.cs index 5d4e31fd83..ebd32710af 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/IResourceResolverService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Resource/IResourceResolverService.cs @@ -4,7 +4,7 @@ using Azure.Core; using Azure.Mcp.Core.Options; -namespace Azure.Mcp.Tools.Monitor.Services; +namespace Azure.Mcp.Core.Services.Azure.Resource; /// /// Service interface for resolving Azure resource identifiers diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/ResourceResolverService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Resource/ResourceResolverService.cs similarity index 98% rename from tools/Azure.Mcp.Tools.Monitor/src/Services/ResourceResolverService.cs rename to core/Azure.Mcp.Core/src/Services/Azure/Resource/ResourceResolverService.cs index 16b79b860a..7c3685b1fb 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/ResourceResolverService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Resource/ResourceResolverService.cs @@ -3,11 +3,10 @@ using Azure.Core; using Azure.Mcp.Core.Options; -using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; -namespace Azure.Mcp.Tools.Monitor.Services; +namespace Azure.Mcp.Core.Services.Azure.Resource; public class ResourceResolverService(ISubscriptionService subscriptionService, ITenantService tenantService) : BaseAzureService(tenantService), IResourceResolverService diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics/ResourceResolverServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/ResourceResolverServiceTests.cs similarity index 99% rename from tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics/ResourceResolverServiceTests.cs rename to core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/ResourceResolverServiceTests.cs index 81eabb769b..7a8ff76719 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics/ResourceResolverServiceTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/ResourceResolverServiceTests.cs @@ -6,14 +6,14 @@ using System.Text.Json; using Azure.Core; using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure.Resource; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; -using Azure.Mcp.Tools.Monitor.Services; using Azure.ResourceManager.Resources; using NSubstitute; using Xunit; -namespace Azure.Mcp.Tools.Monitor.UnitTests.Metrics; +namespace Azure.Mcp.Core.UnitTests.Services.Azure; public class ResourceResolverServiceTests { diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index 06c09a7129..f97b211568 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -8,6 +8,7 @@ The Azure MCP Server updates automatically by default whenever a new release com - Added Azure AI Best Practices toolset providing comprehensive guidance for building AI apps with Azure AI Foundry and Microsoft Agent Framework. Includes model selection guidance, SDK recommendations, and implementation patterns for agent development. [[#1031](https://github.com/microsoft/mcp/pull/1031)] - Added support for text-to-speech synthesis via the command `speech_tts_synthesize`. [[#902](https://github.com/microsoft/mcp/pull/902)] +- Added support for listing Application Insights distributed trace metadata via the command `azmcp_applicationinsights_apptrace_list` [[#671](https://github.com/microsoft/mcp/issues/671)] ### Breaking Changes diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 93a18a77ef..5d4250325e 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -102,7 +102,7 @@ All Azure MCP tools in a single server. The Azure MCP Server implements the [MCP Install Azure MCP Server using either an IDE extension or package manager. Choose one method below. -> [!IMPORTANT] +> [!IMPORTANT] > Authenticate to Azure before running the Azure MCP server. See the [Authentication guide](https://github.com/microsoft/mcp/blob/main/docs/Authentication.md) for authentication methods and instructions. ## IDE @@ -123,7 +123,7 @@ Compatible with both the [Stable](https://code.visualstudio.com/download) and [I - If Visual Studio 2026 is already installed, open the **Visual Studio Installer** and select the **Modify** button, which displays the available workloads. 1. On the Workloads tab, select **Azure and AI development** and select **GitHub Copilot**. 1. Click **install while downloading** to complete the installation. - + For more information, visit [Install GitHub Copilot for Azure in Visual Studio 2026](https://aka.ms/ghcp4a/vs2026) ### Visual Studio 2022 @@ -235,7 +235,7 @@ Install the .NET Tool: [Azure.Mcp](https://www.nuget.org/packages/Azure.Mcp). ```bash dotnet tool install Azure.Mcp ``` -or +or ```bash dotnet tool install Azure.Mcp --version ``` @@ -365,7 +365,7 @@ To use Azure Entra ID, review the [troubleshooting guide](https://github.com/mic * Create AI Foundry agent threads * List AI Foundry agent threads * Get messages of an AI Foundry thread - + ### πŸ”Ž Azure AI Search * "What indexes do I have in my Azure AI Search service 'mysvc'?" @@ -397,6 +397,13 @@ To use Azure Entra ID, review the [troubleshooting guide](https://github.com/mic * "Get the details for website 'my-website'" * "Get the details for app service plan 'my-app-service-plan'" +### πŸ“ˆ Azure Application Insights + +* "List Application Insights traces for app 'my-ai-app' over the last hour" +* "List Application Insights trace metadata for app 'my-ai-app' in resource group 'rg-observability'" +* "Show trace metadata for my Application Insights component 'my-ai-app' using the requests table" +* "List Application Insights recommendations in my subscription" + ### πŸ–₯️ Azure CLI Generate * Generate Azure CLI commands based on user intent @@ -525,6 +532,7 @@ The Azure MCP Server provides tools for interacting with **40+ Azure service are - πŸ€– **Azure AI Best Practices** - AI app development guidance for Azure AI Foundry and Microsoft Agent Framework - βš™οΈ **Azure App Configuration** - Configuration management - πŸ•ΈοΈ **Azure App Service** - Web app hosting +- πŸ“ˆ **Azure Application Insights** - Distributed trace metadata & performance recommendations - πŸ›‘οΈ **Azure Best Practices** - Secure, production-grade guidance - πŸ–₯️ **Azure CLI Generate** - Generate Azure CLI commands from natural language - πŸ“ž **Azure Communication Services** - SMS messaging and communication diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 99c85bdbe0..0535fd3dc9 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -88,7 +88,7 @@ azmcp server start \ #### Specific Tool Filtering -Exposes only specific tools by name, providing the finest level of granularity. The `--namespace` and `--tool` options cannot be used together. Use multiple `--tool` parameters to include multiple tools. Using `--tool` automatically switches to `all` mode. +Exposes only specific tools by name, providing the finest level of granularity. The `--namespace` and `--tool` options cannot be used together. Use multiple `--tool` parameters to include multiple tools. Using `--tool` automatically switches to `all` mode. ```bash # Start MCP Server with default mode and only subscription and resource group tools @@ -567,6 +567,34 @@ azmcp applicationinsights recommendation list --subscription \ --resource-group ``` +#### Distributed Trace + +List distributed trace metadata (request/exception/dependency/availability) for a specific Application Insights component. This returns a summarized set of rows containing timestamps, operation/span identifiers, and table source information that you can use to further drill into telemetry. + +```bash +# List request trace metadata for an Application Insights component by resource name +azmcp applicationinsights apptrace list --subscription \ + --resource-group \ + --resource-name \ + --table requests \ + --start-time 2025-01-01T00:00:00Z \ + --end-time 2025-01-01T01:00:00Z + +# Same query using a full resource ID and the exceptions table +azmcp applicationinsights apptrace list --subscription \ + --resource-id /subscriptions//resourceGroups//providers/microsoft.insights/components/ \ + --table exceptions + +# List availability result traces with an optional filter (multiple --filters allowed) +azmcp applicationinsights apptrace list --subscription \ + --resource-group \ + --resource-name \ + --table availabilityResults \ + --filters "durationMs > 1000" +``` + +Supported tables: `requests`, `exceptions`, `dependencies`, `availabilityResults`. + ### Azure App Service Operations ```bash @@ -1654,7 +1682,7 @@ azmcp monitor webtests update --subscription \ # List Azure Managed Lustre Filesystems available in a subscription or resource group # ❌ Destructive | βœ… Idempotent | ❌ OpenWorld | βœ… ReadOnly | ❌ Secret | ❌ LocalRequired azmcp managedlustre fs list --subscription \ - --resource-group + --resource-group # Create an Azure Managed Lustre filesystem # βœ… Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 78410df2a8..e498d0e185 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -149,6 +149,12 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | applicationinsights_recommendation_list | Show me code optimization recommendations for all Application Insights resources in my subscription | | applicationinsights_recommendation_list | List profiler recommendations for Application Insights in resource group | | applicationinsights_recommendation_list | Show me performance improvement recommendations from Application Insights | +| applicationinsights_apptrace_list | List Application Insights trace metadata for app in resource group from to | +| applicationinsights_apptrace_list | Show trace metadata for Application Insights component using the requests table | +| applicationinsights_apptrace_list | List Application Insights traces for app over the last hour | +| applicationinsights_apptrace_list | List dependency traces for Application Insights app in resource group between and | +| applicationinsights_apptrace_list | Get availability result trace metadata for Application Insights app between and | +| applicationinsights_apptrace_list | List exception trace metadata for Application Insights app in resource group | ## Azure CLI diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs index 3f24ce4778..a8837737e0 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs @@ -4,6 +4,8 @@ using Azure.Mcp.Core.Areas; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Services.Azure.Resource; +using Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; using Azure.Mcp.Tools.ApplicationInsights.Commands.Recommendation; using Azure.Mcp.Tools.ApplicationInsights.Services; using Microsoft.Extensions.DependencyInjection; @@ -25,26 +27,35 @@ public void ConfigureServices(IServiceCollection services) // Service for accessing Profiler dataplane. services.AddSingleton(); - + services.AddSingleton(); + services.AddSingleton(_ => KQLQueryBuilder.Instance); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { var group = new CommandGroup(Name, """ - Application Insights operations - Commands for listing and managing Application Insights components. + Application Insights operations - Commands for listing and managing Application Insights components. These commands do not support querying metrics or logs. Use Azure Monitor querying tools for that purpose. """, Title); - var recommendation = new CommandGroup("recommendation", "Application Insights recommendation operations - list recommendation targets (components)."); - group.AddSubGroup(recommendation); + var recommendationGroup = new CommandGroup("recommendation", "Application Insights recommendation operations - list recommendation targets (components)."); + group.AddSubGroup(recommendationGroup); + + var appInsightsGroup = new CommandGroup("apptrace", "Application Insights trace operations - list traces and spans for components."); + group.AddSubGroup(appInsightsGroup); var recommendationList = serviceProvider.GetRequiredService(); - recommendation.AddCommand(recommendationList.Name, recommendationList); + recommendationGroup.AddCommand(recommendationList.Name, recommendationList); + + var appTraceList = serviceProvider.GetRequiredService(); + appInsightsGroup.AddCommand(appTraceList.Name, appTraceList); return group; } diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Azure.Mcp.Tools.ApplicationInsights.csproj b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Azure.Mcp.Tools.ApplicationInsights.csproj index 395155caba..8a620634da 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Azure.Mcp.Tools.ApplicationInsights.csproj +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Azure.Mcp.Tools.ApplicationInsights.csproj @@ -11,6 +11,7 @@ + diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs new file mode 100644 index 0000000000..27ed6bc0a5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.ApplicationInsights.Models; +using Azure.Mcp.Tools.ApplicationInsights.Options; +using Azure.Mcp.Tools.ApplicationInsights.Services; +using Microsoft.Extensions.Logging; +using static Azure.Mcp.Tools.ApplicationInsights.Options.ApplicationInsightsOptionDefinitions; + +namespace Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; + +/// +/// Command to list Application Insights trace metadata for components in a subscription or resource group. +/// +public sealed class AppTraceListCommand(ILogger logger) : SubscriptionCommand() +{ + private const string CommandTitle = "List Application Insights Trace Metadata"; + private readonly ILogger _logger = logger; + + public override string Name => "list"; + + public override string Description => + $$""" + List the most relevant traces from an Application Insights table. + + This tool is useful for correlating errors and dependencies to specific transactions in an application. + + Returns a list of traceIds and spanIds that can be further explored for each operation. + + Example usage: + Filter to dependency failures + "table": "dependencies", + "filters": ["success=\"false\""] + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, LocalRequired = false, OpenWorld = false, Secret = false, ReadOnly = true }; + + public override string Id => "2bc7216a-9ceb-4dce-b587-aa8be9c807cc"; + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + AppTraceListOptions options = BindOptions(parseResult); + try + { + IApplicationInsightsService service = context.GetService(); + AppListTraceResult traces = await service.ListDistributedTracesAsync( + subscription: options.Subscription!, + resourceGroup: options.ResourceGroup, + resourceName: options.ResourceName, + resourceId: options.ResourceId, + filters: options.Filters, + table: options.Table!, + startTime: options.StartTimeUtc!.Value, + endTime: options.EndTimeUtc!.Value, + tenant: options.Tenant, + options.RetryPolicy); + + context.Response.Results = traces is not null ? + ResponseResult.Create(new AppTraceListCommandResult(traces), ApplicationInsightsJsonContext.Default.AppTraceListCommandResult) : + null; + } + catch (Exception ex) + { + // Log error with all relevant context + _logger.LogError(ex, + "Error in {Name}. Subscription: {Subscription}, ResourceGroup: {ResourceGroup}, ResourceName: {ResourceName}, Options: {@Options}", + Name, options.Subscription, options.ResourceGroup, options.ResourceName, options); + HandleException(context, ex); + } + + return context.Response; + } + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); + command.Options.Add(ResourceName); + command.Options.Add(ResourceId); + command.Options.Add(Table); + command.Options.Add(Filters); + command.Options.Add(StartTime); + command.Options.Add(EndTime); + + command.Validators.Add(ResourceNameOrIdRequired); + command.Validators.Add(TimeRangeValid); + command.Validators.Add(TableNameValid); + } + + protected override AppTraceListOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + + options.ResourceName ??= parseResult.GetValueOrDefault(ResourceName.Name); + options.ResourceId ??= parseResult.GetValueOrDefault(ResourceId.Name); + + options.Table = parseResult.GetValueOrDefault(Table.Name); + options.Filters = parseResult.GetValueOrDefault(Filters.Name) ?? []; + + string? startRaw = parseResult.GetValueOrDefault(StartTime.Name); + string? endRaw = parseResult.GetValueOrDefault(EndTime.Name); + + if (DateTime.TryParse(startRaw, out DateTime startUtc)) + { + options.StartTimeUtc = startUtc.ToUniversalTime(); + } + + if (DateTime.TryParse(endRaw, out DateTime endUtc)) + { + options.EndTimeUtc = endUtc.ToUniversalTime(); + } + + return options; + } + + private void ResourceNameOrIdRequired(CommandResult result) + { + // Either resourceName or resourceId must be provided + string? resourceName = result.GetValueOrDefault(ResourceName.Name); + string? resourceId = result.GetValueOrDefault(ResourceId.Name); + + if (string.IsNullOrEmpty(resourceName) && string.IsNullOrEmpty(resourceId)) + { + result.AddError($"Either --{ResourceNameName} or --{ResourceIdName} must be provided."); + } + } + + private void TimeRangeValid(CommandResult result) + { + if (!DateTime.TryParse(result.GetValueOrDefault(StartTime.Name), out DateTime startTime) || + !DateTime.TryParse(result.GetValueOrDefault(EndTime.Name), out DateTime endTime) || + startTime >= endTime) + { + result.AddError($"Invalid time range specified. Ensure that --{StartTimeName} is before --{EndTimeName} and that both are valid dates in ISO format."); + } + } + + private void TableNameValid(CommandResult result) + { + string? table = result.GetValueOrDefault(Table.Name); + + if (!string.Equals(table, "exceptions", StringComparison.OrdinalIgnoreCase) && + !string.Equals(table, "dependencies", StringComparison.OrdinalIgnoreCase) && + !string.Equals(table, "availabilityResults", StringComparison.OrdinalIgnoreCase) && + !string.Equals(table, "requests", StringComparison.OrdinalIgnoreCase)) + { + result.AddError($"Invalid table specified. Valid options are: exceptions, dependencies, availabilityResults, requests."); + } + } + + internal record AppTraceListCommandResult(AppListTraceResult? Traces); +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/ApplicationInsightsJsonContext.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/ApplicationInsightsJsonContext.cs index de61910f64..4c44ee67d8 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/ApplicationInsightsJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/ApplicationInsightsJsonContext.cs @@ -3,12 +3,14 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; using Azure.Mcp.Tools.ApplicationInsights.Commands.Recommendation; using Azure.Mcp.Tools.ApplicationInsights.Models; namespace Azure.Mcp.Tools.ApplicationInsights.Commands; [JsonSerializable(typeof(RecommendationListCommand.RecommendationListCommandResult))] +[JsonSerializable(typeof(AppTraceListCommand.AppTraceListCommandResult))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(BulkAppsPostBody))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs new file mode 100644 index 0000000000..73ff136a3b --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ApplicationInsights.Models; + +public class AppListTraceEntry +{ + [JsonPropertyName("operation_Name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OperationName { get; set; } + + [JsonPropertyName("resultCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResultCode { get; set; } + + [JsonPropertyName("problemId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ProblemId { get; set; } + + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } + + [JsonPropertyName("target")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Target { get; set; } + + [JsonPropertyName("testName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TestName { get; set; } + + [JsonPropertyName("testLocation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TestLocation { get; set; } + + [JsonPropertyName("traces")] + public List Traces { get; set; } = new(); +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceResult.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceResult.cs new file mode 100644 index 0000000000..d64670e761 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceResult.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ApplicationInsights.Models; + +public class AppListTraceResult +{ + [JsonPropertyName("table")] + public string Table { get; set; } = string.Empty; + + [JsonPropertyName("rows")] + public List Rows { get; set; } = new(); +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs new file mode 100644 index 0000000000..b82ce85a13 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs @@ -0,0 +1,10 @@ +ο»Ώ// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ApplicationInsights.Models; + +public class AppLogsQueryRow +{ + public required T Data { get; set; } + public Dictionary OtherColumns { get; set; } = []; +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/ListTraceQueryResponse.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/ListTraceQueryResponse.cs new file mode 100644 index 0000000000..411f999db9 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/ListTraceQueryResponse.cs @@ -0,0 +1,17 @@ +namespace Azure.Mcp.Tools.ApplicationInsights.Models.Query; + +public class ListTraceQueryResponse +{ + public string? problemId { get; set; } + public string? target { get; set; } + + public string? location { get; set; } + + public string? name { get; set; } + + public string? type { get; set; } + + public string? operation_Name { get; set; } + public string? resultCode { get; set; } + public string? traces { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/QueryToResponseModelConversions.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/QueryToResponseModelConversions.cs new file mode 100644 index 0000000000..df9f3484fb --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/QueryToResponseModelConversions.cs @@ -0,0 +1,26 @@ +ο»Ώ// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + + +using System.Text.Json; +using Azure.Mcp.Tools.ApplicationInsights.Commands; + +namespace Azure.Mcp.Tools.ApplicationInsights.Models.Query; + +public static class QueryToResponseModelConversions +{ + public static AppListTraceEntry ToResponseModel(this AppLogsQueryRow row) + { + return new AppListTraceEntry + { + ProblemId = row.Data.problemId, + Target = row.Data.target, + TestLocation = row.Data.location, + TestName = row.Data.name, + Type = row.Data.type, + OperationName = row.Data.operation_Name, + ResultCode = row.Data.resultCode, + Traces = string.IsNullOrEmpty(row.Data.traces) ? new List() : (JsonSerializer.Deserialize(row.Data.traces!, ApplicationInsightsJsonContext.Default.ListTraceIdEntry) ?? []).Distinct().ToList() + }; + } +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/TraceIdEntry.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/TraceIdEntry.cs new file mode 100644 index 0000000000..51a28b5fc1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/TraceIdEntry.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ApplicationInsights.Models; + +public class TraceIdEntry : IEquatable +{ + [JsonPropertyName("traceId")] + public string TraceId { get; set; } = string.Empty; + + [JsonPropertyName("spanId")] + public string SpanId { get; set; } = string.Empty; + + public bool Equals(TraceIdEntry? other) + { + return other is not null && + string.Equals(TraceId, other.TraceId, StringComparison.OrdinalIgnoreCase) && + string.Equals(SpanId, other.SpanId, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object? obj) + { + if (obj is TraceIdEntry other) + { + return Equals(other); + } + return false; + } + + public override int GetHashCode() + { + return HashCode.Combine(TraceId, SpanId); + } +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs new file mode 100644 index 0000000000..73357fba76 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ApplicationInsights.Options; + +/// +/// Options for listing Application Insights trace metadata. +/// +public class AppTraceListOptions : SubscriptionOptions +{ + /// + /// The name of the Application Insights resource. + /// + [JsonPropertyName(ApplicationInsightsOptionDefinitions.ResourceNameName)] + public string? ResourceName { get; set; } + + /// + /// The resource ID of the Application Insights resource. + /// + [JsonPropertyName(ApplicationInsightsOptionDefinitions.ResourceIdName)] + public string? ResourceId { get; set; } + + /// + /// Optional start time (UTC) for the trace window. + /// + [JsonPropertyName(ApplicationInsightsOptionDefinitions.StartTimeName)] + public DateTime? StartTimeUtc { get; set; } + + /// + /// Optional end time (UTC) for the trace window. + /// + [JsonPropertyName(ApplicationInsightsOptionDefinitions.EndTimeName)] + public DateTime? EndTimeUtc { get; set; } + + /// + /// The table to list traces on + /// + [JsonPropertyName(ApplicationInsightsOptionDefinitions.TableName)] + public string? Table { get; set; } + + /// + /// Filters for the traces + /// + [JsonPropertyName(ApplicationInsightsOptionDefinitions.FiltersName)] + public string[] Filters { get; set; } = []; +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs new file mode 100644 index 0000000000..44ba0fb066 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs @@ -0,0 +1,55 @@ +using System.CommandLine; + +namespace Azure.Mcp.Tools.ApplicationInsights.Options; + +public static class ApplicationInsightsOptionDefinitions +{ + public const string ResourceIdName = "resource-id"; + public const string ResourceNameName = "resource-name"; + public const string StartTimeName = "start-time"; + public const string EndTimeName = "end-time"; + public const string TableName = "table"; + public const string FiltersName = "filters"; + + public static readonly Option ResourceId = new($"--{ResourceIdName}") + { + Required = false, + Description = "The resource ID of the Application Insights resource.", + }; + + public static readonly Option ResourceName = new($"--{ResourceNameName}") + { + Required = false, + Description = "The name of the Application Insights resource.", + }; + + public static readonly Option StartTime = new($"--{StartTimeName}") + { + Required = false, + DefaultValueFactory = (_) => DateTime.UtcNow.AddHours(-24).ToString("o"), + Description = "The start time of the investigation in ISO format (e.g., 2023-01-01T00:00:00Z). Defaults to 24 hours ago." + }; + + public static readonly Option EndTime = new($"--{EndTimeName}") + { + Required = false, + DefaultValueFactory = (_) => DateTime.UtcNow.ToString("o"), + Description = "The end time of the investigation in ISO format (e.g., 2023-01-01T00:00:00Z). Defaults to now." + }; + + public static readonly Option Table = new( + $"--{TableName}") + { + Required = true, + Description = "The table to list traces for. Valid values are 'requests', 'dependencies', 'availabilityResults', 'exceptions'." + }; + + public static readonly Option Filters = new( + $"--{FiltersName}") + { + Required = false, + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = true, + Description = "The filters to apply to the trace results. JSON array of \"dimension=\\\"value\\\"\". Dimension names should be valid Application Insights column names. (e.g. [ \"success=\\\"false\\\"\", \"resultCode=\\\"500\\\"\" ])" + }; +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs new file mode 100644 index 0000000000..9b9a59a8bd --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Azure.Core; +using Azure.Mcp.Tools.ApplicationInsights.Models; +using Azure.Monitor.Query; +using Azure.Monitor.Query.Models; + +namespace Azure.Mcp.Tools.ApplicationInsights.Services; + +public class AppLogsQueryClient(LogsQueryClient logsQueryClient) : IAppLogsQueryClient +{ + private readonly LogsQueryClient _logsQueryClient = logsQueryClient; + + public async Task>> QueryResourceAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(ResourceIdentifier resourceId, string kql, QueryTimeRange timeRange) where T : new() + { + Response response = await _logsQueryClient.QueryResourceAsync( + resourceId, + kql, + timeRange); + + PropertyInfo[] destinationProperties = typeof(T).GetProperties(); + // Convert data into T + // First we need to know the column indices relevant for conversion and the destination property indices they convert to + Dictionary conversionMap = []; + IReadOnlyList columns = response.Value.Table.Columns; + for (int i = 0; i < columns.Count; i++) + { + var column = columns[i]; + for (int j = 0; j < destinationProperties.Length; j++) + { + PropertyInfo info = destinationProperties[j]; + if (column.Name.Equals(info.Name, StringComparison.InvariantCultureIgnoreCase)) + { + conversionMap[i] = j; + break; + } + } + } + + // Now we can generate the final data + var rows = response.Value.Table.Rows.Select(row => + { + Dictionary otherColumns = new Dictionary(); + + T retObj = new T(); + for (int i = 0; i < row.Count; i++) + { + if (conversionMap.TryGetValue(i, out int j)) + { + PropertyInfo property = destinationProperties[j]; + Type destType = property.PropertyType; + var currentToken = row[i]; + + if (destType == typeof(string)) + { + // force conversion to string + property.SetValue(retObj, currentToken.ToString()); + } + else + { + property.SetValue(retObj, currentToken); + } + } + else + { + otherColumns[columns[i].Name] = row[i]; + } + } + + return new AppLogsQueryRow + { + Data = retObj, + OtherColumns = otherColumns + }; + }).ToList(); // Use ToList to force conversion to happen now, not on demand + + return rows.AsReadOnly(); + } +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryService.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryService.cs new file mode 100644 index 0000000000..c117238678 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryService.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure; +using Azure.Monitor.Query; + +namespace Azure.Mcp.Tools.ApplicationInsights.Services; + +public class AppLogsQueryService() : BaseAzureService, IAppLogsQueryService +{ + public async Task CreateClientAsync(ResourceIdentifier resolvedResource, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + { + var credential = await GetCredential(tenant); + var options = AddDefaultPolicies(new LogsQueryClientOptions()); + + if (retryPolicy != null) + { + options.Retry.Delay = TimeSpan.FromSeconds(retryPolicy.DelaySeconds); + options.Retry.MaxDelay = TimeSpan.FromSeconds(retryPolicy.MaxDelaySeconds); + options.Retry.MaxRetries = retryPolicy.MaxRetries; + options.Retry.Mode = retryPolicy.Mode; + options.Retry.NetworkTimeout = TimeSpan.FromSeconds(retryPolicy.NetworkTimeoutSeconds); + } + + var client = new LogsQueryClient(credential, options); + + return new AppLogsQueryClient(client); + } +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs index 6c2ea914f3..b5dcdeed85 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs @@ -2,11 +2,16 @@ // Licensed under the MIT License. using System.Text.Json.Nodes; +using Azure.Core; using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Resource; using Azure.Mcp.Core.Services.Azure.ResourceGroup; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.ApplicationInsights.Models; +using Azure.Mcp.Tools.ApplicationInsights.Models.Query; +using Azure.Monitor.Query; using Azure.ResourceManager.ApplicationInsights; using Azure.ResourceManager.Resources; using Microsoft.Extensions.Logging; @@ -18,12 +23,18 @@ public class ApplicationInsightsService( ITenantService tenantService, IResourceGroupService resourceGroupService, IProfilerDataService profilerDataClient, + IResourceResolverService resourceResolverService, + IAppLogsQueryService queryService, + IKQLQueryBuilder kqlQueryBuilder, ILogger logger) : BaseAzureService(tenantService), IApplicationInsightsService { private const int MaxRecommendations = 20; private readonly ISubscriptionService _subscriptionService = subscriptionService; private readonly IResourceGroupService _resourceGroupService = resourceGroupService; private readonly IProfilerDataService _profilerDataClient = profilerDataClient ?? throw new ArgumentNullException(nameof(profilerDataClient)); + private readonly IResourceResolverService _resourceResolverService = resourceResolverService ?? throw new ArgumentNullException(nameof(resourceResolverService)); + private readonly IAppLogsQueryService _queryService = queryService ?? throw new ArgumentNullException(nameof(queryService)); + private readonly IKQLQueryBuilder _kqlQueryBuilder = kqlQueryBuilder; private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); public async Task> GetProfilerInsightsAsync(string subscription, string? resourceGroup = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null) @@ -33,6 +44,94 @@ public async Task> GetProfilerInsightsAsync(string subscri return results.Take(MaxRecommendations); } + /// + /// Retrieves trace metadata for Application Insights components. This is an initial implementation that surfaces + /// component identifiers which can be used by future enhancements to query detailed trace/span data. + /// + public async Task ListDistributedTracesAsync( + string subscription, + string? resourceGroup, + string? resourceName, + string? resourceId, + string[] filters, + string table, + DateTime startTime, + DateTime endTime, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null) + { + ResourceIdentifier resolvedResource = await _resourceResolverService.ResolveResourceIdAsync(subscription, resourceGroup, "microsoft.insights/components", resourceName ?? resourceId!, tenant, retryPolicy); + + IAppLogsQueryClient client = await _queryService.CreateClientAsync(resolvedResource, tenant, retryPolicy); + + QueryTimeRange queryTimeRange = new(startTime, endTime); + + string query = _kqlQueryBuilder.ListTraces(table, filters); + + var response = await client.QueryResourceAsync(resolvedResource, query, queryTimeRange); + + if (response == null || response.Count == 0) + { + return new AppListTraceResult + { + Table = table, + Rows = new List(), + }; + } + + List rows = response.Select(t => t.ToResponseModel()).ToList(); + + return new AppListTraceResult + { + Table = table, + Rows = rows + }; + } + + public async Task> ListDistributedTracesAsync( + string subscription, + string? resourceGroup = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + DateTime? startDateTimeUtc = null, + DateTime? endDateTimeUtc = null) + { + ValidateRequiredParameters((nameof(subscription), subscription)); + List results = []; + + try + { + List components = await GetApplicationInsightsComponentsAsync(subscription, resourceGroup, tenant, retryPolicy).ConfigureAwait(false); + + startDateTimeUtc ??= DateTime.UtcNow.AddHours(-1); + endDateTimeUtc ??= DateTime.UtcNow; + + foreach (var component in components) + { + JsonObject traceMetadata = new() + { + ["componentId"] = component.Id.ToString(), + ["appId"] = component.Data.AppId, + ["name"] = component.Data.Name, + ["location"] = component.Data.Location.ToString(), + ["resourceGroup"] = component.Id.ResourceGroupName, + ["subscriptionId"] = component.Id.SubscriptionId, + ["startTimeUtc"] = startDateTimeUtc.Value.ToString("o"), + ["endTimeUtc"] = endDateTimeUtc.Value.ToString("o"), + ["note"] = "Trace listing currently returns component metadata. Future versions may query detailed traces via Log Analytics or data plane APIs." + }; + results.Add(traceMetadata); + } + } + catch (Exception ex) when (ex is not ArgumentNullException) + { + _logger.LogError(ex, "Error retrieving Application Insights trace metadata"); + throw; + } + + return results; + } + private async Task> GetProfilerInsightsImpAsync(string subscription, string? resourceGroup, string? tenant = null, RetryPolicyOptions? retryPolicy = null) { ValidateRequiredParameters((nameof(subscription), subscription)); diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryClient.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryClient.cs new file mode 100644 index 0000000000..678da7e4a0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryClient.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Azure.Core; +using Azure.Mcp.Tools.ApplicationInsights.Models; +using Azure.Monitor.Query; + +namespace Azure.Mcp.Tools.ApplicationInsights.Services; + +public interface IAppLogsQueryClient +{ + Task>> QueryResourceAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(ResourceIdentifier resourceId, string kql, QueryTimeRange timeRange) where T : new(); +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryService.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryService.cs new file mode 100644 index 0000000000..50629371a1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryService.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ApplicationInsights.Services; + +public interface IAppLogsQueryService +{ + Task CreateClientAsync(ResourceIdentifier resolvedResource, string? tenant = null, RetryPolicyOptions? retryPolicy = null); +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IApplicationInsightsService.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IApplicationInsightsService.cs index a44b9e98f3..1859f88c4d 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IApplicationInsightsService.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IApplicationInsightsService.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.ApplicationInsights.Models; namespace Azure.Mcp.Tools.ApplicationInsights.Services; @@ -13,4 +14,20 @@ Task> GetProfilerInsightsAsync( string? resourceGroup = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null); + + /// + /// List Application Insights trace metadata (placeholder until full trace retrieval is implemented). + /// Currently returns basic component information that can be used to scope future trace queries. + /// + Task ListDistributedTracesAsync( + string subscription, + string? resourceGroup, + string? resourceName, + string? resourceId, + string[] filters, + string table, + DateTime startTime, + DateTime endTime, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null); } diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IKQLQueryBuilder.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IKQLQueryBuilder.cs new file mode 100644 index 0000000000..807e176ffb --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IKQLQueryBuilder.cs @@ -0,0 +1,11 @@ +namespace Azure.Mcp.Tools.ApplicationInsights.Services; + +public interface IKQLQueryBuilder +{ + string GetDistributedTrace(string traceId); + string GetImpact(string table, string[] filters); + string[] GetKqlFilters(KeyValuePair[] parsedFilters); + string GetKqlInterval(DateTime start, DateTime end); + string ListTraces(string table, string[] filters); + KeyValuePair[] ParseFilters(string[] filters); +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs new file mode 100644 index 0000000000..99aaf41a85 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs @@ -0,0 +1,206 @@ +ο»Ώusing System.Text.RegularExpressions; + +namespace Azure.Mcp.Tools.ApplicationInsights.Services; + +internal sealed class KQLQueryBuilder : IKQLQueryBuilder +{ + private KQLQueryBuilder() { } + public static KQLQueryBuilder Instance { get; } = new(); + + private static readonly Regex filterParser = new(@"(?.*)=\s*['""](?.*)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public string GetDistributedTrace(string traceId) + { + return $""" + union requests, dependencies, exceptions, (availabilityResults | extend success=iff(success=='1', "True", "False")) + | where operation_Id == "{traceId}" + | project-away customMeasurements, _ResourceId, itemCount, client_Type, client_Model, client_OS, client_IP, client_City, client_StateOrProvince, client_CountryOrRegion, client_Browser, appId, appName, iKey, sdkVersion + """; + } + + public string GetImpact(string table, string[] filters) + { + string filtersClause = filters != null && filters.Length > 0 + ? string.Join("\r\n", GetKqlFilters(ParseFilters(filters))) + : ""; + return $""" + let total={table} + | summarize TotalInstances=dcount(cloud_RoleInstance), TotalRequests=sum(itemCount) by cloud_RoleName; + {table} {filtersClause} + | summarize ImpactedInstances=dcount(cloud_RoleInstance), ImpactedRequests=sum(itemCount) by cloud_RoleName + | join kind=rightouter (total) on cloud_RoleName + | extend ImpactedInstances = iff(isempty(ImpactedInstances), 0, ImpactedInstances) + | extend ImpactedRequests = iff(isempty(ImpactedRequests), 0, ImpactedRequests) + | project + cloud_RoleName=cloud_RoleName1, + ImpactedInstances, + TotalInstances, + TotalRequests, + ImpactedRequests + | extend + ImpactedRequestsPercent = round((todouble(ImpactedRequests) / TotalRequests) * 100, 3), + ImpactedInstancePercent = round((todouble(ImpactedInstances) / TotalInstances) * 100, 3) + | order by ImpactedRequestsPercent desc + """; + } + + public string ListTraces(string table, string[] filters) + { + var parsedFilters = ParseFilters(filters ?? Array.Empty()); + List kqlFilters = GetKqlFilters(parsedFilters).ToList(); + + KeyValuePair[]? percentileFilters = parsedFilters + .Where(kvp => string.Equals(kvp.Key, "duration") && kvp.Value.EndsWith('p')) + .Distinct() + .ToArray(); + List percentileFunctions = new(); + if (percentileFilters != null) + { + foreach (var filter in percentileFilters) + { + if (!double.TryParse(filter.Value.Trim('p'), out double percentileValue) || percentileValue < 0 || percentileValue > 100) + { + throw new ArgumentException($"Invalid percentile value '{filter.Value}' for filter '{filter.Key}'. Must be a number between 0 and 100."); + } + percentileFunctions.Add($""" + let percentile{percentileValue} = toscalar({table} {string.Join("\r\n", kqlFilters)} + | summarize percentile(duration, {percentileValue})); + """); + kqlFilters.Add($"| where duration > percentile{percentileValue}"); + } + } + + string requestsQuery = $""" + requests{(table == "requests" ? string.Join("\r\n", kqlFilters) : "")} + | project operation_Name, resultCode, operation_Id, itemType{(table == "requests" ? ", id, itemCount" : "")} + """; + + string dependenciesQuery = $""" + dependencies{(table == "dependencies" ? string.Join("\r\n", kqlFilters) : "")} + | where type != "InProc" + | project target, type, resultCode, operation_Id, itemType{(table == "dependencies" ? ", id, itemCount" : "")} + """; + + string exceptionsQuery = $""" + exceptions{(table == "exceptions" ? string.Join("\r\n", kqlFilters) : "")} + | project problemId, type, operation_Id, itemType{(table == "exceptions" ? ", itemCount" : "")} + """; + + string availabilityResultsQuery = $""" + availabilityResults + | extend success=iff(success == '1', "True", "False"){(table == "availabilityResults" ? string.Join("\r\n", kqlFilters) : "")} + | project name, location, operation_Id, itemType{(table == "availabilityResults" ? ", id, itemCount" : "")} + """; + + string mainTableQuery; + string[] remainingQueries; + string keyDimensions; + switch (table) + { + case "requests": + mainTableQuery = requestsQuery; + remainingQueries = new[] { dependenciesQuery, exceptionsQuery, availabilityResultsQuery }; + keyDimensions = "operation_Name, resultCode"; + break; + case "dependencies": + mainTableQuery = dependenciesQuery; + remainingQueries = new[] { requestsQuery, exceptionsQuery, availabilityResultsQuery }; + keyDimensions = "target, type, resultCode"; + break; + case "exceptions": + mainTableQuery = exceptionsQuery; + remainingQueries = new[] { requestsQuery, dependenciesQuery, availabilityResultsQuery }; + keyDimensions = "problemId, type"; + break; + case "availabilityResults": + mainTableQuery = availabilityResultsQuery; + remainingQueries = new[] { requestsQuery, dependenciesQuery, exceptionsQuery }; + keyDimensions = "name, location"; + break; + default: + throw new InvalidOperationException("Invalid table specified. Valid values are 'requests', 'dependencies', 'exceptions', or 'availabilityResults'."); + } + + return $$""" + {{string.Join("\r\n", percentileFunctions)}} + let min_length_8 = (s: string) {let len = strlen(s);case(len == 1, strcat(s, s, s, s, s, s, s, s), len == 2 or len == 3, strcat(s, s, s, s), len == 4 or len == 5 or len == 6 or len == 7, strcat(s, s), s)}; + let ai_hash = (s: string) { + abs(toint(__hash_djb2(min_length_8(s)))) + }; + {{mainTableQuery}} + | join kind=leftouter ({{remainingQueries[0]}}) on operation_Id + | join kind=leftouter ({{remainingQueries[1]}}) on operation_Id + | join kind=leftouter ({{remainingQueries[2]}}) on operation_Id + | summarize sum(itemCount), arg_min(ai_hash(operation_Id), operation_Id, column_ifexists("id", '')) by itemType, operation_Name, resultCode, problemId, target, type, resultCode1, name, location + | summarize traces=make_list(bag_pack('traceId', operation_Id, 'spanId', column_ifexists("id", '')), 3), sum(sum_itemCount) by itemType, {{keyDimensions}} + | top 10 by sum_sum_itemCount desc + """; + } + + public string[] GetKqlFilters(KeyValuePair[] parsedFilters) + { + return parsedFilters + .Where(kvp => !string.Equals(kvp.Key, "duration", StringComparison.OrdinalIgnoreCase) || !kvp.Value.EndsWith('p')) + .Select(kvp => $"| where {kvp.Key} contains \"{kvp.Value}\"") + .ToArray(); + } + + public KeyValuePair[] ParseFilters(string[] filters) + { + return filters + .Select(f => f.Trim()) + .Where(f => !string.IsNullOrEmpty(f)) + .Select(f => + { + var result = filterParser.Match(f); + if (!result.Success) + { + throw new ArgumentException($"Invalid filter format: '{f}'. Expected format is 'key=\"value\"'."); + } + return new KeyValuePair(result.Groups["key"].Value.Trim(), result.Groups["value"].Value.Trim()); + }).ToArray(); + } + + public string GetKqlInterval(DateTime start, DateTime end) + { + // Compute the interval based on the time range. + // Try to keep a maximum of 60 buckets in the result. + TimeSpan duration = end - start; + if (duration.TotalMinutes <= 60) + { + return "2m"; // 2 minute interval for short ranges + } + else if (duration.TotalHours <= 4) + { + return "10m"; // 10 minute interval for short ranges + } + else if (duration.TotalHours <= 12) + { + return "30m"; // 30 minute interval for medium ranges + } + else if (duration.TotalHours <= 24) + { + return "1h"; // 1 hour interval for medium ranges + } + else if (duration.TotalDays <= 3) + { + return "2h"; // 2 hour interval for longer ranges + } + else if (duration.TotalDays <= 7) + { + return "6h"; // 6 hour interval for longer ranges + } + else if (duration.TotalDays <= 14) + { + return "12h"; // 12 hour interval for longer ranges + } + else if (duration.TotalDays <= 30) + { + return "1d"; // 24 hour interval for longer ranges + } + else + { + return "2d"; // 2d interval for longer ranges + } + } +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.UnitTests/AppTraceListCommandTests.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.UnitTests/AppTraceListCommandTests.cs new file mode 100644 index 0000000000..a8909e86bb --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.UnitTests/AppTraceListCommandTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; +using Azure.Mcp.Tools.ApplicationInsights.Models; +using Azure.Mcp.Tools.ApplicationInsights.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.ApplicationInsights.UnitTests; + +public class AppTraceListCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IApplicationInsightsService _serviceMock; + private readonly AppTraceListCommand _command; + private readonly CommandContext _context; + + public AppTraceListCommandTests() + { + var sc = new ServiceCollection(); + _serviceMock = Substitute.For(); + sc.AddSingleton(_serviceMock); + var logger = Substitute.For>(); + _serviceProvider = sc.BuildServiceProvider(); + _command = new AppTraceListCommand(logger); + _context = new(_serviceProvider); + } + + [Fact] + public async Task ExecuteAsync_WhenServiceReturnsTraces_SetsResults() + { + AppListTraceResult traceResult = new() + { + Table = "requests", + Rows = new List + { + new() { OperationName = "GET /a", ResultCode = "200" }, + new() { OperationName = "GET /b", ResultCode = "500" } + } + }; + _serviceMock.ListDistributedTracesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(traceResult)); + + var args = _command.GetCommand().Parse(["--subscription", "sub1", "--resource-name", "app1", "--table", "requests", "--start-time", DateTime.UtcNow.AddMinutes(-30).ToString("o"), "--end-time", DateTime.UtcNow.ToString("o")]); + await _command.ExecuteAsync(_context, args); + + Assert.NotNull(_context.Response.Results); + string json = JsonSerializer.Serialize(_context.Response.Results); + Assert.Contains("GET /a", json); + Assert.Contains("GET /b", json); + } + + [Fact] + public async Task ExecuteAsync_WhenServiceReturnsNoTraces_NoResults() + { + _serviceMock.ListDistributedTracesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(new AppListTraceResult { Table = "requests", Rows = new List() })); + + var args = _command.GetCommand().Parse(["--subscription", "sub1", "--resource-name", "app1", "--table", "requests", "--start-time", DateTime.UtcNow.AddMinutes(-30).ToString("o"), "--end-time", DateTime.UtcNow.ToString("o")]); + await _command.ExecuteAsync(_context, args); + + // Empty result still yields a result object per current command implementation + // Adjust expectation: if command changes to suppress empty results, update this assertion. + Assert.NotNull(_context.Response.Results); + } + + [Fact] + public async Task ExecuteAsync_WithStartEndTime_ParsesAndPassesValues() + { + DateTime capturedStart = default; + DateTime capturedEnd = default; + _serviceMock.ListDistributedTracesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do(d => capturedStart = d), + Arg.Do(d => capturedEnd = d), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(new AppListTraceResult())); + + string start = DateTime.UtcNow.AddHours(-2).ToString("o"); + string end = DateTime.UtcNow.AddHours(-1).ToString("o"); + var args = _command.GetCommand().Parse(["--subscription", "sub1", "--resource-name", "app1", "--table", "requests", "--start-time", start, "--end-time", end]); + await _command.ExecuteAsync(_context, args); + + Assert.NotEqual(default, capturedStart); + Assert.NotEqual(default, capturedEnd); + Assert.True(capturedStart < capturedEnd); + } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs index c9237182b6..de70027ec9 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs @@ -3,6 +3,7 @@ using Azure.Mcp.Core.Areas; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Services.Azure.Resource; using Azure.Mcp.Tools.Monitor.Commands.ActivityLog; using Azure.Mcp.Tools.Monitor.Commands.HealthModels.Entity; using Azure.Mcp.Tools.Monitor.Commands.Log; diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs index 73f09a327c..587f3ec2b1 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs @@ -4,6 +4,7 @@ using System.Xml; using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Resource; using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Tools.Monitor.Models; using Azure.Monitor.Query; diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorService.cs index 43ddea5c9b..2f7c38f565 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorService.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorService.cs @@ -6,6 +6,7 @@ using Azure.Core; using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Resource; using Azure.Mcp.Core.Services.Azure.ResourceGroup; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.LiveTests/MonitorCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.LiveTests/MonitorCommandTests.cs index fbacaac13e..c1fb1394fb 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.LiveTests/MonitorCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.LiveTests/MonitorCommandTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Azure.Resource; using Azure.Mcp.Core.Services.Azure.ResourceGroup; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics/MonitorMetricsServiceTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics/MonitorMetricsServiceTests.cs index 22fa34a5c0..d8175e4923 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics/MonitorMetricsServiceTests.cs +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics/MonitorMetricsServiceTests.cs @@ -3,6 +3,7 @@ using Azure.Core; using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure.Resource; using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Tools.Monitor.Services; using Azure.Monitor.Query;