From 99fdb61373b8da3635382088f52407a9d47588f4 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Wed, 24 Sep 2025 16:25:15 -0700 Subject: [PATCH 01/20] Scaffolding AppTrace list command --- .../src/ApplicationInsightsSetup.cs | 8 ++ .../Commands/AppTrace/AppTraceListCommand.cs | 98 +++++++++++++++++++ .../ApplicationInsightsJsonContext.cs | 2 + .../src/Options/AppTraceListOptions.cs | 22 +++++ .../Services/ApplicationInsightsService.cs | 48 +++++++++ .../Services/IApplicationInsightsService.cs | 12 +++ .../ApplicationInsightsCommandTests.cs | 46 +++++++++ 7 files changed, 236 insertions(+) create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs index 3f24ce4778..73bb20e361 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs @@ -5,6 +5,7 @@ using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Extensions; using Azure.Mcp.Tools.ApplicationInsights.Commands.Recommendation; +using Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; using Azure.Mcp.Tools.ApplicationInsights.Services; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +30,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -43,9 +45,15 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var recommendation = new CommandGroup("recommendation", "Application Insights recommendation operations - list recommendation targets (components)."); group.AddSubGroup(recommendation); + var appTrace = new CommandGroup("apptrace", "Application Insights trace operations - list trace metadata for components."); + group.AddSubGroup(appTrace); + var recommendationList = serviceProvider.GetRequiredService(); recommendation.AddCommand(recommendationList.Name, recommendationList); + var appTraceList = serviceProvider.GetRequiredService(); + appTrace.AddCommand(appTraceList.Name, appTraceList); + return group; } } 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..d48455b616 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json.Nodes; +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.Options; +using Azure.Mcp.Tools.ApplicationInsights.Services; +using Microsoft.Extensions.Logging; + +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 Application Insights trace metadata (component identifiers and time window) in a subscription. Optionally filter by resource group when --resource-group is provided. + This is an initial implementation that returns component metadata and a requested time window; future versions may return detailed trace/span data. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, LocalRequired = false, OpenWorld = false, Secret = false, ReadOnly = true }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); + // Optional --start-time and --end-time (ISO 8601) + var startTime = new Option("--start-time") { Description = "Optional start time in ISO 8601 UTC (e.g., 2025-01-01T00:00:00Z). Defaults to 1 hour ago." }; + var endTime = new Option("--end-time") { Description = "Optional end time in ISO 8601 UTC (e.g., 2025-01-01T01:00:00Z). Defaults to now." }; + command.Options.Add(startTime); + command.Options.Add(endTime); + } + + protected override AppTraceListOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + + string? startRaw = parseResult.GetValueOrDefault("start-time"); + string? endRaw = parseResult.GetValueOrDefault("end-time"); + if (DateTime.TryParse(startRaw, out var startUtc)) + { + options.StartTimeUtc = startUtc.ToUniversalTime(); + } + if (DateTime.TryParse(endRaw, out var endUtc)) + { + options.EndTimeUtc = endUtc.ToUniversalTime(); + } + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + var service = context.GetService(); + var traces = await service.GetAppTracesAsync( + options.Subscription!, + options.ResourceGroup, + options.Tenant, + options.RetryPolicy, + options.StartTimeUtc, + options.EndTimeUtc); + + context.Response.Results = traces?.Any() == true ? + ResponseResult.Create(new AppTraceListCommandResult(traces), ApplicationInsightsJsonContext.Default.AppTraceListCommandResult) : + null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing Application Insights trace metadata."); + HandleException(context, ex); + } + return context.Response; + } + + internal record AppTraceListCommandResult(IEnumerable 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/Options/AppTraceListOptions.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs new file mode 100644 index 0000000000..9226ef4480 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ApplicationInsights.Options; + +/// +/// Options for listing Application Insights trace metadata. +/// +public class AppTraceListOptions : SubscriptionOptions +{ + /// + /// Optional start time (UTC) for the trace window. + /// + public DateTime? StartTimeUtc { get; set; } + + /// + /// Optional end time (UTC) for the trace window. + /// + public DateTime? EndTimeUtc { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs index 6c2ea914f3..82ed8fc2e5 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs @@ -33,6 +33,54 @@ 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> GetAppTracesAsync( + string subscription, + string? resourceGroup = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + DateTime? startDateTimeUtc = null, + DateTime? endDateTimeUtc = null) + { + ValidateRequiredParameters(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/IApplicationInsightsService.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IApplicationInsightsService.cs index a44b9e98f3..ae2781e6f8 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IApplicationInsightsService.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IApplicationInsightsService.cs @@ -13,4 +13,16 @@ 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> GetAppTracesAsync( + string subscription, + string? resourceGroup = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + DateTime? startDateTimeUtc = null, + DateTime? endDateTimeUtc = null); } diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs new file mode 100644 index 0000000000..9676f61a74 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tests; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.ApplicationInsights.LiveTests; + +[Trait("Area", "ApplicationInsights")] +[Trait("Category", "Live")] +public class ApplicationInsightsCommandTests(ITestOutputHelper output) : CommandTestsBase(output) +{ + [Fact] + public async Task Should_list_applicationinsights_recommendations_by_subscription() + { + var result = await CallToolAsync( + "azmcp_applicationinsights_recommendation_list", + new() + { + { "subscription", Settings.SubscriptionId } + }); + + var recommendations = result.AssertProperty("recommendations"); + Assert.Equal(JsonValueKind.Array, recommendations.ValueKind); + // Note: recommendations array can be empty if no profiler-based recommendations are available. + } + + [Fact] + public async Task Should_list_applicationinsights_recommendations_by_subscription_and_resource_group() + { + var result = await CallToolAsync( + "azmcp_applicationinsights_recommendation_list", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName } + }); + + var recommendations = result.AssertProperty("recommendations"); + Assert.Equal(JsonValueKind.Array, recommendations.ValueKind); + // Note: recommendations array can be empty if no profiler-based recommendations are available in the RG. + } +} From 07617acd383e59ffbe936d5d197f2a428617feb5 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Tue, 30 Sep 2025 16:39:10 -0700 Subject: [PATCH 02/20] Basic implementation in --- .../Resource}/IResourceResolverService.cs | 2 +- .../Resource}/ResourceResolverService.cs | 3 +- .../Azure}/ResourceResolverServiceTests.cs | 4 +- .../src/ApplicationInsightsSetup.cs | 19 +- ...Azure.Mcp.Tools.ApplicationInsights.csproj | 1 + .../Commands/AppTrace/AppTraceListCommand.cs | 66 ++++-- .../src/Models/AppListTraceEntry.cs | 36 +++ .../src/Models/AppListTraceResult.cs | 12 + .../src/Models/AppLogsQueryRow.cs | 10 + .../Models/Query/ListTraceQueryResponse.cs | 17 ++ .../Query/QueryToResponseModelConversions.cs | 26 +++ .../src/Models/TraceIdEntry.cs | 33 +++ .../src/Options/AppTraceListOptions.cs | 27 +++ .../ApplicationInsightsOptionDefinitions.cs | 57 +++++ .../src/Services/AppLogsQueryClient.cs | 84 +++++++ .../src/Services/AppLogsQueryService.cs | 31 +++ .../Services/ApplicationInsightsService.cs | 53 ++++- .../src/Services/IAppLogsQueryClient.cs | 14 ++ .../src/Services/IAppLogsQueryService.cs | 12 + .../Services/IApplicationInsightsService.cs | 15 +- .../src/Services/IKQLQueryBuilder.cs | 11 + .../src/Services/KQLQueryBuilder.cs | 206 ++++++++++++++++++ .../src/MonitorSetup.cs | 1 + .../src/Services/MonitorMetricsService.cs | 1 + .../Metrics/MonitorMetricsServiceTests.cs | 1 + 25 files changed, 701 insertions(+), 41 deletions(-) rename {tools/Azure.Mcp.Tools.Monitor/src/Services => core/Azure.Mcp.Core/src/Services/Azure/Resource}/IResourceResolverService.cs (95%) rename {tools/Azure.Mcp.Tools.Monitor/src/Services => core/Azure.Mcp.Core/src/Services/Azure/Resource}/ResourceResolverService.cs (98%) rename {tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.UnitTests/Metrics => core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure}/ResourceResolverServiceTests.cs (99%) create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceResult.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/ListTraceQueryResponse.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/Query/QueryToResponseModelConversions.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/TraceIdEntry.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryService.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryClient.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IAppLogsQueryService.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/IKQLQueryBuilder.cs create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs 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/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs index 73bb20e361..78e6f0ed3f 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs @@ -8,6 +8,7 @@ using Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; using Azure.Mcp.Tools.ApplicationInsights.Services; using Microsoft.Extensions.DependencyInjection; +using Azure.Mcp.Core.Services.Azure.Resource; namespace Azure.Mcp.Tools.ApplicationInsights; @@ -26,7 +27,9 @@ public void ConfigureServices(IServiceCollection services) // Service for accessing Profiler dataplane. services.AddSingleton(); - + services.AddSingleton(); + services.AddSingleton(_ => KQLQueryBuilder.Instance); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -37,22 +40,22 @@ 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 appTrace = new CommandGroup("apptrace", "Application Insights trace operations - list trace metadata for components."); - group.AddSubGroup(appTrace); + var appInsightsGroup = new CommandGroup("app-trace", "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(); - appTrace.AddCommand(appTraceList.Name, appTraceList); + 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 index d48455b616..881235dcff 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -2,12 +2,12 @@ // Licensed under the MIT License. using System.CommandLine; -using System.Text.Json.Nodes; 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; @@ -38,11 +38,12 @@ protected override void RegisterOptions(Command command) { base.RegisterOptions(command); command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); - // Optional --start-time and --end-time (ISO 8601) - var startTime = new Option("--start-time") { Description = "Optional start time in ISO 8601 UTC (e.g., 2025-01-01T00:00:00Z). Defaults to 1 hour ago." }; - var endTime = new Option("--end-time") { Description = "Optional end time in ISO 8601 UTC (e.g., 2025-01-01T01:00:00Z). Defaults to now." }; - command.Options.Add(startTime); - command.Options.Add(endTime); + command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceName.AsOptional()); + command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceId.AsOptional()); + command.Options.Add(ApplicationInsightsOptionDefinitions.Table.AsRequired()); + command.Options.Add(ApplicationInsightsOptionDefinitions.Filters.AsOptional()); + command.Options.Add(ApplicationInsightsOptionDefinitions.StartTime.AsOptional()); + command.Options.Add(ApplicationInsightsOptionDefinitions.EndTime.AsOptional()); } protected override AppTraceListOptions BindOptions(ParseResult parseResult) @@ -50,16 +51,29 @@ protected override AppTraceListOptions BindOptions(ParseResult parseResult) var options = base.BindOptions(parseResult); options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); - string? startRaw = parseResult.GetValueOrDefault("start-time"); - string? endRaw = parseResult.GetValueOrDefault("end-time"); - if (DateTime.TryParse(startRaw, out var startUtc)) + options.ResourceName ??= parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceName.Name); + options.ResourceId ??= parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceId.Name); + + options.Table = parseResult.GetValue(ApplicationInsightsOptionDefinitions.Table.Name); + options.Filters = parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.Filters.Name) ?? []; + + string? startRaw = parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.StartTime.Name); + string? endRaw = parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.EndTime.Name); + + // How to leverage default values from Option definitions? + options.EndTimeUtc = DateTime.UtcNow; + options.StartTimeUtc = options.EndTimeUtc.Value.AddHours(-24); + + if (DateTime.TryParse(startRaw, out DateTime startUtc)) { options.StartTimeUtc = startUtc.ToUniversalTime(); } - if (DateTime.TryParse(endRaw, out var endUtc)) + + if (DateTime.TryParse(endRaw, out DateTime endUtc)) { options.EndTimeUtc = endUtc.ToUniversalTime(); } + return options; } @@ -73,26 +87,34 @@ public override async Task ExecuteAsync(CommandContext context, var options = BindOptions(parseResult); try { - var service = context.GetService(); - var traces = await service.GetAppTracesAsync( - options.Subscription!, - options.ResourceGroup, - options.Tenant, - options.RetryPolicy, - options.StartTimeUtc, - options.EndTimeUtc); - - context.Response.Results = traces?.Any() == true ? + 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) { - _logger.LogError(ex, "Error listing Application Insights trace metadata."); + // 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; } - internal record AppTraceListCommandResult(IEnumerable Traces); + internal record AppTraceListCommandResult(AppListTraceResult? Traces); } 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..0fd7b679e5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs @@ -0,0 +1,36 @@ +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..8790019300 --- /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; } = new Dictionary(); +} 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 index 9226ef4480..73357fba76 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/AppTraceListOptions.cs @@ -1,6 +1,7 @@ // 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; @@ -10,13 +11,39 @@ namespace Azure.Mcp.Tools.ApplicationInsights.Options; /// 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..8692f15d67 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs @@ -0,0 +1,57 @@ +using System.CommandLine; + +namespace Azure.Mcp.Tools.ApplicationInsights.Options; + +public static class ApplicationInsightsOptionDefinitions +{ + public const string ResourceNameName = "resource-name"; + public const string ResourceIdName = "resource-id"; + + public const string EndTimeName = "end-time"; + public const string StartTimeName = "start-time"; + public const string TableName = "table"; + public const string FiltersName = "filters"; + + public static readonly Option ResourceName = new($"--{ResourceNameName}") + { + Required = false, + Description = "The name of the Application Insights resource.", + }; + + public static readonly Option ResourceId = new($"--{ResourceIdName}") + { + Required = false, + Description = "The resource ID 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..818975221b --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs @@ -0,0 +1,84 @@ +// 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; + +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() + { + var 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 = new Dictionary(); + var 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; + } +} 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 82ed8fc2e5..bfa6f320cf 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) @@ -37,7 +48,47 @@ public async Task> GetProfilerInsightsAsync(string subscri /// 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> GetAppTracesAsync( + 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, 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 ae2781e6f8..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; @@ -18,11 +19,15 @@ Task> GetProfilerInsightsAsync( /// 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> GetAppTracesAsync( + Task ListDistributedTracesAsync( string subscription, - string? resourceGroup = null, + string? resourceGroup, + string? resourceName, + string? resourceId, + string[] filters, + string table, + DateTime startTime, + DateTime endTime, string? tenant = null, - RetryPolicyOptions? retryPolicy = null, - DateTime? startDateTimeUtc = null, - DateTime? endDateTimeUtc = 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..4ae55de456 --- /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"; // 1d interval for longer ranges + } + } +} 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/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; From f5ce728e09a79d72b43bc7c16c8d8b2472ff5fa4 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Wed, 1 Oct 2025 15:48:16 -0700 Subject: [PATCH 03/20] Update validation logics --- .../Commands/AppTrace/AppTraceListCommand.cs | 91 ++++++++++++++++--- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs index 881235dcff..735be6d188 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.CommandLine; +using System.CommandLine.Parsing; +using System.Net; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Commands.Subscription; using Azure.Mcp.Core.Extensions; @@ -20,6 +22,8 @@ namespace Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; public sealed class AppTraceListCommand(ILogger logger) : SubscriptionCommand() { private const string CommandTitle = "List Application Insights Trace Metadata"; + private static readonly Option _startTimeOption = ApplicationInsightsOptionDefinitions.StartTime; + private static readonly Option _endTimeOption = ApplicationInsightsOptionDefinitions.EndTime; private readonly ILogger _logger = logger; public override string Name => "list"; @@ -37,13 +41,78 @@ List Application Insights trace metadata (component identifiers and time window) protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); - command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceName.AsOptional()); - command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceId.AsOptional()); - command.Options.Add(ApplicationInsightsOptionDefinitions.Table.AsRequired()); - command.Options.Add(ApplicationInsightsOptionDefinitions.Filters.AsOptional()); - command.Options.Add(ApplicationInsightsOptionDefinitions.StartTime.AsOptional()); - command.Options.Add(ApplicationInsightsOptionDefinitions.EndTime.AsOptional()); + command.Options.Add(OptionDefinitions.Common.ResourceGroup); + command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceName); + command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceId); + command.Options.Add(ApplicationInsightsOptionDefinitions.Table); + command.Options.Add(ApplicationInsightsOptionDefinitions.Filters); + command.Options.Add(_startTimeOption); + command.Options.Add(_endTimeOption); + } + + public override ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null) + { + ValidationResult result = base.Validate(commandResult, commandResponse); + + // Short circuit if base validation failed + if (!result.IsValid) + { + return result; + } + + // Additional validation: either resourceName or resourceId must be provided + string? resourceName = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceName); + string? resourceId = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceId); + if (string.IsNullOrEmpty(resourceName) && string.IsNullOrEmpty(resourceId)) + { + result.IsValid = false; + result.ErrorMessage = $"Either --{ApplicationInsightsOptionDefinitions.ResourceNameName} or --{ApplicationInsightsOptionDefinitions.ResourceIdName} must be provided."; + if (commandResponse != null) + { + commandResponse.Status = HttpStatusCode.BadRequest; + commandResponse.Message = result.ErrorMessage; + } + + return result; + } + + // Validate time range + if (!DateTime.TryParse(commandResult.GetValueOrDefault(_startTimeOption), out DateTime startTime) || + !DateTime.TryParse(commandResult.GetValueOrDefault(_endTimeOption), out DateTime endTime) || + startTime >= endTime) + { + result.IsValid = false; + result.ErrorMessage = $"Invalid time range specified. Ensure that --{ApplicationInsightsOptionDefinitions.StartTimeName} is before --{ApplicationInsightsOptionDefinitions.EndTimeName} and that --{ApplicationInsightsOptionDefinitions.StartTimeName} and --{ApplicationInsightsOptionDefinitions.EndTimeName} are valid dates in ISO format."; + if (commandResponse != null) + { + commandResponse.Status = HttpStatusCode.BadRequest; + commandResponse.Message = result.ErrorMessage; + } + + return result; + } + + // Validate table option + if (result.IsValid) + { + string? table = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.Table); + + 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.IsValid = false; + result.ErrorMessage = $"Invalid table specified. Valid options are: exceptions, dependencies, availabilityResults, requests."; + if (commandResponse != null) + { + commandResponse.Status = HttpStatusCode.BadRequest; + commandResponse.Message = result.ErrorMessage; + } + } + } + + return result; } protected override AppTraceListOptions BindOptions(ParseResult parseResult) @@ -57,12 +126,8 @@ protected override AppTraceListOptions BindOptions(ParseResult parseResult) options.Table = parseResult.GetValue(ApplicationInsightsOptionDefinitions.Table.Name); options.Filters = parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.Filters.Name) ?? []; - string? startRaw = parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.StartTime.Name); - string? endRaw = parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.EndTime.Name); - - // How to leverage default values from Option definitions? - options.EndTimeUtc = DateTime.UtcNow; - options.StartTimeUtc = options.EndTimeUtc.Value.AddHours(-24); + string? startRaw = parseResult.GetValueOrDefault(_startTimeOption.Name); + string? endRaw = parseResult.GetValueOrDefault(_endTimeOption.Name); if (DateTime.TryParse(startRaw, out DateTime startUtc)) { From e0c6e565ae849c297b6ce86513e4d1cb0fa45b21 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Wed, 1 Oct 2025 16:14:26 -0700 Subject: [PATCH 04/20] Adapt to the new validation after rebase --- .../Commands/AppTrace/AppTraceListCommand.cs | 72 +++++++------------ 1 file changed, 26 insertions(+), 46 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs index 735be6d188..70be6baadd 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -41,7 +41,7 @@ List Application Insights trace metadata (component identifiers and time window) protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(OptionDefinitions.Common.ResourceGroup); + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceName); command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceId); command.Options.Add(ApplicationInsightsOptionDefinitions.Table); @@ -50,30 +50,16 @@ protected override void RegisterOptions(Command command) command.Options.Add(_endTimeOption); } - public override ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null) + private ValidationResult OnExecuting(CommandResult commandResult, CommandResponse? commandResponse = null) { - ValidationResult result = base.Validate(commandResult, commandResponse); + ValidationResult result = new(); - // Short circuit if base validation failed - if (!result.IsValid) - { - return result; - } - - // Additional validation: either resourceName or resourceId must be provided + // Either resourceName or resourceId must be provided string? resourceName = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceName); string? resourceId = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceId); if (string.IsNullOrEmpty(resourceName) && string.IsNullOrEmpty(resourceId)) { - result.IsValid = false; - result.ErrorMessage = $"Either --{ApplicationInsightsOptionDefinitions.ResourceNameName} or --{ApplicationInsightsOptionDefinitions.ResourceIdName} must be provided."; - if (commandResponse != null) - { - commandResponse.Status = HttpStatusCode.BadRequest; - commandResponse.Message = result.ErrorMessage; - } - - return result; + result.Errors.Add($"Either --{ApplicationInsightsOptionDefinitions.ResourceNameName} or --{ApplicationInsightsOptionDefinitions.ResourceIdName} must be provided."); } // Validate time range @@ -81,35 +67,24 @@ public override ValidationResult Validate(CommandResult commandResult, CommandRe !DateTime.TryParse(commandResult.GetValueOrDefault(_endTimeOption), out DateTime endTime) || startTime >= endTime) { - result.IsValid = false; - result.ErrorMessage = $"Invalid time range specified. Ensure that --{ApplicationInsightsOptionDefinitions.StartTimeName} is before --{ApplicationInsightsOptionDefinitions.EndTimeName} and that --{ApplicationInsightsOptionDefinitions.StartTimeName} and --{ApplicationInsightsOptionDefinitions.EndTimeName} are valid dates in ISO format."; - if (commandResponse != null) - { - commandResponse.Status = HttpStatusCode.BadRequest; - commandResponse.Message = result.ErrorMessage; - } - - return result; + result.Errors.Add($"Invalid time range specified. Ensure that --{ApplicationInsightsOptionDefinitions.StartTimeName} is before --{ApplicationInsightsOptionDefinitions.EndTimeName} and that both are valid dates in ISO format."); } - // Validate table option - if (result.IsValid) + // Validate table name + string? table = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.Table); + + 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)) { - string? table = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.Table); - - 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.IsValid = false; - result.ErrorMessage = $"Invalid table specified. Valid options are: exceptions, dependencies, availabilityResults, requests."; - if (commandResponse != null) - { - commandResponse.Status = HttpStatusCode.BadRequest; - commandResponse.Message = result.ErrorMessage; - } - } + result.Errors.Add($"Invalid table specified. Valid options are: exceptions, dependencies, availabilityResults, requests."); + } + + if (!result.IsValid && commandResponse != null) + { + commandResponse.Status = HttpStatusCode.BadRequest; + commandResponse.Message = string.Join('\n', result.Errors); } return result; @@ -149,7 +124,12 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } - var options = BindOptions(parseResult); + if (!OnExecuting(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + AppTraceListOptions options = BindOptions(parseResult); try { IApplicationInsightsService service = context.GetService(); From ffcd8d0922f148a2b92e973938b66ac32f694453 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Wed, 1 Oct 2025 16:21:43 -0700 Subject: [PATCH 05/20] Add a basic unit test for app trace list command --- .../AppTraceListCommandTests.cs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.UnitTests/AppTraceListCommandTests.cs 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..d0483bf4ea --- /dev/null +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.UnitTests/AppTraceListCommandTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Nodes; // kept for potential future parsing +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); + } +} From 0751ee4ef3eace857364b02ad4db640b3e435eaa Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 10:57:51 -0700 Subject: [PATCH 06/20] Remove live test that's not ready --- .../ApplicationInsightsCommandTests.cs | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs deleted file mode 100644 index 9676f61a74..0000000000 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/tests/Azure.Mcp.Tools.ApplicationInsights.LiveTests/ApplicationInsightsCommandTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Azure.Mcp.Tests; -using Azure.Mcp.Tests.Client; -using Azure.Mcp.Tests.Client.Helpers; -using Xunit; - -namespace Azure.Mcp.Tools.ApplicationInsights.LiveTests; - -[Trait("Area", "ApplicationInsights")] -[Trait("Category", "Live")] -public class ApplicationInsightsCommandTests(ITestOutputHelper output) : CommandTestsBase(output) -{ - [Fact] - public async Task Should_list_applicationinsights_recommendations_by_subscription() - { - var result = await CallToolAsync( - "azmcp_applicationinsights_recommendation_list", - new() - { - { "subscription", Settings.SubscriptionId } - }); - - var recommendations = result.AssertProperty("recommendations"); - Assert.Equal(JsonValueKind.Array, recommendations.ValueKind); - // Note: recommendations array can be empty if no profiler-based recommendations are available. - } - - [Fact] - public async Task Should_list_applicationinsights_recommendations_by_subscription_and_resource_group() - { - var result = await CallToolAsync( - "azmcp_applicationinsights_recommendation_list", - new() - { - { "subscription", Settings.SubscriptionId }, - { "resource-group", Settings.ResourceGroupName } - }); - - var recommendations = result.AssertProperty("recommendations"); - Assert.Equal(JsonValueKind.Array, recommendations.ValueKind); - // Note: recommendations array can be empty if no profiler-based recommendations are available in the RG. - } -} From 95635ca64e5e6e58a3df7004ac2aeed434c08e9a Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 11:20:54 -0700 Subject: [PATCH 07/20] Fix formatting --- .../src/ApplicationInsightsSetup.cs | 4 ++-- .../AppTraceListCommandTests.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs index 78e6f0ed3f..2ee3fda273 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs @@ -4,11 +4,11 @@ using Azure.Mcp.Core.Areas; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Extensions; -using Azure.Mcp.Tools.ApplicationInsights.Commands.Recommendation; +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; -using Azure.Mcp.Core.Services.Azure.Resource; namespace Azure.Mcp.Tools.ApplicationInsights; 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 index d0483bf4ea..3c26f0c811 100644 --- 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 @@ -83,9 +83,9 @@ public async Task ExecuteAsync_WhenServiceReturnsNoTraces_NoResults() 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); + // 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] From 35c10a56487fb2c0a2ccafe973daebf3928eab8a Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 12:02:37 -0700 Subject: [PATCH 08/20] Simplify option access in AppTraceList command --- .vscode/cspell.json | 16 +++- .../Commands/AppTrace/AppTraceListCommand.cs | 78 +++++++++++++------ .../src/Services/AppLogsQueryClient.cs | 2 +- 3 files changed, 66 insertions(+), 30 deletions(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index ab3cee428c..b937f44310 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -268,9 +268,9 @@ "words": [ "1espt", "aarch", - "accesspolicy", "acaenvironment", "activitylog", + "accesspolicy", "adminprovider", "agentic", "aieval", @@ -280,8 +280,8 @@ "alcoop", "amlfs", "aoai", - "apos", "apim", + "apos", "appconfig", "applens", "appservice", @@ -387,6 +387,7 @@ "datasources", "dataverse", "dbforpostgresql", + "dcount", "deallocate", "debugtelemetry", "deregistering", @@ -435,6 +436,8 @@ "hostpools", "idempotency", "idtyp", + "ifexists", + "IKQL", "indonesiacentral", "infile", "intelli", @@ -453,6 +456,7 @@ "kusto", "kvps", "lakehouse", + "leftouter", "liftr", "ligar", "linkedservices", @@ -500,8 +504,8 @@ "northeurope", "norwayeast", "norwaywest", - "npmjs", "npgsql", + "npmjs", "nupkg", "nuxt", "occured", @@ -518,8 +522,8 @@ "paygo", "persistable", "pgrep", - "piechart", "pids", + "piechart", "polandcentral", "portalsettings", "predeploy", @@ -538,6 +542,7 @@ "resourcehealth", "RESTAPI", "rhvm", + "rightouter", "rulesets", "runtimes", "searchdocs", @@ -581,8 +586,11 @@ "tfvars", "timechart", "timespan", + "todouble", + "toint", "toolset", "toolsets", + "toscalar", "typespec", "uaenorth", "uksouth", diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs index 70be6baadd..340f6ff952 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -13,6 +13,7 @@ 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; @@ -22,18 +23,45 @@ namespace Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; public sealed class AppTraceListCommand(ILogger logger) : SubscriptionCommand() { private const string CommandTitle = "List Application Insights Trace Metadata"; - private static readonly Option _startTimeOption = ApplicationInsightsOptionDefinitions.StartTime; - private static readonly Option _endTimeOption = ApplicationInsightsOptionDefinitions.EndTime; private readonly ILogger _logger = logger; public override string Name => "list"; public override string Description => - """ - List Application Insights trace metadata (component identifiers and time window) in a subscription. Optionally filter by resource group when --resource-group is provided. - This is an initial implementation that returns component metadata and a requested time window; future versions may return detailed trace/span data. + $$""" + 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\""] + + Filter to request failures with 500 code + "table": "requests", + "filters": ["success=\"false\"", "resultCode=\"500\""] + + Filter to requests slower than 95th percentile (use start and end time filters to filter to the duration spike). Any percentile is valid (e.g. 99p is also valid) + "table": "requests", + "filters": ["duration=\"95p\""], + "start-time":"start of spike (ISO date)", + "end-time":"end of spike (ISO date)" + + Use this tool for investigating issues with Application Insights resources. + Required options: + - {{ResourceName.Name}}: {{ResourceName.Description}} or {{ResourceId.Name}}: {{ResourceId.Description}} + - {{Table.Name}}: {{Table.Description}} + Optional options: + - {{Filters.Name}}: {{Filters.Description}} + - {{OptionDefinitions.Common.ResourceGroup.Name}}: {{OptionDefinitions.Common.ResourceGroup.Description}} + - {{StartTime.Name}}: {{StartTime.Description}} + - {{EndTime.Name}}: {{EndTime.Description}} """; + public override string Title => CommandTitle; public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, LocalRequired = false, OpenWorld = false, Secret = false, ReadOnly = true }; @@ -42,12 +70,12 @@ protected override void RegisterOptions(Command command) { base.RegisterOptions(command); command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); - command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceName); - command.Options.Add(ApplicationInsightsOptionDefinitions.ResourceId); - command.Options.Add(ApplicationInsightsOptionDefinitions.Table); - command.Options.Add(ApplicationInsightsOptionDefinitions.Filters); - command.Options.Add(_startTimeOption); - command.Options.Add(_endTimeOption); + command.Options.Add(ResourceName); + command.Options.Add(ResourceId); + command.Options.Add(Table); + command.Options.Add(Filters); + command.Options.Add(StartTime); + command.Options.Add(EndTime); } private ValidationResult OnExecuting(CommandResult commandResult, CommandResponse? commandResponse = null) @@ -55,23 +83,23 @@ private ValidationResult OnExecuting(CommandResult commandResult, CommandRespons ValidationResult result = new(); // Either resourceName or resourceId must be provided - string? resourceName = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceName); - string? resourceId = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceId); + string? resourceName = commandResult.GetValueOrDefault(ResourceName); + string? resourceId = commandResult.GetValueOrDefault(ResourceId); if (string.IsNullOrEmpty(resourceName) && string.IsNullOrEmpty(resourceId)) { - result.Errors.Add($"Either --{ApplicationInsightsOptionDefinitions.ResourceNameName} or --{ApplicationInsightsOptionDefinitions.ResourceIdName} must be provided."); + result.Errors.Add($"Either --{ResourceNameName} or --{ResourceIdName} must be provided."); } // Validate time range - if (!DateTime.TryParse(commandResult.GetValueOrDefault(_startTimeOption), out DateTime startTime) || - !DateTime.TryParse(commandResult.GetValueOrDefault(_endTimeOption), out DateTime endTime) || + if (!DateTime.TryParse(commandResult.GetValueOrDefault(StartTime), out DateTime startTime) || + !DateTime.TryParse(commandResult.GetValueOrDefault(EndTime), out DateTime endTime) || startTime >= endTime) { - result.Errors.Add($"Invalid time range specified. Ensure that --{ApplicationInsightsOptionDefinitions.StartTimeName} is before --{ApplicationInsightsOptionDefinitions.EndTimeName} and that both are valid dates in ISO format."); + result.Errors.Add($"Invalid time range specified. Ensure that --{StartTimeName} is before --{EndTimeName} and that both are valid dates in ISO format."); } // Validate table name - string? table = commandResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.Table); + string? table = commandResult.GetValueOrDefault(Table); if (!string.Equals(table, "exceptions", StringComparison.OrdinalIgnoreCase) && !string.Equals(table, "dependencies", StringComparison.OrdinalIgnoreCase) && @@ -93,16 +121,16 @@ private ValidationResult OnExecuting(CommandResult commandResult, CommandRespons protected override AppTraceListOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); - options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup); - options.ResourceName ??= parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceName.Name); - options.ResourceId ??= parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.ResourceId.Name); + options.ResourceName ??= parseResult.GetValueOrDefault(ResourceName); + options.ResourceId ??= parseResult.GetValueOrDefault(ResourceId); - options.Table = parseResult.GetValue(ApplicationInsightsOptionDefinitions.Table.Name); - options.Filters = parseResult.GetValueOrDefault(ApplicationInsightsOptionDefinitions.Filters.Name) ?? []; + options.Table = parseResult.GetValue(Table); + options.Filters = parseResult.GetValueOrDefault(Filters) ?? []; - string? startRaw = parseResult.GetValueOrDefault(_startTimeOption.Name); - string? endRaw = parseResult.GetValueOrDefault(_endTimeOption.Name); + string? startRaw = parseResult.GetValueOrDefault(StartTime); + string? endRaw = parseResult.GetValueOrDefault(EndTime); if (DateTime.TryParse(startRaw, out DateTime startUtc)) { diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs index 818975221b..7f1f7e3956 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs @@ -77,7 +77,7 @@ public class AppLogsQueryClient(LogsQueryClient logsQueryClient) : IAppLogsQuery Data = retObj, OtherColumns = otherColumns }; - }).ToList(); // Use tolist to force conversion to happen now, not on demand + }).ToList(); // Use ToList to force conversion to happen now, not on demand return rows; } From b0324e52c10a65ab1fd3e7f45996247a3113d8df Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 14:43:22 -0700 Subject: [PATCH 09/20] Address a format issue manually --- .../src/Models/AppListTraceEntry.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs index 0fd7b679e5..73ff136a3b 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppListTraceEntry.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; namespace Azure.Mcp.Tools.ApplicationInsights.Models; + public class AppListTraceEntry { [JsonPropertyName("operation_Name")] From 28c3e346c861f762cd4336b519020973bdba9f6a Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 15:03:33 -0700 Subject: [PATCH 10/20] Update readme --- servers/Azure.Mcp.Server/README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 From 515a5a84054e6387396aacfabc22023b3d718985 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 15:16:11 -0700 Subject: [PATCH 11/20] Update commands markdown --- .../Azure.Mcp.Server/docs/azmcp-commands.md | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) 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 From 2f3c98e62fa45117be4f606cc7e97218a95dc830 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 15:54:37 -0700 Subject: [PATCH 12/20] Update e2e test prompt and run description eval --- .vscode/cspell.json | 1 + servers/Azure.Mcp.Server/docs/e2eTestPrompts.md | 6 ++++++ .../src/ApplicationInsightsSetup.cs | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index b937f44310..dc66c4adb2 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -285,6 +285,7 @@ "appconfig", "applens", "appservice", + "apptrace", "aspnetcore", "australiacentral", "australiaeast", 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 2ee3fda273..a8837737e0 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/ApplicationInsightsSetup.cs @@ -48,7 +48,7 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var recommendationGroup = new CommandGroup("recommendation", "Application Insights recommendation operations - list recommendation targets (components)."); group.AddSubGroup(recommendationGroup); - var appInsightsGroup = new CommandGroup("app-trace", "Application Insights trace operations - list traces and spans for components."); + var appInsightsGroup = new CommandGroup("apptrace", "Application Insights trace operations - list traces and spans for components."); group.AddSubGroup(appInsightsGroup); var recommendationList = serviceProvider.GetRequiredService(); From 9cf0c20f1ae6c05810d27420f21de59847927851 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 2 Oct 2025 15:58:12 -0700 Subject: [PATCH 13/20] Update the change log --- servers/Azure.Mcp.Server/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 118fa7e69c6c6f6ae6066e3dc8432a6d46a0d09d Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Fri, 3 Oct 2025 15:22:52 -0700 Subject: [PATCH 14/20] Fix some minor issues spotted in PR review --- .../Commands/AppTrace/AppTraceListCommand.cs | 21 ------------------- .../src/Models/AppLogsQueryRow.cs | 2 +- .../ApplicationInsightsOptionDefinitions.cs | 14 ++++++------- .../src/Services/AppLogsQueryClient.cs | 10 ++++----- .../src/Services/KQLQueryBuilder.cs | 2 +- .../AppTraceListCommandTests.cs | 1 - 6 files changed, 12 insertions(+), 38 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs index 340f6ff952..75f010bde5 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -39,29 +39,8 @@ List the most relevant traces from an Application Insights table. Filter to dependency failures "table": "dependencies", "filters": ["success=\"false\""] - - Filter to request failures with 500 code - "table": "requests", - "filters": ["success=\"false\"", "resultCode=\"500\""] - - Filter to requests slower than 95th percentile (use start and end time filters to filter to the duration spike). Any percentile is valid (e.g. 99p is also valid) - "table": "requests", - "filters": ["duration=\"95p\""], - "start-time":"start of spike (ISO date)", - "end-time":"end of spike (ISO date)" - - Use this tool for investigating issues with Application Insights resources. - Required options: - - {{ResourceName.Name}}: {{ResourceName.Description}} or {{ResourceId.Name}}: {{ResourceId.Description}} - - {{Table.Name}}: {{Table.Description}} - Optional options: - - {{Filters.Name}}: {{Filters.Description}} - - {{OptionDefinitions.Common.ResourceGroup.Name}}: {{OptionDefinitions.Common.ResourceGroup.Description}} - - {{StartTime.Name}}: {{StartTime.Description}} - - {{EndTime.Name}}: {{EndTime.Description}} """; - public override string Title => CommandTitle; public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, LocalRequired = false, OpenWorld = false, Secret = false, ReadOnly = true }; diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs index 8790019300..b82ce85a13 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Models/AppLogsQueryRow.cs @@ -6,5 +6,5 @@ namespace Azure.Mcp.Tools.ApplicationInsights.Models; public class AppLogsQueryRow { public required T Data { get; set; } - public Dictionary OtherColumns { get; set; } = new Dictionary(); + public Dictionary OtherColumns { get; set; } = []; } diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs index 8692f15d67..44ba0fb066 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Options/ApplicationInsightsOptionDefinitions.cs @@ -4,24 +4,23 @@ namespace Azure.Mcp.Tools.ApplicationInsights.Options; public static class ApplicationInsightsOptionDefinitions { - public const string ResourceNameName = "resource-name"; public const string ResourceIdName = "resource-id"; - - public const string EndTimeName = "end-time"; + 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 ResourceName = new($"--{ResourceNameName}") + public static readonly Option ResourceId = new($"--{ResourceIdName}") { Required = false, - Description = "The name of the Application Insights resource.", + Description = "The resource ID of the Application Insights resource.", }; - public static readonly Option ResourceId = new($"--{ResourceIdName}") + public static readonly Option ResourceName = new($"--{ResourceNameName}") { Required = false, - Description = "The resource ID of the Application Insights resource.", + Description = "The name of the Application Insights resource.", }; public static readonly Option StartTime = new($"--{StartTimeName}") @@ -38,7 +37,6 @@ public static class ApplicationInsightsOptionDefinitions 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}") { diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs index 7f1f7e3956..66c9c3af05 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs @@ -6,6 +6,7 @@ using Azure.Core; using Azure.Mcp.Tools.ApplicationInsights.Models; using Azure.Monitor.Query; +using Azure.Monitor.Query.Models; namespace Azure.Mcp.Tools.ApplicationInsights.Services; @@ -15,7 +16,7 @@ public class AppLogsQueryClient(LogsQueryClient logsQueryClient) : IAppLogsQuery public async Task>> QueryResourceAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(ResourceIdentifier resourceId, string kql, QueryTimeRange timeRange) where T : new() { - var response = await _logsQueryClient.QueryResourceAsync( + Response response = await _logsQueryClient.QueryResourceAsync( resourceId, kql, timeRange); @@ -23,8 +24,8 @@ public class AppLogsQueryClient(LogsQueryClient logsQueryClient) : IAppLogsQuery 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 = new Dictionary(); - var columns = response.Value.Table.Columns; + Dictionary conversionMap = []; + IReadOnlyList columns = response.Value.Table.Columns; for (int i = 0; i < columns.Count; i++) { var column = columns[i]; @@ -40,9 +41,6 @@ public class AppLogsQueryClient(LogsQueryClient logsQueryClient) : IAppLogsQuery } // Now we can generate the final data - - - var rows = response.Value.Table.Rows.Select(row => { Dictionary otherColumns = new Dictionary(); diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs index 4ae55de456..99aaf41a85 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/KQLQueryBuilder.cs @@ -200,7 +200,7 @@ public string GetKqlInterval(DateTime start, DateTime end) } else { - return "2d"; // 1d interval for longer ranges + 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 index 3c26f0c811..a8909e86bb 100644 --- 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 @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.Json.Nodes; // kept for potential future parsing using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Options; using Azure.Mcp.Tools.ApplicationInsights.Commands.AppTrace; From 73e91bf8ac71769bb65317a9150a3630be7fb9a6 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Fri, 3 Oct 2025 15:46:47 -0700 Subject: [PATCH 15/20] Hook up validators --- .../Commands/AppTrace/AppTraceListCommand.cs | 137 +++++++++--------- 1 file changed, 65 insertions(+), 72 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs index 75f010bde5..7f63f8d1d6 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -3,7 +3,6 @@ using System.CommandLine; using System.CommandLine.Parsing; -using System.Net; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Commands.Subscription; using Azure.Mcp.Core.Extensions; @@ -45,6 +44,45 @@ Filter to dependency failures public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, LocalRequired = false, OpenWorld = false, Secret = false, ReadOnly = true }; + 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); @@ -55,46 +93,10 @@ protected override void RegisterOptions(Command command) command.Options.Add(Filters); command.Options.Add(StartTime); command.Options.Add(EndTime); - } - private ValidationResult OnExecuting(CommandResult commandResult, CommandResponse? commandResponse = null) - { - ValidationResult result = new(); - - // Either resourceName or resourceId must be provided - string? resourceName = commandResult.GetValueOrDefault(ResourceName); - string? resourceId = commandResult.GetValueOrDefault(ResourceId); - if (string.IsNullOrEmpty(resourceName) && string.IsNullOrEmpty(resourceId)) - { - result.Errors.Add($"Either --{ResourceNameName} or --{ResourceIdName} must be provided."); - } - - // Validate time range - if (!DateTime.TryParse(commandResult.GetValueOrDefault(StartTime), out DateTime startTime) || - !DateTime.TryParse(commandResult.GetValueOrDefault(EndTime), out DateTime endTime) || - startTime >= endTime) - { - result.Errors.Add($"Invalid time range specified. Ensure that --{StartTimeName} is before --{EndTimeName} and that both are valid dates in ISO format."); - } - - // Validate table name - string? table = commandResult.GetValueOrDefault(Table); - - 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.Errors.Add($"Invalid table specified. Valid options are: exceptions, dependencies, availabilityResults, requests."); - } - - if (!result.IsValid && commandResponse != null) - { - commandResponse.Status = HttpStatusCode.BadRequest; - commandResponse.Message = string.Join('\n', result.Errors); - } - - return result; + command.Validators.Add(ResourceNameOrIdRequired); + command.Validators.Add(TimeRangeValid); + command.Validators.Add(TableNameValid); } protected override AppTraceListOptions BindOptions(ParseResult parseResult) @@ -124,48 +126,39 @@ protected override AppTraceListOptions BindOptions(ParseResult parseResult) return options; } - public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + private void ResourceNameOrIdRequired(CommandResult result) { - if (!Validate(parseResult.CommandResult, context.Response).IsValid) + // Either resourceName or resourceId must be provided + string? resourceName = result.GetValueOrDefault(ResourceName); + string? resourceId = result.GetValueOrDefault(ResourceId); + + if (string.IsNullOrEmpty(resourceName) && string.IsNullOrEmpty(resourceId)) { - return context.Response; + result.AddError($"Either --{ResourceNameName} or --{ResourceIdName} must be provided."); } + } - if (!OnExecuting(parseResult.CommandResult, context.Response).IsValid) + private void TimeRangeValid(CommandResult result) + { + if (!DateTime.TryParse(result.GetValueOrDefault(StartTime), out DateTime startTime) || + !DateTime.TryParse(result.GetValueOrDefault(EndTime), out DateTime endTime) || + startTime >= endTime) { - return context.Response; + result.AddError($"Invalid time range specified. Ensure that --{StartTimeName} is before --{EndTimeName} and that both are valid dates in ISO format."); } + } - 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); + private void TableNameValid(CommandResult result) + { + string? table = result.GetValueOrDefault(Table); - context.Response.Results = traces is not null ? - ResponseResult.Create(new AppTraceListCommandResult(traces), ApplicationInsightsJsonContext.Default.AppTraceListCommandResult) : - null; - } - catch (Exception ex) + 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)) { - // 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); + result.AddError($"Invalid table specified. Valid options are: exceptions, dependencies, availabilityResults, requests."); } - - return context.Response; } internal record AppTraceListCommandResult(AppListTraceResult? Traces); From a1fff0db0fa2dc74b0d4f60cff64cf3927875ec6 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Wed, 8 Oct 2025 12:09:55 -0700 Subject: [PATCH 16/20] Use name for option look up --- .../src/Extensions/CommandResultExtensions.cs | 6 +++++ .../Commands/AppTrace/AppTraceListCommand.cs | 24 +++++++++---------- 2 files changed, 18 insertions(+), 12 deletions(-) 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.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs index 7f63f8d1d6..5c255f9db8 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -102,16 +102,16 @@ protected override void RegisterOptions(Command command) protected override AppTraceListOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); - options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); - options.ResourceName ??= parseResult.GetValueOrDefault(ResourceName); - options.ResourceId ??= parseResult.GetValueOrDefault(ResourceId); + options.ResourceName ??= parseResult.GetValueOrDefault(ResourceName.Name); + options.ResourceId ??= parseResult.GetValueOrDefault(ResourceId.Name); - options.Table = parseResult.GetValue(Table); - options.Filters = parseResult.GetValueOrDefault(Filters) ?? []; + options.Table = parseResult.GetValueOrDefault(Table.Name); + options.Filters = parseResult.GetValueOrDefault(Filters.Name) ?? []; - string? startRaw = parseResult.GetValueOrDefault(StartTime); - string? endRaw = parseResult.GetValueOrDefault(EndTime); + string? startRaw = parseResult.GetValueOrDefault(StartTime.Name); + string? endRaw = parseResult.GetValueOrDefault(EndTime.Name); if (DateTime.TryParse(startRaw, out DateTime startUtc)) { @@ -129,8 +129,8 @@ protected override AppTraceListOptions BindOptions(ParseResult parseResult) private void ResourceNameOrIdRequired(CommandResult result) { // Either resourceName or resourceId must be provided - string? resourceName = result.GetValueOrDefault(ResourceName); - string? resourceId = result.GetValueOrDefault(ResourceId); + string? resourceName = result.GetValueOrDefault(ResourceName.Name); + string? resourceId = result.GetValueOrDefault(ResourceId.Name); if (string.IsNullOrEmpty(resourceName) && string.IsNullOrEmpty(resourceId)) { @@ -140,8 +140,8 @@ private void ResourceNameOrIdRequired(CommandResult result) private void TimeRangeValid(CommandResult result) { - if (!DateTime.TryParse(result.GetValueOrDefault(StartTime), out DateTime startTime) || - !DateTime.TryParse(result.GetValueOrDefault(EndTime), out DateTime endTime) || + 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."); @@ -150,7 +150,7 @@ private void TimeRangeValid(CommandResult result) private void TableNameValid(CommandResult result) { - string? table = result.GetValueOrDefault(Table); + string? table = result.GetValueOrDefault(Table.Name); if (!string.Equals(table, "exceptions", StringComparison.OrdinalIgnoreCase) && !string.Equals(table, "dependencies", StringComparison.OrdinalIgnoreCase) && From 824eeccd6be7132ef3a1aa8b4ca5ebb27ffe9202 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 9 Oct 2025 14:22:14 -0700 Subject: [PATCH 17/20] Fix build error due to recent change --- .../src/Services/ApplicationInsightsService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs index bfa6f320cf..b5dcdeed85 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/ApplicationInsightsService.cs @@ -96,7 +96,7 @@ public async Task> ListDistributedTracesAsync( DateTime? startDateTimeUtc = null, DateTime? endDateTimeUtc = null) { - ValidateRequiredParameters(subscription); + ValidateRequiredParameters((nameof(subscription), subscription)); List results = []; try From 3a12918cb2d44051437e6532755eb038aa5ec4d1 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 9 Oct 2025 15:03:20 -0700 Subject: [PATCH 18/20] Fix another build error --- tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorService.cs | 1 + .../Azure.Mcp.Tools.Monitor.LiveTests/MonitorCommandTests.cs | 1 + 2 files changed, 2 insertions(+) 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; From 226bd9a985cf82b6a21128c0f0b3753744e769f7 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Mon, 13 Oct 2025 11:10:07 -0700 Subject: [PATCH 19/20] Make list immutable for app logs query client --- .../src/Services/AppLogsQueryClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs index 66c9c3af05..9b9a59a8bd 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Services/AppLogsQueryClient.cs @@ -77,6 +77,6 @@ public class AppLogsQueryClient(LogsQueryClient logsQueryClient) : IAppLogsQuery }; }).ToList(); // Use ToList to force conversion to happen now, not on demand - return rows; + return rows.AsReadOnly(); } } From d6dc71d1c500ffb56301e76097b5ec8e6dd0b1c7 Mon Sep 17 00:00:00 2001 From: Saar Shen Date: Thu, 30 Oct 2025 16:02:28 -0700 Subject: [PATCH 20/20] Add unique id for the app trace list command --- .../src/Commands/AppTrace/AppTraceListCommand.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs index 5c255f9db8..27ed6bc0a5 100644 --- a/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs +++ b/tools/Azure.Mcp.Tools.ApplicationInsights/src/Commands/AppTrace/AppTraceListCommand.cs @@ -44,6 +44,8 @@ Filter to dependency failures 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)