From b02a662354a709e26c06d68c8dc556c1cee3426f Mon Sep 17 00:00:00 2001 From: Dalibor Kovacevic <56942801+RobiladK@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:59:47 -0700 Subject: [PATCH] feat(monitor): add Basic and Auxiliary Log Analytics search (#3581) --- .../CommandGroupDiscoveryStrategyTests.cs | 18 + .../CommandGroupServerProviderTests.cs | 39 + .../ToolLoading/NamespaceToolLoaderTests.cs | 2 + .../CommandGroupDiscoveryStrategy.cs | 1 + .../Discovery/CommandGroupServerProvider.cs | 11 + .../ConsolidatedToolDiscoveryStrategy.cs | 5 +- .../ToolLoading/NamespaceToolLoader.cs | 3 +- .../log-analytics-basic-auxiliary-search.md | 85 ++ servers/Azure.Mcp.Server/README.md | 5 +- .../dakova-monitor-workspace-log-search.yml | 9 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 28 + .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 3 + .../src/Resources/consolidated-tools.json | 3 +- .../MonitorLogSearchServerModeTests.cs | 227 ++++ .../Infrastructure/ServerModeCoverageTests.cs | 9 +- .../Commands/Log/WorkspaceLogSearchCommand.cs | 85 ++ .../src/Commands/MonitorJsonContext.cs | 11 + .../src/Models/Log/LogSearchApiColumn.cs | 11 + .../src/Models/Log/LogSearchApiError.cs | 13 + .../src/Models/Log/LogSearchApiErrorDetail.cs | 11 + .../src/Models/Log/LogSearchApiRequest.cs | 6 + .../src/Models/Log/LogSearchApiResponse.cs | 11 + .../src/Models/Log/LogSearchApiTable.cs | 15 + .../src/Models/Log/LogSearchColumn.cs | 6 + .../src/Models/Log/LogSearchError.cs | 9 + .../src/Models/Log/LogSearchErrorDetail.cs | 6 + .../src/Models/Log/LogSearchTimeRange.cs | 11 + .../Models/Log/WorkspaceLogSearchResult.cs | 17 + .../src/MonitorSetup.cs | 3 + .../src/Options/WorkspaceLogSearchOptions.cs | 34 + .../src/Services/IMonitorLogSearchService.cs | 23 + .../src/Services/MonitorLogSearchService.cs | 645 ++++++++++ .../src/Validation/LogSearchQueryValidator.cs | 250 ++++ .../Validation/LogSearchTimeRangeParser.cs | 128 ++ .../Log/LogSearchQueryValidatorTests.cs | 143 +++ .../Log/LogSearchTimeRangeParserTests.cs | 144 +++ .../Log/WorkspaceLogSearchCommandTests.cs | 314 +++++ .../MonitorCommandTests.cs | 234 ++++ .../MonitorLogSearchServiceTests.cs | 1060 +++++++++++++++++ .../Azure.Mcp.Tools.Monitor.Tests/assets.json | 2 +- .../tests/test-resources-post.ps1 | 193 ++- .../tests/test-resources.bicep | 164 +++ 42 files changed, 3977 insertions(+), 20 deletions(-) create mode 100644 docs/design/log-analytics-basic-auxiliary-search.md create mode 100644 servers/Azure.Mcp.Server/changelog-entries/dakova-monitor-workspace-log-search.yml create mode 100644 servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/MonitorLogSearchServerModeTests.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Commands/Log/WorkspaceLogSearchCommand.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiColumn.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiError.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiErrorDetail.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiRequest.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiResponse.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiTable.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchColumn.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchError.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchErrorDetail.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchTimeRange.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Models/Log/WorkspaceLogSearchResult.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Options/WorkspaceLogSearchOptions.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorLogSearchService.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorLogSearchService.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchQueryValidator.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchTimeRangeParser.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchQueryValidatorTests.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchTimeRangeParserTests.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/WorkspaceLogSearchCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorLogSearchServiceTests.cs diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs index a8e607b49..f21606121 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Mcp.Core.Areas.Server; using Microsoft.Mcp.Core.Areas.Server.Commands.Discovery; +using Microsoft.Mcp.Core.Areas.Server.Options; using Microsoft.Mcp.Tests.Client.Helpers; using NSubstitute; using Xunit; @@ -153,6 +154,23 @@ public class CommandGroupDiscoveryStrategyTests Assert.All(result, provider => Assert.True(((CommandGroupServerProvider)provider).ReadOnly)); } + [Theory] + [InlineData(StructuredOutputMode.Duplicated)] + [InlineData(StructuredOutputMode.Compact)] + public async Task DiscoverServersAsync_WithStructuredOutputMode_ForwardsMode( + StructuredOutputMode mode) + { + var configuration = new ServerRuntimeConfiguration { StructuredOutputMode = mode }; + var strategy = CreateStrategy(configuration: configuration); + + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + Assert.NotEmpty(result); + Assert.All( + result, + provider => Assert.Equal(mode, ((CommandGroupServerProvider)provider).StructuredOutputMode)); + } + [Fact] public async Task DiscoverServersAsync_WithCustomEntryPoint_SetsEntryPointOnAllProviders() { diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs index c29701096..58307a550 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.Mcp.Core.Areas.Server.Commands.Discovery; +using Microsoft.Mcp.Core.Areas.Server.Options; using Microsoft.Mcp.Core.Commands; using Microsoft.Mcp.Tests.Client.Helpers; using ModelContextProtocol.Client; @@ -181,4 +182,42 @@ public class CommandGroupServerProviderTests var expected = new[] { "server", "start", "--mode", "all", "--namespace", "testGroup", "--transport", "custom-transport" }; Assert.Equal(expected, arguments); } + + [Theory] + [InlineData(StructuredOutputMode.Duplicated, "duplicated")] + [InlineData(StructuredOutputMode.Compact, "compact")] + public void BuildArguments_WithStructuredOutputMode_ForwardsMode( + StructuredOutputMode mode, + string expectedMode) + { + var provider = new CommandGroupServerProvider(new CommandGroup("testGroup", "Test Description")) + { + StructuredOutputMode = mode + }; + + var arguments = provider.BuildArguments(); + + Assert.Equal( + [ + "server", + "start", + "--mode", + "all", + "--namespace", + "testGroup", + "--transport", + "stdio", + "--structured-output-mode", + expectedMode + ], + arguments); + } + + [Fact] + public void BuildArguments_WithoutStructuredOutputMode_OmitsFlag() + { + var provider = new CommandGroupServerProvider(new CommandGroup("testGroup", "Test Description")); + + Assert.DoesNotContain("--structured-output-mode", provider.BuildArguments()); + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs index cacb36de3..df8b033d5 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -81,6 +81,8 @@ public sealed class NamespaceToolLoaderTests : IAsyncDisposable Assert.NotNull(tool.Name); Assert.NotNull(tool.Description); Assert.Contains("hierarchical", tool.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("set \"command\" and wrap its args in \"parameters\"", tool.Description); + Assert.Contains("Set \"learn=true\"", tool.Description); // Verify hierarchical schema structure var schema = tool.InputSchema; diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs index 267cddd75..004773d0e 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs @@ -40,6 +40,7 @@ public sealed class CommandGroupDiscoveryStrategy( { ReadOnly = _configuration.Value.ReadOnly, Transport = _configuration.Value.Transport, + StructuredOutputMode = _configuration.Value.StructuredOutputMode, EntryPoint = EntryPoint, }) .Cast(); diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs index 1aad22cc3..999a28871 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs @@ -36,6 +36,11 @@ public sealed class CommandGroupServerProvider(CommandGroup commandGroup) : IMcp /// public string Transport { get; set; } = TransportTypes.StdIo; + /// + /// Gets or sets the structured output mode forwarded to the child server. + /// + public StructuredOutputMode? StructuredOutputMode { get; set; } + /// public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) { @@ -70,6 +75,12 @@ public sealed class CommandGroupServerProvider(CommandGroup commandGroup) : IMcp arguments.Add($"--read-only"); } + if (StructuredOutputMode.HasValue) + { + arguments.Add("--structured-output-mode"); + arguments.Add(StructuredOutputMode.Value.ToString().ToLowerInvariant()); + } + return [.. arguments]; } diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs index 8895752d9..ee540d567 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs @@ -245,7 +245,10 @@ internal sealed class SingleConsolidatedToolAreaSetup( public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { // Create command group for this consolidated tool - var commandGroup = new CommandGroup(Name, Title); + var commandGroup = new CommandGroup( + Name, + _consolidatedTool.Description ?? Name, + Title); // Add all matching commands to this group foreach (var cmd in _matchingCommands) diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index 045926df3..d63aba79f 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -134,9 +134,8 @@ public sealed class NamespaceToolLoader( var tool = new Tool { Name = namespaceName, - Description = group.Description + """ + Description = group.Description + Environment.NewLine + Environment.NewLine + """ This tool is a hierarchical MCP command router. - Sub commands are routed to MCP servers that require specific fields inside the "parameters" object. To invoke a command, set "command" and wrap its args in "parameters". Set "learn=true" to discover available sub commands. """, diff --git a/docs/design/log-analytics-basic-auxiliary-search.md b/docs/design/log-analytics-basic-auxiliary-search.md new file mode 100644 index 000000000..69b306a4f --- /dev/null +++ b/docs/design/log-analytics-basic-auxiliary-search.md @@ -0,0 +1,85 @@ + + +# Basic and Auxiliary log search + +`monitor workspace log search` (`monitor_workspace_log_search`) queries one primary Basic or Auxiliary table in a Log Analytics workspace. Existing Analytics query tools are unchanged. + +## Why a separate tool + +Analytics queries use `/v1/workspaces/{workspaceId}/query`. Basic and Auxiliary queries use `/v1/workspaces/{workspaceId}/search`, with workspace-only scope and a narrower KQL language. + +Keeping separate tools avoids guessing the primary table from arbitrary KQL or adding metadata requests to existing Analytics queries. There is no automatic fallback between endpoints. + +## Inputs and limits + +| Option | Meaning | +| --- | --- | +| `subscription` | ID or name. If omitted, the standard resolver uses the configured default. | +| `resource-group` | Required to identify the workspace unambiguously. | +| `workspace` | Required workspace name; ARM supplies its customer GUID. | +| `table` | Required Basic or Auxiliary table name, validated as an ASCII identifier. | +| `query` | Required KQL pipeline beginning with `\|`, without the primary table name. | +| `timespan` | Required positive ISO 8601 duration or RFC 3339 `start/end` interval, at most 30 days. Calendar years and months are not accepted. | +| `limit` | Maximum returned rows: 1-100, default 20. | +| `tenant` | Optional tenant ID or name. | + +Basic queries cannot start more than 30 days ago. Auxiliary supports older retained data, but this tool limits each call to a 30-day interval. Query cost depends on data scanned across that interval, not the returned row limit. + +## Query construction + +The service builds ` | take `. It always appends the final `take`, including when the supplied pipeline already has a row limit. + +The validator rejects multiple statements, comments, source functions, nested tabular pipelines, and unsupported operators such as `join`, `find`, `search`, `externaldata`, and `invoke`. It is not a KQL parser; Azure checks the remaining syntax and semantics. Azure-supported `union` and `lookup` enrichment from Analytics tables remains available. The result's `table` and `plan` fields identify the primary table, not every enrichment source. + +## Table-plan checks + +Before querying logs, the service reads the workspace and table through the ARM SDK: + +1. Missing resources return 404. +2. Analytics or other unsupported plans return 409 with guidance to use `monitor_workspace_log_query`, even if their plan-change timestamp is absent. +3. A missing plan, or missing or invalid transition metadata on a Basic or Auxiliary table, returns 502. +4. Ranges starting before `LastPlanModifiedDate` return 409 with the supported boundary. This prevents a query spanning different access behavior from appearing complete. + +If the plan changes after the metadata read, the service error is returned without retrying through another endpoint. + +## Results and failures + +Results contain typed `columns`, positional `rows`, `rowCount`, `limit`, `isPartial`, and `error`. Numbers, booleans, nulls, and dynamic JSON keep their types. + +An Azure `PartialError` retains usable rows with `isPartial: true` and sanitized error details. Fatal errors, malformed responses, invalid row shapes, and responses exceeding 1 MiB return an error rather than silently dropping data. HTTP 204 returns an empty result. `isPartial` reflects service-reported incompleteness; the row limit still applies to successful results. + +There are no application retries, pagination, caching, or parallel queries. Throttling returns 429 with retry guidance. A linked cancellation token bounds both the HTTP request and response-body read. + +## Authentication and cloud support + +The service uses the repository's per-request Azure credential provider in both stdio and HTTP modes. It requires workspace read, table metadata read, and log query permissions. Callers cannot supply tokens, endpoints, or workspace customer GUIDs. + +Only the documented public-cloud `/search` endpoint is enabled. Other clouds return an unsupported-cloud error before network access. Endpoint fallback is intentionally absent: retrying a query on another host could repeat a billable scan. + +## Server discovery modes + +| Server start configuration | Exposed tool | Routed command | +| --- | --- | --- | +| `--mode all --namespace monitor` | `monitor_workspace_log_search` | Direct tool call | +| `--tool monitor_workspace_log_search` | `monitor_workspace_log_search` | Direct tool call | +| `--namespace monitor` (default namespace mode) | `monitor` | `monitor_workspace_log_search` | +| `--mode namespace --namespace monitor` | `monitor` | `monitor_workspace_log_search` | +| `--mode single --namespace monitor` | `azure` | Tool `monitor`, command `monitor_workspace_log_search` | +| `--mode consolidated --namespace monitor` | `get_azure_resource_and_app_health_status` | `get_azure_resource_and_app_health_status_monitor_workspace_log_search` | + +`--tool` and `--namespace` cannot be combined. Routers accept the child arguments under `parameters`; `learn: true` lists their exact command names. + +Structured output is opt-in with `--structured-output-mode duplicated` or `compact`. Direct mode advertises `WorkspaceLogSearchResult`; namespace and consolidated modes use the shared `tool-result` envelope. Single mode wraps the complete downstream MCP call result and forwards the output setting to its child server. See [output-schema conventions](https://github.com/microsoft/mcp/blob/main/docs/output-schema-migration.md). + +## Search jobs are separate + +Search jobs create persistent `*_SRCH` tables and can run for up to 24 hours. They require write permissions, incur ingestion costs, and need polling and cleanup. This synchronous read-only tool does not start search jobs. + +## References + +- [Query data in a Basic and Auxiliary table](https://learn.microsoft.com/azure/azure-monitor/logs/basic-logs-query) +- [Access the Azure Monitor Log Analytics API](https://learn.microsoft.com/azure/azure-monitor/logs/api/access-api) +- [Log Analytics API response format](https://learn.microsoft.com/azure/azure-monitor/logs/api/response-format) +- [Azure Monitor service limits: log queries and language](https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-limits#log-queries-and-language) +- [Configure a table plan](https://learn.microsoft.com/azure/azure-monitor/logs/logs-table-plans) +- [Run search jobs in Azure Monitor](https://learn.microsoft.com/azure/azure-monitor/logs/search-jobs) diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index b560e017b..afa05adfb 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1198,7 +1198,8 @@ Example prompts that generate Azure CLI commands: ### 📊 Azure Monitor -* "Query my Log Analytics workspace" +* "Query an Analytics table in my Log Analytics workspace" +* "Search a Basic or Auxiliary table in my Log Analytics workspace over the last day" * "List my Azure Monitor Health Models" * "Get details for my Azure Monitor Health Model 'my-health-model'" @@ -1358,7 +1359,7 @@ The Azure MCP Server provides tools for interacting with **45+ Azure service are - 🗃️ **Azure Managed Lustre** - High-performance Lustre filesystem operations - 🏪 **Azure Marketplace** - Product discovery - 🔄 **Azure Migrate** - Platform Landing Zone generation and modification guidance -- 📈 **Azure Monitor** - Logging, metrics, health models, health monitoring, and instrumentation onboarding/migration workflow for local applications +- 📈 **Azure Monitor** - Log queries, Basic and Auxiliary table search, metrics, health models, health monitoring, and instrumentation onboarding/migration workflow for local applications - ⚖️ **Azure Policy** - Policies set to enforce organizational standards - ⚙️ **Azure Native ISV Services** - Third-party integrations - 🛡️ **Azure Quick Review CLI** - Compliance scanning diff --git a/servers/Azure.Mcp.Server/changelog-entries/dakova-monitor-workspace-log-search.yml b/servers/Azure.Mcp.Server/changelog-entries/dakova-monitor-workspace-log-search.yml new file mode 100644 index 000000000..1bec255d0 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/dakova-monitor-workspace-log-search.yml @@ -0,0 +1,9 @@ +changes: + - section: "Features Added" + description: "Added `monitor workspace log search` for synchronous, bounded searches of Basic and Auxiliary Log Analytics tables." + - section: "Bugs Fixed" + description: "Preserved configured consolidated-tool descriptions and structured output when single mode starts child namespace servers." + - section: "Bugs Fixed" + description: "Corrected Analytics-table routing guidance when Log Analytics table metadata omits the plan-change timestamp." + - section: "Bugs Fixed" + description: "Fixed log search playback tests to use sanitized workspace and column metadata." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index d8318ffc2..a95825b9a 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3397,6 +3397,34 @@ azmcp monitor workspace log query --subscription \ --workspace \ --table "AppEvents_CL" \ --query "| order by TimeGenerated desc" + +# Search a Basic or Auxiliary table in a Log Analytics workspace. +# Use workspace log query for Analytics tables. +# --query must begin with '|' and omit the primary table name. +# The server binds --table and caps output at --limit (default 20, maximum 100). +# --timespan is a positive ISO 8601 duration (such as "P1D") or a closed +# RFC 3339 start/end interval, up to 30 days. Basic queries cover only the last 30 days. +# Results preserve column types and flag service-reported partial results. +# Scan cost depends on ingested volume across --timespan, not --limit. +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp monitor workspace log search --subscription \ + --resource-group \ + --workspace \ + --table
\ + --query \ + --timespan \ + [--limit ] \ + [--tenant ] + +# Search the last day of a Basic or Auxiliary table for error records +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp monitor workspace log search --subscription \ + --resource-group \ + --workspace \ + --table "ContainerLogV2" \ + --query "| where LogLevel == 'error' | project TimeGenerated, LogMessage" \ + --timespan "P1D" \ + --limit 50 ``` #### Health Models diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 420a9a9b3..8638b54f4 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -975,6 +975,9 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | monitor_workspace_list | Show me my Log Analytics workspaces | none | | monitor_workspace_list | Show me the Log Analytics workspaces in my subscription | none | | monitor_workspace_log_query | Show me the logs for the past hour in the Log Analytics workspace | none | +| monitor_workspace_log_search | Find records matching in the Basic table in Log Analytics workspace in resource group over | none | +| monitor_workspace_log_search | Search the Auxiliary table in workspace in resource group for during , limited to results | none | +| monitor_workspace_log_search | Search for in the Basic or Auxiliary table in Log Analytics workspace in resource group over the last day | none | ## Azure Native ISV diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 6b7e9b958..7f2812d68 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -911,7 +911,7 @@ }, { "name": "get_azure_resource_and_app_health_status", - "description": "Get Azure resource and application health status, metrics, availability, and service health events. Query Log Analytics, list Log Analytics workspaces, list activity logs, get the current availability status of Azure resources, list service health events and incidents, list Grafana instances, view Datadog monitored resources, perform App Lens diagnostics for comprehensive application troubleshooting, retrieve Application Insights recommendations for performance optimization, manage web tests for application monitoring, and get health status of entities in Azure Monitor health models.", + "description": "Query Analytics logs or search Basic and Auxiliary tables in a Log Analytics workspace. Find matching records in a named table over a time range, with a result limit. Get Azure resource and application health, metrics, availability, and service health events. List Log Analytics workspaces, tables, activity logs, Grafana instances, and Datadog monitored resources. Diagnose applications with App Lens, get Application Insights recommendations, manage web tests, and inspect Azure Monitor health models.", "toolMetadata": { "destructive": { "value": false, @@ -955,6 +955,7 @@ "monitor_table_type_list", "monitor_webtests_get", "monitor_workspace_log_query", + "monitor_workspace_log_search", "resourcehealth_availability-status_get", "resourcehealth_health-events_list" ] diff --git a/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/MonitorLogSearchServerModeTests.cs b/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/MonitorLogSearchServerModeTests.cs new file mode 100644 index 000000000..3639ba744 --- /dev/null +++ b/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/MonitorLogSearchServerModeTests.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace Azure.Mcp.Server.Tests.Infrastructure; + +/// +/// Checks Monitor discovery and schemas without querying Azure. +/// Stateless requests are covered by . +/// +public sealed class MonitorLogSearchServerModeTests +{ + private const string SearchTool = "monitor_workspace_log_search"; + private const string ConsolidatedTool = "get_azure_resource_and_app_health_status"; + private const string ConsolidatedSearchCommand = + "get_azure_resource_and_app_health_status_monitor_workspace_log_search"; + private const string LearnIntent = "Discover Monitor log search commands."; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(120); + + private static string AzmcpPath => + Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? "azmcp.exe" : "azmcp"); + + [Theory] + [InlineData("server start --mode all --namespace monitor --structured-output-mode duplicated")] + [InlineData("server start --tool " + SearchTool + " --structured-output-mode duplicated")] + public async Task DirectModes_ExposeSearchToolWithOutputSchema(string arguments) + { + await using var server = await ServerSession.StartAsync(arguments); + + var tools = await server.ListToolsAsync(); + var search = Assert.Single(tools, tool => tool.Name == SearchTool); + var schema = RequireOutputSchema(search); + + AssertTypeIncludes(schema.GetProperty("type"), "object"); + var properties = schema.GetProperty("properties"); + Assert.True(properties.TryGetProperty("rows", out var rows)); + AssertTypeIncludes(rows.GetProperty("type"), "array"); + AssertTypeIncludes(rows.GetProperty("items").GetProperty("type"), "array"); + + var errorType = properties.GetProperty("error").GetProperty("type"); + AssertTypeIncludes(errorType, "object"); + AssertTypeIncludes(errorType, "null"); + } + + [Theory] + [InlineData("server start --namespace monitor --structured-output-mode duplicated")] + [InlineData("server start --mode namespace --namespace monitor --structured-output-mode duplicated")] + public async Task NamespaceModes_WithMonitorFilter_DiscoverSearchCommand(string arguments) + { + await using var server = await ServerSession.StartAsync(arguments); + + var tools = await server.ListToolsAsync(); + var monitor = Assert.Single(tools, tool => tool.Name == "monitor"); + AssertAggregateSchema(RequireOutputSchema(monitor), includesTool: false); + + var structuredContent = await server.LearnAsync("monitor"); + AssertContainsCommand(structuredContent, SearchTool); + } + + [Fact] + public async Task SingleMode_WithMonitorFilter_DiscoversSearchCommand() + { + await using var server = await ServerSession.StartAsync( + "server start --mode single --namespace monitor --structured-output-mode duplicated"); + + var azure = Assert.Single(await server.ListToolsAsync()); + Assert.Equal("azure", azure.Name); + AssertAggregateSchema(RequireOutputSchema(azure), includesTool: true); + + var structuredContent = await server.LearnAsync("azure", tool: "monitor"); + AssertContainsCommand(structuredContent, SearchTool); + } + + [Fact] + public async Task ConsolidatedMode_WithMonitorFilter_DiscoversMappedSearchCommand() + { + await using var server = await ServerSession.StartAsync( + "server start --mode consolidated --namespace monitor --structured-output-mode duplicated"); + + var consolidated = Assert.Single(await server.ListToolsAsync(), tool => tool.Name == ConsolidatedTool); + Assert.Contains( + "search Basic and Auxiliary tables in a Log Analytics workspace", + consolidated.Description, + StringComparison.OrdinalIgnoreCase); + AssertAggregateSchema(RequireOutputSchema(consolidated), includesTool: false); + + var structuredContent = await server.LearnAsync(ConsolidatedTool); + AssertContainsCommand(structuredContent, ConsolidatedSearchCommand); + } + + private static JsonElement RequireOutputSchema(Tool tool) + { + Assert.True(tool.OutputSchema.HasValue, $"Tool '{tool.Name}' did not advertise an output schema."); + return tool.OutputSchema!.Value; + } + + private static void AssertContainsCommand(JsonElement structuredContent, string expectedCommand) + { + Assert.Equal("tool-list", structuredContent.GetProperty("kind").GetString()); + + var commands = structuredContent.GetProperty("tools") + .EnumerateArray() + .Select(tool => tool.GetProperty("command").GetString()) + .ToList(); + + Assert.Contains(expectedCommand, commands); + } + + private static void AssertTypeIncludes(JsonElement type, string expected) + { + if (type.ValueKind == JsonValueKind.String) + { + Assert.Equal(expected, type.GetString()); + return; + } + + Assert.Contains(expected, type.EnumerateArray().Select(item => item.GetString())); + } + + private static void AssertAggregateSchema(JsonElement schema, bool includesTool) + { + var variants = schema.GetProperty("oneOf").EnumerateArray().ToList(); + var toolResult = Assert.Single( + variants, + variant => + variant.GetProperty("properties") + .GetProperty("kind") + .GetProperty("const") + .GetString() == "tool-result"); + var required = toolResult.GetProperty("required") + .EnumerateArray() + .Select(item => item.GetString()) + .ToList(); + + Assert.Contains("command", required); + Assert.Contains("result", required); + Assert.Equal(includesTool, required.Contains("tool")); + } + + private sealed class ServerSession(McpClient client, string arguments, ConcurrentQueue standardError) + : IAsyncDisposable + { + public static async Task StartAsync(string arguments) + { + Assert.True(File.Exists(AzmcpPath), $"Executable not found at {AzmcpPath}. Build Azure.Mcp.Server first."); + + var standardError = new ConcurrentQueue(); + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "monitor-log-search-mode-test", + Command = AzmcpPath, + Arguments = arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries), + StandardErrorLines = standardError.Enqueue + }); + + var client = await McpClient.CreateAsync( + transport, + new McpClientOptions { InitializationTimeout = Timeout }, + cancellationToken: TestContext.Current.CancellationToken); + + return new ServerSession(client, arguments, standardError); + } + + public async Task> ListToolsAsync() + { + using var cancellation = CreateRequestCancellation(); + + var tools = new List(); + string? cursor = null; + do + { + var result = await client.ListToolsAsync( + new ListToolsRequestParams { Cursor = cursor }, + cancellation.Token); + tools.AddRange(result.Tools); + cursor = result.NextCursor; + } + while (cursor is not null); + + return tools; + } + + public async Task LearnAsync(string toolName, string? tool = null) + { + var callArguments = new Dictionary + { + ["intent"] = LearnIntent, + ["learn"] = true + }; + + if (tool is not null) + { + callArguments["tool"] = tool; + } + + using var cancellation = CreateRequestCancellation(); + var result = await client.CallToolAsync(toolName, callArguments, cancellationToken: cancellation.Token); + + Assert.False( + result.IsError == true, + $"'{toolName}' learn call failed for 'azmcp {arguments}'. StdErr: {Describe(standardError)}"); + Assert.True( + result.StructuredContent.HasValue, + $"'{toolName}' learn call returned no structured content for 'azmcp {arguments}'."); + + return result.StructuredContent!.Value; + } + + public ValueTask DisposeAsync() => client.DisposeAsync(); + + private static CancellationTokenSource CreateRequestCancellation() + { + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.CancelAfter(Timeout); + return cancellation; + } + + private static string Describe(ConcurrentQueue standardError) => + standardError.IsEmpty ? "" : string.Join(Environment.NewLine, standardError); + } +} diff --git a/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/ServerModeCoverageTests.cs b/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/ServerModeCoverageTests.cs index 1aa98a4fb..b171c3c55 100644 --- a/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/ServerModeCoverageTests.cs +++ b/servers/Azure.Mcp.Server/tests/Azure.Mcp.Server.Tests/Infrastructure/ServerModeCoverageTests.cs @@ -111,8 +111,10 @@ public class ServerModeCoverageTests } } - [Fact] - public async Task AllMode_Should_List_Tools_Without_Initialize() + [Theory] + [InlineData("server start --mode all")] + [InlineData("server start --tool monitor_workspace_log_search")] + public async Task AllMode_Should_List_Tools_Without_Initialize(string arguments) { // "all" mode exposes every individual Azure tool directly. // Uses a longer timeout because registering all tools takes more time on slow CI runners. @@ -121,7 +123,7 @@ public class ServerModeCoverageTests var processStartInfo = new System.Diagnostics.ProcessStartInfo { FileName = AzmcpPath, - Arguments = "server start --mode all", + Arguments = arguments, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -148,6 +150,7 @@ public class ServerModeCoverageTests Assert.NotNull(response); Assert.Contains("\"result\"", response, StringComparison.OrdinalIgnoreCase); Assert.Contains("\"tools\"", response, StringComparison.OrdinalIgnoreCase); + Assert.Contains("monitor_workspace_log_search", response, StringComparison.Ordinal); } finally { diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/Log/WorkspaceLogSearchCommand.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/Log/WorkspaceLogSearchCommand.cs new file mode 100644 index 000000000..87403a9c1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/Log/WorkspaceLogSearchCommand.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization.Metadata; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.Monitor.Models.Log; +using Azure.Mcp.Tools.Monitor.Options; +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.Log; + +[CommandMetadata( + Id = "f65f15c4-edf2-47f0-af56-0db47e4e4f74", + Name = "search", + Title = "Search Basic or Auxiliary Workspace Logs", + Description = """ + Searches one Basic or Auxiliary table in an Azure Log Analytics workspace through the synchronous Logs search API. + Requires resourceGroup, workspace, table, a KQL pipeline fragment beginning with '|', and an explicit timespan of at most 30 days. + The server binds the table and appends a final take from limit (default 20, maximum 100). Returns typed columns and rows and explicitly marks partial results. + Scan cost is based on table ingestion volume across the timespan, not limit. Use monitor_workspace_log_query for Analytics-plan tables. + """, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class WorkspaceLogSearchCommand( + ILogger logger, + IMonitorLogSearchService logSearchService, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly IMonitorLogSearchService _logSearchService = logSearchService; + + public override JsonTypeInfo? ResultTypeInfo => + MonitorJsonContext.Default.WorkspaceLogSearchResult; + + public override async Task ExecuteAsync( + CommandContext context, + WorkspaceLogSearchOptions options, + CancellationToken cancellationToken) + { + try + { + var result = await _logSearchService.SearchWorkspaceLogs( + options.Subscription!, + options.ResourceGroup, + options.Workspace, + options.Table, + options.Query, + options.Timespan, + options.Limit, + options.Tenant, + cancellationToken); + + SetResult(context, result); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (CommandValidationException ex) + { + HandleException(context, ex); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Workspace log search failed. ResourceGroup: {ResourceGroup}, Workspace: {Workspace}, Table: {Table}", + options.ResourceGroup, + options.Workspace, + options.Table); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs index 84c2d04f3..8b3aaee71 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs @@ -14,6 +14,7 @@ using Azure.Mcp.Tools.Monitor.Commands.WebTests; using Azure.Mcp.Tools.Monitor.Commands.Workspace; using Azure.Mcp.Tools.Monitor.Models.ActivityLog; using Azure.Mcp.Tools.Monitor.Models.HealthModels; +using Azure.Mcp.Tools.Monitor.Models.Log; namespace Azure.Mcp.Tools.Monitor.Commands; @@ -27,6 +28,15 @@ namespace Azure.Mcp.Tools.Monitor.Commands; [JsonSerializable(typeof(HealthModelGetCommand.HealthModelGetCommandResult))] [JsonSerializable(typeof(HealthModelIdentity))] [JsonSerializable(typeof(HealthModelSummary))] +[JsonSerializable(typeof(LogSearchApiColumn))] +[JsonSerializable(typeof(LogSearchApiError))] +[JsonSerializable(typeof(LogSearchApiErrorDetail))] +[JsonSerializable(typeof(LogSearchApiRequest))] +[JsonSerializable(typeof(LogSearchApiResponse))] +[JsonSerializable(typeof(LogSearchApiTable))] +[JsonSerializable(typeof(LogSearchColumn))] +[JsonSerializable(typeof(LogSearchError))] +[JsonSerializable(typeof(LogSearchErrorDetail))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(MetricsBatchQueryCommand.MetricsBatchQueryCommandResult))] @@ -41,6 +51,7 @@ namespace Azure.Mcp.Tools.Monitor.Commands; [JsonSerializable(typeof(WebTestsCreateOrUpdateCommand.WebTestsCreateOrUpdateCommandResult))] [JsonSerializable(typeof(WebTestsGetCommand.WebTestsGetCommandResult))] [JsonSerializable(typeof(WorkspaceListCommand.WorkspaceListCommandResult))] +[JsonSerializable(typeof(WorkspaceLogSearchResult))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(object))] diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiColumn.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiColumn.cs new file mode 100644 index 000000000..3c07aa9f8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiColumn.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +internal sealed class LogSearchApiColumn +{ + public string? Name { get; set; } + + public string? Type { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiError.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiError.cs new file mode 100644 index 000000000..8a9b5b0a5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiError.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +internal sealed class LogSearchApiError +{ + public string? Code { get; set; } + + public string? Message { get; set; } + + public List? Details { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiErrorDetail.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiErrorDetail.cs new file mode 100644 index 000000000..18c29277d --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiErrorDetail.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +internal sealed class LogSearchApiErrorDetail +{ + public string? Code { get; set; } + + public string? Message { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiRequest.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiRequest.cs new file mode 100644 index 000000000..faa289ba9 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiRequest.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +internal sealed record LogSearchApiRequest(string Query); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiResponse.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiResponse.cs new file mode 100644 index 000000000..6d1d03eab --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiResponse.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +internal sealed class LogSearchApiResponse +{ + public List? Tables { get; set; } + + public LogSearchApiError? Error { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiTable.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiTable.cs new file mode 100644 index 000000000..5988892ab --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchApiTable.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +internal sealed class LogSearchApiTable +{ + public string? Name { get; set; } + + public List? Columns { get; set; } + + public List>? Rows { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchColumn.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchColumn.cs new file mode 100644 index 000000000..b3c076cf2 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchColumn.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +public sealed record LogSearchColumn(string Name, string Type); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchError.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchError.cs new file mode 100644 index 000000000..f04fcaa76 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchError.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +public sealed record LogSearchError( + string Code, + string Message, + IReadOnlyList Details); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchErrorDetail.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchErrorDetail.cs new file mode 100644 index 000000000..c55cf9b86 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchErrorDetail.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +public sealed record LogSearchErrorDetail(string Code, string Message); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchTimeRange.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchTimeRange.cs new file mode 100644 index 000000000..06de3e8f2 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/LogSearchTimeRange.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +internal readonly record struct LogSearchTimeRange( + DateTimeOffset Start, + DateTimeOffset End) +{ + public TimeSpan Duration => End - Start; +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/WorkspaceLogSearchResult.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/WorkspaceLogSearchResult.cs new file mode 100644 index 000000000..59574bf66 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/Log/WorkspaceLogSearchResult.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace Azure.Mcp.Tools.Monitor.Models.Log; + +public sealed record WorkspaceLogSearchResult( + string Table, + string Plan, + string Timespan, + IReadOnlyList Columns, + IReadOnlyList> Rows, + int RowCount, + int Limit, + bool IsPartial, + LogSearchError? Error); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs index bd8f64326..9e5fc9e86 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs @@ -29,6 +29,7 @@ public class MonitorSetup : IAreaSetup public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -71,6 +72,7 @@ public class MonitorSetup : IAreaSetup services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -132,6 +134,7 @@ public class MonitorSetup : IAreaSetup // Register Monitor commands workspaceLogs.AddCommand(serviceProvider); + workspaceLogs.AddCommand(serviceProvider); resourceLogs.AddCommand(serviceProvider); diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Options/WorkspaceLogSearchOptions.cs b/tools/Azure.Mcp.Tools.Monitor/src/Options/WorkspaceLogSearchOptions.cs new file mode 100644 index 000000000..d70f3825d --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Options/WorkspaceLogSearchOptions.cs @@ -0,0 +1,34 @@ +// 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; + +public sealed class WorkspaceLogSearchOptions : ISubscriptionOption +{ + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = "The name of the Log Analytics workspace containing the table.")] + public required string Workspace { get; set; } + + [Option(Description = "The ASCII name of the Basic or Auxiliary Log Analytics table to search.")] + public required string Table { get; set; } + + [Option(Description = "A KQL pipeline fragment beginning with '|'. The table is bound by the server.")] + public required string Query { get; set; } + + [Option(Description = "A positive ISO 8601 duration or a closed RFC 3339 start/end interval, up to 30 days.")] + public required string Timespan { get; set; } + + [Option(Description = "The maximum number of rows to return, from 1 through 100.", DefaultValue = 20)] + public int Limit { get; set; } = 20; + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorLogSearchService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorLogSearchService.cs new file mode 100644 index 000000000..96ba60392 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorLogSearchService.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.Monitor.Models.Log; + +namespace Azure.Mcp.Tools.Monitor.Services; + +/// +/// Searches a single Basic or Auxiliary Log Analytics table through the synchronous Logs search API. +/// +public interface IMonitorLogSearchService +{ + Task SearchWorkspaceLogs( + string subscription, + string resourceGroup, + string workspace, + string table, + string query, + string timespan, + int limit, + string? tenant, + CancellationToken cancellationToken); +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorLogSearchService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorLogSearchService.cs new file mode 100644 index 000000000..31057f8f8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorLogSearchService.cs @@ -0,0 +1,645 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tools.Monitor.Commands; +using Azure.Mcp.Tools.Monitor.Models.Log; +using Azure.Mcp.Tools.Monitor.Validation; +using Azure.ResourceManager.OperationalInsights; +using Azure.ResourceManager.Resources; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Services.Azure.Authentication; + +namespace Azure.Mcp.Tools.Monitor.Services; + +/// +/// Runs a single bounded Basic or Auxiliary table search against the Log Analytics /search API. +/// +public sealed class MonitorLogSearchService( + IAzureService azureService, + IHttpClientFactory httpClientFactory) + : BaseAzureService(azureService), IMonitorLogSearchService +{ + private const int MaxResponseBytes = 1024 * 1024; + private const int MaxRowLimit = 100; + private const string PublicCloudScope = "https://api.loganalytics.io/.default"; + private const string SearchTimedOutMessage = + "The Logs search timed out. Use a shorter timespan or a more selective predicate."; + private static readonly Uri s_publicCloudBaseUri = new("https://api.loganalytics.io/"); + private static readonly TimeSpan s_httpTimeout = TimeSpan.FromSeconds(200); + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + + public async Task SearchWorkspaceLogs( + string subscription, + string resourceGroup, + string workspace, + string table, + string query, + string timespan, + int limit, + string? tenant, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var timeRange = ValidateRequest(subscription, resourceGroup, workspace, table, query, timespan, limit, now); + var endpoint = ResolveLogsEndpoint(); + + var target = await ResolveSearchTargetAsync( + subscription, + resourceGroup, + workspace, + table, + timeRange, + now, + tenant, + cancellationToken); + + var responseBytes = await SendSearchAsync( + target.CustomerId, + table, + query, + timespan, + limit, + endpoint, + tenant, + cancellationToken); + + if (responseBytes is null) + { + return new(table, target.Plan, timespan, [], [], 0, limit, false, null); + } + + return MapResponse(responseBytes, table, target.Plan, timespan, limit); + } + + private static LogSearchTimeRange ValidateRequest( + string subscription, + string resourceGroup, + string workspace, + string table, + string query, + string timespan, + int limit, + DateTimeOffset now) + { + ValidateRequiredParameters( + (nameof(subscription), subscription), + (nameof(resourceGroup), resourceGroup), + (nameof(workspace), workspace), + (nameof(table), table), + (nameof(query), query), + (nameof(timespan), timespan)); + LogSearchQueryValidator.Validate(table, query); + + if (limit is < 1 or > MaxRowLimit) + { + throw new CommandValidationException($"--limit must be between 1 and {MaxRowLimit}."); + } + + return LogSearchTimeRangeParser.Parse(timespan, now); + } + + private async Task<(Guid CustomerId, string Plan)> ResolveSearchTargetAsync( + string subscription, + string resourceGroup, + string workspace, + string table, + LogSearchTimeRange timeRange, + DateTimeOffset now, + string? tenant, + CancellationToken cancellationToken) + { + ResourceGroupResource? resourceGroupResource; + try + { + resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken); + } + catch (RequestFailedException ex) + { + throw CreateMetadataException(ex, "The specified resource group was not found.", "ResourceGroupNotFound"); + } + + if (resourceGroupResource is null) + { + throw new CommandValidationException( + "The specified resource group was not found.", + HttpStatusCode.NotFound, + "ResourceGroupNotFound"); + } + + OperationalInsightsWorkspaceResource workspaceResource; + try + { + var workspaceResponse = await resourceGroupResource + .GetOperationalInsightsWorkspaces() + .GetAsync(workspace, cancellationToken); + workspaceResource = workspaceResponse.Value; + } + catch (RequestFailedException ex) + { + throw CreateMetadataException( + ex, + "The specified Log Analytics workspace was not found in the resource group.", + "WorkspaceNotFound"); + } + + var customerId = workspaceResource.Data.CustomerId; + if (!customerId.HasValue) + { + throw new CommandValidationException( + "The Log Analytics workspace metadata did not contain a customer identifier.", + HttpStatusCode.BadGateway, + "InvalidWorkspaceMetadata"); + } + + OperationalInsightsTableResource tableResource; + try + { + var tableResponse = await workspaceResource + .GetOperationalInsightsTables() + .GetAsync(table, cancellationToken); + tableResource = tableResponse.Value; + } + catch (RequestFailedException ex) + { + throw CreateMetadataException(ex, "The specified Log Analytics table was not found.", "TableNotFound"); + } + + var plan = ValidateTablePlan(tableResource, timeRange, now); + return (customerId.Value, plan); + } + + private static string ValidateTablePlan( + OperationalInsightsTableResource tableResource, + LogSearchTimeRange timeRange, + DateTimeOffset now) + { + var plan = tableResource.Data.Plan?.ToString(); + if (string.IsNullOrWhiteSpace(plan)) + { + throw CreateIncompleteTableMetadataException(); + } + + // Default Analytics tables can omit the plan-change timestamp. + bool isBasic = string.Equals(plan, "Basic", StringComparison.OrdinalIgnoreCase); + if (!isBasic && !string.Equals(plan, "Auxiliary", StringComparison.OrdinalIgnoreCase)) + { + throw new CommandValidationException( + "This tool only searches Basic and Auxiliary tables. Use monitor_workspace_log_query for Analytics tables.", + HttpStatusCode.Conflict, + "UnsupportedTablePlan"); + } + + var lastPlanModifiedDate = tableResource.Data.LastPlanModifiedDate; + if (string.IsNullOrWhiteSpace(lastPlanModifiedDate)) + { + throw CreateIncompleteTableMetadataException(); + } + + if (!DateTimeOffset.TryParse( + lastPlanModifiedDate, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var transition)) + { + throw new CommandValidationException( + "The Log Analytics table plan transition metadata was invalid.", + HttpStatusCode.BadGateway, + "InvalidTableMetadata"); + } + + if (isBasic && timeRange.Start < now - LogSearchTimeRangeParser.MaximumTimespan) + { + throw new CommandValidationException( + "Basic table searches cannot start more than 30 days ago.", + HttpStatusCode.BadRequest, + "BasicTimespanTooOld"); + } + + if (timeRange.Start < transition) + { + throw timeRange.End <= transition + ? new CommandValidationException( + $"The requested interval is entirely before the table's current plan boundary at {transition:O}. Query a range beginning at or after that boundary.", + HttpStatusCode.Conflict, + "HistoricalTablePlanRange") + : new CommandValidationException( + $"The requested interval crosses a table plan boundary at {transition:O}. Split the interval and query the supported portion beginning at that boundary.", + HttpStatusCode.Conflict, + "TablePlanTransition"); + } + + return plan; + } + + /// + /// Sends one bounded /search request. Returns when the service reports + /// no content, otherwise the size-limited response body. + /// + private async Task SendSearchAsync( + Guid customerId, + string table, + string query, + string timespan, + int limit, + (Uri BaseUri, string Scope) endpoint, + string? tenant, + CancellationToken cancellationToken) + { + var accessToken = await AcquireLogsTokenAsync(endpoint.Scope, tenant, cancellationToken); + + var requestUri = new Uri( + endpoint.BaseUri, + $"v1/workspaces/{customerId:D}/search?timespan={Uri.EscapeDataString(timespan)}"); + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); + request.Headers.TryAddWithoutValidation("Prefer", "wait=180"); + var requestBytes = JsonSerializer.SerializeToUtf8Bytes( + new LogSearchApiRequest($"{table} {query.Trim()}\n| take {limit}"), + MonitorJsonContext.Default.LogSearchApiRequest); + request.Content = new ByteArrayContent(requestBytes); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using var client = _httpClientFactory.CreateClient(); + // ResponseHeadersRead ends HttpClient.Timeout before the body is read. + client.Timeout = Timeout.InfiniteTimeSpan; + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(s_httpTimeout); + + try + { + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + timeoutSource.Token); + + if (response.StatusCode == HttpStatusCode.NoContent) + { + return null; + } + + var responseBytes = await ReadSizeLimitedBytesAsync(response.Content, timeoutSource.Token); + return response.IsSuccessStatusCode + ? responseBytes + : throw CreateSearchHttpException(response, responseBytes); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new CommandValidationException( + SearchTimedOutMessage, + HttpStatusCode.GatewayTimeout, + "LogsSearchTimeout"); + } + } + + private async Task AcquireLogsTokenAsync( + string scope, + string? tenant, + CancellationToken cancellationToken) + { + try + { + var tenantId = string.IsNullOrWhiteSpace(tenant) + ? null + : await AzureService.ResolveTenantIdAsync(tenant, cancellationToken); + var credential = await AzureService.GetTokenCredentialAsync(tenantId, cancellationToken); + return await credential.GetTokenAsync(new TokenRequestContext([scope]), cancellationToken); + } + catch (CredentialUnavailableException) + { + throw new CommandValidationException( + "Azure credentials were unavailable for the Logs search.", + HttpStatusCode.Unauthorized, + "CredentialUnavailable"); + } + catch (AuthenticationFailedException) + { + throw new CommandValidationException( + "Authentication failed while acquiring credentials for the Logs search.", + HttpStatusCode.Unauthorized, + "LogsAuthenticationFailed"); + } + catch (RequestFailedException ex) + { + var status = ex.Status is >= 400 and <= 599 + ? (HttpStatusCode)ex.Status + : HttpStatusCode.BadGateway; + throw new CommandValidationException( + $"Azure could not resolve the requested tenant ({SanitizeBackendCode(ex.ErrorCode)}).", + status, + "TenantResolutionFailed"); + } + } + + private static WorkspaceLogSearchResult MapResponse( + byte[] responseBytes, + string table, + string plan, + string timespan, + int limit) + { + LogSearchApiResponse apiResponse; + try + { + apiResponse = JsonSerializer.Deserialize( + responseBytes, + MonitorJsonContext.Default.LogSearchApiResponse) + ?? throw new JsonException("The response body was empty."); + } + catch (JsonException) + { + throw new CommandValidationException( + "The Logs service returned a malformed response.", + HttpStatusCode.BadGateway, + "MalformedLogsResponse"); + } + + var result = MapLogSearchResponse(apiResponse, table, plan, timespan, limit); + var serializedResult = JsonSerializer.SerializeToUtf8Bytes( + result, + MonitorJsonContext.Default.WorkspaceLogSearchResult); + if (serializedResult.Length > MaxResponseBytes) + { + throw CreateResponseTooLargeException(); + } + + return result; + } + + private (Uri BaseUri, string Scope) ResolveLogsEndpoint() => + AzureService.CloudConfiguration.CloudType switch + { + AzureCloudConfiguration.AzureCloud.AzurePublicCloud => (s_publicCloudBaseUri, PublicCloudScope), + _ => throw new CommandValidationException( + "Synchronous Basic and Auxiliary log search is not supported for the configured Azure cloud because its endpoint has not been verified.", + HttpStatusCode.NotImplemented, + "UnsupportedCloud") + }; + + /// + /// Translates an Azure Resource Manager failure into a sanitized validation error so no raw service + /// message, header, or body content reaches the caller. + /// + private static CommandValidationException CreateMetadataException( + RequestFailedException exception, + string notFoundMessage, + string notFoundCode) + { + var status = (HttpStatusCode)exception.Status; + return status switch + { + HttpStatusCode.NotFound => new(notFoundMessage, HttpStatusCode.NotFound, notFoundCode), + HttpStatusCode.Unauthorized => new( + "Authentication failed while reading Log Analytics workspace or table metadata.", + HttpStatusCode.Unauthorized, + "MetadataAuthenticationFailed"), + HttpStatusCode.Forbidden => new( + "Authorization failed while reading Log Analytics workspace or table metadata.", + HttpStatusCode.Forbidden, + "MetadataAuthorizationFailed"), + HttpStatusCode.Conflict => new( + "Azure Resource Manager reported a conflict while reading Log Analytics workspace or table metadata.", + HttpStatusCode.Conflict, + "MetadataConflict"), + (HttpStatusCode)429 => new( + "Azure Resource Manager throttled the workspace or table metadata request. Retry later.", + (HttpStatusCode)429, + "MetadataThrottled"), + _ when exception.Status >= 500 => new( + $"Azure Resource Manager could not complete the workspace or table metadata request ({SanitizeBackendCode(exception.ErrorCode)}).", + status, + "MetadataRequestFailed"), + _ when exception.Status >= 400 => new( + $"Azure Resource Manager rejected the workspace or table metadata request ({SanitizeBackendCode(exception.ErrorCode)}).", + status, + "MetadataRequestRejected"), + _ => new( + $"Azure Resource Manager could not complete the workspace or table metadata request ({SanitizeBackendCode(exception.ErrorCode)}).", + HttpStatusCode.BadGateway, + "MetadataRequestFailed") + }; + } + + private static async Task ReadSizeLimitedBytesAsync( + HttpContent content, + CancellationToken cancellationToken) + { + if (content.Headers.ContentLength > MaxResponseBytes) + { + throw CreateResponseTooLargeException(); + } + + await using var stream = await content.ReadAsStreamAsync(cancellationToken); + using var buffer = new MemoryStream(); + var chunk = new byte[16 * 1024]; + while (true) + { + var bytesRead = await stream.ReadAsync(chunk, cancellationToken); + if (bytesRead == 0) + { + break; + } + + if (buffer.Length + bytesRead > MaxResponseBytes) + { + throw CreateResponseTooLargeException(); + } + + await buffer.WriteAsync(chunk.AsMemory(0, bytesRead), cancellationToken); + } + + return buffer.ToArray(); + } + + private static WorkspaceLogSearchResult MapLogSearchResponse( + LogSearchApiResponse response, + string table, + string plan, + string timespan, + int limit) + { + bool isPartial = string.Equals(response.Error?.Code, "PartialError", StringComparison.OrdinalIgnoreCase); + if (response.Error is not null && !isPartial) + { + throw new CommandValidationException( + $"The Logs service returned a fatal query error ({SanitizeBackendCode(response.Error.Code)}).", + HttpStatusCode.BadGateway, + "FatalLogsError"); + } + + if (response.Tables is null) + { + throw new CommandValidationException( + "The Logs service response did not contain a tables collection.", + HttpStatusCode.BadGateway, + "MalformedLogsResponse"); + } + + List columns = []; + List> rows = []; + if (response.Tables.Count > 0) + { + var resultTable = SelectResultTable(response.Tables); + if (resultTable.Columns is null || resultTable.Rows is null || + resultTable.Columns.Any(column => string.IsNullOrWhiteSpace(column.Name) || string.IsNullOrWhiteSpace(column.Type))) + { + throw new CommandValidationException( + "The Logs service response contained invalid table metadata.", + HttpStatusCode.BadGateway, + "MalformedLogsResponse"); + } + + if (resultTable.Rows.Count > limit || + resultTable.Rows.Any(row => row.Count != resultTable.Columns.Count)) + { + throw new CommandValidationException( + "The Logs service response contained an invalid row shape.", + HttpStatusCode.BadGateway, + "InvalidRowShape"); + } + + columns = resultTable.Columns + .Select(column => new LogSearchColumn(column.Name!, column.Type!)) + .ToList(); + rows = resultTable.Rows + .Select(row => (IReadOnlyList)row) + .ToList(); + } + + return new( + table, + plan, + timespan, + columns, + rows, + rows.Count, + limit, + isPartial, + isPartial ? CreatePartialError(response.Error!) : null); + } + + private static LogSearchApiTable SelectResultTable(List tables) + { + var primaryTables = tables + .Where(item => string.Equals(item.Name, "PrimaryResult", StringComparison.OrdinalIgnoreCase)) + .ToList(); + return primaryTables.Count switch + { + 1 => primaryTables[0], + 0 when tables.Count == 1 => tables[0], + _ => throw new CommandValidationException( + "The Logs service response contained an ambiguous table shape.", + HttpStatusCode.BadGateway, + "MalformedLogsResponse") + }; + } + + private static LogSearchError CreatePartialError(LogSearchApiError error) + { + var details = error.Details? + .Take(10) + .Select(detail => new LogSearchErrorDetail( + SanitizeBackendCode(detail.Code), + "The service reported an additional partial-query detail.")) + .ToList() ?? []; + + return new( + "PartialError", + "The service returned incomplete query results.", + details); + } + + private static CommandValidationException CreateSearchHttpException( + HttpResponseMessage response, + byte[] responseBytes) + { + string code = "LogsRequestFailed"; + if (responseBytes.Length > 0) + { + try + { + var errorResponse = JsonSerializer.Deserialize( + responseBytes, + MonitorJsonContext.Default.LogSearchApiResponse); + if (errorResponse?.Error is not null) + { + code = SanitizeBackendCode(errorResponse.Error.Code); + } + else + { + var error = JsonSerializer.Deserialize( + responseBytes, + MonitorJsonContext.Default.LogSearchApiError); + code = SanitizeBackendCode(error?.Code); + } + } + catch (JsonException) + { + code = "MalformedErrorResponse"; + } + } + + var status = response.StatusCode; + string message = status switch + { + HttpStatusCode.BadRequest => $"The Logs service rejected the query ({code}).", + HttpStatusCode.Unauthorized => "Authentication failed for the Logs service.", + HttpStatusCode.Forbidden => "Authorization failed for the Logs data query.", + HttpStatusCode.NotFound => "The requested Logs search resource was not found.", + HttpStatusCode.RequestTimeout or HttpStatusCode.GatewayTimeout => SearchTimedOutMessage, + (HttpStatusCode)429 => CreateThrottledMessage(response), + _ => $"The Logs service request failed with status {(int)status} ({code})." + }; + + return new(message, status, code); + } + + private static string CreateThrottledMessage(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + var delay = retryAfter?.Delta ?? (retryAfter?.Date - DateTimeOffset.UtcNow); + if (!delay.HasValue) + { + return "The Logs service throttled the search. Retry later."; + } + + var seconds = Math.Clamp((int)Math.Ceiling(delay.Value.TotalSeconds), 0, 3600); + return $"The Logs service throttled the search. Retry after {seconds} seconds."; + } + + private static string SanitizeBackendCode(string? code) + { + if (string.IsNullOrWhiteSpace(code)) + { + return "Unknown"; + } + + var sanitized = new string(code + .Where(character => char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-') + .Take(64) + .ToArray()); + return string.IsNullOrEmpty(sanitized) ? "Unknown" : sanitized; + } + + private static CommandValidationException CreateResponseTooLargeException() => + new( + "The Logs service response exceeded the 1 MiB limit. Use a shorter timespan, a more selective predicate, or fewer projected columns.", + HttpStatusCode.RequestEntityTooLarge, + "ResponseTooLarge"); + + private static CommandValidationException CreateIncompleteTableMetadataException() => + new( + "The Log Analytics table plan metadata was incomplete.", + HttpStatusCode.BadGateway, + "InvalidTableMetadata"); +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchQueryValidator.cs b/tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchQueryValidator.cs new file mode 100644 index 000000000..3368529a0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchQueryValidator.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// cspell:ignore externaldata + +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.Monitor.Validation; + +/// +/// Enforces the structural invariants of the server-owned single-table log search contract. +/// This is deliberately not a KQL parser: it only rejects shapes that would break the contract +/// (extra sources, extra statements, nested pipelines). Azure validates query semantics. +/// +internal static class LogSearchQueryValidator +{ + private const int MaxPipelineLength = 10_000; + + private static readonly HashSet s_unsupportedOperators = new(StringComparer.OrdinalIgnoreCase) + { + "externaldata", + "find", + "invoke", + "join", + "search" + }; + + private static readonly HashSet s_sourceFunctions = new(StringComparer.OrdinalIgnoreCase) + { + "app", + "cluster", + "resource", + "table", + "workspace" + }; + + public static void Validate(string? table, string? pipeline) + { + ValidateTableIdentifier(table); + ValidatePipeline(pipeline); + } + + public static void ValidateTableIdentifier(string? table) + { + if (string.IsNullOrEmpty(table)) + { + throw new CommandValidationException("--table is required."); + } + + if (!IsAsciiLetter(table[0]) || table.Any(character => !IsAsciiLetterOrDigitOrUnderscore(character))) + { + throw new CommandValidationException( + "--table must be an ASCII KQL identifier beginning with a letter and containing only letters, digits, or underscores."); + } + } + + public static void ValidatePipeline(string? pipeline) + { + if (string.IsNullOrWhiteSpace(pipeline)) + { + throw new CommandValidationException("--query is required."); + } + + if (pipeline.Length > MaxPipelineLength) + { + throw new CommandValidationException( + $"--query cannot exceed {MaxPipelineLength:N0} characters."); + } + + var trimmed = pipeline.Trim(); + if (trimmed.Any(character => char.IsControl(character) && character is not ('\t' or '\r' or '\n'))) + { + throw new CommandValidationException("--query contains a disallowed control character."); + } + + if (trimmed[0] != '|') + { + throw new CommandValidationException("--query must be a KQL pipeline fragment beginning with '|'."); + } + + ValidateStructure(BlankQuotedTextAndRejectComments(trimmed)); + } + + /// + /// Blanks out string literals so structural scanning never sees quoted content, and rejects + /// comments, which can only be recognized while scanning outside a literal. + /// Standard literals ('..' / "..") use backslash escapes; verbatim literals (@'..' / @"..") + /// have no backslash escape and represent an embedded quote by doubling it. + /// + private static string BlankQuotedTextAndRejectComments(string pipeline) + { + var result = pipeline.ToCharArray(); + char quote = '\0'; + bool verbatim = false; + bool escaped = false; + + for (int index = 0; index < pipeline.Length; index++) + { + var character = pipeline[index]; + if (quote == '\0') + { + if ((character == '/' && index + 1 < pipeline.Length && + pipeline[index + 1] is '/' or '*') || + (character == '*' && index + 1 < pipeline.Length && + pipeline[index + 1] == '/')) + { + throw new CommandValidationException("--query comments are not allowed."); + } + + if (character is '\'' or '"') + { + quote = character; + verbatim = index > 0 && pipeline[index - 1] == '@'; + result[index] = ' '; + } + + continue; + } + + result[index] = ' '; + + if (escaped) + { + escaped = false; + continue; + } + + if (!verbatim && character == '\\') + { + escaped = true; + continue; + } + + if (character != quote) + { + continue; + } + + if (verbatim && index + 1 < pipeline.Length && pipeline[index + 1] == quote) + { + result[++index] = ' '; + continue; + } + + quote = '\0'; + } + + if (quote != '\0') + { + throw new CommandValidationException("--query contains an unterminated string literal."); + } + + return new(result); + } + + private static void ValidateStructure(string pipeline) + { + int parenthesisDepth = 0; + foreach (var character in pipeline) + { + if (character == ';') + { + throw new CommandValidationException( + "--query must contain one pipeline and cannot contain semicolons or multiple statements."); + } + + if (character == '(') + { + parenthesisDepth++; + } + else if (character == ')') + { + parenthesisDepth--; + if (parenthesisDepth < 0) + { + throw new CommandValidationException("--query contains unbalanced parentheses."); + } + } + else if (character == '|' && parenthesisDepth > 0) + { + throw new CommandValidationException( + "--query cannot contain nested tabular pipelines or source expressions."); + } + } + + if (parenthesisDepth != 0) + { + throw new CommandValidationException("--query contains unbalanced parentheses."); + } + + var tokens = Tokenize(pipeline); + for (int index = 0; index < tokens.Count; index++) + { + var token = tokens[index]; + if (token.Equals("let", StringComparison.OrdinalIgnoreCase)) + { + throw new CommandValidationException("--query cannot contain statement or source rebinding."); + } + + if (s_unsupportedOperators.Contains(token)) + { + throw new CommandValidationException( + $"The '{token}' operator is not supported for Basic or Auxiliary table searches."); + } + + if (s_sourceFunctions.Contains(token) && + index + 1 < tokens.Count && + tokens[index + 1] == "(") + { + throw new CommandValidationException( + $"The '{token}()' source form is not allowed in a workspace log search."); + } + } + } + + private static List Tokenize(string value) + { + var tokens = new List(); + for (int index = 0; index < value.Length;) + { + if (IsAsciiLetter(value[index]) || value[index] == '_') + { + int start = index++; + while (index < value.Length && IsAsciiLetterOrDigitOrUnderscore(value[index])) + { + index++; + } + + tokens.Add(value[start..index]); + } + else + { + if (!char.IsWhiteSpace(value[index])) + { + tokens.Add(value[index].ToString()); + } + + index++; + } + } + + return tokens; + } + + private static bool IsAsciiLetter(char character) => + character is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static bool IsAsciiLetterOrDigitOrUnderscore(char character) => + IsAsciiLetter(character) || character is >= '0' and <= '9' or '_'; +} diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchTimeRangeParser.cs b/tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchTimeRangeParser.cs new file mode 100644 index 000000000..f0e565417 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/src/Validation/LogSearchTimeRangeParser.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// cspell:ignore FFFFFFFK + +using System.Globalization; +using System.Xml; +using Azure.Mcp.Tools.Monitor.Models.Log; +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.Monitor.Validation; + +internal static class LogSearchTimeRangeParser +{ + public static TimeSpan MaximumTimespan { get; } = TimeSpan.FromDays(30); + + private static readonly string[] s_rfc3339Formats = + [ + "yyyy-MM-dd'T'HH:mm:ssK", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK" + ]; + + public static LogSearchTimeRange Parse(string? timespan, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(timespan)) + { + throw new CommandValidationException("--timespan is required."); + } + + if (timespan.Contains('/')) + { + return ParseClosedInterval(timespan, now); + } + + int timeSeparator = timespan.IndexOf('T'); + var dateComponent = timespan.AsSpan(0, timeSeparator >= 0 ? timeSeparator : timespan.Length); + if (dateComponent.Contains('Y') || dateComponent.Contains('M')) + { + throw new CommandValidationException( + "--timespan durations cannot use calendar year or month components."); + } + + TimeSpan duration; + try + { + duration = XmlConvert.ToTimeSpan(timespan); + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + throw new CommandValidationException( + "--timespan must be a positive ISO 8601 duration or a closed RFC 3339 start/end interval."); + } + + if (duration <= TimeSpan.Zero) + { + throw new CommandValidationException("--timespan duration must be positive."); + } + + if (duration > MaximumTimespan) + { + throw new CommandValidationException("--timespan cannot exceed 30 days."); + } + + return new(now - duration, now); + } + + private static LogSearchTimeRange ParseClosedInterval(string timespan, DateTimeOffset now) + { + var parts = timespan.Split('/'); + if (parts.Length != 2 || + !TryParseRfc3339(parts[0], out var start) || + !TryParseRfc3339(parts[1], out var end)) + { + throw new CommandValidationException( + "--timespan intervals must contain closed RFC 3339 start and end timestamps."); + } + + if (start >= end) + { + throw new CommandValidationException("--timespan start must be earlier than its end."); + } + + if (start >= now) + { + throw new CommandValidationException("--timespan cannot be entirely in the future."); + } + + if (end - start > MaximumTimespan) + { + throw new CommandValidationException("--timespan cannot exceed 30 days."); + } + + return new(start, end); + } + + private static bool TryParseRfc3339(string value, out DateTimeOffset result) + { + if (!value.Contains('T') || + !(value.EndsWith('Z') || HasNumericOffset(value))) + { + result = default; + return false; + } + + return DateTimeOffset.TryParseExact( + value, + s_rfc3339Formats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out result); + } + + private static bool HasNumericOffset(string value) + { + if (value.Length < 6) + { + return false; + } + + var offsetStart = value.Length - 6; + return (value[offsetStart] is '+' or '-') && + char.IsAsciiDigit(value[offsetStart + 1]) && + char.IsAsciiDigit(value[offsetStart + 2]) && + value[offsetStart + 3] == ':' && + char.IsAsciiDigit(value[offsetStart + 4]) && + char.IsAsciiDigit(value[offsetStart + 5]); + } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchQueryValidatorTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchQueryValidatorTests.cs new file mode 100644 index 000000000..cc5fd1e2e --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchQueryValidatorTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// cspell:ignore externaldata getschema leftouter toscalar Тable + +using Azure.Mcp.Tools.Monitor.Validation; +using Microsoft.Mcp.Core.Commands; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests.Log; + +public sealed class LogSearchQueryValidatorTests +{ + [Theory] + [InlineData("McpBasic_CL")] + [InlineData("A")] + [InlineData("Table_123")] + public void ValidateTableIdentifier_AcceptsAsciiIdentifiers(string table) + { + LogSearchQueryValidator.ValidateTableIdentifier(table); + } + + [Theory] + [InlineData("")] + [InlineData(" table")] + [InlineData("1Table")] + [InlineData("Table Name")] + [InlineData("Table.Name")] + [InlineData("Table-Name")] + [InlineData("['Table']")] + [InlineData("Table\"")] + [InlineData("Тable")] + [InlineData("Table | take 1")] + public void ValidateTableIdentifier_RejectsInvalidOrInjectedIdentifiers(string table) + { + Assert.Throws( + () => LogSearchQueryValidator.ValidateTableIdentifier(table)); + } + + [Theory] + [InlineData("| where Level == 'Error' | project TimeGenerated, Message")] + [InlineData(" \r\n\t| summarize Count=count() by bin(TimeGenerated, 1h) | order by Count desc")] + [InlineData("| extend Parsed=parse_json(Properties), Text=tostring(Message) | take 10")] + [InlineData("| parse Message with Prefix ':' Value | project-away Prefix")] + [InlineData("| where isnotempty(Message) and strlen(Message) > 2 | limit 5")] + [InlineData("| getschema")] + [InlineData("| lookup kind=leftouter AnalyticsTable on CorrelationId")] + [InlineData("| union kind=outer AnalyticsOne, AnalyticsTwo")] + [InlineData("| where (Count > 1) and (Level == 'Error')")] + [InlineData("| extend Value=ordinary_function(Message)")] + [InlineData("| extend Length=strlen(tostring(Message)) | project Length")] + public void ValidatePipeline_AcceptsNormalPipelines(string pipeline) + { + LogSearchQueryValidator.ValidatePipeline(pipeline); + } + + /// + /// Quoted content is blanked before structural scanning, so characters that would be rejected + /// outside a literal must still be accepted inside one. + /// + [Theory] + [InlineData("| where Message == 'https://example.test/a//b;still-a-string'")] + [InlineData("| where Message == \"a | b; // not a comment\"")] + [InlineData("| where Message has 'C:\\\\temp\\\\file'")] + [InlineData("| where Message has 'it\\'s escaped'")] + [InlineData("| where Message has @'C:\\temp\\file|pipe;semicolon'")] + [InlineData("| where Message has @\"verbatim \"\"quoted\"\" | text\"")] + public void ValidatePipeline_AcceptsStringLiteralContent(string pipeline) + { + LogSearchQueryValidator.ValidatePipeline(pipeline); + } + + [Theory] + [InlineData("")] + [InlineData("McpBasic_CL | take 1")] + [InlineData("where Level == 'Error'")] + public void ValidatePipeline_RequiresLeadingPipe(string pipeline) + { + Assert.Throws( + () => LogSearchQueryValidator.ValidatePipeline(pipeline)); + } + + [Fact] + public void ValidatePipeline_RejectsMoreThanTenThousandCharacters() + { + var pipeline = "|" + new string('x', 10_000); + + Assert.Throws( + () => LogSearchQueryValidator.ValidatePipeline(pipeline)); + } + + [Theory] + [InlineData("| where true; OtherTable | take 1")] + [InlineData("| take 1 // comment")] + [InlineData("| take 1 /* comment */")] + [InlineData("| let replacement = OtherTable")] + [InlineData("| table('OtherTable')")] + [InlineData("| workspace('other').Table")] + [InlineData("| app('other').Table")] + [InlineData("| resource('/subscriptions/other')")] + [InlineData("| cluster('other').database('db').Table")] + [InlineData("| join kind=inner OtherTable on CorrelationId")] + [InlineData("| find where Message has 'error'")] + [InlineData("| search 'needle'")] + [InlineData("| externaldata(Value:string)['https://example.test/data']")] + [InlineData("| invoke SomeStoredFunction()")] + [InlineData("| union workspace('other').AnalyticsOne")] + [InlineData("| lookup workspace('other').AnalyticsOne on CorrelationId")] + [InlineData("| extend Leaked=toscalar(SensitiveTable | summarize make_list(SecretColumn)) | project Leaked")] + [InlineData("| where (Count > 1")] + [InlineData("| where Count > 1)")] + [InlineData("| where Message == 'unterminated")] + public void ValidatePipeline_RejectsUnsafeOrUnsupportedPipelines(string pipeline) + { + Assert.Throws( + () => LogSearchQueryValidator.ValidatePipeline(pipeline)); + } + + [Fact] + public void ValidatePipeline_CommentContainingQuote_ReportsCommentError() + { + var exception = Assert.Throws( + () => LogSearchQueryValidator.ValidatePipeline("| take 1 // don't return more")); + + Assert.Contains("comments are not allowed", exception.Message); + } + + [Fact] + public void ValidatePipeline_RejectsControlCharacters() + { + Assert.Throws( + () => LogSearchQueryValidator.ValidatePipeline("| where Message == 1\u0001")); + } + + [Fact] + public void Validate_RejectsTableInjectionBeforePipelineChecks() + { + var exception = Assert.Throws( + () => LogSearchQueryValidator.Validate("McpBasic_CL | take 1", "| take 1")); + + Assert.Contains("--table", exception.Message); + } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchTimeRangeParserTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchTimeRangeParserTests.cs new file mode 100644 index 000000000..389a4d722 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/LogSearchTimeRangeParserTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.Monitor.Validation; +using Microsoft.Mcp.Core.Commands; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests.Log; + +public sealed class LogSearchTimeRangeParserTests +{ + private static readonly DateTimeOffset s_now = new(2026, 9, 3, 12, 0, 0, TimeSpan.Zero); + + [Theory] + [InlineData("PT30M", 30)] + [InlineData("PT1H", 60)] + [InlineData("P1D", 1_440)] + [InlineData("P30D", 43_200)] + [InlineData("P1DT12H30M", 2_190)] + public void Parse_PositiveDuration_EndsAtNow(string timespan, double expectedMinutes) + { + var range = LogSearchTimeRangeParser.Parse(timespan, s_now); + + Assert.Equal(TimeSpan.FromMinutes(expectedMinutes), range.Duration); + Assert.Equal(s_now.AddMinutes(-expectedMinutes), range.Start); + Assert.Equal(s_now, range.End); + } + + [Theory] + [InlineData("P1M")] + [InlineData("P1Y")] + [InlineData("P1Y1M")] + public void Parse_CalendarDuration_Throws(string timespan) + { + var exception = Assert.Throws( + () => LogSearchTimeRangeParser.Parse(timespan, s_now)); + + Assert.Contains("calendar year or month", exception.Message); + } + + [Theory] + [InlineData("P31D")] + [InlineData("PT721H")] + public void Parse_DurationLongerThanThirtyDays_Throws(string timespan) + { + var exception = Assert.Throws( + () => LogSearchTimeRangeParser.Parse(timespan, s_now)); + + Assert.Contains("30 days", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("one day")] + [InlineData("1d")] + [InlineData("PT0S")] + [InlineData("-PT1H")] + public void Parse_MissingZeroOrNegativeDuration_Throws(string? timespan) + { + var exception = Assert.Throws( + () => LogSearchTimeRangeParser.Parse(timespan, s_now)); + + Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode); + } + + [Fact] + public void Parse_ClosedUtcInterval_UsesSuppliedBoundaries() + { + var range = LogSearchTimeRangeParser.Parse( + "2026-09-02T00:00:00Z/2026-09-02T06:00:00.500Z", + s_now); + + Assert.Equal(new DateTimeOffset(2026, 9, 2, 0, 0, 0, TimeSpan.Zero), range.Start); + Assert.Equal(TimeSpan.FromHours(6) + TimeSpan.FromMilliseconds(500), range.Duration); + } + + [Fact] + public void Parse_ClosedNumericOffsetInterval_PreservesOffset() + { + var range = LogSearchTimeRangeParser.Parse( + "2026-09-02T10:00:00-07:00/2026-09-02T12:30:00-07:00", + s_now); + + Assert.Equal(TimeSpan.FromHours(2.5), range.Duration); + Assert.Equal(new DateTimeOffset(2026, 9, 2, 10, 0, 0, TimeSpan.FromHours(-7)), range.Start); + } + + [Theory] + [InlineData("2026-09-01T00:00:00Z/")] + [InlineData("/2026-09-01T00:00:00Z")] + [InlineData("2026-09-01T00:00:00Z/2026-09-02T00:00:00Z/2026-09-03T00:00:00Z")] + [InlineData("2026-09-01T00:00:00/2026-09-02T00:00:00")] + [InlineData("2026-09-01/2026-09-02")] + [InlineData("2026-09-01T00:00:00+7/2026-09-02T00:00:00+7")] + public void Parse_OpenOrMalformedInterval_Throws(string timespan) + { + var exception = Assert.Throws( + () => LogSearchTimeRangeParser.Parse(timespan, s_now)); + + Assert.Contains("closed RFC 3339", exception.Message); + } + + [Theory] + [InlineData("2026-09-02T00:00:00Z/2026-09-01T00:00:00Z")] + [InlineData("2026-09-01T00:00:00Z/2026-09-01T00:00:00Z")] + public void Parse_ReversedOrEmptyInterval_Throws(string timespan) + { + var exception = Assert.Throws( + () => LogSearchTimeRangeParser.Parse(timespan, s_now)); + + Assert.Contains("earlier than its end", exception.Message); + } + + [Fact] + public void Parse_IntervalEntirelyInTheFuture_Throws() + { + var exception = Assert.Throws( + () => LogSearchTimeRangeParser.Parse( + "2026-09-04T00:00:00Z/2026-09-05T00:00:00Z", + s_now)); + + Assert.Contains("entirely in the future", exception.Message); + } + + [Fact] + public void Parse_IntervalLongerThanThirtyDays_Throws() + { + var exception = Assert.Throws( + () => LogSearchTimeRangeParser.Parse( + "2026-08-01T00:00:00Z/2026-09-01T00:00:01Z", + s_now)); + + Assert.Contains("30 days", exception.Message); + } + + [Fact] + public void MaximumTimespan_IsThirtyDays() + { + Assert.Equal(TimeSpan.FromDays(30), LogSearchTimeRangeParser.MaximumTimespan); + } +} diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/WorkspaceLogSearchCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/WorkspaceLogSearchCommandTests.cs new file mode 100644 index 000000000..7b2778111 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Log/WorkspaceLogSearchCommandTests.cs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.Monitor.Commands; +using Azure.Mcp.Tools.Monitor.Commands.Log; +using Azure.Mcp.Tools.Monitor.Models.Log; +using Azure.Mcp.Tools.Monitor.Options; +using Azure.Mcp.Tools.Monitor.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests.Log; + +public sealed class WorkspaceLogSearchCommandTests + : SubscriptionCommandUnitTestsBase +{ + private const string Subscription = "sub"; + private const string ResourceGroup = "rg"; + private const string Workspace = "workspace"; + private const string Table = "McpBasic_CL"; + private const string Query = "| where Message has 'test'"; + private const string Timespan = "P1D"; + private const string Tenant = "tenant"; + + [Fact] + public void Command_HasExpectedMetadataAndOptions() + { + Assert.Equal("search", Command.Name); + Assert.Equal("search", CommandDefinition.Name); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + Assert.True(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.OpenWorld); + Assert.False(Command.Metadata.LocalRequired); + Assert.False(Command.Metadata.Secret); + Assert.Same(MonitorJsonContext.Default.WorkspaceLogSearchResult, Command.ResultTypeInfo); + + var options = CommandDefinition.Options.ToDictionary(option => option.Name); + Assert.Equal( + [ + "--limit", + "--query", + "--resource-group", + "--subscription", + "--table", + "--tenant", + "--timespan", + "--workspace" + ], + options.Keys.Order(StringComparer.Ordinal).ToArray()); + Assert.True(options["--resource-group"].Required); + Assert.True(options["--workspace"].Required); + Assert.True(options["--table"].Required); + Assert.True(options["--query"].Required); + Assert.True(options["--timespan"].Required); + } + + [Theory] + [InlineData("--resource-group rg --workspace workspace --table McpBasic_CL --query \"| take 1\" --timespan P1D")] + [InlineData("--subscription sub --workspace workspace --table McpBasic_CL --query \"| take 1\" --timespan P1D")] + [InlineData("--subscription sub --resource-group rg --table McpBasic_CL --query \"| take 1\" --timespan P1D")] + [InlineData("--subscription sub --resource-group rg --workspace workspace --query \"| take 1\" --timespan P1D")] + [InlineData("--subscription sub --resource-group rg --workspace workspace --table McpBasic_CL --timespan P1D")] + [InlineData("--subscription sub --resource-group rg --workspace workspace --table McpBasic_CL --query \"| take 1\"")] + public async Task ExecuteAsync_MissingRequiredField_ReturnsBadRequest(string args) + { + var response = await ExecuteCommandAsync(args); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + await AssertNoSearchReceived(); + } + + [Theory] + [InlineData(1)] + [InlineData(100)] + public async Task ExecuteAsync_ValidBoundaryLimit_PassesLimitToService(int limit) + { + SetupSearchResult(EmptyResult(limit)); + + var response = await ExecuteCommandAsync( + ValidArguments().Concat(["--limit", limit.ToString()]).ToArray()); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await AssertSearchReceived(limit, null, TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(101)] + public async Task ExecuteAsync_OutOfRangeLimit_DelegatesToServiceForValidation(int limit) + { + SetupSearchFailure( + new CommandValidationException("--limit must be between 1 and 100.")); + + var response = await ExecuteCommandAsync( + ValidArguments().Concat(["--limit", limit.ToString()]).ToArray()); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Equal("--limit must be between 1 and 100.", response.Message); + await AssertSearchReceived(limit, null, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ExecuteAsync_OmittedLimit_UsesTwenty() + { + SetupSearchResult(EmptyResult(20)); + + var response = await ExecuteCommandAsync(ValidArguments()); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await AssertSearchReceived(20, null, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ExecuteAsync_PassesEveryServiceArgumentExactly() + { + SetupSearchResult(EmptyResult(37)); + + var response = await ExecuteCommandAsync( + ValidArguments().Concat(["--limit", "37", "--tenant", Tenant]).ToArray()); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await AssertSearchReceived(37, Tenant, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ExecuteAsync_SerializesTypedCellsAndPartialError() + { + var serviceResult = new WorkspaceLogSearchResult( + Table, + "Basic", + Timespan, + [ + new("Count", "long"), + new("Enabled", "bool"), + new("Message", "string") + ], + [ + [Element("42"), Element("true"), Element("null")] + ], + 1, + 20, + true, + new( + "PartialError", + "The service returned incomplete query results.", + [new("ServiceCode", "Sanitized detail")])); + SetupSearchResult(serviceResult); + + var response = await ExecuteCommandAsync(ValidArguments()); + var result = ValidateAndDeserializeResponse( + response, + MonitorJsonContext.Default.WorkspaceLogSearchResult); + + Assert.Equal(JsonValueKind.Number, result.Rows[0][0].ValueKind); + Assert.Equal(42, result.Rows[0][0].GetInt32()); + Assert.Equal(JsonValueKind.True, result.Rows[0][1].ValueKind); + Assert.Equal(JsonValueKind.Null, result.Rows[0][2].ValueKind); + Assert.True(result.IsPartial); + Assert.Equal("PartialError", result.Error?.Code); + Assert.Equal("ServiceCode", result.Error?.Details.Single().Code); + } + + [Fact] + public async Task ExecuteAsync_ServiceValidationFailure_ReturnsSafeStatusWithoutErrorLogging() + { + SetupSearchFailure(new CommandValidationException( + "The table uses the Analytics plan. Use monitor_workspace_log_query for Analytics tables.", + HttpStatusCode.Conflict, + "UnsupportedTablePlan")); + + var response = await ExecuteCommandAsync(ValidArguments()); + + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Equal( + "The table uses the Analytics plan. Use monitor_workspace_log_query for Analytics tables.", + response.Message); + Assert.Null(response.Results); + Assert.DoesNotContain( + Logger.ReceivedCalls(), + call => call.GetMethodInfo().Name == nameof(ILogger.Log) && + call.GetArguments()[0] is LogLevel.Error); + } + + [Fact] + public async Task ExecuteAsync_UnexpectedServiceFailure_ReturnsUnprocessableEntity() + { + SetupSearchFailure(new InvalidOperationException("service failed")); + + var response = await ExecuteCommandAsync(ValidArguments()); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.Status); + Assert.Contains("service failed", response.Message); + } + + [Fact] + public async Task ExecuteAsync_PropagatesCallerCancellation() + { + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + var token = cancellationSource.Token; + SetupCanceledSearch(token); + var options = new WorkspaceLogSearchOptions + { + Subscription = Subscription, + ResourceGroup = ResourceGroup, + Workspace = Workspace, + Table = Table, + Query = Query, + Timespan = Timespan + }; + + await Assert.ThrowsAnyAsync( + () => Command.ExecuteAsync(Context, options, token)); + await AssertSearchReceived(20, null, token); + Assert.Null(Context.Response.Results); + } + + private static string[] ValidArguments() => + [ + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--workspace", Workspace, + "--table", Table, + "--query", Query, + "--timespan", Timespan + ]; + + private void SetupSearchResult(WorkspaceLogSearchResult result) + { + Service.SearchWorkspaceLogs( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(result); + } + + private void SetupSearchFailure(Exception exception) + { + Service.SearchWorkspaceLogs( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(exception); + } + + private void SetupCanceledSearch(CancellationToken cancellationToken) + { + Service.SearchWorkspaceLogs( + Subscription, + ResourceGroup, + Workspace, + Table, + Query, + Timespan, + 20, + null, + cancellationToken) + .Returns(Task.FromCanceled(cancellationToken)); + } + + private async Task AssertSearchReceived(int limit, string? tenant, CancellationToken cancellationToken) + { + await Service.Received(1).SearchWorkspaceLogs( + Subscription, + ResourceGroup, + Workspace, + Table, + Query, + Timespan, + limit, + tenant, + cancellationToken); + } + + private async Task AssertNoSearchReceived() + { + await Service.DidNotReceive().SearchWorkspaceLogs( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + private static WorkspaceLogSearchResult EmptyResult(int limit) => + new(Table, "Basic", Timespan, [], [], 0, limit, false, null); + + private static JsonElement Element(string json) => + JsonSerializer.Deserialize(json); +} 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 d664b7b82..a2a936007 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 @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Globalization; using System.Text.Json; using Azure.ResourceManager.CloudHealth.Models; using Microsoft.Mcp.Tests; +using Microsoft.Mcp.Tests.Attributes; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; using Microsoft.Mcp.Tests.Generated.Models; @@ -15,6 +17,8 @@ namespace Azure.Mcp.Tools.Monitor.Tests; public sealed class MonitorCommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture) : RecordedCommandTestsBase(output, fixture, liveServerFixture) { + private const string EmptyGuid = "00000000-0000-0000-0000-000000000000"; + private string? _appInsightsName; private string? _bingWebTestName; private string? _healthModelParentName; @@ -52,6 +56,18 @@ public sealed class MonitorCommandTests(ITestOutputHelper output, TestProxyFixtu Regex = "webtests/([^?\\/]+)", Value = "Sanitized", GroupForReplace = "1" + }), + new(new UriRegexSanitizerBody + { + Regex = "timespan=([^&]+)", + Value = "P1D", + GroupForReplace = "1" + }), + new(new UriRegexSanitizerBody + { + Regex = "/v1/workspaces/([^?\\/]+)/(?:query|search)", + Value = EmptyGuid, + GroupForReplace = "1" }) ]; @@ -61,6 +77,10 @@ public sealed class MonitorCommandTests(ITestOutputHelper output, TestProxyFixtu new BodyKeySanitizer(new BodyKeySanitizerBody("$..resourceGroup")), new BodyKeySanitizer(new BodyKeySanitizerBody("$..displayName")), new BodyKeySanitizer(new BodyKeySanitizerBody("$..TimeZone")), + new BodyKeySanitizer(new BodyKeySanitizerBody("$..customerId") + { + Value = EmptyGuid + }), new BodyKeySanitizer(new BodyKeySanitizerBody("$..id"){ Regex = "resource[Gg]roups/([^?\\/]+)", Value = "Sanitized", @@ -89,6 +109,220 @@ public sealed class MonitorCommandTests(ITestOutputHelper output, TestProxyFixtu _storageAccountName = $"{Settings.ResourceBaseName}mon"; } + #region Workspace Log Search Integration Tests + + [Fact] + [CustomMatcher(compareBody: true)] + public async Task Should_Search_Basic_Table() + { + var table = GetLogSearchDeploymentOutput("logSearchBasicTableName"); + var fixtureId = GetLogSearchDeploymentOutput("logSearchBasicFixtureId"); + var result = await SearchWorkspaceLogsAsync( + table, + "| where FixtureId startswith 'mcp-log-search-basic-' | order by Count asc | project FixtureId, Message, Count, Enabled, OptionalValue"); + + AssertLogSearchResult( + result, + table, + "Basic", + fixtureId, + "basic fixture", + 7, + expectedEnabled: true); + } + + [Fact] + [CustomMatcher(compareBody: true)] + public async Task Should_Search_Auxiliary_Table() + { + var table = GetLogSearchDeploymentOutput("logSearchAuxiliaryTableName"); + var fixtureId = GetLogSearchDeploymentOutput("logSearchAuxiliaryFixtureId"); + var result = await SearchWorkspaceLogsAsync( + table, + "| where FixtureId startswith 'mcp-log-search-auxiliary-' | order by Count asc | project FixtureId, Message, Count, Enabled, OptionalValue"); + + AssertLogSearchResult( + result, + table, + "Auxiliary", + fixtureId, + "auxiliary fixture", + 11, + expectedEnabled: false); + } + + [Fact] + [CustomMatcher(compareBody: true)] + public async Task Should_Return_Empty_Log_Search_Result() + { + var table = GetLogSearchDeploymentOutput("logSearchBasicTableName"); + var result = await SearchWorkspaceLogsAsync( + table, + "| where FixtureId == 'missing-log-search-fixture' | project Message, Count"); + + Assert.NotNull(result); + Assert.Equal(table, result.Value.AssertProperty("table").GetString()); + Assert.Equal("Basic", result.Value.AssertProperty("plan").GetString()); + Assert.Equal(0, result.Value.AssertProperty("rowCount").GetInt32()); + Assert.Empty(result.Value.AssertProperty("rows").EnumerateArray()); + Assert.False(result.Value.AssertProperty("isPartial").GetBoolean()); + Assert.Equal(JsonValueKind.Null, result.Value.AssertProperty("error").ValueKind); + } + + [Fact] + [CustomMatcher(compareBody: true)] + public async Task Should_Reject_Analytics_Search_And_Preserve_Analytics_Query() + { + var table = GetLogSearchDeploymentOutput("logSearchAnalyticsTableName"); + var searchResponse = await CallToolAsync( + "monitor_workspace_log_search", + CreateLogSearchArguments(table, "| take 1", "P1D", 1), + resultProcessor: element => element); + + AssertErrorContains(searchResponse, "Analytics"); + + var queryResult = await CallToolAsync( + "monitor_workspace_log_query", + new() + { + { "subscription", Settings.SubscriptionId }, + { "workspace", Settings.ResourceBaseName }, + { "table", table }, + { "query", $"{table} | take 1" }, + { "hours", 24 }, + { "limit", 1 }, + { "tenant", Settings.TenantId } + }); + + Assert.NotNull(queryResult); + Assert.Equal(JsonValueKind.Array, queryResult.Value.ValueKind); + } + + [Fact] + [CustomMatcher(compareBody: true)] + public async Task Should_Reject_Log_Search_Over_Thirty_Days() + { + var table = GetLogSearchDeploymentOutput("logSearchBasicTableName"); + var response = await CallToolAsync( + "monitor_workspace_log_search", + CreateLogSearchArguments( + table, + "| where FixtureId startswith 'mcp-log-search-basic-'", + "P31D", + 1), + resultProcessor: element => element); + + AssertErrorContains(response, "30"); + } + + [Fact] + [CustomMatcher(compareBody: true)] + public async Task Should_Reject_Log_Search_Crossing_Plan_Transition() + { + var transition = DateTimeOffset.Parse( + GetLogSearchDeploymentOutput("logSearchAuxiliaryLastPlanModifiedDate"), + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind); + var timespan = $"{transition.AddMinutes(-1):o}/{transition.AddMinutes(1):o}"; + var table = GetLogSearchDeploymentOutput("logSearchAuxiliaryTableName"); + + var response = await CallToolAsync( + "monitor_workspace_log_search", + CreateLogSearchArguments( + table, + "| where FixtureId startswith 'mcp-log-search-auxiliary-'", + timespan, + 1), + resultProcessor: element => element); + + AssertErrorContains(response, "boundary"); + } + + private async Task SearchWorkspaceLogsAsync(string table, string query) + { + var playbackEnd = DateTimeOffset.UtcNow; + var timespan = TestMode == TestMode.Playback + ? $"{playbackEnd.AddMinutes(-5):o}/{playbackEnd:o}" + : $"{GetLogSearchDeploymentOutput("logSearchFixtureStartTime")}/{GetLogSearchDeploymentOutput("logSearchFixtureEndTime")}"; + var result = await CallToolAsync( + "monitor_workspace_log_search", + CreateLogSearchArguments( + table, + query, + timespan, + 1)); + + Assert.NotNull(result); + return result; + } + + private Dictionary CreateLogSearchArguments( + string table, + string query, + string timespan, + int limit) + { + return new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "workspace", Settings.ResourceBaseName }, + { "table", table }, + { "query", query }, + { "timespan", timespan }, + { "limit", limit }, + { "tenant", Settings.TenantId } + }; + } + + private string GetLogSearchDeploymentOutput(string name) => + RegisterOrRetrieveDeploymentOutputVariable(name, name); + + private static void AssertLogSearchResult( + JsonElement? result, + string expectedTable, + string expectedPlan, + string expectedFixtureId, + string expectedMessage, + long expectedCount, + bool expectedEnabled) + { + Assert.NotNull(result); + Assert.Equal(expectedTable, result.Value.AssertProperty("table").GetString()); + Assert.Equal(expectedPlan, result.Value.AssertProperty("plan").GetString()); + Assert.Equal(1, result.Value.AssertProperty("rowCount").GetInt32()); + Assert.Equal(1, result.Value.AssertProperty("limit").GetInt32()); + Assert.False(result.Value.AssertProperty("isPartial").GetBoolean()); + Assert.Equal(JsonValueKind.Null, result.Value.AssertProperty("error").ValueKind); + + // Recordings scrub column names; the explicit project clause fixes their order. + var columns = result.Value.AssertProperty("columns").EnumerateArray().ToArray(); + Assert.Equal( + ["string", "string", "long", "bool", "string"], + columns.Select(column => column.AssertProperty("type").GetString())); + Assert.All(columns, column => Assert.Equal( + JsonValueKind.String, + column.AssertProperty("name").ValueKind)); + var row = result.Value.AssertProperty("rows")[0]; + + Assert.Equal(columns.Length, row.GetArrayLength()); + Assert.Equal(expectedFixtureId, row[0].GetString()); + Assert.Equal(expectedMessage, row[1].GetString()); + Assert.Equal(expectedCount, row[2].GetInt64()); + Assert.Equal(expectedEnabled, row[3].GetBoolean()); + Assert.Equal(string.Empty, row[4].GetString()); + } + + private static void AssertErrorContains(JsonElement? response, string expected) + { + Assert.NotNull(response); + var message = response.Value.AssertProperty("message").GetString(); + Assert.NotNull(message); + Assert.Contains(expected, message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + // [Fact] // public async Task Should_list_monitor_tables() // { diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorLogSearchServiceTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorLogSearchServiceTests.cs new file mode 100644 index 000000000..cbeeafbec --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorLogSearchServiceTests.cs @@ -0,0 +1,1060 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using System.Text.Json; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Identity; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tools.Monitor.Models.Log; +using Azure.Mcp.Tools.Monitor.Services; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Services.Azure.Authentication; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests; + +public sealed class MonitorLogSearchServiceTests +{ + private const string Subscription = "subscription"; + private const string SubscriptionId = "11111111-1111-1111-1111-111111111111"; + private const string ResourceGroup = "resource-group"; + private const string Workspace = "workspace"; + private const string WorkspaceId = "22222222-2222-2222-2222-222222222222"; + private const string Table = "McpBasic_CL"; + private const string Query = "| where Message has 'test'"; + private const string Timespan = "P1D"; + + [Fact] + public async Task SearchWorkspaceLogs_SendsExpectedAuthenticatedRequestAndPreservesCellTypes() + { + var fixture = CreateFixture( + "Auxiliary", + DataResponse(""" + { + "tables": [{ + "name": "PrimaryResult", + "columns": [ + { "name": "Count", "type": "long" }, + { "name": "Enabled", "type": "bool" }, + { "name": "Message", "type": "string" } + ], + "rows": [[42, true, null]] + }] + } + """)); + + var result = await fixture.SearchAsync( + TestContext.Current.CancellationToken, + timespan: "2026-09-02T00:00:00Z/2026-09-03T00:00:00Z", + limit: 7, + tenant: "tenant"); + + Assert.Equal("Auxiliary", result.Plan); + Assert.Equal(1, result.RowCount); + Assert.Equal(["Count", "Enabled", "Message"], result.Columns.Select(column => column.Name).ToArray()); + Assert.Equal(["long", "bool", "string"], result.Columns.Select(column => column.Type).ToArray()); + Assert.Equal(JsonValueKind.Number, result.Rows[0][0].ValueKind); + Assert.Equal(42, result.Rows[0][0].GetInt32()); + Assert.Equal(JsonValueKind.True, result.Rows[0][1].ValueKind); + Assert.Equal(JsonValueKind.Null, result.Rows[0][2].ValueKind); + Assert.False(result.IsPartial); + Assert.Null(result.Error); + + Assert.Equal(1, fixture.DataHandler.CallCount); + Assert.Equal(2, fixture.ArmHandler.CallCount); + Assert.Contains( + fixture.ArmHandler.RequestUris, + uri => uri.AbsolutePath.Contains( + $"/subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}/providers/Microsoft.OperationalInsights/workspaces/{Workspace}", + StringComparison.OrdinalIgnoreCase)); + Assert.Equal(HttpMethod.Post, fixture.DataHandler.Method); + Assert.Equal( + $"https://api.loganalytics.io/v1/workspaces/{WorkspaceId}/search?timespan=2026-09-02T00%3A00%3A00Z%2F2026-09-03T00%3A00%3A00Z", + fixture.DataHandler.RequestUri?.AbsoluteUri); + Assert.Equal("Bearer", fixture.DataHandler.AuthorizationScheme); + Assert.Equal("logs-token", fixture.DataHandler.AuthorizationParameter); + Assert.Equal("application/json", fixture.DataHandler.ContentType); + Assert.Equal("wait=180", fixture.DataHandler.Prefer); + using var body = JsonDocument.Parse(fixture.DataHandler.RequestBody!); + Assert.Equal( + $"{Table} {Query}\n| take 7", + body.RootElement.GetProperty("query").GetString()); + + Assert.NotNull(fixture.LogsCredential.Scopes); + Assert.Equal( + "https://api.loganalytics.io/.default", + Assert.Single(fixture.LogsCredential.Scopes)); + Assert.Equal( + TestContext.Current.CancellationToken, + fixture.LogsCredential.CancellationToken); + } + + [Theory] + [InlineData("Basic")] + [InlineData("Auxiliary")] + public async Task SearchWorkspaceLogs_AcceptsSupportedPlans(string plan) + { + var fixture = CreateFixture(plan, DataResponse("""{"tables":[]}""")); + + var result = await fixture.SearchAsync(TestContext.Current.CancellationToken); + + Assert.Equal(plan, result.Plan); + Assert.Equal(0, result.RowCount); + Assert.False(result.IsPartial); + } + + [Fact] + public async Task SearchWorkspaceLogs_UnsupportedCloud_FailsBeforeAzureRequests() + { + var fixture = CreateFixture( + "Basic", + DataResponse("""{"tables":[]}"""), + cloud: AzureCloudConfiguration.AzureCloud.AzureChinaCloud); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.NotImplemented, exception.StatusCode); + Assert.Equal("UnsupportedCloud", exception.Code); + Assert.Equal(0, fixture.ArmHandler.CallCount); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Theory] + [InlineData("Basic", null)] + [InlineData("Basic", "")] + [InlineData("Auxiliary", null)] + [InlineData("Auxiliary", " ")] + public async Task SearchWorkspaceLogs_RejectsMissingOrBlankLastPlanModifiedDate( + string plan, + string? lastPlanModified) + { + var fixture = CreateFixture( + plan, + DataResponse("""{"tables":[]}"""), + lastPlanModified); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("InvalidTableMetadata", exception.Code); + Assert.Contains("metadata was incomplete", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_RejectsMalformedLastPlanModifiedDate() + { + var fixture = CreateFixture( + "Auxiliary", + DataResponse("""{"tables":[]}"""), + "not-a-timestamp"); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("InvalidTableMetadata", exception.Code); + Assert.Contains("transition metadata was invalid", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Theory] + [InlineData("2000-01-01T00:00:00Z")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-timestamp")] + public async Task SearchWorkspaceLogs_RejectsAnalyticsBeforeDataRequest(string? lastPlanModified) + { + var fixture = CreateFixture("Analytics", DataResponse("""{"tables":[]}"""), lastPlanModified); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.Conflict, exception.StatusCode); + Assert.Equal("UnsupportedTablePlan", exception.Code); + Assert.Contains("monitor_workspace_log_query", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_RejectsMissingTablePlan() + { + var fixture = CreateFixture(string.Empty, DataResponse("""{"tables":[]}""")); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("InvalidTableMetadata", exception.Code); + Assert.Contains("metadata was incomplete", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_RejectsMoreThanThirtyDaysBeforeAzureRequests() + { + var fixture = CreateFixture("Auxiliary", DataResponse("""{"tables":[]}""")); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken, timespan: "P31D")); + + Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode); + Assert.Contains("30 days", exception.Message); + Assert.Equal(0, fixture.ArmHandler.CallCount); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_RejectsInvalidLimitBeforeAzureRequests() + { + var fixture = CreateFixture("Basic", DataResponse("""{"tables":[]}""")); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken, limit: 101)); + + Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode); + Assert.Contains("between 1 and 100", exception.Message); + Assert.Equal(0, fixture.ArmHandler.CallCount); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_RejectsBasicRangeStartingMoreThanThirtyDaysAgo() + { + var fixture = CreateFixture( + "Basic", + DataResponse("""{"tables":[]}"""), + "1990-01-01T00:00:00Z"); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync( + TestContext.Current.CancellationToken, + timespan: "2000-01-01T00:00:00Z/2000-01-02T00:00:00Z")); + + Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode); + Assert.Equal("BasicTimespanTooOld", exception.Code); + Assert.Contains("cannot start more than 30 days ago", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_RejectsRangeCrossingPlanTransition() + { + var fixture = CreateFixture( + "Auxiliary", + DataResponse("""{"tables":[]}"""), + "2026-09-02T12:00:00Z"); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync( + TestContext.Current.CancellationToken, + timespan: "2026-09-02T00:00:00Z/2026-09-03T00:00:00Z")); + + Assert.Equal(HttpStatusCode.Conflict, exception.StatusCode); + Assert.Equal("TablePlanTransition", exception.Code); + Assert.Contains("split", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_RejectsRangeWhollyBeforePlanTransition() + { + var fixture = CreateFixture( + "Auxiliary", + DataResponse("""{"tables":[]}"""), + "2026-09-03T00:00:00Z"); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync( + TestContext.Current.CancellationToken, + timespan: "2026-09-01T00:00:00Z/2026-09-02T00:00:00Z")); + + Assert.Equal(HttpStatusCode.Conflict, exception.StatusCode); + Assert.Equal("HistoricalTablePlanRange", exception.Code); + Assert.Contains("entirely before", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_NoContent_ReturnsCompleteEmptyResult() + { + var fixture = CreateFixture( + "Basic", + () => new HttpResponseMessage(HttpStatusCode.NoContent)); + + var result = await fixture.SearchAsync(TestContext.Current.CancellationToken); + + Assert.Empty(result.Columns); + Assert.Empty(result.Rows); + Assert.Equal(0, result.RowCount); + Assert.False(result.IsPartial); + Assert.Null(result.Error); + } + + [Fact] + public async Task SearchWorkspaceLogs_PartialError_ReturnsRowsAndExplicitError() + { + var fixture = CreateFixture( + "Basic", + DataResponse(""" + { + "tables": [{ + "name": "PrimaryResult", + "columns": [{ "name": "Value", "type": "long" }], + "rows": [[1]] + }], + "error": { + "code": "PartialError", + "message": "backend details", + "details": [{ "code": "Service.Code", "message": "sensitive details" }] + } + } + """)); + + var result = await fixture.SearchAsync(TestContext.Current.CancellationToken); + + Assert.True(result.IsPartial); + Assert.Equal(1, result.RowCount); + Assert.Equal("PartialError", result.Error?.Code); + Assert.Equal("Service.Code", result.Error?.Details.Single().Code); + Assert.DoesNotContain("sensitive", result.Error?.Details.Single().Message); + } + + [Fact] + public async Task SearchWorkspaceLogs_EmptyPartialResult_PreservesError() + { + var fixture = CreateFixture("Basic", DataResponse( + """{"tables":[],"error":{"code":"PartialError","details":[]}}""")); + + var result = await fixture.SearchAsync(TestContext.Current.CancellationToken); + + Assert.Empty(result.Columns); + Assert.Empty(result.Rows); + Assert.Equal(0, result.RowCount); + Assert.True(result.IsPartial); + Assert.Equal("PartialError", result.Error?.Code); + } + + [Theory] + [InlineData("""{"tables":[{"name":"First"},{"name":"Second"}]}""")] + [InlineData("""{"tables":[{"name":"PrimaryResult"},{"name":"PrimaryResult"}]}""")] + public async Task SearchWorkspaceLogs_AmbiguousResultTables_ReturnsBadGateway(string body) + { + var fixture = CreateFixture("Basic", DataResponse(body)); + + var exception = await Assert.ThrowsAsync( + () => fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("MalformedLogsResponse", exception.Code); + } + + [Fact] + public async Task SearchWorkspaceLogs_FatalEmbeddedError_ThrowsWithoutResult() + { + var fixture = CreateFixture( + "Basic", + DataResponse(""" + { + "tables": [], + "error": { "code": "SemanticError", "message": "backend details" } + } + """)); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("FatalLogsError", exception.Code); + Assert.Contains("SemanticError", exception.Message); + Assert.DoesNotContain("backend details", exception.Message); + } + + [Fact] + public async Task SearchWorkspaceLogs_MalformedResponse_ThrowsBadGateway() + { + var fixture = CreateFixture("Basic", DataResponse("{not-json")); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("MalformedLogsResponse", exception.Code); + Assert.Contains("malformed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SearchWorkspaceLogs_RowWidthMismatch_ThrowsBadGateway() + { + var fixture = CreateFixture( + "Basic", + DataResponse(""" + { + "tables": [{ + "name": "PrimaryResult", + "columns": [ + { "name": "First", "type": "string" }, + { "name": "Second", "type": "string" } + ], + "rows": [["only-one-cell"]] + }] + } + """)); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal("InvalidRowShape", exception.Code); + Assert.Contains("row shape", exception.Message); + } + + [Fact] + public async Task SearchWorkspaceLogs_ResponseOverOneMiB_ThrowsPayloadTooLarge() + { + var fixture = CreateFixture( + "Basic", + DataResponse(new string('x', (1024 * 1024) + 1))); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, exception.StatusCode); + Assert.Equal("ResponseTooLarge", exception.Code); + Assert.Contains("1 MiB", exception.Message); + } + + [Fact] + public async Task SearchWorkspaceLogs_ThrottlePreservesRetryAfterAndDoesNotRetry() + { + var fixture = CreateFixture( + "Basic", + () => + { + var response = DataResponse( + """{"code":"TooManyRequests","message":"backend details"}""", + (HttpStatusCode)429)(); + response.Headers.RetryAfter = new(TimeSpan.FromSeconds(17)); + return response; + }); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal((HttpStatusCode)429, exception.StatusCode); + Assert.Contains("17 seconds", exception.Message); + Assert.DoesNotContain("backend details", exception.Message); + Assert.Equal(1, fixture.DataHandler.CallCount); + } + + [Theory] + [InlineData(false, "Retry later.")] + [InlineData(true, "Retry after 0 seconds.")] + public async Task SearchWorkspaceLogs_ThrottleWithMissingOrPastRetryDate_ReturnsGuidance( + bool includePastDate, + string expectedGuidance) + { + var fixture = CreateFixture("Basic", () => + { + var response = DataResponse("""{"code":"TooManyRequests"}""", (HttpStatusCode)429)(); + if (includePastDate) + { + response.Headers.RetryAfter = new(DateTimeOffset.UtcNow.AddMinutes(-1)); + } + + return response; + }); + + var exception = await Assert.ThrowsAsync( + () => fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal((HttpStatusCode)429, exception.StatusCode); + Assert.Contains(expectedGuidance, exception.Message); + Assert.Equal(1, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_GatewayTimeoutDoesNotRetry() + { + var fixture = CreateFixture( + "Basic", + DataResponse( + """{"code":"GatewayTimeout","message":"backend details"}""", + HttpStatusCode.GatewayTimeout)); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.GatewayTimeout, exception.StatusCode); + Assert.Contains("shorter timespan", exception.Message); + Assert.DoesNotContain("backend details", exception.Message); + Assert.Equal(1, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_BodyReadTimeout_ReturnsStructuredGatewayTimeoutAndDisposesResponse() + { + var content = new TrackingHttpContent(new TimeoutCancellationStream()); + var fixture = CreateFixture( + "Basic", + () => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content + }); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.GatewayTimeout, exception.StatusCode); + Assert.Equal("LogsSearchTimeout", exception.Code); + Assert.Contains("shorter timespan", exception.Message); + Assert.True(content.IsDisposed); + Assert.Equal(1, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_CallerCancellationDuringBodyRead_Propagates() + { + using var cancellationSource = new CancellationTokenSource(); + var stream = new BlockingUntilCanceledStream(cancellationSource.Cancel); + var content = new TrackingHttpContent(stream); + var fixture = CreateFixture( + "Basic", + () => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content + }); + + var exception = await Assert.ThrowsAnyAsync(() => + fixture.SearchAsync(cancellationSource.Token)); + + Assert.True(cancellationSource.IsCancellationRequested); + Assert.True(exception.CancellationToken.IsCancellationRequested); + Assert.True(stream.ReadStarted); + Assert.True(stream.ObservedCancellation); + Assert.True(content.IsDisposed); + Assert.Equal(1, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_PropagatesCancellationToHttpSend() + { + using var cancellationSource = new CancellationTokenSource(); + var token = cancellationSource.Token; + var fixture = CreateFixture( + "Basic", + (_, requestToken) => + { + cancellationSource.Cancel(); + Assert.True(requestToken.IsCancellationRequested); + return Task.FromCanceled(requestToken); + }); + + await Assert.ThrowsAnyAsync(() => + fixture.SearchAsync(token)); + + Assert.Equal(1, fixture.DataHandler.CallCount); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound, HttpStatusCode.NotFound, "WorkspaceNotFound", "workspace was not found")] + [InlineData(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized, "MetadataAuthenticationFailed", "Authentication failed")] + [InlineData(HttpStatusCode.Forbidden, HttpStatusCode.Forbidden, "MetadataAuthorizationFailed", "Authorization failed")] + [InlineData(HttpStatusCode.Conflict, HttpStatusCode.Conflict, "MetadataConflict", "conflict")] + [InlineData((HttpStatusCode)429, (HttpStatusCode)429, "MetadataThrottled", "throttled")] + [InlineData(HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError, "MetadataRequestFailed", "could not complete")] + [InlineData(HttpStatusCode.BadRequest, HttpStatusCode.BadRequest, "MetadataRequestRejected", "rejected")] + public async Task SearchWorkspaceLogs_WorkspaceMetadataFailure_IsMappedSafely( + HttpStatusCode workspaceStatus, + HttpStatusCode expectedStatus, + string expectedCode, + string expectedMessageFragment) + { + var fixture = CreateFixture( + "Basic", + DataResponse("""{"tables":[]}"""), + workspaceStatus: workspaceStatus); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(expectedStatus, exception.StatusCode); + Assert.Equal(expectedCode, exception.Code); + Assert.Contains(expectedMessageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("sensitive ARM details", exception.Message); + Assert.DoesNotContain( + fixture.ArmHandler.RequestUris, + uri => uri.AbsolutePath.Contains("/tables/", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound, "TableNotFound", "table was not found")] + [InlineData(HttpStatusCode.Forbidden, "MetadataAuthorizationFailed", "Authorization failed")] + public async Task SearchWorkspaceLogs_TableMetadataFailure_IsMappedSafelyAndStopsBeforeData( + HttpStatusCode tableStatus, + string expectedCode, + string expectedMessageFragment) + { + var fixture = CreateFixture( + "Basic", + DataResponse("""{"tables":[]}"""), + tableStatus: tableStatus); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(tableStatus, exception.StatusCode); + Assert.Equal(expectedCode, exception.Code); + Assert.Contains(expectedMessageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("sensitive ARM details", exception.Message); + Assert.Contains( + fixture.ArmHandler.RequestUris, + uri => uri.AbsolutePath.Contains("/tables/", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_DataQueryForbidden_IsSanitizedAndDoesNotRetry() + { + var fixture = CreateFixture( + "Basic", + DataResponse( + """{"code":"AccessDenied","message":"sensitive downstream details"}""", + HttpStatusCode.Forbidden)); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.Forbidden, exception.StatusCode); + Assert.Equal("AccessDenied", exception.Code); + Assert.Equal("Authorization failed for the Logs data query.", exception.Message); + Assert.DoesNotContain("sensitive downstream details", exception.Message); + Assert.Equal(1, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_TenantResolutionFailure_IsSanitized() + { + var fixture = CreateFixture("Basic", DataResponse("""{"tables":[]}""")); + fixture.AzureService.ResolveTenantIdAsync( + "tenant", + Arg.Any()) + .Returns>(_ => throw new RequestFailedException( + 403, + "sensitive tenant details", + "TenantDenied", + null)); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken, tenant: "tenant")); + + Assert.Equal(HttpStatusCode.Forbidden, exception.StatusCode); + Assert.Equal("TenantResolutionFailed", exception.Code); + Assert.DoesNotContain("sensitive tenant details", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + [Fact] + public async Task SearchWorkspaceLogs_CredentialFailure_IsSanitized() + { + var fixture = CreateFixture("Basic", DataResponse("""{"tables":[]}""")); + var credential = Substitute.For(); + credential.GetTokenAsync( + Arg.Any(), + Arg.Any()) + .Returns>(_ => + throw new AuthenticationFailedException("sensitive credential details")); + fixture.AzureService.GetTokenCredentialAsync( + Arg.Any(), + Arg.Any()) + .Returns(credential); + + var exception = await Assert.ThrowsAsync(() => + fixture.SearchAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.Unauthorized, exception.StatusCode); + Assert.Equal("LogsAuthenticationFailed", exception.Code); + Assert.DoesNotContain("sensitive credential details", exception.Message); + Assert.Equal(0, fixture.DataHandler.CallCount); + } + + private static ServiceFixture CreateFixture( + string plan, + Func responseFactory, + string? lastPlanModified = "2000-01-01T00:00:00Z", + HttpStatusCode workspaceStatus = HttpStatusCode.OK, + HttpStatusCode tableStatus = HttpStatusCode.OK, + AzureCloudConfiguration.AzureCloud cloud = AzureCloudConfiguration.AzureCloud.AzurePublicCloud) => + CreateFixture( + plan, + (_, _) => Task.FromResult(responseFactory()), + lastPlanModified, + workspaceStatus, + tableStatus, + cloud); + + private static ServiceFixture CreateFixture( + string plan, + Func> responseFactory, + string? lastPlanModified = "2000-01-01T00:00:00Z", + HttpStatusCode workspaceStatus = HttpStatusCode.OK, + HttpStatusCode tableStatus = HttpStatusCode.OK, + AzureCloudConfiguration.AzureCloud cloud = AzureCloudConfiguration.AzureCloud.AzurePublicCloud) + { + var armHandler = new CapturingHttpMessageHandler((request, _) => + Task.FromResult(CreateArmResponse( + request.RequestUri!, + plan, + lastPlanModified, + workspaceStatus, + tableStatus))); + var armCredential = Substitute.For(); + armCredential.GetToken( + Arg.Any(), + Arg.Any()) + .Returns(new AccessToken("arm-token", DateTimeOffset.UtcNow.AddHours(1))); + armCredential.GetTokenAsync( + Arg.Any(), + Arg.Any()) + .Returns(new ValueTask( + new AccessToken("arm-token", DateTimeOffset.UtcNow.AddHours(1)))); + var armOptions = new ArmClientOptions + { + Transport = new HttpClientTransport(new HttpClient(armHandler)) + }; + + // Keep transient-failure mappings deterministic and fast: assert the first response, never a retry. + armOptions.Retry.MaxRetries = 0; + var armClient = new ArmClient(armCredential, SubscriptionId, armOptions); + var resourceGroup = armClient.GetResourceGroupResource( + ResourceGroupResource.CreateResourceIdentifier(SubscriptionId, ResourceGroup)); + + var azureService = Substitute.For(); + var cloudConfiguration = Substitute.For(); + cloudConfiguration.CloudType.Returns(cloud); + cloudConfiguration.ArmEnvironment.Returns(ArmEnvironment.AzurePublicCloud); + azureService.CloudConfiguration.Returns(cloudConfiguration); + azureService.GetResourceGroupResource( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(resourceGroup); + azureService.ResolveTenantIdAsync( + Arg.Any(), + Arg.Any()) + .Returns(call => call.ArgAt(0)); + + var logsCredential = new CapturingTokenCredential("logs-token"); + azureService.GetTokenCredentialAsync( + Arg.Any(), + Arg.Any()) + .Returns(logsCredential); + + var dataHandler = new CapturingHttpMessageHandler(responseFactory); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()) + .Returns(_ => new HttpClient(dataHandler, disposeHandler: false)); + var service = new MonitorLogSearchService(azureService, httpClientFactory); + + return new(service, azureService, armHandler, dataHandler, logsCredential); + } + + private static HttpResponseMessage CreateArmResponse( + Uri requestUri, + string plan, + string? lastPlanModified, + HttpStatusCode workspaceStatus, + HttpStatusCode tableStatus) + { + string content; + if (requestUri.AbsolutePath.EndsWith( + $"/workspaces/{Workspace}", + StringComparison.OrdinalIgnoreCase)) + { + if (workspaceStatus != HttpStatusCode.OK) + { + return ArmErrorResponse(workspaceStatus); + } + + content = $$""" + { + "id": "/subscriptions/{{SubscriptionId}}/resourceGroups/{{ResourceGroup}}/providers/Microsoft.OperationalInsights/workspaces/{{Workspace}}", + "name": "{{Workspace}}", + "type": "Microsoft.OperationalInsights/workspaces", + "location": "westus", + "properties": { + "customerId": "{{WorkspaceId}}", + "provisioningState": "Succeeded" + } + } + """; + } + else if (requestUri.AbsolutePath.Contains("/tables/", StringComparison.OrdinalIgnoreCase)) + { + if (tableStatus != HttpStatusCode.OK) + { + return ArmErrorResponse(tableStatus); + } + + var table = Uri.UnescapeDataString(requestUri.Segments[^1]); + var requestedPlan = table.Equals(Table, StringComparison.OrdinalIgnoreCase) + ? plan + : "Analytics"; + var transitionProperty = lastPlanModified is null + ? string.Empty + : $", \"lastPlanModifiedDate\": {JsonSerializer.Serialize(lastPlanModified)}"; + content = $$""" + { + "id": "/subscriptions/{{SubscriptionId}}/resourceGroups/{{ResourceGroup}}/providers/Microsoft.OperationalInsights/workspaces/{{Workspace}}/tables/{{table}}", + "name": "{{table}}", + "type": "Microsoft.OperationalInsights/workspaces/tables", + "properties": { + "plan": "{{requestedPlan}}"{{transitionProperty}}, + "schema": { + "name": "{{table}}", + "columns": [], + "standardColumns": [] + } + } + } + """; + } + else + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + return DataResponse(content)(); + } + + private static HttpResponseMessage ArmErrorResponse(HttpStatusCode status) => + DataResponse( + """{"error":{"code":"ArmFailure","message":"sensitive ARM details"}}""", + status)(); + + private static Func DataResponse( + string body, + HttpStatusCode status = HttpStatusCode.OK) => + () => new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + private sealed record ServiceFixture( + MonitorLogSearchService Service, + IAzureService AzureService, + CapturingHttpMessageHandler ArmHandler, + CapturingHttpMessageHandler DataHandler, + CapturingTokenCredential LogsCredential) + { + public Task SearchAsync( + CancellationToken cancellationToken, + string timespan = Timespan, + int limit = 20, + string? tenant = null) => + Service.SearchWorkspaceLogs( + Subscription, + ResourceGroup, + Workspace, + Table, + Query, + timespan, + limit, + tenant, + cancellationToken); + } + + private sealed class CapturingTokenCredential(string token) : TokenCredential + { + public string[]? Scopes { get; private set; } + public CancellationToken CancellationToken { get; private set; } + + public override AccessToken GetToken( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + Capture(requestContext, cancellationToken); + return CreateToken(); + } + + public override ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + Capture(requestContext, cancellationToken); + return ValueTask.FromResult(CreateToken()); + } + + private void Capture( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + Scopes = [.. requestContext.Scopes]; + CancellationToken = cancellationToken; + } + + private AccessToken CreateToken() => + new(token, DateTimeOffset.UtcNow.AddHours(1)); + } + + private sealed class TrackingHttpContent(Stream stream) : HttpContent + { + public bool IsDisposed { get; private set; } + + protected override Task SerializeToStreamAsync( + Stream target, + TransportContext? context) => + stream.CopyToAsync(target); + + protected override Task CreateContentReadStreamAsync() => + Task.FromResult(stream); + + protected override Task CreateContentReadStreamAsync( + CancellationToken cancellationToken) => + Task.FromResult(stream); + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } + + private sealed class TimeoutCancellationStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) => + ValueTask.FromException(new OperationCanceledException(cancellationToken)); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + } + + private sealed class BlockingUntilCanceledStream(Action cancelCaller) : Stream + { + public bool ReadStarted { get; private set; } + public bool ObservedCancellation { get; private set; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + ReadStarted = true; + cancelCaller(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + catch (OperationCanceledException) + { + ObservedCancellation = true; + throw; + } + } + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + } + + private sealed class CapturingHttpMessageHandler( + Func> responseFactory) + : HttpMessageHandler + { + public int CallCount { get; private set; } + public List RequestUris { get; } = []; + public HttpMethod? Method { get; private set; } + public Uri? RequestUri { get; private set; } + public string? AuthorizationScheme { get; private set; } + public string? AuthorizationParameter { get; private set; } + public string? ContentType { get; private set; } + public string? Prefer { get; private set; } + public string? RequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + CallCount++; + Method = request.Method; + RequestUri = request.RequestUri; + if (request.RequestUri is not null) + { + RequestUris.Add(request.RequestUri); + } + AuthorizationScheme = request.Headers.Authorization?.Scheme; + AuthorizationParameter = request.Headers.Authorization?.Parameter; + ContentType = request.Content?.Headers.ContentType?.MediaType; + Prefer = request.Headers.TryGetValues("Prefer", out var values) + ? values.Single() + : null; + RequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return await responseFactory(request, cancellationToken); + } + } +} 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 3eb1d14eb..96c83f37d 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_42583a5140" + "Tag": "Azure.Mcp.Tools.Monitor.Tests_5bef5ca6ec" } diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.Monitor/tests/test-resources-post.ps1 index 870888137..4c43f7611 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/test-resources-post.ps1 +++ b/tools/Azure.Mcp.Tools.Monitor/tests/test-resources-post.ps1 @@ -12,8 +12,6 @@ $ErrorActionPreference = "Stop" . "$PSScriptRoot/../../../eng/common/scripts/common.ps1" . "$PSScriptRoot/../../../eng/scripts/helpers/TestResourcesHelpers.ps1" -$testSettings = New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot - # Add static deployment outputs $staticDeploymentOutputs = @{ "staticStorageAccountName" = "azuresdktrainingdatatme" @@ -21,16 +19,17 @@ $staticDeploymentOutputs = @{ "staticWorkspace" = "monitor-query-ws" } -# Merge with existing deployment outputs if they exist -if ($testSettings.DeploymentOutputs) { - foreach ($key in $staticDeploymentOutputs.Keys) { - $testSettings.DeploymentOutputs[$key] = $staticDeploymentOutputs[$key] - } -} else { - # If DeploymentOutputs doesn't exist, add it as a property - $testSettings | Add-Member -MemberType NoteProperty -Name "DeploymentOutputs" -Value $staticDeploymentOutputs +if (!$DeploymentOutputs) { + $DeploymentOutputs = @{} } +foreach ($key in $staticDeploymentOutputs.Keys) { + $DeploymentOutputs[$key] = $staticDeploymentOutputs[$key] +} + +$PSBoundParameters['DeploymentOutputs'] = $DeploymentOutputs +New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot | Out-Null + $storageAccountName = "$($BaseName)mon" $containerName = 'foo' $context = New-AzStorageContext -StorageAccountName $storageAccountName -UseConnectedAccount @@ -39,3 +38,177 @@ $files = Get-ChildItem -Path "$PSScriptRoot/samples" -Filter '*.md' foreach ($file in $files) { Set-AzStorageBlobContent -File $file.FullName -Container $containerName -Blob $file.Name -Context $context -Force -ProgressAction SilentlyContinue | Out-Null } + +function Get-PlainTextAccessToken { + param([Parameter(Mandatory)] [string] $ResourceUrl) + + $token = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token + if ($token -is [System.Security.SecureString]) { + return [System.Net.NetworkCredential]::new('', $token).Password + } + + return $token +} + +function Send-LogSearchFixture { + param( + [Parameter(Mandatory)] [string] $Endpoint, + [Parameter(Mandatory)] [string] $DcrImmutableId, + [Parameter(Mandatory)] [string] $Stream, + [Parameter(Mandatory)] [object[]] $Records, + [Parameter(Mandatory)] [string] $AccessToken + ) + + $uri = "$($Endpoint.TrimEnd('/'))/dataCollectionRules/$DcrImmutableId/streams/$Stream`?api-version=2023-01-01" + $headers = @{ Authorization = "Bearer $AccessToken" } + $payload = ConvertTo-Json -InputObject $Records -Depth 10 -Compress + + for ($attempt = 1; $attempt -le 12; $attempt++) { + try { + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json' -Body $payload | Out-Null + return + } catch { + if ($_.Exception -isnot [Microsoft.PowerShell.Commands.HttpResponseException] -or $null -eq $_.Exception.Response) { + throw + } + + $statusCode = [int]$_.Exception.Response.StatusCode + $isTransient = $statusCode -eq 403 -or $statusCode -eq 429 -or $statusCode -ge 500 + if (!$isTransient -or $attempt -eq 12) { + throw + } + + Write-Host "Fixture ingestion is not ready yet (HTTP $statusCode); retrying." -ForegroundColor DarkYellow + Start-Sleep -Seconds ([Math]::Min(30, $attempt * 5)) + } + } +} + +function Test-LogSearchFixtureAvailable { + param( + [Parameter(Mandatory)] [string] $WorkspaceCustomerId, + [Parameter(Mandatory)] [string] $Table, + [Parameter(Mandatory)] [string] $FixtureId, + [Parameter(Mandatory)] [datetime] $StartTime, + [Parameter(Mandatory)] [string] $AccessToken + ) + + $endTime = [DateTime]::UtcNow.AddMinutes(1) + $timespan = [Uri]::EscapeDataString("$($StartTime.ToString('o'))/$($endTime.ToString('o'))") + $uri = "https://api.loganalytics.io/v1/workspaces/$WorkspaceCustomerId/search?timespan=$timespan" + $headers = @{ + Authorization = "Bearer $AccessToken" + Prefer = 'wait=10' + } + $payload = ConvertTo-Json -Compress -InputObject @{ + query = "$Table | where FixtureId == '$FixtureId' | take 1" + } + + try { + $response = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json' -Body $payload + return $response.tables -and $response.tables[0].rows.Count -gt 0 + } catch { + Write-Host "Log fixture for $Table is not queryable yet: $($_.Exception.Message)" -ForegroundColor DarkYellow + return $false + } +} + +$fixtureTimestamp = [DateTime]::UtcNow.AddSeconds(1) +$fixtureTimestampText = $fixtureTimestamp.ToString('yyyy-MM-ddTHH:mm:ss.ffffffZ') +$fixtureStartTime = $fixtureTimestamp.ToString('o') +$fixtureEndTime = $fixtureTimestamp.AddMinutes(30).ToString('o') +$basicFixtureId = 'mcp-log-search-basic-1' +$auxiliaryFixtureId = 'mcp-log-search-auxiliary-1' +$basicTableName = $DeploymentOutputs['logSearchBasicTableName'] +$auxiliaryTableName = $DeploymentOutputs['logSearchAuxiliaryTableName'] +$ingestionEndpoint = $DeploymentOutputs['logSearchIngestionEndpoint'] +$dcrImmutableId = $DeploymentOutputs['logSearchDcrImmutableId'] +$workspaceCustomerId = $DeploymentOutputs['logSearchWorkspaceCustomerId'] + +$ingestionToken = Get-PlainTextAccessToken -ResourceUrl 'https://monitor.azure.com' +$basicRecords = @( + [ordered]@{ + TimeGenerated = $fixtureTimestampText + FixtureId = $basicFixtureId + Message = 'basic fixture' + Count = 7 + Enabled = $true + OptionalValue = $null + } + [ordered]@{ + TimeGenerated = $fixtureTimestamp.AddSeconds(1).ToString('yyyy-MM-ddTHH:mm:ss.ffffffZ') + FixtureId = 'mcp-log-search-basic-2' + Message = 'second basic fixture' + Count = 8 + Enabled = $false + OptionalValue = 'present' + } +) +$auxiliaryRecords = @( + [ordered]@{ + TimeGenerated = $fixtureTimestampText + FixtureId = $auxiliaryFixtureId + Message = 'auxiliary fixture' + Count = 11 + Enabled = $false + OptionalValue = $null + } + [ordered]@{ + TimeGenerated = $fixtureTimestamp.AddSeconds(1).ToString('yyyy-MM-ddTHH:mm:ss.ffffffZ') + FixtureId = 'mcp-log-search-auxiliary-2' + Message = 'second auxiliary fixture' + Count = 12 + Enabled = $true + OptionalValue = 'present' + } +) + +Write-Host 'Sending deterministic Basic and Auxiliary Log Analytics fixtures' -ForegroundColor Yellow +Send-LogSearchFixture ` + -Endpoint $ingestionEndpoint ` + -DcrImmutableId $dcrImmutableId ` + -Stream "Custom-$basicTableName" ` + -Records $basicRecords ` + -AccessToken $ingestionToken +Send-LogSearchFixture ` + -Endpoint $ingestionEndpoint ` + -DcrImmutableId $dcrImmutableId ` + -Stream "Custom-$auxiliaryTableName" ` + -Records $auxiliaryRecords ` + -AccessToken $ingestionToken + +$queryToken = Get-PlainTextAccessToken -ResourceUrl 'https://api.loganalytics.io' +$pendingTables = @{ + $basicTableName = $basicFixtureId + $auxiliaryTableName = $auxiliaryFixtureId +} + +$fixtureDeadline = [DateTime]::UtcNow.AddMinutes(20) +while ($pendingTables.Count -gt 0 -and [DateTime]::UtcNow -lt $fixtureDeadline) { + foreach ($tableName in @($pendingTables.Keys)) { + if (Test-LogSearchFixtureAvailable ` + -WorkspaceCustomerId $workspaceCustomerId ` + -Table $tableName ` + -FixtureId $pendingTables[$tableName] ` + -StartTime $fixtureTimestamp ` + -AccessToken $queryToken) { + Write-Host "Log fixture for $tableName is available." -ForegroundColor Green + $pendingTables.Remove($tableName) + } + } + + if ($pendingTables.Count -gt 0) { + Start-Sleep -Seconds 10 + } +} + +if ($pendingTables.Count -gt 0) { + throw "Timed out waiting for Log Analytics fixtures in: $($pendingTables.Keys -join ', ')" +} + +$DeploymentOutputs['logSearchBasicFixtureId'] = $basicFixtureId +$DeploymentOutputs['logSearchAuxiliaryFixtureId'] = $auxiliaryFixtureId +$DeploymentOutputs['logSearchFixtureStartTime'] = $fixtureStartTime +$DeploymentOutputs['logSearchFixtureEndTime'] = $fixtureEndTime + +New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot | Out-Null diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.Monitor/tests/test-resources.bicep index 9df2953a6..3e1783308 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.Monitor/tests/test-resources.bicep @@ -16,6 +16,44 @@ param testApplicationOid string param healthModelsLocation string = 'swedencentral' +var logSearchBasicTableName = 'McpBasic_CL' +var logSearchAuxiliaryTableName = 'McpAuxiliary_CL' +var logSearchAnalyticsTableName = 'McpAnalytics_CL' +var logSearchDcrName = '${baseName}-log-search-dcr' +var logSearchDestinationName = 'log-search-workspace' +var logSearchColumns = [ + { + name: 'TimeGenerated' + type: 'dateTime' + } + { + name: 'FixtureId' + type: 'string' + } + { + name: 'Message' + type: 'string' + } + { + name: 'Count' + type: 'long' + } + { + name: 'Enabled' + type: 'boolean' + } + { + name: 'OptionalValue' + type: 'string' + } +] +var logSearchStreamColumns = [ + for column in logSearchColumns: { + name: column.name + type: column.type == 'dateTime' ? 'datetime' : column.type + } +] + resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { name: baseName location: location @@ -33,6 +71,124 @@ resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { } } +resource logSearchBasicTable 'Microsoft.OperationalInsights/workspaces/tables@2025-07-01' = { + name: logSearchBasicTableName + parent: workspace + properties: { + plan: 'Basic' + schema: { + name: logSearchBasicTableName + columns: logSearchColumns + } + } +} + +resource logSearchAuxiliaryTable 'Microsoft.OperationalInsights/workspaces/tables@2025-07-01' = { + name: logSearchAuxiliaryTableName + parent: workspace + properties: { + plan: 'Auxiliary' + schema: { + name: logSearchAuxiliaryTableName + columns: logSearchColumns + } + } +} + +resource logSearchAnalyticsTable 'Microsoft.OperationalInsights/workspaces/tables@2025-07-01' = { + name: logSearchAnalyticsTableName + parent: workspace + properties: { + plan: 'Analytics' + schema: { + name: logSearchAnalyticsTableName + columns: logSearchColumns + } + } +} + +resource logSearchDataCollectionRule 'Microsoft.Insights/dataCollectionRules@2024-03-11' = { + name: logSearchDcrName + location: location + kind: 'Direct' + properties: { + streamDeclarations: { + 'Custom-${logSearchBasicTableName}': { + columns: logSearchStreamColumns + } + 'Custom-${logSearchAuxiliaryTableName}': { + columns: logSearchStreamColumns + } + } + destinations: { + logAnalytics: [ + { + name: logSearchDestinationName + workspaceResourceId: workspace.id + } + ] + } + dataFlows: [ + { + streams: [ + 'Custom-${logSearchBasicTableName}' + ] + destinations: [ + logSearchDestinationName + ] + transformKql: 'source' + outputStream: 'Custom-${logSearchBasicTableName}' + } + { + streams: [ + 'Custom-${logSearchAuxiliaryTableName}' + ] + destinations: [ + logSearchDestinationName + ] + transformKql: 'source' + outputStream: 'Custom-${logSearchAuxiliaryTableName}' + } + ] + } + dependsOn: [ + logSearchBasicTable + logSearchAuxiliaryTable + ] +} + +// Generic test infrastructure provides one service principal. The multi-identity OBO denial matrix +// remains a dedicated external validation. +resource logAnalyticsReaderRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + scope: subscription() + name: '73c42c96-874c-492b-b04d-ab87d138a893' +} + +resource logSearchReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(workspace.id, testApplicationOid, logAnalyticsReaderRoleDefinition.id) + scope: workspace + properties: { + principalId: testApplicationOid + roleDefinitionId: logAnalyticsReaderRoleDefinition.id + description: 'Log Analytics Reader for Monitor live tests' + } +} + +resource monitoringMetricsPublisherRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + scope: subscription() + name: '3913510d-42f4-4e42-8a64-420c390055eb' +} + +resource logSearchIngestionRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(logSearchDataCollectionRule.id, testApplicationOid, monitoringMetricsPublisherRoleDefinition.id) + scope: logSearchDataCollectionRule + properties: { + principalId: testApplicationOid + roleDefinitionId: monitoringMetricsPublisherRoleDefinition.id + description: 'Monitoring Metrics Publisher for Monitor live-test fixtures' + } +} + // Create a storage account to monitor resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = { name: '${baseName}mon' @@ -234,3 +390,11 @@ module healthModelsModule 'test-resources.healthmodels.module.bicep' = { output healthModelParentName string = healthModelsModule.outputs.healthModelAName output healthModelChildName string = healthModelsModule.outputs.healthModelBName +output logSearchWorkspaceName string = workspace.name +output logSearchWorkspaceCustomerId string = workspace.properties.customerId +output logSearchBasicTableName string = logSearchBasicTable.name +output logSearchAuxiliaryTableName string = logSearchAuxiliaryTable.name +output logSearchAnalyticsTableName string = logSearchAnalyticsTable.name +output logSearchAuxiliaryLastPlanModifiedDate string = logSearchAuxiliaryTable.properties.lastPlanModifiedDate +output logSearchDcrImmutableId string = logSearchDataCollectionRule.properties.immutableId +output logSearchIngestionEndpoint string = logSearchDataCollectionRule.properties.endpoints.logsIngestion