From 501f994c7a01f93066036016b66780bb95147407 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 4 Sep 2026 13:50:19 +0200 Subject: [PATCH] logs proxy: three telemetry stores, a wall-clock budget and typed partial results (x29 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/global/raw/logs (MCP query_backend_logs) now takes `source` = backend | web | mcp and `budgetSeconds` 5..180 (default 30). Parity with the az CLI was already by construction (same Kusto REST endpoint, verbatim query); the four remaining deltas were properties of the proxy and are gone (D-186): Backend - LogQuerySources (Shared vocabulary, exported as manifest section `logSources`) and LogQuerySourceCatalog: APPINSIGHTS_APP_ID → backend, APPINSIGHTS_WEB_APP_ID → web (both api.applicationinsights.io), MCP_LOG_ANALYTICS_WORKSPACE_ID → mcp (api.loganalytics.io). A missing setting makes one source answer 503 naming it, never the others. - AppInsightsQueryFunction: injectable IHttpClientFactory (named client without timeout) and TokenCredential; one CancellationTokenSource per request plus `Prefer: wait=` upstream, 504 names the budget; typed QueryBackendLogsResponse keeps the Kusto tables[].columns/rows verbatim (JsonElement cells) and adds source, budgetSeconds, elapsedMs and `partial`/`partialReason` when the store cut the result (previously swallowed); on failure the full upstream error JSON rides along as `upstream`. - TypedResponseGuard: the last SerializeBaseline entry is gone (typed request record). MCP - query_backend_logs: source (z.enum from the generated LOG_SOURCES), budgetSeconds, a client signal of budget + 15 s, rawTable result cap, description with per-source table hints. Submodules - .claude/commands: backend-logs / ux-insights / mcp-telemetry on `source`; query-appinsights.sh deleted (35 call sites rewritten); az only for the live container tail. - infra: migration scripts carry the two new app settings and role assignments (Monitoring Reader on the portal component, Log Analytics Reader on the MCP workspace). - internal: concept doc backend/log-query-proxy.md, D-186, docs and plan updated. Tests: catalog resolution, budget clamp, Kusto parsing incl. PartialError, error extraction, wire parity of the new response; backend 5832, MCP 683, web tsc, solution build green. Before live use: grant the two roles and set the two app settings (see the plan file). Co-Authored-By: Claude Fable 5.1 --- .claude/commands | 2 +- infra | 2 +- internal | 2 +- .../AppInsightsQueryFunctionTests.cs | 111 ++++-- .../LogQuerySourceCatalogTests.cs | 91 +++++ .../RawWireParityTests.cs | 59 ++++ .../SharedManifestParityTests.cs | 2 + .../TypedResponseGuardTests.cs | 11 +- .../Functions/Raw/AppInsightsQueryFunction.cs | 332 +++++++++++++----- .../AutopilotMonitor.Functions/Program.cs | 6 + .../Security/EndpointAccessPolicyCatalog.cs | 3 +- .../Services/LogQuerySourceCatalog.cs | 85 +++++ .../src/generated/wire-types.generated.ts | 32 ++ .../generated/wire-vocabularies.generated.ts | 8 + .../autopilot-monitor-mcp/src/tools.ts | 3 +- .../autopilot-monitor-mcp/src/tools/admin.ts | 31 +- .../DataAccess/LogQuerySources.cs | 27 ++ .../Models/RawApiModels.cs | 53 +++ .../scripts/generate-shared-manifest-types.js | 2 + .../utils/shared-manifests.generated.ts | 5 + .../utils/shared-manifests.json | 135 +++++++ .../utils/wire-types.generated.ts | 32 ++ 22 files changed, 900 insertions(+), 134 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/LogQuerySourceCatalogTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/LogQuerySourceCatalog.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/DataAccess/LogQuerySources.cs diff --git a/.claude/commands b/.claude/commands index 4bb1f714..18d7ec76 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 4bb1f7142f9e33d978c818a41c7f7082483eeaa4 +Subproject commit 18d7ec76309cc6ca46f98767f0e53dd4bd2eba81 diff --git a/infra b/infra index 3bab9bbf..2c2687b6 160000 --- a/infra +++ b/infra @@ -1 +1 @@ -Subproject commit 3bab9bbf83da56ed0734009e9d932f3c26389ddb +Subproject commit 2c2687b6abf35ef6580d630b236af403098b8d36 diff --git a/internal b/internal index d0fdca23..86e09d7e 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit d0fdca2321e706fbdbaa6bbcec824434480b046e +Subproject commit 86e09d7e12c6fb32dbb1b6bd7cc641d8d5bde88f diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs index 78c595ae..bb5f7372 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using AutopilotMonitor.Functions.Functions.Raw; using AutopilotMonitor.Functions.Middleware; using AutopilotMonitor.Functions.Security; using Xunit; @@ -6,26 +8,13 @@ namespace AutopilotMonitor.Functions.Tests; /// /// Tests for the POST /api/global/raw/logs KQL-proxy endpoint served by -/// — an operator tool -/// that forwards an operator-supplied KQL query VERBATIM to the Application Insights REST API. Because -/// the query is passed through un-sandboxed and the App Insights workspace can surface secret-bearing -/// backend traces, the TRUST BOUNDARY for this endpoint is its authorization gate: only a platform -/// Global Admin may call it. These tests pin that gate. -/// -/// SCOPE / WHAT IS AND IS NOT COVERED HERE (deliberate): -/// The function's own in-request behavior — appId-not-configured → 503, empty/whitespace query → -/// 400 BadRequest, App-Insights-error → 502 BadGateway structured error (innererror message + code + -/// SyntaxError/SemanticError hint), happy path → 200 with the raw AI tables JSON — is NOT exercised -/// here. The entrypoint Run sits directly on the abstract HttpRequestData / -/// HttpResponseData pair (whose HttpResponseData.Cookies returns the abstract -/// HttpCookies), reads the body via ReadFromJsonAsync and writes via -/// CreateResponse/WriteAsJsonAsync (all of which need a live worker serializer resolved -/// from FunctionContext.InstanceServices), and calls Application Insights through a PRIVATE -/// STATIC HttpClient + DefaultAzureCredential with no injection seam. Faking that whole -/// pipeline + a live Managed Identity is "more setup than the test is worth" — the exact rationale the -/// sibling function tests cite (see DeviceBlockFunctionTests / GetAllBlockedDevicesFunctionTests, -/// which likewise test the reachable seam instead of the HTTP entrypoint). The authorization gate -/// below IS the security-relevant, compilable seam for this endpoint. +/// — an operator tool that forwards an operator-supplied KQL +/// query VERBATIM to one of three telemetry stores (backend / web App Insights, MCP Log Analytics). +/// Because the query is passed through un-sandboxed and the stores surface secret-bearing traces and +/// portal user telemetry, the TRUST BOUNDARY for this endpoint is its authorization gate: only a +/// platform Global Admin may call it. These tests pin that gate, plus the pure pieces of the proxy +/// (budget clamp, Kusto body parsing incl. partial results, error extraction). The HTTP entrypoint +/// itself (worker serializer, managed identity) is not exercised — the seams are. /// /// The gate is asserted through (the single source /// of truth the running middleware consults) and . @@ -34,6 +23,88 @@ public class AppInsightsQueryFunctionTests { private const string LogsRoute = "/api/global/raw/logs"; + // ── Budget: null/0 → default, otherwise clamped into [5, 180] ── + [Theory] + [InlineData(null, 30)] + [InlineData(0, 30)] + [InlineData(-7, 30)] + [InlineData(1, 5)] + [InlineData(30, 30)] + [InlineData(120, 120)] + [InlineData(180, 180)] + [InlineData(600, 180)] + public void Budget_is_defaulted_and_clamped(int? requested, int expected) + { + Assert.Equal(expected, AppInsightsQueryFunction.ClampBudget(requested)); + } + + // ── Kusto body → typed tables; cells forwarded as the JSON the store produced ── + [Fact] + public void Kusto_body_is_parsed_cell_for_cell_and_complete_results_carry_no_partial() + { + const string body = """ + {"tables":[{"name":"PrimaryResult","columns":[{"name":"timestamp","type":"datetime"},{"name":"count_","type":"long"},{"name":"customDimensions","type":"dynamic"},{"name":"note","type":"string"}], + "rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]} + """; + + var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body); + + Assert.Null(partial); + var table = Assert.Single(tables); + Assert.Equal("PrimaryResult", table.Name); + Assert.Equal(new[] { "timestamp", "count_", "customDimensions", "note" }, table.Columns.Select(c => c.Name)); + Assert.Equal(new[] { "datetime", "long", "dynamic", "string" }, table.Columns.Select(c => c.Type)); + var row = Assert.Single(table.Rows); + Assert.Equal(JsonValueKind.String, row[0].ValueKind); + Assert.Equal(42, row[1].GetInt64()); + // customDimensions stays the JSON STRING App Insights emits — not re-parsed, not re-encoded. + Assert.Equal(JsonValueKind.String, row[2].ValueKind); + Assert.Equal("{\"TenantId\":\"t1\"}", row[2].GetString()); + Assert.Equal(JsonValueKind.Null, row[3].ValueKind); + // Cloned cells outlive the parsed document. + Assert.Equal("42", row[1].GetRawText()); + } + + [Fact] + public void A_200_with_a_PartialError_next_to_tables_is_surfaced_not_swallowed() + { + const string body = """ + {"tables":[{"name":"PrimaryResult","columns":[{"name":"n","type":"long"}],"rows":[[1]]}], + "error":{"code":"PartialError","message":"There were some errors when processing your query.","innererror":{"code":"E_QUERY_RESULT_SET_TOO_LARGE","message":"Query result set has exceeded the internal record count limit 500000."}}} + """; + + var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body); + + Assert.Single(tables); + Assert.Equal("E_QUERY_RESULT_SET_TOO_LARGE: Query result set has exceeded the internal record count limit 500000.", partial); + } + + [Fact] + public void Empty_or_shapeless_body_yields_no_tables_and_no_partial() + { + var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}"); + Assert.Empty(tables); + Assert.Null(partial); + } + + // ── Error extraction: innererror first, then outer; tolerant of non-JSON ── + [Fact] + public void Error_extraction_prefers_innererror_and_tolerates_non_json() + { + var (msg, code) = AppInsightsQueryFunction.ExtractError( + """{"error":{"message":"The request had some invalid properties","code":"BadArgumentError","innererror":{"code":"SyntaxError","message":"Query could not be parsed at 'foo'"}}}"""); + Assert.Equal("SyntaxError", code); + Assert.Equal("Query could not be parsed at 'foo'", msg); + + var (outerMsg, outerCode) = AppInsightsQueryFunction.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}"""); + Assert.Equal("InsufficientAccessError", outerCode); + Assert.Equal("Forbidden", outerMsg); + + var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("502 Bad Gateway"); + Assert.Null(plainCode); + Assert.Equal("Telemetry store query failed", plainMsg); + } + // ── Authorization gate: the KQL proxy is Global-Admin-only ── [Fact] public void RawLogs_post_is_registered_as_GlobalAdminOnly() diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/LogQuerySourceCatalogTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/LogQuerySourceCatalogTests.cs new file mode 100644 index 00000000..cda6e99a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/LogQuerySourceCatalogTests.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The operator KQL proxy resolves its source against app settings once: every configured +/// store maps to its own Kusto endpoint and token scope, a known-but-unconfigured store names the +/// missing setting (503 material), an unknown name lists the vocabulary (400 material), and the +/// default is the backend store. +/// +public class LogQuerySourceCatalogTests +{ + private static LogQuerySourceCatalog Build(params (string Key, string? Value)[] settings) + { + var dict = new Dictionary(); + foreach (var (k, v) in settings) dict[k] = v; + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + return new LogQuerySourceCatalog(config); + } + + [Fact] + public void Every_configured_source_resolves_to_its_own_endpoint_and_scope() + { + var catalog = Build( + (LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend"), + (LogQuerySourceCatalog.WebAppIdSetting, "bbbb-web"), + (LogQuerySourceCatalog.McpWorkspaceIdSetting, "cccc-workspace")); + + Assert.True(catalog.TryResolve("backend", out var backend, out _)); + Assert.Equal("https://api.applicationinsights.io/v1/apps/aaaa-backend/query", backend!.QueryUri.ToString()); + Assert.Equal("https://api.applicationinsights.io/.default", backend.TokenScope); + + Assert.True(catalog.TryResolve("web", out var web, out _)); + Assert.Equal("https://api.applicationinsights.io/v1/apps/bbbb-web/query", web!.QueryUri.ToString()); + + Assert.True(catalog.TryResolve("mcp", out var mcp, out _)); + Assert.Equal("https://api.loganalytics.io/v1/workspaces/cccc-workspace/query", mcp!.QueryUri.ToString()); + Assert.Equal("https://api.loganalytics.io/.default", mcp.TokenScope); + + Assert.Equal(3, catalog.Available.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Missing_source_defaults_to_backend(string? name) + { + var catalog = Build((LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend")); + + Assert.True(catalog.TryResolve(name, out var source, out _)); + Assert.Equal(LogQuerySources.Backend, source!.Name); + } + + [Fact] + public void Known_but_unconfigured_source_names_the_missing_setting_and_leaves_others_intact() + { + var catalog = Build((LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend")); + + Assert.False(catalog.TryResolve("web", out var web, out var error)); + Assert.Null(web); + Assert.Contains(LogQuerySourceCatalog.WebAppIdSetting, error); + Assert.Contains("not configured", error); + + Assert.True(catalog.TryResolve("backend", out _, out _)); + } + + [Fact] + public void Unknown_source_lists_the_vocabulary() + { + var catalog = Build((LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend")); + + Assert.False(catalog.TryResolve("portal", out _, out var error)); + Assert.Contains("Unknown log source 'portal'", error); + foreach (var s in LogQuerySources.All) + Assert.Contains(s, error); + } + + [Fact] + public void Vocabulary_is_the_three_stores_in_declaration_order() + { + Assert.Equal(new[] { "backend", "web", "mcp" }, LogQuerySources.All); + Assert.True(LogQuerySources.IsKnown("mcp")); + Assert.False(LogQuerySources.IsKnown("MCP")); + Assert.False(LogQuerySources.IsKnown(null)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs index cd00f039..24c6f515 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs @@ -140,6 +140,65 @@ public class RawWireParityTests new ListRawTablesResponse { Count = tables.Count, Tables = tables }); } + // ── AppInsightsQueryFunction — POST /api/global/raw/logs ───────────────────────────── + // The proxy used to forward the App Insights body byte for byte (no DTO). The typed wrapper keeps + // the Kusto `tables[].columns/rows` shape verbatim inside and adds source/budget/partial around it. + + [Fact] + public void QueryBackendLogs_wire_shape_keeps_kusto_tables_verbatim_and_drops_absent_partial() + { + using var cells = JsonDocument.Parse("[\"2026-09-04T10:00:00Z\",42,\"{\\\"TenantId\\\":\\\"t1\\\"}\",null]"); + var row = cells.RootElement.EnumerateArray().Select(c => c.Clone()).ToList(); + var response = new QueryBackendLogsResponse + { + Success = true, + Source = "backend", + Timespan = "PT1H", + BudgetSeconds = 30, + ElapsedMs = 812, + Partial = null, + PartialReason = null, + Tables = new[] + { + new KqlTable + { + Name = "PrimaryResult", + Columns = new[] { new KqlColumn { Name = "timestamp", Type = "datetime" }, new KqlColumn { Name = "count_", Type = "long" }, new KqlColumn { Name = "customDimensions", Type = "dynamic" }, new KqlColumn { Name = "note", Type = "string" } }, + Rows = new[] { row }, + }, + }, + }; + + var json = JsonSerializer.Serialize(response, WireOptions); + + // The dynamic cell is the JSON STRING App Insights emits; the wire serializer escapes its inner + // quotes as " (default encoder) — the same string after decoding, so consumers that + // parse(customDimensions) are unaffected. + Assert.Equal( + "{\"success\":true,\"source\":\"backend\",\"timespan\":\"PT1H\",\"budgetSeconds\":30,\"elapsedMs\":812," + + "\"tables\":[{\"name\":\"PrimaryResult\",\"columns\":[{\"name\":\"timestamp\",\"type\":\"datetime\"},{\"name\":\"count_\",\"type\":\"long\"},{\"name\":\"customDimensions\",\"type\":\"dynamic\"},{\"name\":\"note\",\"type\":\"string\"}]," + + "\"rows\":[[\"2026-09-04T10:00:00Z\",42,\"{\\u0022TenantId\\u0022:\\u0022t1\\u0022}\",null]]}]}", + json); + using var roundTrip = JsonDocument.Parse(json); + var cell = roundTrip.RootElement.GetProperty("tables")[0].GetProperty("rows")[0][2].GetString(); + Assert.Equal("{\"TenantId\":\"t1\"}", cell); + } + + [Fact] + public void QueryBackendLogs_partial_result_carries_flag_and_reason() + { + var response = new QueryBackendLogsResponse + { + Success = true, Source = "mcp", Timespan = "P7D", BudgetSeconds = 120, ElapsedMs = 95000, + Partial = true, PartialReason = "E_QUERY_RESULT_SET_TOO_LARGE: too many rows", + Tables = new List(), + }; + + var json = JsonSerializer.Serialize(response, WireOptions); + + Assert.Contains("\"partial\":true,\"partialReason\":\"E_QUERY_RESULT_SET_TOO_LARGE: too many rows\",\"tables\":[]", json); + } + // ── AccessProbeFunction — GET /api/global/raw/access-probe ─────────────────────────── // New route (no anonymous predecessor): the wire shape is pinned as fixed JSON. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs index d26ed397..7d0c0d94 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -81,6 +81,8 @@ public sealed class SharedManifestParityTests ["opsEventSeverities"] = ConstStrings(typeof(OpsEventSeverity)), // Declaration order (grouped by category) — the MCP renders this catalog verbatim. ["opsEventTypes"] = OpsEventTypes.All, + // Telemetry stores behind the operator KQL proxy (query_backend_logs `source`). + ["logSources"] = LogQuerySources.All, ["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)), ["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)), ["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)), diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs index e56e3f08..7ec0af00 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs @@ -71,14 +71,11 @@ public class TypedResponseGuardTests private static readonly Dictionary IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase); /// - /// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. The single entry is - /// an outgoing HTTP CLIENT request body (App Insights query POST) — not response debt. - /// Everything else must be a typed DTO (error shapes are tolerated by the shape check). + /// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. EMPTY: the last entry (the + /// App Insights query POST body) became a typed request record when the proxy learned sources. + /// Everything must be a typed DTO (error shapes are tolerated by the shape check). /// - private static readonly Dictionary SerializeBaseline = new(StringComparer.OrdinalIgnoreCase) - { - ["Functions/Raw/AppInsightsQueryFunction.cs"] = 1, - }; + private static readonly Dictionary SerializeBaseline = new(StringComparer.OrdinalIgnoreCase); /// /// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs index 0cb10eaf..94c2c9ac 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs @@ -1,34 +1,64 @@ +using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using AutopilotMonitor.Functions.Helpers; -using Azure.Identity; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Azure.Core; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Raw { + /// + /// POST /api/global/raw/logs — the operator KQL proxy. Forwards an operator-supplied query VERBATIM + /// to one of the telemetry stores in (backend App Insights, + /// web App Insights, the MCP Container App's Log Analytics workspace) under the Function App's + /// managed identity and returns the Kusto result tables typed (). + /// + /// Parity with az monitor app-insights query / az monitor log-analytics query is by + /// construction: same REST endpoint, same {query, timespan} body, tables forwarded cell for + /// cell. What the proxy adds is what the CLI leaves implicit — the source, the wall-clock budget the + /// call ran under (Prefer: wait upstream, a cancellation token here, no static client + /// timeout), an explicit partial flag when the store truncated the result, and the full + /// upstream error JSON on failure. There is no continuation: Kusto has none; a query that does not + /// fit the budget is narrowed (smaller timespan, summarize), not resumed. + /// + /// Authorization: catalog GlobalAdminOnly + policy middleware, re-checked in the body. + /// public class AppInsightsQueryFunction { + internal const int DefaultBudgetSeconds = 30; + internal const int MinBudgetSeconds = 5; + /// Stays well under the ~230 s Azure HTTP front-end limit plus the MCP's own margin. + internal const int MaxBudgetSeconds = 180; + /// Upstream error bodies are forwarded whole up to this size so nothing az would show is lost. + private const int UpstreamErrorCap = 8192; + private readonly ILogger _logger; - private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; - private static readonly DefaultAzureCredential _credential = new(); + private readonly LogQuerySourceCatalog _sources; + private readonly IHttpClientFactory _httpClientFactory; + private readonly TokenCredential _credential; - // Application Insights REST API scope for Azure AD auth - private const string AppInsightsScope = "https://api.applicationinsights.io/.default"; - - public AppInsightsQueryFunction(ILogger logger) + public AppInsightsQueryFunction( + ILogger logger, + LogQuerySourceCatalog sources, + IHttpClientFactory httpClientFactory, + TokenCredential credential) { _logger = logger; + _sources = sources; + _httpClientFactory = httpClientFactory; + _credential = credential; } /// - /// POST /api/global/raw/logs — Proxy KQL queries to Application Insights - /// Body: { "query": "traces | where ...", "timespan": "PT1H" } - /// Uses Managed Identity (DefaultAzureCredential) for authentication. - /// Requires APPINSIGHTS_APP_ID app setting and "Monitoring Reader" RBAC role. + /// Body: { "query": "traces | ...", "timespan": "PT1H", "source": "backend", "budgetSeconds": 30 }. /// [Function("QueryBackendLogs")] public async Task Run( @@ -40,129 +70,239 @@ namespace AutopilotMonitor.Functions.Functions.Raw if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied) return denied; + LogQueryRequest? body; try { - var appId = Environment.GetEnvironmentVariable("APPINSIGHTS_APP_ID"); + body = await req.ReadFromJsonAsync(); + } + catch (JsonException) + { + body = null; + } + if (body == null || string.IsNullOrWhiteSpace(body.Query)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error = "query is required" }); + return bad; + } - if (string.IsNullOrEmpty(appId)) + if (!_sources.TryResolve(body.Source, out var source, out var sourceError)) + { + var known = LogQuerySources.IsKnown(string.IsNullOrWhiteSpace(body.Source) ? LogQuerySources.Backend : body.Source.Trim()); + var status = known ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.BadRequest; + var resp = req.CreateResponse(status); + await resp.WriteAsJsonAsync(new { error = sourceError }); + return resp; + } + + var timespan = string.IsNullOrWhiteSpace(body.Timespan) ? "PT1H" : body.Timespan.Trim(); + var budget = ClampBudget(body.BudgetSeconds); + var stopwatch = Stopwatch.StartNew(); + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budget)); + + var token = await _credential.GetTokenAsync(new TokenRequestContext(new[] { source!.TokenScope }), cts.Token); + + using var upstreamRequest = new HttpRequestMessage(HttpMethod.Post, source.QueryUri); + upstreamRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + // Ask the store to keep working as long as we are willing to wait (default server wait is shorter). + upstreamRequest.Headers.TryAddWithoutValidation("Prefer", $"wait={budget}"); + upstreamRequest.Content = new StringContent( + JsonSerializer.Serialize(new KustoQuery(body.Query, timespan)), Encoding.UTF8, "application/json"); + + var client = _httpClientFactory.CreateClient(LogQuerySourceCatalog.HttpClientName); + using var upstreamResponse = await client.SendAsync(upstreamRequest, cts.Token); + var responseText = await upstreamResponse.Content.ReadAsStringAsync(cts.Token); + stopwatch.Stop(); + + if (!upstreamResponse.IsSuccessStatusCode) { - var unavailable = req.CreateResponse(HttpStatusCode.ServiceUnavailable); - await unavailable.WriteAsJsonAsync(new { error = "Application Insights diagnostics is not configured. Set the APPINSIGHTS_APP_ID app setting and assign 'Monitoring Reader' role to the Function App's Managed Identity." }); - return unavailable; - } - - var body = await req.ReadFromJsonAsync(); - if (body == null || string.IsNullOrWhiteSpace(body.Query)) - { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteAsJsonAsync(new { error = "query is required" }); - return bad; - } - - var timespan = body.Timespan ?? "PT1H"; - - // Acquire Bearer token via Managed Identity / DefaultAzureCredential - var tokenResult = await _credential.GetTokenAsync( - new Azure.Core.TokenRequestContext(new[] { AppInsightsScope })); - - // Call Application Insights REST API - var aiUrl = $"https://api.applicationinsights.io/v1/apps/{appId}/query"; - var requestBody = JsonSerializer.Serialize(new - { - query = body.Query, - timespan - }); - - using var aiRequest = new HttpRequestMessage(HttpMethod.Post, aiUrl); - aiRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.Token); - aiRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); - - using var aiResponse = await _httpClient.SendAsync(aiRequest); - var responseText = await aiResponse.Content.ReadAsStringAsync(); - - if (!aiResponse.IsSuccessStatusCode) - { - _logger.LogWarning("App Insights query failed: {StatusCode} {Response}", - aiResponse.StatusCode, responseText); - - // Extract the meaningful error message from AppInsights error JSON - // Format: { "error": { "message": "...", "code": "...", "innererror": { "code": "SyntaxError", "message": "..." } } } - var errorMessage = "Application Insights query failed"; - var errorCode = (string?)null; - try - { - using var doc = JsonDocument.Parse(responseText); - if (doc.RootElement.TryGetProperty("error", out var errorObj)) - { - if (errorObj.TryGetProperty("innererror", out var inner) && - inner.TryGetProperty("message", out var innerMsg)) - { - errorMessage = innerMsg.GetString() ?? errorMessage; - } - else if (errorObj.TryGetProperty("message", out var msg)) - { - errorMessage = msg.GetString() ?? errorMessage; - } - - if (errorObj.TryGetProperty("innererror", out var innerErr) && - innerErr.TryGetProperty("code", out var innerCode)) - { - errorCode = innerCode.GetString(); - } - else if (errorObj.TryGetProperty("code", out var code)) - { - errorCode = code.GetString(); - } - } - } - catch - { - // responseText is not JSON — keep generic message - } + _logger.LogWarning("Log query ({Source}) failed: {StatusCode} {Response}", + source.Name, upstreamResponse.StatusCode, LogSanitizer.Clean(Cap(responseText, 2048))); + var (errorMessage, errorCode) = ExtractError(responseText); var errorResp = req.CreateResponse(HttpStatusCode.BadGateway); await errorResp.WriteAsJsonAsync(new { error = errorMessage, errorCode, - statusCode = (int)aiResponse.StatusCode, + statusCode = (int)upstreamResponse.StatusCode, + source = source.Name, hint = errorCode == "SyntaxError" ? "The KQL query has a syntax error. Check operators, pipe stages, and string quoting. Example: traces | where message contains 'error' | take 50" : errorCode == "SemanticError" - ? "The KQL query references an unknown table or column. Valid tables: traces, requests, exceptions, customEvents, dependencies." + ? $"The KQL query references an unknown table or column for source '{source.Name}'. Run ' | getschema' to list columns." : (string?)null, + // Everything the store said, so nothing the CLI would print is lost. + upstream = Cap(responseText, UpstreamErrorCap), }); return errorResp; } + var (tables, partialReason) = ParseKustoBody(responseText); var response = req.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/json"); - await response.WriteStringAsync(responseText); + await response.WriteAsJsonAsync(new QueryBackendLogsResponse + { + Success = true, + Source = source.Name, + Timespan = timespan, + BudgetSeconds = budget, + ElapsedMs = stopwatch.ElapsedMilliseconds, + Partial = partialReason != null ? true : null, + PartialReason = partialReason, + Tables = tables, + }); return response; } - catch (TaskCanceledException) + catch (OperationCanceledException) { var timeout = req.CreateResponse(HttpStatusCode.GatewayTimeout); - await timeout.WriteAsJsonAsync(new { error = "Application Insights query timed out (30s limit)" }); + await timeout.WriteAsJsonAsync(new + { + error = $"Log query ({source!.Name}) exceeded its budget of {budget}s", + budgetSeconds = budget, + hint = $"Narrow the query (smaller timespan, `summarize` instead of raw rows, `take N`) or raise budgetSeconds (max {MaxBudgetSeconds}).", + }); return timeout; } catch (Azure.Identity.CredentialUnavailableException ex) { - _logger.LogError(ex, "Managed Identity not available for App Insights query"); + _logger.LogError(ex, "Managed Identity not available for log query ({Source})", source!.Name); var err = req.CreateResponse(HttpStatusCode.ServiceUnavailable); - await err.WriteAsJsonAsync(new { error = "Managed Identity is not configured. Enable System-assigned Managed Identity on the Function App and assign 'Monitoring Reader' role on the Application Insights resource." }); + await err.WriteAsJsonAsync(new { error = "Managed Identity is not configured. Enable the system-assigned Managed Identity on the Function App and grant it read access to the telemetry store." }); return err; } catch (Exception ex) { - return await req.InternalServerErrorAsync(_logger, ex, "Query Application Insights"); + return await req.InternalServerErrorAsync(_logger, ex, "Query telemetry store"); } } - private class LogQueryRequest + /// Null or out-of-range budgets collapse to the default / the nearest bound. + internal static int ClampBudget(int? requested) + { + if (requested is null || requested <= 0) + return DefaultBudgetSeconds; + return Math.Clamp(requested.Value, MinBudgetSeconds, MaxBudgetSeconds); + } + + /// + /// Kusto REST success body → typed tables plus the store's partial-result note. A 200 can carry + /// "error": { "code": "PartialError", ... } next to tables when the result was cut + /// (size cap, shard timeout); that used to pass through unread. Cells are cloned out of the + /// document so they outlive it. + /// + internal static (IReadOnlyList Tables, string? PartialReason) ParseKustoBody(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var tables = new List(); + + if (root.TryGetProperty("tables", out var tablesEl) && tablesEl.ValueKind == JsonValueKind.Array) + { + foreach (var t in tablesEl.EnumerateArray()) + { + var columns = new List(); + if (t.TryGetProperty("columns", out var colsEl) && colsEl.ValueKind == JsonValueKind.Array) + { + foreach (var c in colsEl.EnumerateArray()) + { + columns.Add(new KqlColumn + { + Name = c.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty, + Type = c.TryGetProperty("type", out var ty) ? ty.GetString() ?? string.Empty : string.Empty, + }); + } + } + + var rows = new List>(); + if (t.TryGetProperty("rows", out var rowsEl) && rowsEl.ValueKind == JsonValueKind.Array) + { + foreach (var r in rowsEl.EnumerateArray()) + { + var cells = new List(columns.Count); + if (r.ValueKind == JsonValueKind.Array) + foreach (var cell in r.EnumerateArray()) + cells.Add(cell.Clone()); + rows.Add(cells); + } + } + + tables.Add(new KqlTable + { + Name = t.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty, + Columns = columns, + Rows = rows, + }); + } + } + + string? partialReason = null; + if (root.TryGetProperty("error", out var errorEl) && errorEl.ValueKind == JsonValueKind.Object) + { + var (message, code) = ExtractError(errorEl); + partialReason = string.IsNullOrEmpty(code) ? message : $"{code}: {message}"; + } + + return (tables, partialReason); + } + + /// + /// Message + code from a Kusto error body — innererror first (the specific reason), then the + /// outer error. Tolerates non-JSON. + /// + internal static (string Message, string? Code) ExtractError(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var errorObj) && errorObj.ValueKind == JsonValueKind.Object) + return ExtractError(errorObj); + } + catch (JsonException) + { + // not JSON — keep the generic message + } + return ("Telemetry store query failed", null); + } + + private static (string Message, string? Code) ExtractError(JsonElement errorObj) + { + var message = "Telemetry store query failed"; + string? code = null; + + if (errorObj.TryGetProperty("innererror", out var inner) && inner.ValueKind == JsonValueKind.Object) + { + if (inner.TryGetProperty("message", out var innerMsg) && innerMsg.ValueKind == JsonValueKind.String) + message = innerMsg.GetString() ?? message; + if (inner.TryGetProperty("code", out var innerCode) && innerCode.ValueKind == JsonValueKind.String) + code = innerCode.GetString(); + } + if (message == "Telemetry store query failed" && errorObj.TryGetProperty("message", out var msg) && msg.ValueKind == JsonValueKind.String) + message = msg.GetString() ?? message; + if (code == null && errorObj.TryGetProperty("code", out var outerCode) && outerCode.ValueKind == JsonValueKind.String) + code = outerCode.GetString(); + + return (message, code); + } + + private static string Cap(string s, int max) => s.Length <= max ? s : s[..max] + "…"; + + private sealed class LogQueryRequest { public string Query { get; set; } = string.Empty; public string? Timespan { get; set; } + /// One of ; defaults to backend. + public string? Source { get; set; } + /// Wall-clock budget for the upstream call; clamped to 5..180, default 30. + public int? BudgetSeconds { get; set; } } + + /// Kusto REST request body — both App Insights and Log Analytics accept exactly this. + private sealed record KustoQuery( + [property: JsonPropertyName("query")] string Query, + [property: JsonPropertyName("timespan")] string Timespan); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index cb74b60b..eae24b12 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -285,6 +285,12 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Assume-breach observer on the policy middleware's deny path (GlobalAdminOnly 403 → Critical ops event). builder.Services.AddSingleton(); +// Operator KQL proxy (global/raw/logs): the three telemetry stores it can reach, the managed-identity +// credential as an injectable seam, and a client WITHOUT its own timeout — the per-request budget +// (CancellationTokenSource + Prefer: wait) is the only clock. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(_ => new Azure.Identity.DefaultAzureCredential()); +builder.Services.AddHttpClient(LogQuerySourceCatalog.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan); // IME pattern-drift loop: folds ime_pattern_hits into ImePatternStats and raises // ImePatternDriftSuspected; singleton because it caches the stats snapshot for evaluation. builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 5e84f112..019d4a90 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -418,7 +418,8 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/raw/events", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), // Raw table/log access stays GlobalAdminOnly: query_table can dump arbitrary tables // (incl. TenantConfiguration secrets), which would bypass the GlobalReader config-secret - // redaction. A read-only GlobalReader uses the curated read endpoints instead. + // redaction, and the KQL proxy reaches three telemetry stores (backend traces, portal user + // telemetry, MCP container logs). A read-only GlobalReader uses the curated read endpoints. new("GET", "global/raw/tables", EndpointPolicy.GlobalAdminOnly), new("GET", "global/raw/tables/{tableName}", EndpointPolicy.GlobalAdminOnly), new("POST", "global/raw/logs", EndpointPolicy.GlobalAdminOnly), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/LogQuerySourceCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Services/LogQuerySourceCatalog.cs new file mode 100644 index 00000000..0b542c4d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/LogQuerySourceCatalog.cs @@ -0,0 +1,85 @@ +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Configuration; + +namespace AutopilotMonitor.Functions.Services +{ + /// One resolvable telemetry store behind the operator KQL proxy. + /// Wire name (). + /// The store's Kusto query endpoint. + /// Entra scope the managed identity requests a token for. + /// App setting that carries the store's id — named in the 503 when absent. + public sealed record LogQuerySource(string Name, Uri QueryUri, string TokenScope, string SettingName); + + /// + /// Resolves a source name to the telemetry store the proxy queries. All three stores speak + /// the same Kusto REST dialect ({query, timespan} in, tables[].columns/rows out) and + /// differ only in host, resource id and token scope: + /// + /// backend, web — Application Insights components (App-ID from app settings), scope api.applicationinsights.io. + /// mcp — the Container App's Log Analytics workspace (customer id), scope api.loganalytics.io. + /// + /// The Function App's managed identity needs Monitoring Reader on each App Insights component + /// and Log Analytics Reader on the workspace (infra: New-TargetInfrastructure.ps1). Settings are + /// read once at construction; a missing one makes that source unavailable (503), never the others. + /// + public sealed class LogQuerySourceCatalog + { + public const string BackendAppIdSetting = "APPINSIGHTS_APP_ID"; + public const string WebAppIdSetting = "APPINSIGHTS_WEB_APP_ID"; + public const string McpWorkspaceIdSetting = "MCP_LOG_ANALYTICS_WORKSPACE_ID"; + + /// Named HttpClient without a client-side timeout: the per-request budget governs. + public const string HttpClientName = "log-query"; + + private const string AppInsightsScope = "https://api.applicationinsights.io/.default"; + private const string LogAnalyticsScope = "https://api.loganalytics.io/.default"; + + private readonly Dictionary _sources = new(StringComparer.Ordinal); + private readonly Dictionary _missing = new(StringComparer.Ordinal); + + public LogQuerySourceCatalog(IConfiguration configuration) + { + Register(LogQuerySources.Backend, configuration[BackendAppIdSetting], BackendAppIdSetting, + id => new Uri($"https://api.applicationinsights.io/v1/apps/{id}/query"), AppInsightsScope); + Register(LogQuerySources.Web, configuration[WebAppIdSetting], WebAppIdSetting, + id => new Uri($"https://api.applicationinsights.io/v1/apps/{id}/query"), AppInsightsScope); + Register(LogQuerySources.Mcp, configuration[McpWorkspaceIdSetting], McpWorkspaceIdSetting, + id => new Uri($"https://api.loganalytics.io/v1/workspaces/{id}/query"), LogAnalyticsScope); + } + + private void Register(string name, string? id, string settingName, Func uri, string scope) + { + if (string.IsNullOrWhiteSpace(id)) + _missing[name] = settingName; + else + _sources[name] = new LogQuerySource(name, uri(id.Trim()), scope, settingName); + } + + /// Sources that are configured on this instance. + public IReadOnlyCollection Available => _sources.Keys; + + /// + /// Resolves . Returns false with naming the + /// missing app setting (known but unconfigured) or the allowed vocabulary (unknown name). + /// + public bool TryResolve(string? name, out LogQuerySource? source, out string error) + { + source = null; + error = string.Empty; + var key = string.IsNullOrWhiteSpace(name) ? LogQuerySources.Backend : name.Trim(); + + if (_sources.TryGetValue(key, out var found)) + { + source = found; + return true; + } + if (_missing.TryGetValue(key, out var setting)) + { + error = $"Log source '{key}' is not configured on this backend. Set the {setting} app setting and grant the Function App's managed identity read access to that resource."; + return false; + } + error = $"Unknown log source '{key}'. Valid sources: {string.Join(", ", LogQuerySources.All)}."; + return false; + } + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 5d3ed45c..37fb17db 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -2129,6 +2129,20 @@ export interface IsGlobalAdminResponse { upn?: string; } +/** A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool). */ +export interface KqlColumn { + name: string; + type: string; +} + +/** One Kusto result table: column headers once, then rows as positional cell arrays. */ +export interface KqlTable { + name: string; + columns: KqlColumn[]; + /** Positional cells, one list per row, aligned with Columns. Cells are forwarded as the JSON the store produced (numbers stay numbers, customDimensions stays the JSON string App Insights emits, nulls stay null) — JsonElement is the honest "unknown" in the wire manifest. */ + rows: unknown[][]; +} + /** Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline. */ export interface ListBackupsResponse { backupIds: string[]; @@ -2638,6 +2652,24 @@ export interface ProgressLookupSessionResponse { session?: SessionSummary; } +/** Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one telemetry store in the Kusto REST shape (tables[].columns/rows), wrapped with the source it came from and the proxy's own observations. The table shape is kept verbatim so every consumer that parsed the raw App Insights body keeps working. */ +export interface QueryBackendLogsResponse { + success: boolean; + /** The store the query ran against — one of LogQuerySources.All. */ + source: string; + /** ISO 8601 duration the query was bounded to (echo of the request, defaulted). */ + timespan: string; + /** Server-side budget the upstream call ran under (clamped request value). */ + budgetSeconds: number; + /** Wall-clock time of the upstream call. */ + elapsedMs: number; + /** True when the store answered 200 but flagged the result as incomplete (Kusto PartialError: result-size cap, shard timeout). Absent on a complete result — never silent, because a truncated aggregate reads like a smaller number. */ + partial?: boolean; + /** The store's own explanation when Partial is true. */ + partialReason?: string; + tables: KqlTable[]; +} + /** Success body of GET /api/raw/events and /api/global/raw/events (QueryRawEvents / QueryRawEventsGlobal): raw event rows, PascalCase-verbatim stored columns. */ export interface QueryRawEventsResponse { /** Null on the global scope when no tenantId filter was given (cross-tenant query). */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts index 5406dcf3..0e9c298d 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts @@ -152,3 +152,11 @@ export const ANNOTATION_VERDICTS = [ "inconclusive", ] as const; export type AnnotationVerdict = (typeof ANNOTATION_VERDICTS)[number]; + +/** Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources). */ +export const LOG_SOURCES = [ + "backend", + "web", + "mcp", +] as const; +export type LogSourceName = (typeof LOG_SOURCES)[number]; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools.ts b/src/McpServer/autopilot-monitor-mcp/src/tools.ts index 34b2d89b..00854fe8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools.ts @@ -13,7 +13,8 @@ import type { DocsSearchBundle } from './search-provider.js'; * cross-tenant read tools. `strictGa` is true ONLY for a real Global Admin and gates two groups whose * backend endpoints stay GlobalAdminOnly: the raw tools (list_tables / query_table / * query_backend_logs — they can dump secret-bearing tables that the GlobalReader config redaction - * would otherwise hide) and the tenant-config write surface (get_tenant_config / + * would otherwise hide, and the KQL proxy reaches three telemetry stores incl. portal user + * telemetry and the MCP container logs) and the tenant-config write surface (get_tenant_config / * get_tenant_config_schema / update_tenant_config / list_tenant_config_backups / * revert_tenant_config — the server's only mutating tools; every write is snapshot-backed * and verified server-side). When `ga` is false diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index ab50dea4..929799a4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -88,6 +88,7 @@ import { ANNOTATION_LANES, ANNOTATION_VERDICTS, EVENT_SEVERITIES, + LOG_SOURCES, OPS_EVENT_CATEGORIES, OPS_EVENT_SEVERITIES, SESSION_STATUSES, @@ -1433,23 +1434,41 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo if (strictGa) server.registerTool( 'query_backend_logs', { - title: 'Query Backend Logs', + title: 'Query Platform Logs (KQL)', description: - 'Query backend Application Insights logs using KQL. Global Admin only. ' + - 'Use for debugging backend issues, tracing requests by correlation ID, and platform diagnostics.', + 'Run a KQL query against one of the platform telemetry stores. Global Admin only. ' + + 'The query is forwarded VERBATIM (same REST API as az monitor app-insights/log-analytics query); ' + + 'the result is the Kusto shape `tables[].columns/rows` plus `source`, `budgetSeconds`, `elapsedMs` and — ' + + 'when the store cut the result (size cap, shard timeout) — `partial: true` with `partialReason`. ' + + 'Sources: "backend" (default) = the API\'s Application Insights: requests (one canonical row per HTTP call, ' + + 'customDimensions.Source == \'WorkerMiddleware\'), traces, exceptions, customEvents, dependencies; ' + + '"web" = the portal\'s Application Insights: pageViews, browser customEvents, browserTimings, client dependencies; ' + + '"mcp" = the MCP Container App\'s Log Analytics workspace: ContainerAppConsoleLogs_CL (column Log_s is the ' + + 'raw JSON line — parse_json(Log_s)) and ContainerAppSystemLogs_CL. ' + + 'Budget: the backend runs the query for at most budgetSeconds (default 30, max 180) and answers 504 with a ' + + 'hint when exceeded — there is no continuation (Kusto has none): narrow the timespan, aggregate with ' + + 'summarize, cap rows with take. Both `timespan` and any ago() in the query bound the window; keep them ' + + 'consistent. Write string literals in single quotes (the call is JSON) and project only the columns you need.', inputSchema: { query: z.string().describe('KQL query (e.g. "traces | where message contains \'error\' | take 50")'), - timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D'), + timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D, P7D'), + source: z.enum(LOG_SOURCES).optional().default('backend') + .describe('Telemetry store: backend (API App Insights, default), web (portal App Insights), mcp (MCP Container App Log Analytics).'), + budgetSeconds: z.coerce.number().int().min(5).max(180).optional().default(30) + .describe('Server-side wall-clock budget for the query (5-180, default 30). Raise it for multi-day summarize scans instead of retrying a timeout.'), }, annotations: READ_ONLY_OPEN, }, async (args) => withToolTelemetry('query_backend_logs', args, async () => { try { + const budget = args.budgetSeconds ?? 30; const data = await apiFetch('/api/global/raw/logs', { method: 'POST', - body: JSON.stringify({ query: args.query, timespan: args.timespan }), + body: JSON.stringify({ query: args.query, timespan: args.timespan, source: args.source, budgetSeconds: budget }), + // The backend enforces the budget; the client only needs to outlast it (token mint + transfer margin). + signal: AbortSignal.timeout((budget + 15) * 1000), }); - return toolResultText(data, MAX_RESULT_SIZE_CHARS.events); + return toolResultText(data, MAX_RESULT_SIZE_CHARS.rawTable); } catch (error: unknown) { return toolError('query_backend_logs', args, error); } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/LogQuerySources.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/LogQuerySources.cs new file mode 100644 index 00000000..0ccc171d --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/LogQuerySources.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// The telemetry stores the operator KQL proxy (POST /api/global/raw/logs, MCP + /// query_backend_logs) can be pointed at. Wire values are lowercase and travel as the + /// request's source field; the MCP derives its z.enum from this list through the + /// shared manifest (section logSources), so the vocabulary is never retyped. + /// + public static class LogQuerySources + { + /// The Functions backend's Application Insights component (requests, traces, exceptions, customEvents, dependencies). + public const string Backend = "backend"; + /// The portal's Application Insights component (pageViews, browser customEvents, browserTimings, client dependencies). + public const string Web = "web"; + /// The MCP Container App's Log Analytics workspace (ContainerAppConsoleLogs_CL / ContainerAppSystemLogs_CL). + public const string Mcp = "mcp"; + + /// Declaration order; the MCP renders the enum in this order. + public static readonly IReadOnlyList All = new[] { Backend, Web, Mcp }; + + public static bool IsKnown(string? source) + => source != null && Array.IndexOf(new[] { Backend, Web, Mcp }, source) >= 0; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs index 5a37cd19..174d7b46 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs @@ -1,7 +1,60 @@ using System.Collections.Generic; +using System.Text.Json; namespace AutopilotMonitor.Shared.Models { + // Declaration order == wire order. + /// + /// Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one + /// telemetry store in the Kusto REST shape (tables[].columns/rows), wrapped with the + /// source it came from and the proxy's own observations. The table shape is kept verbatim so + /// every consumer that parsed the raw App Insights body keeps working. + /// + public class QueryBackendLogsResponse : IApiResponse + { + public bool Success { get; set; } + /// The store the query ran against — one of LogQuerySources.All. + public string Source { get; set; } = default!; + /// ISO 8601 duration the query was bounded to (echo of the request, defaulted). + public string Timespan { get; set; } = default!; + /// Server-side budget the upstream call ran under (clamped request value). + public int BudgetSeconds { get; set; } + /// Wall-clock time of the upstream call. + public long ElapsedMs { get; set; } + /// + /// True when the store answered 200 but flagged the result as incomplete (Kusto + /// PartialError: result-size cap, shard timeout). Absent on a complete result — + /// never silent, because a truncated aggregate reads like a smaller number. + /// + public bool? Partial { get; set; } + /// The store's own explanation when is true. + public string? PartialReason { get; set; } + public IReadOnlyList Tables { get; set; } = default!; + } + + // Declaration order == wire order. + /// One Kusto result table: column headers once, then rows as positional cell arrays. + public class KqlTable + { + public string Name { get; set; } = default!; + public IReadOnlyList Columns { get; set; } = default!; + /// + /// Positional cells, one list per row, aligned with . Cells are + /// forwarded as the JSON the store produced (numbers stay numbers, customDimensions + /// stays the JSON string App Insights emits, nulls stay null) — + /// is the honest "unknown" in the wire manifest. + /// + public IReadOnlyList> Rows { get; set; } = default!; + } + + // Declaration order == wire order. + /// A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool). + public class KqlColumn + { + public string Name { get; set; } = default!; + public string Type { get; set; } = default!; + } + // Raw endpoints return the literal stored table columns as dictionary rows // (PascalCase-verbatim, e.g. "PartitionKey", "EventType"). This is wire-safe because // System.Text.Json only renames PROPERTY names via PropertyNamingPolicy — dictionary diff --git a/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js index 888f975a..6e26a02c 100644 --- a/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js +++ b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js @@ -93,6 +93,8 @@ const MCP_VOCABULARIES = [ doc: "Session-annotation lanes (C# AnnotationLanes)." }, { section: "annotationVerdicts", constName: "ANNOTATION_VERDICTS", typeName: "AnnotationVerdict", doc: "Session-annotation verdicts (C# AnnotationVerdicts)." }, + { section: "logSources", constName: "LOG_SOURCES", typeName: "LogSourceName", + doc: "Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources)." }, ]; /** diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index dc3d326f..a2088f19 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -309,6 +309,11 @@ export const SHARED_MANIFEST = { "SlaEvaluationCompleted", "AzureMonitorAlert" ], + "logSources": [ + "backend", + "web", + "mcp" + ], "tenantRoles": [ "Admin", "Operator", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 5c1120a5..cb22cc3a 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -305,6 +305,11 @@ "SlaEvaluationCompleted", "AzureMonitorAlert" ], + "logSources": [ + "backend", + "web", + "mcp" + ], "tenantRoles": [ "Admin", "Operator", @@ -9905,6 +9910,63 @@ } ] }, + "KqlColumn": { + "kind": "object", + "doc": "A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool).", + "fields": [ + { + "name": "name", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "type", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "KqlTable": { + "kind": "object", + "doc": "One Kusto result table: column headers once, then rows as positional cell arrays.", + "fields": [ + { + "name": "name", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "columns", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "KqlColumn" + } + } + }, + { + "name": "rows", + "type": { + "kind": "array", + "element": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "unknown" + } + } + }, + "doc": "Positional cells, one list per row, aligned with Columns. Cells are forwarded as the JSON the store produced (numbers stay numbers, customDimensions stays the JSON string App Insights emits, nulls stay null) — JsonElement is the honest \"unknown\" in the wire manifest." + } + ] + }, "ListBackupsResponse": { "kind": "object", "doc": "Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline.", @@ -12365,6 +12427,79 @@ } ] }, + "QueryBackendLogsResponse": { + "kind": "object", + "doc": "Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one telemetry store in the Kusto REST shape (tables[].columns/rows), wrapped with the source it came from and the proxy's own observations. The table shape is kept verbatim so every consumer that parsed the raw App Insights body keeps working.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "The store the query ran against — one of LogQuerySources.All." + }, + { + "name": "timespan", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "ISO 8601 duration the query was bounded to (echo of the request, defaulted)." + }, + { + "name": "budgetSeconds", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Server-side budget the upstream call ran under (clamped request value)." + }, + { + "name": "elapsedMs", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Wall-clock time of the upstream call." + }, + { + "name": "partial", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "optional": true, + "doc": "True when the store answered 200 but flagged the result as incomplete (Kusto PartialError: result-size cap, shard timeout). Absent on a complete result — never silent, because a truncated aggregate reads like a smaller number." + }, + { + "name": "partialReason", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The store's own explanation when Partial is true." + }, + { + "name": "tables", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "KqlTable" + } + } + } + ] + }, "QueryRawEventsResponse": { "kind": "object", "doc": "Success body of GET /api/raw/events and /api/global/raw/events (QueryRawEvents / QueryRawEventsGlobal): raw event rows, PascalCase-verbatim stored columns.", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 6ad6a011..db8445c0 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -2129,6 +2129,20 @@ export interface IsGlobalAdminResponse { upn?: string; } +/** A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool). */ +export interface KqlColumn { + name: string; + type: string; +} + +/** One Kusto result table: column headers once, then rows as positional cell arrays. */ +export interface KqlTable { + name: string; + columns: KqlColumn[]; + /** Positional cells, one list per row, aligned with Columns. Cells are forwarded as the JSON the store produced (numbers stay numbers, customDimensions stays the JSON string App Insights emits, nulls stay null) — JsonElement is the honest "unknown" in the wire manifest. */ + rows: unknown[][]; +} + /** Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline. */ export interface ListBackupsResponse { backupIds: string[]; @@ -2638,6 +2652,24 @@ export interface ProgressLookupSessionResponse { session?: SessionSummary; } +/** Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one telemetry store in the Kusto REST shape (tables[].columns/rows), wrapped with the source it came from and the proxy's own observations. The table shape is kept verbatim so every consumer that parsed the raw App Insights body keeps working. */ +export interface QueryBackendLogsResponse { + success: boolean; + /** The store the query ran against — one of LogQuerySources.All. */ + source: string; + /** ISO 8601 duration the query was bounded to (echo of the request, defaulted). */ + timespan: string; + /** Server-side budget the upstream call ran under (clamped request value). */ + budgetSeconds: number; + /** Wall-clock time of the upstream call. */ + elapsedMs: number; + /** True when the store answered 200 but flagged the result as incomplete (Kusto PartialError: result-size cap, shard timeout). Absent on a complete result — never silent, because a truncated aggregate reads like a smaller number. */ + partial?: boolean; + /** The store's own explanation when Partial is true. */ + partialReason?: string; + tables: KqlTable[]; +} + /** Success body of GET /api/raw/events and /api/global/raw/events (QueryRawEvents / QueryRawEventsGlobal): raw event rows, PascalCase-verbatim stored columns. */ export interface QueryRawEventsResponse { /** Null on the global scope when no tenantId filter was given (cross-tenant query). */