diff --git a/Directory.Packages.props b/Directory.Packages.props index 1e72e808dd..9f1c753617 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/servers/Azure.Mcp.Server/changelog-entries/monitor-metrics-batchquery.yml b/servers/Azure.Mcp.Server/changelog-entries/monitor-metrics-batchquery.yml new file mode 100644 index 0000000000..5df91d7230 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/monitor-metrics-batchquery.yml @@ -0,0 +1,3 @@ +changes: + - section: Features Added + description: Added the `azmcp monitor metrics batchquery` command for querying Azure Monitor metrics across multiple resources in a single request. diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index ca869c8ca8..4088918462 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -2859,7 +2859,7 @@ azmcp insights get --scope tenant \ azmcp iothub hub get --subscription \ --resource-group \ --hub-name -``` +``` ### Azure Key Vault Operations @@ -3292,6 +3292,36 @@ azmcp monitor metrics query --subscription \ --end-time "2024-01-01T23:59:59Z" \ --interval "PT1H" \ --aggregation "Average" + +# Query Azure Monitor metrics for multiple resources in a single batch request +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp monitor metrics batchquery --subscription \ + --resources \ + --metric-namespace \ + --metric-names \ + [--resource-group ] \ + [--resource-type ] \ + [--start-time ] \ + [--end-time ] \ + [--interval ] \ + [--aggregation ] \ + [--filter ] \ + [--order-by ] \ + [--top ] \ + [--max-buckets ] + +# Query CPU metrics across multiple storage accounts at once +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp monitor metrics batchquery --subscription \ + --resources "storageaccount1,storageaccount2,storageaccount3" \ + --resource-group \ + --resource-type "Microsoft.Storage/storageAccounts" \ + --metric-namespace "Microsoft.Storage/storageAccounts" \ + --metric-names "Transactions" \ + --start-time "2024-01-01T00:00:00Z" \ + --end-time "2024-01-01T23:59:59Z" \ + --interval "PT1H" \ + --aggregation "Total" ``` #### Web Tests (Availability Tests) diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index bc0d0da564..6dc40a2d78 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -866,6 +866,9 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | monitor_instrumentation_send-enhancement-select | Submit enhancement selection keys for Azure Monitor instrumentation session after enhancement options are presented | investigation-required | | monitor_instrumentation_send-enhancement-select | Continue instrumentation enhancement flow by sending selected keys to session | none | | monitor_instrumentation_send-enhancement-select | Send chosen enhancement option keys to Azure Monitor instrumentation onboarding session | none | +| monitor_metrics_batchquery | Get the metric for storage accounts , , and over the last | none | +| monitor_metrics_batchquery | Compare across resources and in resource group for the last | none | +| monitor_metrics_batchquery | Query for multiple resources , in one request | none | | monitor_metrics_definitions | Get metric definitions for from the namespace | none | | monitor_metrics_definitions | Show me all available metrics and their definitions for storage account | none | | monitor_metrics_definitions | What metric definitions are available for the Application Insights resource | none | diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 28fb049433..9f27a06069 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -544,6 +544,7 @@ "monitor_activitylog_list", "monitor_healthmodels_list", "monitor_healthmodels_get", + "monitor_metrics_batchquery", "monitor_metrics_definitions", "monitor_metrics_query", "monitor_resource_log_query", diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj b/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj index 3a03b666a8..39f5546962 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj +++ b/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj @@ -25,6 +25,7 @@ + diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/Metrics/MetricsBatchQueryCommand.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/Metrics/MetricsBatchQueryCommand.cs new file mode 100644 index 0000000000..e33c709bff --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/Metrics/MetricsBatchQueryCommand.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.Monitor.Models; +using Azure.Mcp.Tools.Monitor.Options.Metrics; +using Azure.Mcp.Tools.Monitor.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.Monitor.Commands.Metrics; + +/// +/// Command for querying Azure Monitor metrics across multiple resources in a single batch request +/// +[CommandMetadata( + Id = "6c1b0f5f-04c1-4b2e-8f0b-0d6f4f7cba2e", + Name = "batchquery", + Title = "Query Azure Monitor Metrics for Multiple Resources", + Description = "Query Azure Monitor metrics for multiple resources in a single batch request. Returns time series data for the specified metrics, grouped by resource. All resources must belong to the same subscription, Azure region, and resource type.", + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class MetricsBatchQueryCommand(ILogger logger, IMonitorMetricsService metricsService, ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly IMonitorMetricsService _metricsService = metricsService; + + public override void ValidateOptions(MetricsBatchQueryOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (string.IsNullOrWhiteSpace(options.Resources)) + { + validationResult.Errors.Add($"Invalid format for '--resources'. Provide a comma-separated list of resource names or resource IDs to query (e.g. resource1,resource2)."); + } + else + { + string[] resources = [.. options.Resources.Split(',').Select(t => t.Trim())]; + if (resources.Length == 0 || resources.Any(s => string.IsNullOrWhiteSpace(s))) + { + validationResult.Errors.Add($"Invalid format for '--resources'. Provide a comma-separated list of resource names or resource IDs to query (e.g. resource1,resource2)."); + } + } + + if (string.IsNullOrWhiteSpace(options.MetricNames)) + { + validationResult.Errors.Add($"Invalid format for '--metric-names'. Provide a comma-separated list of metric names to query (e.g. CPU,memory)."); + } + else + { + string[] metricNames = [.. options.MetricNames.Split(',').Select(t => t.Trim())]; + + if (metricNames.Length == 0 || metricNames.Any(s => string.IsNullOrWhiteSpace(s))) + { + validationResult.Errors.Add($"Invalid format for '--metric-names'. Provide a comma-separated list of metric names to query (e.g. CPU,memory)."); + } + } + } + + public override void PostBindOptions(MetricsBatchQueryOptions options) + { + base.PostBindOptions(options); + options.StartTime ??= DateTime.UtcNow.AddHours(-24).ToString("o"); // Default to 24 hours ago if not specified + options.EndTime ??= DateTime.UtcNow.ToString("o"); // Default to now if not specified + } + + public override async Task ExecuteAsync(CommandContext context, MetricsBatchQueryOptions options, CancellationToken cancellationToken) + { + try + { + string[] resources = [.. options.Resources.Split(',').Select(t => t.Trim())]; + string[] metricNames = [.. options.MetricNames.Split(',').Select(t => t.Trim())]; + + var results = await _metricsService.QueryMetricsBatchAsync( + options.Subscription!, + options.ResourceGroup, + options.ResourceType, + resources, + options.MetricNamespace, + metricNames, + options.StartTime, + options.EndTime, + options.Interval, + options.Aggregation, + options.Filter, + options.OrderBy, + options.Top, + options.Tenant, + cancellationToken); + + // Validate bucket count limit + if (results?.Count > 0) + { + int maxBuckets = options.MaxBuckets ?? 50; // Use provided value or default to 50 + + foreach (var resourceResult in results) + { + foreach (var metric in resourceResult.Metrics) + { + foreach (var timeSeries in metric.TimeSeries) + { + // Check each bucket array for exceeding the limit + var bucketCounts = new[] + { + timeSeries.AvgBuckets?.Length ?? 0, + timeSeries.MinBuckets?.Length ?? 0, + timeSeries.MaxBuckets?.Length ?? 0, + timeSeries.TotalBuckets?.Length ?? 0, + timeSeries.CountBuckets?.Length ?? 0 + }; + + int maxBucketCount = bucketCounts.Max(); + + if (maxBucketCount > maxBuckets) + { + string errorMessage = $"Time series for metric '{metric.Name}' on resource '{resourceResult.ResourceId}' contains {maxBucketCount} time buckets, " + + $"which exceeds the maximum allowed limit of {maxBuckets}. " + + $"To resolve this issue, either query a smaller time range, " + + $"increase the interval size (e.g., use PT1H instead of PT5M), " + + $"or increase the --max-buckets parameter."; + + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = errorMessage; + + _logger.LogWarning("Bucket limit exceeded. ResourceId: {ResourceId}, MetricName: {MetricName}, BucketCount: {BucketCount}, MaxBuckets: {MaxBuckets}", + resourceResult.ResourceId, metric.Name, maxBucketCount, maxBuckets); + + return context.Response; + } + } + } + } + } + + // Set results + context.Response.Results = ResponseResult.Create(new(results ?? []), MonitorJsonContext.Default.MetricsBatchQueryCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error querying batch metrics. ResourceGroup: {ResourceGroup}, ResourceType: {ResourceType}, Resources: {Resources}, MetricNames: {MetricNames}.", + options.ResourceGroup, options.ResourceType, options.Resources, options.MetricNames); + HandleException(context, ex); + } + + return context.Response; + } + + // Strongly-typed result records + public sealed record MetricsBatchQueryCommandResult(List Results); +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs index 3f0e59a315..84c2d04f35 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs @@ -29,10 +29,13 @@ namespace Azure.Mcp.Tools.Monitor.Commands; [JsonSerializable(typeof(HealthModelSummary))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(MetricsBatchQueryCommand.MetricsBatchQueryCommandResult))] [JsonSerializable(typeof(MetricsDefinitionsCommand.MetricsDefinitionsCommandResult))] [JsonSerializable(typeof(MetricsDefinitionsCommand.MetricsDefinitionsCommandResult))] [JsonSerializable(typeof(MetricsQueryCommand.MetricsQueryCommandResult))] [JsonSerializable(typeof(MetricsQueryCommand.MetricsQueryCommandResult))] +[JsonSerializable(typeof(Azure.Mcp.Tools.Monitor.Models.ResourceMetricsResult))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(TableListCommand.TableListCommandResult))] [JsonSerializable(typeof(TableTypeListCommand.TableTypeListCommandResult))] [JsonSerializable(typeof(WebTestsCreateOrUpdateCommand.WebTestsCreateOrUpdateCommandResult))] diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/ResourceMetricsResult.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/ResourceMetricsResult.cs new file mode 100644 index 0000000000..16cc5184fa --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/ResourceMetricsResult.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Monitor.Models; + +/// +/// Represents the compact metric results for a single resource returned from a batch metrics query +/// +public class ResourceMetricsResult +{ + /// + /// The resource ID the metrics were queried for + /// + [JsonPropertyName("resourceId")] + public string ResourceId { get; set; } = string.Empty; + + /// + /// The compact metric results for this resource + /// + [JsonPropertyName("metrics")] + public List Metrics { get; set; } = []; +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs index 732c4d6c1f..bd8f643269 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs @@ -82,6 +82,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -151,6 +152,7 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) monitor.AddSubGroup(metrics); metrics.AddCommand(serviceProvider); + metrics.AddCommand(serviceProvider); metrics.AddCommand(serviceProvider); var activityLog = new CommandGroup("activitylog", "Azure Monitor activity log operations - Commands for querying and analyzing activity logs for Azure resources."); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Options/Metrics/MetricsBatchQueryOptions.cs b/tools/Azure.Mcp.Tools.Monitor/src/Options/Metrics/MetricsBatchQueryOptions.cs new file mode 100644 index 0000000000..3836da7e31 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Options/Metrics/MetricsBatchQueryOptions.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.Monitor.Options.Metrics; + +/// +/// Options for querying metrics for multiple resources in a single batch request +/// +public sealed class MetricsBatchQueryOptions : ISubscriptionOption +{ + /// + /// The resources to query metrics for (required) + /// + [Option(Description = "Comma-separated list of resource names or full resource IDs to query metrics for (up to 50 resources). " + + "All resources must belong to the same subscription, Azure region, and resource type.")] + public required string Resources { get; set; } + + /// + /// The resource type (optional, e.g., 'Microsoft.Storage/storageAccounts') + /// + [Option(Description = "The Azure resource type (e.g., 'Microsoft.Storage/storageAccounts', 'Microsoft.Compute/virtualMachines'), applied to all resources. If not specified, will attempt to infer from each resource name.")] + public string? ResourceType { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public string? ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + /// + /// The names of metrics to query + /// + [Option(Description = "The names of metrics to query (comma-separated).")] + public required string MetricNames { get; set; } + + [Option(Description = MonitorOptionDescriptions.MetricNamespace)] + public required string MetricNamespace { get; set; } + + /// + /// Start time for the query in ISO format + /// + [Option(Description = "The start time for the query in ISO format (e.g., 2023-01-01T00:00:00Z). Defaults to 24 hours ago.")] + public string? StartTime { get; set; } + + /// + /// End time for the query in ISO format + /// + [Option(Description = "The end time for the query in ISO format (e.g., 2023-01-01T00:00:00Z). Defaults to now.")] + public string? EndTime { get; set; } + + /// + /// Time interval for the query + /// + [Option(Description = "The time interval for data points (e.g., PT1H for 1 hour, PT5M for 5 minutes).")] + public string? Interval { get; set; } + + /// + /// Aggregation type(s) for the metrics (Average, Maximum, Minimum, Total, Count) + /// + [Option(Description = "The aggregation type(s) to use (comma-separated, e.g., Average,Maximum).")] + public string? Aggregation { get; set; } + + /// + /// OData filter for the query + /// + [Option(Description = "OData filter to apply to the metrics query.")] + public string? Filter { get; set; } + + /// + /// The aggregation to use for sorting results and the direction of the sort. Only valid when '--filter' is specified. + /// + [Option(Description = "The aggregation to use for sorting results and the direction of the sort (e.g., 'total asc'). Only valid when '--filter' is specified.")] + public string? OrderBy { get; set; } + + /// + /// The maximum number of records to retrieve per resource. Only valid when '--filter' is specified. + /// + [Option(Description = "The maximum number of time series to retrieve per resource per metric. Only valid when '--filter' is specified. Defaults to 10.")] + public int? Top { get; set; } + + /// + /// The maximum number of time buckets to return per metric time series. Defaults to 50. + /// + [Option(Description = "The maximum number of time buckets to return per metric time series. Defaults to 50.", DefaultValue = 50)] + public int? MaxBuckets { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs index 2a7cb28827..3ecfd7a9cf 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs @@ -83,4 +83,41 @@ Task> ListMetricNamespacesAsync( string? searchString = null, string? tenant = null, CancellationToken cancellationToken = default); + + /// + /// Queries metrics for multiple resources in a single batch request. All resources must belong to the same + /// subscription, Azure region, and resource type. + /// + /// The subscription ID + /// The resource group name (optional, applied to all resources) + /// The resource type (optional, e.g., 'Microsoft.Storage/storageAccounts', applied to all resources) + /// The names or resource IDs of the resources to query metrics for + /// Required metric namespace + /// List of metric names to query + /// Optional start time for the query in ISO format + /// Optional end time for the query in ISO format + /// Optional time interval for data points + /// Optional comma-separated aggregation types (Average, Maximum, Minimum, Total, Count) + /// Optional OData filter to apply + /// Optional sort order, only valid when is specified + /// Optional maximum number of time series to retrieve per resource per metric, only valid when is specified + /// Optional tenant ID for multi-tenant scenarios + /// Cancellation token + /// List of metric results per resource, with time series data + Task> QueryMetricsBatchAsync( + string subscription, + string? resourceGroup, + string? resourceType, + IEnumerable resources, + string metricNamespace, + IEnumerable metricNames, + string? startTime = null, + string? endTime = null, + string? interval = null, + string? aggregation = null, + string? filter = null, + string? orderBy = null, + int? top = null, + string? tenant = null, + CancellationToken cancellationToken = default); } diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs index 9154b1002d..ad89929996 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs @@ -3,13 +3,18 @@ using System.Globalization; using System.Xml; +using Azure.Core; +using Azure.Core.Pipeline; using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Tools.Monitor.Models; +using Azure.Monitor.Query.Metrics; using Azure.ResourceManager.Monitor; using Azure.ResourceManager.Monitor.Models; +using Microsoft.Mcp.Core.Services.Azure.Authentication; using MetricDefinition = Azure.Mcp.Tools.Monitor.Models.MetricDefinition; using MetricNamespace = Azure.Mcp.Tools.Monitor.Models.MetricNamespace; using MetricResult = Azure.Mcp.Tools.Monitor.Models.MetricResult; +using SdkMetricResult = Azure.Monitor.Query.Metrics.Models.MetricResult; namespace Azure.Mcp.Tools.Monitor.Services; @@ -318,6 +323,200 @@ public async Task> ListMetricNamespacesAsync( return results; } + private const int MaxBatchResources = 50; + + public async Task> QueryMetricsBatchAsync( + string subscription, + string? resourceGroup, + string? resourceType, + IEnumerable resources, + string metricNamespace, + IEnumerable metricNames, + string? startTime = null, + string? endTime = null, + string? interval = null, + string? aggregation = null, + string? filter = null, + string? orderBy = null, + int? top = null, + string? tenant = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters((nameof(subscription), subscription), (nameof(metricNamespace), metricNamespace)); + ArgumentNullException.ThrowIfNull(resources); + ArgumentNullException.ThrowIfNull(metricNames); + + var resourceNames = resources.Select(r => r.Trim()).Where(r => r.Length > 0).ToList(); + if (resourceNames.Count == 0) + { + throw new ArgumentException("At least one resource must be specified.", nameof(resources)); + } + + if (resourceNames.Count > MaxBatchResources) + { + throw new ArgumentException($"A maximum of {MaxBatchResources} resources can be queried in a single batch request. Provided: {resourceNames.Count}.", nameof(resources)); + } + + // Resolve each resource name (or already-valid resource ID) to a full resource identifier. + var resourceIds = new List(resourceNames.Count); + foreach (var resourceName in resourceNames) + { + var resourceId = await _resourceResolverService.ResolveResourceIdAsync(subscription, resourceGroup, resourceType, resourceName, tenant, cancellationToken); + resourceIds.Add(resourceId); + } + + var armClient = await CreateArmClientAsync(tenant, cancellationToken: cancellationToken); + + // The batch metrics endpoint is regional and requires all resources to share the same region, so resolve + // the region from the first resource and let the service validate the rest. + var firstResource = await armClient.GetGenericResource(resourceIds[0]).GetAsync(cancellationToken); + var region = firstResource.Value.Data.Location.Name; + + var credential = await GetCredential(tenant, cancellationToken); + var metricsClientOptions = AddDefaultPolicies(new MetricsClientOptions()); + metricsClientOptions.Audience = GetMetricsClientAudience(); + metricsClientOptions.Transport = new HttpClientTransport(AzureService.GetClient()); + + var metricsClient = new MetricsClient(new Uri($"https://{region}.{GetMetricsEndpointHostSuffix()}"), credential, metricsClientOptions); + + var queryOptions = new MetricsQueryResourcesOptions + { + Filter = filter, + OrderBy = orderBy, + Size = top + }; + + if (!string.IsNullOrEmpty(startTime)) + { + if (!DateTimeOffset.TryParse(startTime, out var start)) + { + throw new ArgumentException($"Invalid start time format: {startTime}"); + } + queryOptions.StartTime = start; + } + + if (!string.IsNullOrEmpty(endTime)) + { + if (!DateTimeOffset.TryParse(endTime, out var end)) + { + throw new ArgumentException($"Invalid end time format: {endTime}"); + } + queryOptions.EndTime = end; + } + + if (!string.IsNullOrEmpty(interval)) + { + try + { + queryOptions.Granularity = XmlConvert.ToTimeSpan(interval); + } + catch (Exception ex) + { + throw new ArgumentException($"Invalid interval format: {ex}.", ex); + } + } + + if (!string.IsNullOrEmpty(aggregation)) + { + foreach (var agg in aggregation.Split(',').Select(a => a.Trim()).Where(a => a.Length > 0)) + { + queryOptions.Aggregations.Add(agg); + } + } + + var response = await metricsClient.QueryResourcesAsync(resourceIds, metricNames, metricNamespace, queryOptions, cancellationToken); + + // The response values are returned in the same order as the requested resource IDs. + var values = response.Value.Values; + var results = new List(values.Count); + for (int i = 0; i < values.Count; i++) + { + var resourceResult = values[i]; + var compactResource = new ResourceMetricsResult + { + ResourceId = i < resourceIds.Count ? resourceIds[i].ToString() : string.Empty, + Metrics = [] + }; + + foreach (var metric in resourceResult.Metrics) + { + compactResource.Metrics.Add(ConvertToCompactMetricResult(metric, interval)); + } + + results.Add(compactResource); + } + + return results; + } + + private static MetricResult ConvertToCompactMetricResult(SdkMetricResult metric, string? interval) + { + var compactResult = new MetricResult + { + Name = metric.Name, + Unit = metric.Unit.ToString(), + TimeSeries = [] + }; + + foreach (var timeSeries in metric.TimeSeries) + { + if (timeSeries.Values.Count == 0) + { + continue; + } + + var compactTimeSeries = new MetricTimeSeries + { + Metadata = new Dictionary(timeSeries.Metadata), + Start = timeSeries.Values[0].TimeStamp.UtcDateTime, + End = timeSeries.Values[^1].TimeStamp.UtcDateTime, + Interval = interval ?? "PT1M" + }; + + var avgValues = timeSeries.Values.Where(v => v.Average.HasValue).Select(v => v.Average!.Value).ToArray(); + if (avgValues.Length > 0) + compactTimeSeries.AvgBuckets = avgValues; + + var minValues = timeSeries.Values.Where(v => v.Minimum.HasValue).Select(v => v.Minimum!.Value).ToArray(); + if (minValues.Length > 0) + compactTimeSeries.MinBuckets = minValues; + + var maxValues = timeSeries.Values.Where(v => v.Maximum.HasValue).Select(v => v.Maximum!.Value).ToArray(); + if (maxValues.Length > 0) + compactTimeSeries.MaxBuckets = maxValues; + + var totalValues = timeSeries.Values.Where(v => v.Total.HasValue).Select(v => v.Total!.Value).ToArray(); + if (totalValues.Length > 0) + compactTimeSeries.TotalBuckets = totalValues; + + var countValues = timeSeries.Values.Where(v => v.Count.HasValue).Select(v => v.Count!.Value).ToArray(); + if (countValues.Length > 0) + compactTimeSeries.CountBuckets = countValues; + + compactResult.TimeSeries.Add(compactTimeSeries); + } + + return compactResult; + } + + private MetricsClientAudience GetMetricsClientAudience() => + AzureService.CloudConfiguration.CloudType switch + { + AzureCloudConfiguration.AzureCloud.AzurePublicCloud => MetricsClientAudience.AzurePublicCloud, + AzureCloudConfiguration.AzureCloud.AzureChinaCloud => MetricsClientAudience.AzureChina, + AzureCloudConfiguration.AzureCloud.AzureUSGovernmentCloud => MetricsClientAudience.AzureGovernment, + _ => MetricsClientAudience.AzurePublicCloud + }; + + private string GetMetricsEndpointHostSuffix() => + AzureService.CloudConfiguration.CloudType switch + { + AzureCloudConfiguration.AzureCloud.AzurePublicCloud => "metrics.monitor.azure.com", + AzureCloudConfiguration.AzureCloud.AzureChinaCloud => "metrics.monitor.azure.cn", + AzureCloudConfiguration.AzureCloud.AzureUSGovernmentCloud => "metrics.monitor.azure.us", + _ => "metrics.monitor.azure.com" + }; + private static string ToIsoString(DateTimeOffset dto) { if (dto.Offset == TimeSpan.Zero) diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Metrics/MetricsBatchQueryCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Metrics/MetricsBatchQueryCommandTests.cs new file mode 100644 index 0000000000..d092ed01ff --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Metrics/MetricsBatchQueryCommandTests.cs @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.Monitor.Commands; +using Azure.Mcp.Tools.Monitor.Commands.Metrics; +using Azure.Mcp.Tools.Monitor.Models; +using Azure.Mcp.Tools.Monitor.Services; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests.Metrics; + +public class MetricsBatchQueryCommandTests : SubscriptionCommandUnitTestsBase +{ + #region Constructor and Properties Tests + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("batchquery", CommandDefinition.Name); + Assert.Equal("Query Azure Monitor Metrics for Multiple Resources", Command.Title); + Assert.NotNull(Command.Description); + Assert.NotEmpty(Command.Description); + } + + [Fact] + public void Name_ReturnsCorrectValue() + { + Assert.Equal("batchquery", Command.Name); + } + + #endregion + + #region Option Registration Tests + + [Fact] + public void RegisterOptions_AddsAllExpectedOptions() + { + var options = CommandDefinition.Options.Select(o => o.Name).ToList(); + + Assert.Contains("--resource-group", options); + Assert.Contains("--resource-type", options); + Assert.Contains("--resources", options); + Assert.Contains("--metric-names", options); + Assert.Contains("--metric-namespace", options); + Assert.Contains("--start-time", options); + Assert.Contains("--end-time", options); + Assert.Contains("--interval", options); + Assert.Contains("--aggregation", options); + Assert.Contains("--filter", options); + Assert.Contains("--order-by", options); + Assert.Contains("--top", options); + Assert.Contains("--max-buckets", options); + + var requiredOptions = CommandDefinition.Options.Where(o => o.Required).Select(o => o.Name).ToList(); + Assert.Contains("--resources", requiredOptions); + Assert.Contains("--metric-names", requiredOptions); + Assert.Contains("--metric-namespace", requiredOptions); + } + + #endregion + + #region Option Binding Tests + + [Fact] + public async Task ExecuteAsync_BindsAllOptionsCorrectly() + { + // Arrange + Service.QueryMetricsBatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns([]); + + // Act + await ExecuteCommandAsync( + "--subscription", "sub1", + "--resource-group", "rg1", + "--resource-type", "Microsoft.Storage/storageAccounts", + "--resources", "sa1,sa2", + "--metric-names", "CPU,Memory", + "--metric-namespace", "Microsoft.Storage", + "--start-time", "2023-01-01T00:00:00Z", + "--end-time", "2023-01-02T00:00:00Z", + "--interval", "PT1M", + "--aggregation", "Average", + "--filter", "dimension eq 'value'", + "--order-by", "total asc", + "--top", "5", + "--max-buckets", "100"); + + // Assert + await Service.Received(1).QueryMetricsBatchAsync( + "sub1", + "rg1", + "Microsoft.Storage/storageAccounts", + Arg.Is>(m => m.SequenceEqual(new[] { "sa1", "sa2" })), + "Microsoft.Storage", + Arg.Is>(m => m.SequenceEqual(new[] { "CPU", "Memory" })), + "2023-01-01T00:00:00Z", + "2023-01-02T00:00:00Z", + "PT1M", + "Average", + "dimension eq 'value'", + "total asc", + 5, + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesOptionalParameters() + { + // Arrange + Service.QueryMetricsBatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns([]); + + // Act + await ExecuteCommandAsync( + "--subscription", "sub1", + "--resources", "sa1", + "--metric-names", "CPU", + "--metric-namespace", "microsoft.compute/virtualmachines"); + + // Assert + await Service.Received(1).QueryMetricsBatchAsync( + Arg.Is(t => t == "sub1"), + Arg.Is(t => t == null), + Arg.Is(t => t == null), + Arg.Is>(m => m.SequenceEqual(new[] { "sa1" })), + Arg.Is(t => t == "microsoft.compute/virtualmachines"), + Arg.Is>(m => m.SequenceEqual(new[] { "CPU" })), + Arg.Any(), + Arg.Any(), + Arg.Is(t => t == null), + Arg.Is(t => t == null), + Arg.Is(t => t == null), + Arg.Is(t => t == null), + Arg.Is(t => t == null), + Arg.Is(t => t == null), + Arg.Any()); + } + + #endregion + + #region Validation Tests + + [Theory] + [InlineData("sa1", true)] + [InlineData("sa1,sa2", true)] + [InlineData("sa1, sa2, sa3", true)] + [InlineData(",", false)] + [InlineData("sa1,", false)] + [InlineData(",sa1", false)] + public async Task Validate_Resources_ValidatesCorrectly(string resources, bool shouldBeValid) + { + // Arrange & Act + var result = await ExecuteCommandAsync( + "--subscription", "sub1", + "--resources", resources, + "--metric-namespace", "microsoft.compute/virtualmachines", + "--metric-names", "CPU"); + + // Assert + if (!shouldBeValid) + { + Assert.NotNull(result.Message); + Assert.Contains("Invalid format for '--resources'", result.Message); + Assert.Equal(HttpStatusCode.BadRequest, result.Status); + } + else + { + Assert.Equal("Success", result.Message); + Assert.Equal(HttpStatusCode.OK, result.Status); + } + } + + [Theory] + [InlineData("CPU", true)] + [InlineData("CPU,Memory", true)] + [InlineData(",", false)] + [InlineData("CPU,", false)] + public async Task Validate_MetricNames_ValidatesCorrectly(string metricNames, bool shouldBeValid) + { + // Arrange & Act + var result = await ExecuteCommandAsync( + "--subscription", "sub1", + "--resources", "sa1", + "--metric-namespace", "microsoft.compute/virtualmachines", + "--metric-names", metricNames); + + // Assert + if (!shouldBeValid) + { + Assert.NotNull(result.Message); + Assert.Contains("Invalid format for '--metric-names'", result.Message); + Assert.Equal(HttpStatusCode.BadRequest, result.Status); + } + else + { + Assert.Equal("Success", result.Message); + Assert.Equal(HttpStatusCode.OK, result.Status); + } + } + + #endregion + + #region ExecuteAsync Tests - Success Scenarios + + [Fact] + public async Task ExecuteAsync_ValidInput_ReturnsSuccess() + { + // Arrange + var expectedResults = new List + { + new() + { + ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/sa1", + Metrics = + [ + new() + { + Name = "CPU", + Unit = "Percent", + TimeSeries = + [ + new() + { + Metadata = [], + Start = DateTime.UtcNow.AddHours(-1), + End = DateTime.UtcNow, + Interval = "PT1M", + AvgBuckets = [45.5, 50.2, 48.1] + } + ] + } + ] + } + }; + + Service.QueryMetricsBatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResults); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", "sub1", + "--resources", "sa1", + "--metric-names", "CPU", + "--metric-namespace", "microsoft.compute/virtualmachines"); + + // Assert + var results = ValidateAndDeserializeResponse(response, MonitorJsonContext.Default.MetricsBatchQueryCommandResult); + Assert.Single(results.Results); + var resourceResult = results.Results[0]; + Assert.Equal(expectedResults[0].ResourceId, resourceResult.ResourceId); + Assert.Single(resourceResult.Metrics); + Assert.Equal("CPU", resourceResult.Metrics[0].Name); + Assert.Equal([45.5, 50.2, 48.1], resourceResult.Metrics[0].TimeSeries[0].AvgBuckets!); + } + + [Fact] + public async Task ExecuteAsync_EmptyResults_ReturnsSuccessWithEmptyResults() + { + // Arrange + Service.QueryMetricsBatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns([]); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", "sub1", + "--resources", "sa1", + "--metric-names", "CPU", + "--metric-namespace", "microsoft.compute/virtualmachines"); + + // Assert + var results = ValidateAndDeserializeResponse(response, MonitorJsonContext.Default.MetricsBatchQueryCommandResult); + Assert.Empty(results.Results); + } + + #endregion + + #region ExecuteAsync Tests - Validation Failures + + [Theory] + [InlineData("--subscription sub1 --metric-names CPU --metric-namespace microsoft.compute/virtualmachines")] // Missing resources + [InlineData("--subscription sub1 --resources sa1 --metric-namespace microsoft.compute/virtualmachines")] // Missing metric-names + [InlineData("--subscription sub1 --resources sa1 --metric-names CPU")] // Missing metric-namespace + public async Task ExecuteAsync_InvalidInput_ReturnsBadRequest(string args) + { + // Arrange & Act + var response = await ExecuteCommandAsync(args); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.NotEmpty(response.Message); + Assert.Null(response.Results); + } + + #endregion + + #region ExecuteAsync Tests - Bucket Limit Validation + + [Fact] + public async Task ExecuteAsync_ExceedsBucketLimit_ReturnsBadRequest() + { + // Arrange + var resultsWithTooManyBuckets = new List + { + new() + { + ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/sa1", + Metrics = + [ + new() + { + Name = "CPU", + Unit = "Percent", + TimeSeries = + [ + new() + { + Metadata = [], + Start = DateTime.UtcNow.AddHours(-1), + End = DateTime.UtcNow, + Interval = "PT1M", + AvgBuckets = new double[51] // Exceeds default limit of 50 + } + ] + } + ] + } + }; + + Service.QueryMetricsBatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(resultsWithTooManyBuckets); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", "sub1", + "--resources", "sa1", + "--metric-names", "CPU", + "--metric-namespace", "microsoft.compute/virtualmachines"); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("exceeds the maximum allowed limit", response.Message); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsException_HandlesError() + { + // Arrange + Service.QueryMetricsBatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Test error")); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", "sub1", + "--resources", "sa1", + "--metric-names", "CPU", + "--metric-namespace", "microsoft.compute/virtualmachines"); + + // Assert + Assert.NotEqual(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Message); + } + + #endregion +} diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs index 796f675d9b..d664b7b82c 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs @@ -19,6 +19,7 @@ public sealed class MonitorCommandTests(ITestOutputHelper output, TestProxyFixtu private string? _bingWebTestName; private string? _healthModelParentName; private string? _healthModelChildName; + private string? _storageAccountName; private static readonly string[] s_validHealthStates = [ @@ -85,6 +86,7 @@ public override async ValueTask InitializeAsync() _bingWebTestName = $"{Settings.ResourceBaseName}-bing-test"; _healthModelParentName = $"{Settings.ResourceBaseName}-hm-a"; _healthModelChildName = $"{Settings.ResourceBaseName}-hm-b"; + _storageAccountName = $"{Settings.ResourceBaseName}mon"; } // [Fact] @@ -491,6 +493,79 @@ public override async ValueTask InitializeAsync() // } // } + #region Metrics Integration Tests + + [Fact] + public async Task Should_Batch_Query_Metrics() + { + // The command defaults start/end time to "now" when not supplied, which would produce a different + // query string URI on every run and break playback matching. Registering the computed values as + // variables pins the values used at record time so they're replayed exactly during playback. + var startTime = RegisterOrRetrieveVariable("startTime", DateTime.UtcNow.AddHours(-24).ToString("o")); + var endTime = RegisterOrRetrieveVariable("endTime", DateTime.UtcNow.ToString("o")); + + var result = await CallToolAsync( + "monitor_metrics_batchquery", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "resource-type", "Microsoft.Storage/storageAccounts" }, + { "resources", _storageAccountName }, + { "metric-namespace", "Microsoft.Storage/storageAccounts" }, + { "metric-names", "UsedCapacity" }, + { "start-time", startTime }, + { "end-time", endTime } + }); + + var resultsArray = result.AssertProperty("results"); + Assert.Equal(JsonValueKind.Array, resultsArray.ValueKind); + var resources = resultsArray.EnumerateArray().ToList(); + Assert.NotEmpty(resources); + + var firstResource = resources[0]; + + var resourceId = firstResource.AssertProperty("resourceId"); + Assert.Equal(JsonValueKind.String, resourceId.ValueKind); + Assert.Contains(_storageAccountName!, resourceId.GetString(), StringComparison.OrdinalIgnoreCase); + + var metrics = firstResource.AssertProperty("metrics"); + Assert.Equal(JsonValueKind.Array, metrics.ValueKind); + var metricsList = metrics.EnumerateArray().ToList(); + Assert.NotEmpty(metricsList); + + var firstMetric = metricsList[0]; + + var name = firstMetric.AssertProperty("name"); + Assert.Equal(JsonValueKind.String, name.ValueKind); + Assert.Equal("UsedCapacity", name.GetString()); + + var unit = firstMetric.AssertProperty("unit"); + Assert.Equal(JsonValueKind.String, unit.ValueKind); + Assert.False(string.IsNullOrEmpty(unit.GetString())); + + var timeSeries = firstMetric.AssertProperty("timeSeries"); + Assert.Equal(JsonValueKind.Array, timeSeries.ValueKind); + var timeSeriesList = timeSeries.EnumerateArray().ToList(); + Assert.NotEmpty(timeSeriesList); + + var firstTimeSeries = timeSeriesList[0]; + + var start = firstTimeSeries.AssertProperty("start"); + Assert.Equal(JsonValueKind.String, start.ValueKind); + Assert.True(DateTime.TryParse(start.GetString(), out _)); + + var end = firstTimeSeries.AssertProperty("end"); + Assert.Equal(JsonValueKind.String, end.ValueKind); + Assert.True(DateTime.TryParse(end.GetString(), out _)); + + var interval = firstTimeSeries.AssertProperty("interval"); + Assert.Equal(JsonValueKind.String, interval.ValueKind); + Assert.StartsWith("PT", interval.GetString()); + } + + #endregion + #region WebTests Integration Tests [Fact] diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json index 7854447b75..3eb1d14ebe 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.Monitor.Tests", - "Tag": "Azure.Mcp.Tools.Monitor.Tests_5c8c2c1c62" + "Tag": "Azure.Mcp.Tools.Monitor.Tests_42583a5140" }