mirror of
https://github.com/microsoft/mcp.git
synced 2026-11-04 12:01:31 +00:00
feat(monitor): add Basic and Auxiliary Log Analytics search (#3581)
This commit is contained in:
1 parent
d26f0b99da
commit
b02a662354
42 files changed
+3977
-20
No files matched your search
+18
@@ -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()
|
||||
{
|
||||
|
||||
+39
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
@@ -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;
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ public sealed class CommandGroupDiscoveryStrategy(
|
||||
{
|
||||
ReadOnly = _configuration.Value.ReadOnly,
|
||||
Transport = _configuration.Value.Transport,
|
||||
StructuredOutputMode = _configuration.Value.StructuredOutputMode,
|
||||
EntryPoint = EntryPoint,
|
||||
})
|
||||
.Cast<IMcpServerProvider>();
|
||||
|
||||
+11
@@ -36,6 +36,11 @@ public sealed class CommandGroupServerProvider(CommandGroup commandGroup) : IMcp
|
||||
/// </summary>
|
||||
public string Transport { get; set; } = TransportTypes.StdIo;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the structured output mode forwarded to the child server.
|
||||
/// </summary>
|
||||
public StructuredOutputMode? StructuredOutputMode { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<McpClient> 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];
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+1
-2
@@ -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.
|
||||
""",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<!-- cspell:ignore externaldata SRCH -->
|
||||
|
||||
# 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 `<table> <pipeline> | take <limit>`. 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -3397,6 +3397,34 @@ azmcp monitor workspace log query --subscription <subscription> \
|
||||
--workspace <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 <subscription> \
|
||||
--resource-group <resource-group> \
|
||||
--workspace <workspace> \
|
||||
--table <table> \
|
||||
--query <search-pipeline> \
|
||||
--timespan <timespan> \
|
||||
[--limit <limit>] \
|
||||
[--tenant <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 <subscription> \
|
||||
--resource-group <resource-group> \
|
||||
--workspace <workspace> \
|
||||
--table "ContainerLogV2" \
|
||||
--query "| where LogLevel == 'error' | project TimeGenerated, LogMessage" \
|
||||
--timespan "P1D" \
|
||||
--limit 50
|
||||
```
|
||||
|
||||
#### Health Models
|
||||
|
||||
@@ -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 <workspace_name> | none |
|
||||
| monitor_workspace_log_search | Find records matching <search_query> in the Basic table <table_name> in Log Analytics workspace <workspace_name> in resource group <resource_group> over <timespan> | none |
|
||||
| monitor_workspace_log_search | Search the Auxiliary table <table_name> in workspace <workspace_name> in resource group <resource_group> for <search_query> during <timespan>, limited to <limit> results | none |
|
||||
| monitor_workspace_log_search | Search for <search_query> in the Basic or Auxiliary table <table_name> in Log Analytics workspace <workspace_name> in resource group <resource_group> over the last day | none |
|
||||
|
||||
## Azure Native ISV
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
+227
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Checks Monitor discovery and schemas without querying Azure.
|
||||
/// Stateless requests are covered by <see cref="ServerModeCoverageTests"/>.
|
||||
/// </summary>
|
||||
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<string> standardError)
|
||||
: IAsyncDisposable
|
||||
{
|
||||
public static async Task<ServerSession> StartAsync(string arguments)
|
||||
{
|
||||
Assert.True(File.Exists(AzmcpPath), $"Executable not found at {AzmcpPath}. Build Azure.Mcp.Server first.");
|
||||
|
||||
var standardError = new ConcurrentQueue<string>();
|
||||
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<IReadOnlyList<Tool>> ListToolsAsync()
|
||||
{
|
||||
using var cancellation = CreateRequestCancellation();
|
||||
|
||||
var tools = new List<Tool>();
|
||||
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<JsonElement> LearnAsync(string toolName, string? tool = null)
|
||||
{
|
||||
var callArguments = new Dictionary<string, object?>
|
||||
{
|
||||
["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<string> standardError) =>
|
||||
standardError.IsEmpty ? "<empty>" : string.Join(Environment.NewLine, standardError);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<WorkspaceLogSearchCommand> logger,
|
||||
IMonitorLogSearchService logSearchService,
|
||||
ISubscriptionResolver subscriptionResolver)
|
||||
: SubscriptionCommand<WorkspaceLogSearchOptions, WorkspaceLogSearchResult>(subscriptionResolver)
|
||||
{
|
||||
private readonly ILogger<WorkspaceLogSearchCommand> _logger = logger;
|
||||
private readonly IMonitorLogSearchService _logSearchService = logSearchService;
|
||||
|
||||
public override JsonTypeInfo<WorkspaceLogSearchResult>? ResultTypeInfo =>
|
||||
MonitorJsonContext.Default.WorkspaceLogSearchResult;
|
||||
|
||||
public override async Task<CommandResponse> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<HealthModelSummary>))]
|
||||
[JsonSerializable(typeof(List<JsonNode>))]
|
||||
[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<string, object>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(object))]
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<LogSearchApiErrorDetail>? Details { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<LogSearchApiTable>? Tables { get; set; }
|
||||
|
||||
public LogSearchApiError? Error { get; set; }
|
||||
}
|
||||
@@ -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<LogSearchApiColumn>? Columns { get; set; }
|
||||
|
||||
public List<List<JsonElement>>? Rows { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<LogSearchErrorDetail> Details);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<LogSearchColumn> Columns,
|
||||
IReadOnlyList<IReadOnlyList<JsonElement>> Rows,
|
||||
int RowCount,
|
||||
int Limit,
|
||||
bool IsPartial,
|
||||
LogSearchError? Error);
|
||||
@@ -29,6 +29,7 @@ public class MonitorSetup : IAreaSetup
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IMonitorService, MonitorService>();
|
||||
services.AddSingleton<IMonitorLogSearchService, MonitorLogSearchService>();
|
||||
services.AddSingleton<IMonitorHealthModelService, MonitorHealthModelService>();
|
||||
services.AddSingleton<IMonitorWebTestService, MonitorWebTestService>();
|
||||
services.AddSingleton<IResourceResolverService, ResourceResolverService>();
|
||||
@@ -71,6 +72,7 @@ public class MonitorSetup : IAreaSetup
|
||||
services.AddSingleton<SendBrownfieldAnalysisTool>();
|
||||
|
||||
services.AddSingleton<WorkspaceLogQueryCommand>();
|
||||
services.AddSingleton<WorkspaceLogSearchCommand>();
|
||||
services.AddSingleton<ResourceLogQueryCommand>();
|
||||
|
||||
services.AddSingleton<WorkspaceListCommand>();
|
||||
@@ -132,6 +134,7 @@ public class MonitorSetup : IAreaSetup
|
||||
|
||||
// Register Monitor commands
|
||||
workspaceLogs.AddCommand<WorkspaceLogQueryCommand>(serviceProvider);
|
||||
workspaceLogs.AddCommand<WorkspaceLogSearchCommand>(serviceProvider);
|
||||
|
||||
resourceLogs.AddCommand<ResourceLogQueryCommand>(serviceProvider);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Searches a single Basic or Auxiliary Log Analytics table through the synchronous Logs search API.
|
||||
/// </summary>
|
||||
public interface IMonitorLogSearchService
|
||||
{
|
||||
Task<WorkspaceLogSearchResult> SearchWorkspaceLogs(
|
||||
string subscription,
|
||||
string resourceGroup,
|
||||
string workspace,
|
||||
string table,
|
||||
string query,
|
||||
string timespan,
|
||||
int limit,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a single bounded Basic or Auxiliary table search against the Log Analytics <c>/search</c> API.
|
||||
/// </summary>
|
||||
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<WorkspaceLogSearchResult> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one bounded <c>/search</c> request. Returns <see langword="null"/> when the service reports
|
||||
/// no content, otherwise the size-limited response body.
|
||||
/// </summary>
|
||||
private async Task<byte[]?> 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<AccessToken> 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")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Translates an Azure Resource Manager failure into a sanitized validation error so no raw service
|
||||
/// message, header, or body content reaches the caller.
|
||||
/// </summary>
|
||||
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<byte[]> 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<LogSearchColumn> columns = [];
|
||||
List<IReadOnlyList<JsonElement>> 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<JsonElement>)row)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return new(
|
||||
table,
|
||||
plan,
|
||||
timespan,
|
||||
columns,
|
||||
rows,
|
||||
rows.Count,
|
||||
limit,
|
||||
isPartial,
|
||||
isPartial ? CreatePartialError(response.Error!) : null);
|
||||
}
|
||||
|
||||
private static LogSearchApiTable SelectResultTable(List<LogSearchApiTable> 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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class LogSearchQueryValidator
|
||||
{
|
||||
private const int MaxPipelineLength = 10_000;
|
||||
|
||||
private static readonly HashSet<string> s_unsupportedOperators = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"externaldata",
|
||||
"find",
|
||||
"invoke",
|
||||
"join",
|
||||
"search"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> Tokenize(string value)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
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 '_';
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
+143
@@ -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<CommandValidationException>(
|
||||
() => 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quoted content is blanked before structural scanning, so characters that would be rejected
|
||||
/// outside a literal must still be accepted inside one.
|
||||
/// </summary>
|
||||
[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<CommandValidationException>(
|
||||
() => LogSearchQueryValidator.ValidatePipeline(pipeline));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePipeline_RejectsMoreThanTenThousandCharacters()
|
||||
{
|
||||
var pipeline = "|" + new string('x', 10_000);
|
||||
|
||||
Assert.Throws<CommandValidationException>(
|
||||
() => 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<CommandValidationException>(
|
||||
() => LogSearchQueryValidator.ValidatePipeline(pipeline));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePipeline_CommentContainingQuote_ReportsCommentError()
|
||||
{
|
||||
var exception = Assert.Throws<CommandValidationException>(
|
||||
() => LogSearchQueryValidator.ValidatePipeline("| take 1 // don't return more"));
|
||||
|
||||
Assert.Contains("comments are not allowed", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePipeline_RejectsControlCharacters()
|
||||
{
|
||||
Assert.Throws<CommandValidationException>(
|
||||
() => LogSearchQueryValidator.ValidatePipeline("| where Message == 1\u0001"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsTableInjectionBeforePipelineChecks()
|
||||
{
|
||||
var exception = Assert.Throws<CommandValidationException>(
|
||||
() => LogSearchQueryValidator.Validate("McpBasic_CL | take 1", "| take 1"));
|
||||
|
||||
Assert.Contains("--table", exception.Message);
|
||||
}
|
||||
}
|
||||
+144
@@ -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<CommandValidationException>(
|
||||
() => 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<CommandValidationException>(
|
||||
() => 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<CommandValidationException>(
|
||||
() => 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<CommandValidationException>(
|
||||
() => 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<CommandValidationException>(
|
||||
() => LogSearchTimeRangeParser.Parse(timespan, s_now));
|
||||
|
||||
Assert.Contains("earlier than its end", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_IntervalEntirelyInTheFuture_Throws()
|
||||
{
|
||||
var exception = Assert.Throws<CommandValidationException>(
|
||||
() => 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<CommandValidationException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
+314
@@ -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<WorkspaceLogSearchCommand, IMonitorLogSearchService>
|
||||
{
|
||||
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<OperationCanceledException>(
|
||||
() => 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<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(result);
|
||||
}
|
||||
|
||||
private void SetupSearchFailure(Exception exception)
|
||||
{
|
||||
Service.SearchWorkspaceLogs(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(exception);
|
||||
}
|
||||
|
||||
private void SetupCanceledSearch(CancellationToken cancellationToken)
|
||||
{
|
||||
Service.SearchWorkspaceLogs(
|
||||
Subscription,
|
||||
ResourceGroup,
|
||||
Workspace,
|
||||
Table,
|
||||
Query,
|
||||
Timespan,
|
||||
20,
|
||||
null,
|
||||
cancellationToken)
|
||||
.Returns(Task.FromCanceled<WorkspaceLogSearchResult>(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<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static WorkspaceLogSearchResult EmptyResult(int limit) =>
|
||||
new(Table, "Basic", Timespan, [], [], 0, limit, false, null);
|
||||
|
||||
private static JsonElement Element(string json) =>
|
||||
JsonSerializer.Deserialize<JsonElement>(json);
|
||||
}
|
||||
+234
@@ -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<JsonElement?> 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<string, object?> 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", "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()
|
||||
// {
|
||||
|
||||
+1060
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user