mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-06 10:19:34 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdac34d7ad | |||
| 0b48b1ce63 | |||
| 5cb536c46e | |||
| 3f97a418d4 | |||
| cdd44d152a | |||
| d26d877989 | |||
| 59959c663e | |||
| ed2e3fd96c | |||
| d35edeaf9f | |||
| 21b802a028 | |||
| ae258cd9a7 |
+1
-1
@@ -30,7 +30,7 @@
|
||||
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
|
||||
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
|
||||
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `.github/zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: bba6d21544...823bbac0c8
@@ -101,8 +101,9 @@ updates:
|
||||
# SWA CLI for the deploy job of deploy-web.yml, which installs it from this lockfile next to
|
||||
# the production deploy token: a version reaches that job only through a reviewed bump here.
|
||||
# deploy-web watches this folder, so a merged bump redeploys and proves the new CLI at once.
|
||||
# Not under .github/: there Dependabot's update PR was refused as an unauthorized change.
|
||||
- package-ecosystem: npm
|
||||
directory: /.github/tools/swa-cli
|
||||
directory: /tools/swa-cli
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
|
||||
@@ -5,8 +5,9 @@ name: Workflow gates
|
||||
# version comment (stricter than zizmor's unpinned-uses audit).
|
||||
# * zizmor -- static analysis of every workflow (permissions, credential
|
||||
# persistence, template injection, ...). Configuration and the
|
||||
# reasoned ignores live in zizmor.yml at the repository root; the
|
||||
# same run is expected locally before a workflow change is committed.
|
||||
# reasoned ignores live in .github/zizmor.yml, which zizmor finds on
|
||||
# its own; the same run is expected locally before a workflow change
|
||||
# is committed.
|
||||
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
|
||||
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
|
||||
# registry (the C#, web and MCP registries have guard tests of their own).
|
||||
@@ -39,10 +40,10 @@ name: Workflow gates
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -56,7 +56,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -48,7 +48,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Deploy Web (Static Web App)
|
||||
|
||||
# Build here, upload with the SWA CLI from its own lockfile (.github/tools/swa-cli). No
|
||||
# Build here, upload with the SWA CLI from its own lockfile (tools/swa-cli). No
|
||||
# third-party action sees the deploy token, the build runs on the same path ci.yml tests
|
||||
# (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the action's container,
|
||||
# and the exported site is attested like the agent ZIP and the MSI.
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/tools/swa-cli/**'
|
||||
- 'tools/swa-cli/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
|
||||
# build zone can touch the install.
|
||||
- name: Install the SWA CLI from its lockfile
|
||||
working-directory: .github/tools/swa-cli
|
||||
working-directory: tools/swa-cli
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download build artifact
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
.github/tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# The three submodules are private. "update = none" makes recursive clones skip them (the
|
||||
# public, Dependabot's NuGet updater); a clone with access sets
|
||||
# submodule.<name>.update = checkout once, or runs git submodule update --init --checkout.
|
||||
[submodule ".claude/commands"]
|
||||
path = .claude/commands
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-dotclaude.git
|
||||
update = none
|
||||
[submodule "docs"]
|
||||
path = internal
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-internal.git
|
||||
update = none
|
||||
[submodule "infra"]
|
||||
path = infra
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-infra.git
|
||||
update = none
|
||||
|
||||
+1
-1
Submodule internal updated: 461040a828...ca09da503e
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -71,7 +72,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]}
|
||||
""";
|
||||
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
|
||||
|
||||
Assert.Null(partial);
|
||||
var table = Assert.Single(tables);
|
||||
@@ -97,7 +98,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"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);
|
||||
var (tables, partial) = LogQueryExecutor.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);
|
||||
@@ -106,7 +107,7 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Empty_or_shapeless_body_yields_no_tables_and_no_partial()
|
||||
{
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}");
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody("{}");
|
||||
Assert.Empty(tables);
|
||||
Assert.Null(partial);
|
||||
}
|
||||
@@ -115,16 +116,16 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Error_extraction_prefers_innererror_and_tolerates_non_json()
|
||||
{
|
||||
var (msg, code) = AppInsightsQueryFunction.ExtractError(
|
||||
var (msg, code) = LogQueryExecutor.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"}}""");
|
||||
var (outerMsg, outerCode) = LogQueryExecutor.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
|
||||
Assert.Equal("InsufficientAccessError", outerCode);
|
||||
Assert.Equal("Forbidden", outerMsg);
|
||||
|
||||
var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("<html>502 Bad Gateway</html>");
|
||||
var (plainMsg, plainCode) = LogQueryExecutor.ExtractError("<html>502 Bad Gateway</html>");
|
||||
Assert.Null(plainCode);
|
||||
Assert.Equal("Telemetry store query failed", plainMsg);
|
||||
}
|
||||
|
||||
+3
-3
@@ -96,7 +96,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
Row(Member, "20260903", 7), // read window past today (range only)
|
||||
};
|
||||
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
rows, new McpQuotaService.TenantPlanLimits("pro", 1500, 15000), "20260902", "20260901");
|
||||
|
||||
Assert.Equal("pro", quota.TenantPlan);
|
||||
@@ -114,7 +114,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
public void OrganizationQuota_PurchasedSlots_CarryTheBreakdown()
|
||||
{
|
||||
// 3 000 + 3 × 900 = 5 700 — the limits arrive grown; the slot fields let the page show the sum.
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) },
|
||||
new McpQuotaService.TenantPlanLimits("pro", 5700, 114000, PurchasedDelegatedSlots: 3, SlotTenantDailyLimit: 900, SlotTenantMonthlyLimit: 18000),
|
||||
"20260902", "20260901");
|
||||
@@ -145,7 +145,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
[Fact]
|
||||
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
|
||||
{
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) }, new McpQuotaService.TenantPlanLimits("pro", 0, 0), "20260902", "20260901");
|
||||
|
||||
Assert.Equal(0, quota.DailyLimit);
|
||||
|
||||
@@ -96,6 +96,47 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
|
||||
}
|
||||
|
||||
// ── ExceededAlertKey — one McpQuotaExceeded per account or organization, window and period ──
|
||||
|
||||
private const string OidA = "00000000-0000-0000-0000-00000000000a";
|
||||
private const string OidB = "00000000-0000-0000-0000-00000000000b";
|
||||
|
||||
private static McpQuotaDecision UserDailyBlock(DateTime now)
|
||||
=> McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
|
||||
dailyUsed: 50, monthlyUsed: 60, tenantDailyUsed: 60, tenantMonthlyUsed: 60, now);
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_AccountWindow_IsPerAccountAndDay()
|
||||
{
|
||||
var key = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now));
|
||||
|
||||
Assert.Equal($"user:daily:{OidA}:20260903", key); // the period is the reset date
|
||||
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, UserDailyBlock(Now)));
|
||||
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now.AddDays(1))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_OrganizationWindow_IsSharedByAllMembers()
|
||||
{
|
||||
// The organization's window is one budget: the refusal of any member raises one event for it.
|
||||
var tenantBlock = Blocked("community");
|
||||
|
||||
var a = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home.ToUpperInvariant(), tenantBlock);
|
||||
var b = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, tenantBlock);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal($"tenant:daily:{Home}:20260903", a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_MonthlyWindow_IsItsOwnKey()
|
||||
{
|
||||
var monthly = McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
|
||||
dailyUsed: 10, monthlyUsed: 250, tenantDailyUsed: 10, tenantMonthlyUsed: 10, Now);
|
||||
|
||||
Assert.Equal($"user:monthly:{OidA}:20261001", McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, monthly));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserLevelBlock_ReportsTheUserWindow()
|
||||
{
|
||||
|
||||
@@ -130,7 +130,8 @@ public class McpQuotaServiceTests
|
||||
Func<string?, TenantEdition>? editionResolver = null,
|
||||
Func<string?, string?>? tenantPlanOverrideResolver = null,
|
||||
Func<string?, int>? purchasedSlotsResolver = null,
|
||||
Func<string?, EditionResolution>? resolutionResolver = null)
|
||||
Func<string?, EditionResolution>? resolutionResolver = null,
|
||||
TestTimeProvider? time = null)
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
|
||||
@@ -162,9 +163,8 @@ public class McpQuotaServiceTests
|
||||
mcpUserService,
|
||||
adminConfigService,
|
||||
entitlements,
|
||||
cache,
|
||||
NullLogger<McpQuotaService>.Instance,
|
||||
new TestTimeProvider(Now));
|
||||
time ?? new TestTimeProvider(Now));
|
||||
}
|
||||
|
||||
private static Mock<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
|
||||
@@ -197,7 +197,7 @@ public class McpQuotaServiceTests
|
||||
// 100 requests today (catalog Community fallback: 100/day) → blocked.
|
||||
var svc = Build(UsageRepo(("20260707", 100)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
@@ -210,7 +210,7 @@ public class McpQuotaServiceTests
|
||||
{
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("pro", d.Plan);
|
||||
@@ -224,7 +224,7 @@ public class McpQuotaServiceTests
|
||||
// 2990 across the month + 20 today = 3010 ≥ 3000 monthly Community limit.
|
||||
var svc = Build(UsageRepo(("20260701", 1500), ("20260703", 1490), ("20260707", 20)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
@@ -238,7 +238,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"community","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 5)), planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
@@ -251,7 +251,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -266,7 +266,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power", bound: false);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
@@ -279,7 +279,7 @@ public class McpQuotaServiceTests
|
||||
// Override names a plan that exists nowhere → Community fallback limits.
|
||||
var svc = Build(UsageRepo(("20260707", 100)), mcpUserPlanOverride: "no-such-plan");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("no-such-plan", d.Plan);
|
||||
@@ -300,7 +300,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1200)), planDefinitionsJson: MspPlanJson,
|
||||
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("msp", d.Plan);
|
||||
@@ -318,7 +318,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 2000)), planDefinitionsJson: json,
|
||||
edition: TenantEdition.Pro, mcpUserPlanOverride: "power", tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -335,7 +335,7 @@ public class McpQuotaServiceTests
|
||||
// falls back fail-closed (user: Community catalog; tenant: the EDITION's catalog windows).
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("msp", d.Plan);
|
||||
@@ -351,7 +351,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: MspPlanJson,
|
||||
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp", purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("msp", d.Plan);
|
||||
Assert.Equal(2100, d.DailyLimit); // 1500 + 2 × 300
|
||||
@@ -367,8 +367,8 @@ public class McpQuotaServiceTests
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(first.Allowed);
|
||||
Assert.True(second.Allowed);
|
||||
@@ -383,69 +383,144 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
// ── Soft-boundary contract (Codex finding 2026-07-07) ─────────────────────────
|
||||
// The quota is DELIBERATELY soft: the per-user decision is cached for 60s, so an allowed
|
||||
// decision keeps admitting requests inside that window even after the fire-and-forget
|
||||
// increments push the stored counters past the limit. Overshoot is bounded (~TTL × request
|
||||
// rate per instance) — the same posture as the sliding-window rate limiter. These tests PIN
|
||||
// that contract; if strict limit+1 blocking is ever wanted, the decision cache must be
|
||||
// reworked and these tests consciously rewritten.
|
||||
// ── Boundary: exact per instance, soft across instances ──────────────────────
|
||||
// The counters are read from storage at most every 60 s, and every request the instance admits is
|
||||
// counted on them at once — so one instance blocks exactly at the limit, also when a tool call fans out
|
||||
// into parallel requests. What stays soft is the part of OTHER instances: they learn of these requests
|
||||
// only with their own next read.
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SoftBoundary_CachedAllowedDecision_KeepsAdmitting_WithinTtl()
|
||||
public async Task Check_SequentialRequests_BlockExactlyAtTheLimit_WithoutRereading()
|
||||
{
|
||||
// First read: one request below the daily limit → allowed, decision cached.
|
||||
var repo = UsageRepo(("20260707", 99));
|
||||
// Community catalog: 100/day. 98 served before → the 99th and 100th pass, the 101st is refused.
|
||||
var repo = UsageRepo(("20260707", 98));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
Assert.True(first.Allowed);
|
||||
var ninetyNinth = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var hundredth = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var hundredFirst = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
// The async increments land: stored counters are now OVER the limit — but the cached
|
||||
// allowed decision keeps admitting within the TTL, without re-reading storage.
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord>
|
||||
{
|
||||
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
|
||||
});
|
||||
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(second.Allowed, "soft boundary: cached allowed decision persists within the TTL");
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
Assert.True(ninetyNinth.Allowed);
|
||||
Assert.Equal(98, ninetyNinth.DailyUsed); // the decision reports the count BEFORE this request
|
||||
Assert.True(hundredth.Allowed);
|
||||
Assert.False(hundredFirst.Allowed);
|
||||
Assert.Equal("daily", hundredFirst.Scope);
|
||||
Assert.Equal(100, hundredFirst.DailyUsed);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SoftBoundary_AfterCacheEviction_OverLimitCounters_Block()
|
||||
public async Task ReadState_NeverCounts_OnlyAdmitDoes()
|
||||
{
|
||||
// Same scenario as above, but once the cached decision is gone (TTL expiry — simulated
|
||||
// via eviction), the next check reads the over-limit counters and blocks.
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
// The usage page shows the quota; viewing it is not an MCP request. Three reads leave the count at 99,
|
||||
// so the next real request still passes and only the one after it is refused.
|
||||
var svc = Build(UsageRepo(("20260707", 99)));
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var view = await svc.ReadStateAsync(Oid, Upn, TenantId);
|
||||
Assert.True(view.Allowed);
|
||||
Assert.Equal(99, view.DailyUsed);
|
||||
}
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.Equal(100, (await svc.ReadStateAsync(Oid, Upn, TenantId)).DailyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_ParallelBurst_AdmitsExactlyUpToTheLimit()
|
||||
{
|
||||
// A tool call's parallel requests must not all pass on the same reading: 95 used, 20 at once → 5 pass.
|
||||
var repo = UsageRepo(("20260707", 95));
|
||||
var svc = Build(repo);
|
||||
|
||||
var decisions = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => svc.AdmitAsync(Oid, Upn, TenantId))));
|
||||
|
||||
Assert.Equal(5, decisions.Count(d => d.Allowed));
|
||||
// Single-flight: concurrent first checks share one counter, so storage was read once.
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantWindow_CountsEveryMemberOnThisInstance()
|
||||
{
|
||||
// Community organization window 300/day, 298 used by the organization. Member A and member B each get one
|
||||
// request through; the next one of A is refused at the organization level although A's own window is wide.
|
||||
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 298L) });
|
||||
repo.Setup(r => r.GetUsageByUserAsync(OtherOid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = OtherOid, Date = "20260707", RequestCount = 1 } });
|
||||
var svc = Build(repo);
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId)).Allowed);
|
||||
var third = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(third.Allowed);
|
||||
Assert.Equal("tenant", third.Level);
|
||||
Assert.Equal(300, third.TenantDailyUsed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SecondInstance_LearnsOfTheFirstOnlyWithItsOwnRead()
|
||||
{
|
||||
// The part that stays soft, pinned: two instances share storage, not memory. Storage still says 99 (the
|
||||
// increments of instance A are on their way), so instance B admits one more on its own fresh reading.
|
||||
var repo = UsageRepo(("20260707", 99));
|
||||
var svc = Build(repo, cache: cache);
|
||||
var instanceA = Build(repo);
|
||||
var instanceB = Build(repo);
|
||||
|
||||
Assert.True((await svc.CheckAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_CounterExpiry_RereadsStorage()
|
||||
{
|
||||
// After 60 s the counter is read again, so increments of other instances count from then on.
|
||||
var time = new TestTimeProvider(Now);
|
||||
var repo = UsageRepo(("20260707", 10));
|
||||
var svc = Build(repo, time: time);
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord>
|
||||
{
|
||||
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
|
||||
});
|
||||
cache.Remove(McpQuotaService.UserCacheKey(Oid)); // stands in for the 60s TTL elapsing
|
||||
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = Oid, Date = "20260707", RequestCount = 150 } });
|
||||
time.SetUtcNow(Now.AddSeconds(61));
|
||||
|
||||
var decision = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var decision = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.Equal("daily", decision.Scope);
|
||||
Assert.Equal(150, decision.DailyUsed);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_Midnight_StartsAFreshCounter_InsideTheSixtySeconds()
|
||||
{
|
||||
// A counter read at 23:59:50 never answers a check at 00:00:05: the day is part of the key.
|
||||
var lateEvening = new DateTime(2026, 7, 7, 23, 59, 50, DateTimeKind.Utc);
|
||||
var time = new TestTimeProvider(lateEvening);
|
||||
var repo = UsageRepo(("20260707", 100));
|
||||
var svc = Build(repo, time: time);
|
||||
|
||||
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
|
||||
time.SetUtcNow(lateEvening.AddSeconds(15));
|
||||
var afterMidnight = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(afterMidnight.Allowed);
|
||||
Assert.Equal(0, afterMidnight.DailyUsed);
|
||||
Assert.Equal(new DateTime(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc), afterMidnight.ResetUtc);
|
||||
}
|
||||
|
||||
// ── Tenant (organization-wide) quota ─────────────────────────────────────────
|
||||
@@ -456,7 +531,7 @@ public class McpQuotaServiceTests
|
||||
// Community catalog tenant window = 300/day; other members burned it, this caller made 5 requests.
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 5L) }, tenantRows: new[] { ("20260707", 300L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
@@ -474,7 +549,7 @@ public class McpQuotaServiceTests
|
||||
new[] { ("20260707", 1L) },
|
||||
tenantRows: new[] { ("20260701", 4000L), ("20260703", 4990L), ("20260707", 10L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
@@ -492,7 +567,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 10L) }),
|
||||
planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -509,7 +584,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
@@ -524,7 +599,7 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 999999L) });
|
||||
var svc = Build(repo, planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(0, d.TenantDailyLimit);
|
||||
@@ -537,7 +612,7 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, tenantId: null);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, tenantId: null);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
@@ -551,8 +626,8 @@ public class McpQuotaServiceTests
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(first.Allowed);
|
||||
Assert.True(second.Allowed);
|
||||
@@ -573,7 +648,7 @@ public class McpQuotaServiceTests
|
||||
// Pro home with 3 purchased slots: 1000 + 3 × 300 per account, 3000 + 3 × 900 for the organization.
|
||||
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 3);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1900, d.DailyLimit);
|
||||
Assert.Equal(38000, d.MonthlyLimit);
|
||||
@@ -592,7 +667,7 @@ public class McpQuotaServiceTests
|
||||
// A Pro tenant on its two included slots (purchased = 0) keeps the plain Pro windows.
|
||||
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 0);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(3000, d.TenantDailyLimit);
|
||||
@@ -607,7 +682,7 @@ public class McpQuotaServiceTests
|
||||
var conferred = new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false);
|
||||
var svc = Build(UsageRepo(("20260707", 1)), resolutionResolver: _ => conferred, purchasedSlotsResolver: _ => 3);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("pro", d.Plan);
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
@@ -625,7 +700,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro,
|
||||
mcpUserPlanOverride: "power", purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("power", d.Plan);
|
||||
Assert.Equal(10600, d.DailyLimit);
|
||||
@@ -639,7 +714,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":0,"slotMonthlyRequestLimit":0,"slotTenantDailyRequestLimit":0,"slotTenantMonthlyRequestLimit":0,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 5);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(3000, d.TenantDailyLimit);
|
||||
@@ -653,7 +728,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":50,"slotMonthlyRequestLimit":500,"slotTenantDailyRequestLimit":100,"slotTenantMonthlyRequestLimit":1000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1100, d.DailyLimit);
|
||||
Assert.Equal(21000, d.MonthlyLimit);
|
||||
@@ -668,7 +743,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1300, d.DailyLimit);
|
||||
Assert.Equal(3900, d.TenantDailyLimit);
|
||||
@@ -682,7 +757,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":0,"monthlyRequestLimit":20000,"tenantDailyRequestLimit":0,"tenantMonthlyRequestLimit":60000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 30000)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 4);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(0, d.DailyLimit);
|
||||
@@ -705,10 +780,10 @@ public class McpQuotaServiceTests
|
||||
{
|
||||
// Pro 1000 + 1 × 300 = 1300: the 1300th request of the day is the first blocked one.
|
||||
var below = Build(UsageRepo(("20260707", 1299)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
Assert.True((await below.CheckAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await below.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
|
||||
var at = Build(UsageRepo(("20260707", 1300)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
var d = await at.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await at.AdmitAsync(Oid, Upn, TenantId);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
Assert.Equal("user", d.Level);
|
||||
@@ -726,8 +801,8 @@ public class McpQuotaServiceTests
|
||||
.ReturnsAsync(new List<UserUsageRecord>());
|
||||
var svc = Build(repo);
|
||||
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.CheckAsync(OtherOid, "bob@contoso.com", TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId);
|
||||
|
||||
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The pure fold behind GET global/metrics/mcp-usage/overview: which numbers come from the quota counters and
|
||||
/// which from the telemetry, how platform admins drop out (but never out of a tenant's real organization
|
||||
/// windows), what a call costs, MCP-only tools, the daily trend and the per-minute ranking.
|
||||
/// </summary>
|
||||
public class McpUsageOverviewAssemblyTests
|
||||
{
|
||||
private const string TenantA = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
private const string TenantB = "bbbbbbbb-0000-0000-0000-00000000000b";
|
||||
private const string Alice = "alice@contoso.com";
|
||||
private const string App = "app:11111111-2222-3333-4444-555555555555";
|
||||
private const string Operator = "ops@fabrikam.com";
|
||||
private const string Bob = "bob@fabrikam.com";
|
||||
private const string Carol = "carol@contoso.com";
|
||||
|
||||
// Window 3 days (Nov 3–5); the month started Nov 1, so Nov 1 rows count for the quota, not for the window.
|
||||
private static readonly Dates ThreeDays = new("20261103", "20261105", "20261101");
|
||||
private static readonly DateTime At = new(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantUsageRecord Counter(string tenant, string oid, string upn, string day, long requests)
|
||||
=> new() { TenantId = tenant, UserId = oid, UserPrincipalName = upn, Date = day, RequestCount = requests, LastRequestAt = At };
|
||||
|
||||
private static readonly TenantUsageRecord[] Counters =
|
||||
{
|
||||
Counter(TenantA, "oid-alice", Alice, "20261101", 10),
|
||||
Counter(TenantA, "oid-alice", Alice, "20261104", 20),
|
||||
Counter(TenantA, "oid-alice", Alice, "20261105", 30),
|
||||
Counter(TenantA, "oid-app", App, "20261105", 5),
|
||||
Counter(TenantA, "oid-ops", Operator, "20261105", 100),
|
||||
Counter(TenantB, "oid-bob", Bob, "20261103", 7),
|
||||
};
|
||||
|
||||
private static CallRow Call(string day, string tool, string tenant, string principal, long calls, long served, long requests, string role)
|
||||
=> new(day, tool, tenant, principal, calls, served, requests, At, role);
|
||||
|
||||
private static readonly CallRow[] Calls =
|
||||
{
|
||||
Call("20261104", "get_session_summary", TenantA, Alice, 2, 2, 10, "Admin"),
|
||||
Call("20261105", "search_events", TenantA, Alice, 1, 1, 14, "Admin"),
|
||||
Call("20261105", "get_session_events", TenantA, Alice, 3, 3, 3, "Admin"),
|
||||
Call("20261105", "list_tenants", TenantA, Alice, 1, 1, 1, "Admin"),
|
||||
Call("20261105", "get_session_summary", TenantA, App, 1, 1, 5, "Viewer"),
|
||||
Call("20261105", "query_table", TenantA, Operator, 50, 50, 50, "GlobalAdmin"),
|
||||
Call("20261103", "search_sessions", TenantB, Bob, 2, 2, 7, "Admin"),
|
||||
Call("20261105", "get_time_attribution", TenantA, Carol, 4, 0, 0, "Admin"),
|
||||
};
|
||||
|
||||
private static McpQuotaDecision State(string plan, int daily, int monthly, long dailyUsed, long monthlyUsed)
|
||||
=> new() { Allowed = true, Plan = plan, DailyLimit = daily, MonthlyLimit = monthly, DailyUsed = dailyUsed, MonthlyUsed = monthlyUsed };
|
||||
|
||||
private static TelemetryResult Telemetry(bool available = true) => !available
|
||||
? new TelemetryResult(false, false, false, new(), new(), new(), new(), new(), new(), new ServerFacts(null, null))
|
||||
: new TelemetryResult(
|
||||
RequestsAvailable: true, McpLogAvailable: true, Partial: false,
|
||||
Calls: Calls.ToList(),
|
||||
Routes: new()
|
||||
{
|
||||
new("get_session_summary", "GET sessions/{id}/events", 3),
|
||||
new("get_session_summary", "GET sessions/{id}", 3),
|
||||
},
|
||||
Refusals: new()
|
||||
{
|
||||
new(RefusalQuota, "20261105", TenantA, Alice, "user", "daily", "community", 3, At, At.AddSeconds(9), 50, 50),
|
||||
new(RefusalMinute, "20261103", TenantB, Bob, "", "", "", 2, At, At, 120, 120),
|
||||
},
|
||||
Peaks: new()
|
||||
{
|
||||
new(PeakCalls, TenantA, Alice, 12, At),
|
||||
new(PeakRequests, TenantA, Alice, 40, At),
|
||||
new(PeakRequests, TenantB, Bob, 120, At),
|
||||
new(PeakCalls, TenantB, Bob, 5, At),
|
||||
},
|
||||
ToolQuality: new()
|
||||
{
|
||||
new("get_session_summary", 3, 0, 0, 0, 600, 19000.4),
|
||||
new("search_docs", 4, 1, 0, 0, 80, 5000),
|
||||
},
|
||||
McpRefusals: new() { new(Alice, 7, At) },
|
||||
ServerFacts: new ServerFacts(new DateTime(2026, 11, 4, 1, 0, 0, DateTimeKind.Utc), 60));
|
||||
|
||||
private static GetGlobalMcpUsageOverviewResponse Overview(bool includePlatformAdmins = false, bool telemetry = true)
|
||||
{
|
||||
var result = Telemetry(telemetry);
|
||||
var facts = new Dictionary<(string Tenant, string Principal), AccountFacts>
|
||||
{
|
||||
[(TenantA, Alice)] = new("oid-alice", null, State("community", 50, 250, 30, 60)),
|
||||
[(TenantA, App)] = new("oid-app", null, State("community", 50, 250, 5, 5)),
|
||||
[(TenantA, Operator)] = new("oid-ops", "GlobalAdmin", State("community", 50, 250, 100, 100)),
|
||||
[(TenantB, Bob)] = new("oid-bob", null, State("pro", 500, 6000, 0, 7)),
|
||||
};
|
||||
var limits = new Dictionary<string, McpQuotaService.TenantPlanLimits>
|
||||
{
|
||||
[TenantA] = new("community", 100, 250),
|
||||
[TenantB] = new("pro", 1500, 15000),
|
||||
};
|
||||
var minute = new Dictionary<(string Tenant, string Principal), int> { [(TenantA, Alice)] = 120, [(TenantB, Bob)] = 150 };
|
||||
|
||||
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, LatestRoles(result.Calls), minute, result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_DropOutOfEveryNumber_ButNotOutOfTheOrganizationWindows()
|
||||
{
|
||||
var overview = Overview();
|
||||
var tenantA = overview.Tenants.Single(t => t.TenantId == TenantA);
|
||||
|
||||
Assert.DoesNotContain(tenantA.Accounts, a => a.Principal == Operator);
|
||||
Assert.DoesNotContain(overview.Tools, t => t.Tool == "query_table");
|
||||
Assert.Equal(55, tenantA.Requests); // alice 20 + 30, app 5 — the operator's 100 left out
|
||||
// The organization windows stay the tenant's real counters: today 30 + 5 + 100, the month 10 + 20 + 30 + 5 + 100.
|
||||
Assert.Equal(135, tenantA.Quota.DailyUsed);
|
||||
Assert.Equal(165, tenantA.Quota.MonthlyUsed);
|
||||
Assert.Equal("community", tenantA.Quota.TenantPlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_WhenIncluded_AreMarked()
|
||||
{
|
||||
var tenantA = Overview(includePlatformAdmins: true).Tenants.Single(t => t.TenantId == TenantA);
|
||||
var op = tenantA.Accounts.Single(a => a.Principal == Operator);
|
||||
|
||||
Assert.True(op.IsPlatformAdmin);
|
||||
Assert.Equal("GlobalAdmin", op.Role);
|
||||
Assert.Equal(155, tenantA.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounts_RequestsComeFromTheCounters_CallsFromTheTelemetry()
|
||||
{
|
||||
var tenants = Overview().Tenants;
|
||||
Assert.Equal(new[] { TenantA, TenantB }, tenants.Select(t => t.TenantId)); // most calls first
|
||||
var accounts = tenants[0].Accounts;
|
||||
Assert.Equal(new[] { Alice, Carol, App }, accounts.Select(a => a.Principal));
|
||||
|
||||
var alice = accounts[0];
|
||||
Assert.Equal("oid-alice", alice.UserId);
|
||||
Assert.Equal(7, alice.Calls);
|
||||
Assert.Equal(50, alice.Requests); // Nov 1 lies before the window
|
||||
Assert.Equal("20261105", alice.PeakDay);
|
||||
Assert.Equal(30, alice.PeakDayRequests);
|
||||
Assert.Equal(1, alice.QuotaHits);
|
||||
Assert.Equal("Admin", alice.Role);
|
||||
Assert.Equal(new[] { ("get_session_events", 3L), ("get_session_summary", 2L), ("list_tenants", 1L) },
|
||||
alice.TopTools.Select(t => (t.Tool, t.Calls)));
|
||||
Assert.Equal(("community", 50, 250, 30L, 60L),
|
||||
(alice.Quota!.Plan, alice.Quota.DailyLimit, alice.Quota.MonthlyLimit, alice.Quota.DailyUsed, alice.Quota.MonthlyUsed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAccountKnownOnlyFromTheTelemetry_HasNoObjectIdAndNoQuota()
|
||||
{
|
||||
// Every request refused: no counter row, but the calls happened.
|
||||
var carol = Overview().Tenants.Single(t => t.TenantId == TenantA).Accounts.Single(a => a.Principal == Carol);
|
||||
|
||||
Assert.Null(carol.UserId);
|
||||
Assert.Null(carol.Quota);
|
||||
Assert.Equal(4, carol.Calls);
|
||||
Assert.Equal(0, carol.Requests);
|
||||
Assert.Null(carol.PeakDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tools_RankByCalls_CostPerServedCall_AndMcpOnlyToolsFromTheLog()
|
||||
{
|
||||
var tools = Overview().Tools;
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "get_time_attribution", "search_docs", "get_session_events", "get_session_summary", "search_sessions", "list_tenants", "search_events" },
|
||||
tools.Select(t => t.Tool));
|
||||
|
||||
var summary = tools.Single(t => t.Tool == "get_session_summary");
|
||||
Assert.Equal(3, summary.Calls);
|
||||
Assert.Equal(2, summary.Accounts);
|
||||
Assert.Equal(1, summary.Tenants);
|
||||
Assert.Equal(5.0, summary.RequestsPerCall); // (10 + 5) requests over 3 served calls
|
||||
Assert.Equal(0.0, summary.ErrorRate);
|
||||
Assert.Equal(600, summary.P95DurationMs);
|
||||
Assert.Equal(19000, summary.AvgResultChars);
|
||||
Assert.Equal(new[] { "GET sessions/{id}", "GET sessions/{id}/events" }, summary.Routes.Select(r => r.Route));
|
||||
|
||||
var docs = tools.Single(t => t.Tool == "search_docs");
|
||||
Assert.Equal(4, docs.Calls);
|
||||
Assert.Null(docs.Accounts);
|
||||
Assert.Null(docs.Tenants);
|
||||
Assert.Equal(0, docs.RequestsPerCall);
|
||||
Assert.Equal(0.25, docs.ErrorRate);
|
||||
|
||||
// Every call refused: no served call, no cost to report.
|
||||
Assert.Equal(0, tools.Single(t => t.Tool == "get_time_attribution").RequestsPerCall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Daily_HasEveryDayOfTheWindow_WithCallsByPlan()
|
||||
{
|
||||
var daily = Overview().Daily;
|
||||
|
||||
Assert.Equal(new[] { "20261103", "20261104", "20261105" }, daily.Select(d => d.Date));
|
||||
var today = daily[2];
|
||||
Assert.Equal(10, today.Calls); // alice 5, app 1, carol 4 — the operator's 50 left out
|
||||
Assert.Equal(35, today.Requests); // alice 30, app 5
|
||||
Assert.Equal(3, today.Accounts);
|
||||
Assert.Equal(new[] { ("community", 10L) }, today.CallsByPlan.Select(p => (p.Plan, p.Calls)));
|
||||
Assert.Equal(new[] { ("pro", 2L) }, daily[0].CallsByPlan.Select(p => (p.Plan, p.Calls)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuotaHits_AndMinuteLimits_RankTheAccountsClosestToALimit()
|
||||
{
|
||||
var overview = Overview();
|
||||
|
||||
var hit = Assert.Single(overview.QuotaHits);
|
||||
Assert.Equal((Alice, "user", "daily", 50L, 50L, 3L), (hit.Principal, hit.Level, hit.Scope, hit.Limit, hit.Used, hit.Refused));
|
||||
|
||||
Assert.Equal(60, overview.MinuteLimits.McpServerLimit);
|
||||
var minute = overview.MinuteLimits.Accounts;
|
||||
Assert.Equal(new[] { Bob, Alice }, minute.Select(m => m.Principal)); // 120/150 before 40/120 and 12/60
|
||||
Assert.Equal((120L, 150, 2L), (minute[0].PeakRequests, minute[0].BackendLimit, minute[0].BackendRefused));
|
||||
Assert.Equal((TenantA, 12L, 7L, 0L), (minute[1].TenantId, minute[1].PeakCalls, minute[1].McpRefused, minute[1].BackendRefused));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, 30, false, null)]
|
||||
[InlineData("7", "include", 7, true, null)]
|
||||
[InlineData("90", "exclude", 90, false, null)]
|
||||
[InlineData("91", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("0", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("-5", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("7.5", null, 0, false, "days must be an integer")]
|
||||
[InlineData("30", "all", 0, false, "platformAdmins must be include or exclude")]
|
||||
public void OverviewQuery_DefaultsAndBounds(string? days, string? platformAdmins, int expectedDays, bool expectedInclude, string? expectedError)
|
||||
{
|
||||
var parsed = AutopilotMonitor.Functions.Functions.Metrics.McpUsageMetricsFunction.ParseOverviewQuery(days, platformAdmins);
|
||||
|
||||
Assert.Equal((expectedDays, expectedInclude, expectedError), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_NamesTheMcpLogCoverage_AndATelemetryOutageEmptiesOnlyItsParts()
|
||||
{
|
||||
Assert.Equal("20261104", Overview().Telemetry.McpLogFrom);
|
||||
|
||||
var dark = Overview(telemetry: false);
|
||||
Assert.False(dark.Telemetry.RequestsAvailable);
|
||||
Assert.False(dark.Telemetry.McpLogAvailable);
|
||||
Assert.Empty(dark.Tools);
|
||||
Assert.Empty(dark.QuotaHits);
|
||||
// The counters still carry tenants, accounts, requests and the quota.
|
||||
var tenantA = dark.Tenants.Single(t => t.TenantId == TenantA);
|
||||
Assert.Equal(new[] { Alice, App }, tenantA.Accounts.Select(a => a.Principal).OrderBy(p => p, StringComparer.Ordinal));
|
||||
Assert.Equal(0, tenantA.Calls);
|
||||
Assert.Equal(55, tenantA.Requests);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The fixed KQL of the MCP usage overview: which rows it reads (the worker's request row, MCP only, no access
|
||||
/// probe), that it counts calls exactly, leaves platform admins out unless asked, matches log lines by their
|
||||
/// start, reads only fields the MCP server writes (tests/fixtures/mcp-log-lines, proven on the MCP side by
|
||||
/// usage-log-fields.test.ts) — and that the parsers read the result tables by column name.
|
||||
/// </summary>
|
||||
public class McpUsageTelemetryQueriesTests
|
||||
{
|
||||
private static readonly Window Customers = new(new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 11, 6, 0, 0, 0, DateTimeKind.Utc), IncludePlatformAdmins: false);
|
||||
private static readonly Window Everyone = Customers with { IncludePlatformAdmins = true };
|
||||
|
||||
private static IEnumerable<string> RequestQueries(Window w) => new[] { Calls(w), Routes(w), Refusals(w), Peaks(w) };
|
||||
private static IEnumerable<string> McpLogQueries(Window w) => new[] { ToolQuality(w), McpRefusals(w), McpServerFacts(w) };
|
||||
|
||||
[Fact]
|
||||
public void Window_CoversWholeUtcDays()
|
||||
{
|
||||
Assert.Equal("datetime(2026-10-07T00:00:00Z)", Customers.From);
|
||||
Assert.Equal("datetime(2026-11-06T00:00:00Z)", Customers.To);
|
||||
Assert.Equal("P31D", Customers.Timespan); // the where clauses bound the window; the timespan only has to cover it
|
||||
foreach (var query in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
|
||||
{
|
||||
Assert.Contains(">= datetime(2026-10-07T00:00:00Z)", query);
|
||||
Assert.Contains("< datetime(2026-11-06T00:00:00Z)", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestQueries_ReadTheWorkerRow_McpOnly_WithoutTheAccessProbe()
|
||||
{
|
||||
foreach (var query in RequestQueries(Customers))
|
||||
{
|
||||
Assert.Contains("customDimensions.Source) == 'WorkerMiddleware'", query);
|
||||
Assert.Contains("customDimensions.ClientSource) =~ 'mcp'", query);
|
||||
Assert.Contains("customDimensions.FunctionName) != 'GlobalAccessProbe'", query);
|
||||
Assert.Contains("customDimensions.McpToolName", query);
|
||||
Assert.Contains("customDimensions.CorrelationId", query);
|
||||
Assert.Contains("tolower(tostring(customDimensions.TenantId))", query);
|
||||
Assert.Contains("tolower(tostring(customDimensions.UserId))", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calls_AreCountedOncePerCorrelationId_NeverEstimated()
|
||||
{
|
||||
var query = Calls(Customers);
|
||||
|
||||
Assert.Contains("by corr, tool, tenant, principal", query);
|
||||
Assert.Contains("calls = count()", query);
|
||||
foreach (var q in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
|
||||
Assert.DoesNotContain("dcount(", q);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_AreLeftOut_UnlessIncluded()
|
||||
{
|
||||
foreach (var query in RequestQueries(Customers))
|
||||
Assert.Contains("role !in ('GlobalAdmin', 'GlobalReader')", query);
|
||||
Assert.Contains("tostring(d.scope) != 'ga'", ToolQuality(Customers));
|
||||
|
||||
foreach (var query in RequestQueries(Everyone).Concat(McpLogQueries(Everyone)))
|
||||
{
|
||||
Assert.DoesNotContain("'GlobalAdmin'", query);
|
||||
Assert.DoesNotContain("!= 'ga'", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refusals_AreClassifiedByTheLinesTheMiddlewaresWrite()
|
||||
{
|
||||
var query = Refusals(Customers);
|
||||
|
||||
Assert.Contains($"startswith '{McpQuotaEnforcementMiddleware.BlockedLogPrefix}'", query);
|
||||
Assert.Contains($"startswith '{UserRateLimitMiddleware.ThrottledLogPrefix}'", query);
|
||||
Assert.Equal("[McpQuota] BLOCKED", McpQuotaEnforcementMiddleware.BlockedLogPrefix);
|
||||
Assert.Equal("[UserRateLimit] THROTTLED", UserRateLimitMiddleware.ThrottledLogPrefix);
|
||||
Assert.Contains("join kind=inner lines on operation_Id", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Peaks_SlideASixtySecondWindowInTenSecondSteps()
|
||||
{
|
||||
var query = Peaks(Customers);
|
||||
|
||||
Assert.Contains("bin(timestamp, 10s)", query);
|
||||
Assert.Contains("bin(at, 10s)", query);
|
||||
Assert.Equal(2, Regex.Matches(query, Regex.Escape("mv-expand k = range(0, 5, 1)")).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpLogLines_AreMatchedByTheirStart_NeverByHas()
|
||||
{
|
||||
Assert.Contains($"Log_s startswith '{ToolCallLinePrefix}'", ToolQuality(Customers));
|
||||
Assert.Contains($"Log_s startswith '{McpRefusalLinePrefix}'", McpRefusals(Customers));
|
||||
Assert.Contains($"Log_s startswith '{BootLinePrefix}'", McpServerFacts(Customers));
|
||||
foreach (var query in McpLogQueries(Customers))
|
||||
Assert.DoesNotContain("Log_s has", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpLogQueries_ReadOnlyWhatTheServerWrites()
|
||||
{
|
||||
using var contract = JsonDocument.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "mcp-log-lines", "fields.json")));
|
||||
var prefixes = contract.RootElement.GetProperty("prefixes");
|
||||
var fields = contract.RootElement.GetProperty("fields");
|
||||
|
||||
Assert.Equal(ToolCallLinePrefix, prefixes.GetProperty("tool_call").GetString());
|
||||
Assert.Equal(BootLinePrefix, prefixes.GetProperty("boot").GetString());
|
||||
Assert.Equal(McpRefusalLinePrefix, prefixes.GetProperty("mcp_refusal").GetString());
|
||||
|
||||
var toolCallFields = fields.GetProperty("tool_call").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
|
||||
var readFromToolCall = Regex.Matches(ToolQuality(Everyone), @"\bd\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
|
||||
Assert.NotEmpty(readFromToolCall);
|
||||
Assert.Subset(toolCallFields, readFromToolCall);
|
||||
|
||||
var bootFields = fields.GetProperty("boot").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
|
||||
var readFromBoot = Regex.Matches(McpServerFacts(Everyone), @"parse_json\(Log_s\)\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
|
||||
Assert.Equal(new[] { "rateLimitPerMinute" }, readFromBoot);
|
||||
Assert.Subset(bootFields, readFromBoot);
|
||||
}
|
||||
|
||||
// ── Parsers ──
|
||||
|
||||
private static KqlTable Table(string[] columns, params object?[][] rows)
|
||||
=> new()
|
||||
{
|
||||
Name = "PrimaryResult",
|
||||
Columns = columns.Select(c => new KqlColumn { Name = c, Type = "dynamic" }).ToList(),
|
||||
Rows = rows.Select(r => (IReadOnlyList<JsonElement>)r.Select(cell => JsonSerializer.SerializeToElement(cell)).ToList()).ToList(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ParseCalls_ReadsByColumnName_WhateverTheOrder()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "role", "charged", "day", "principal", "tool", "calls", "tenant", "servedCalls", "firstAt" },
|
||||
new object?[] { "Admin", 59L, "20261028", "user@contoso.com", "get_time_attribution", 64L, "t1", 59L, "2026-10-28T16:12:30.4442709Z" });
|
||||
|
||||
var row = Assert.Single(ParseCalls(table));
|
||||
|
||||
Assert.Equal(new CallRow("20261028", "get_time_attribution", "t1", "user@contoso.com", 64, 59, 59,
|
||||
new DateTime(2026, 10, 28, 16, 12, 30, 444, DateTimeKind.Utc).AddTicks(2709), "Admin"), row);
|
||||
Assert.Equal(DateTimeKind.Utc, row.LastAt!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRefusals_KeepsMissingCountsMissing()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "gate", "day", "tenant", "principal", "level", "scope", "plan", "refused", "firstAt", "lastAt", "usedCount", "limitValue" },
|
||||
new object?[] { "minute", "20261026", "t1", "user@contoso.com", "", "", "", 2L, "2026-10-26T07:55:42Z", "2026-10-26T07:55:43Z", null, null });
|
||||
|
||||
var row = Assert.Single(ParseRefusals(table));
|
||||
|
||||
Assert.Equal("minute", row.Gate);
|
||||
Assert.Equal(2, row.Refused);
|
||||
Assert.Null(row.Used);
|
||||
Assert.Null(row.Limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToolQuality_RoundsAFractionalCount_AndKeepsTheMean()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "tool", "calls", "errors", "cancelled", "overCap", "p95Ms", "avgChars" },
|
||||
new object?[] { "search_events", 26L, 1L, 0L, 0L, 1029.6, 10602.5 });
|
||||
|
||||
var row = Assert.Single(ParseToolQuality(table));
|
||||
|
||||
Assert.Equal(26, row.Calls);
|
||||
Assert.Equal(1030, row.P95Ms);
|
||||
Assert.Equal(10602.5, row.AvgChars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseServerFacts_WithoutABootLine_HasNoLimit()
|
||||
{
|
||||
var facts = ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" }, new object?[] { "2026-10-07T01:52:43.167992Z", null }));
|
||||
|
||||
Assert.Equal(new DateTime(2026, 10, 7, 1, 52, 43, DateTimeKind.Utc).AddTicks(1_679_920), facts.Oldest);
|
||||
Assert.Null(facts.RateLimitPerMinute);
|
||||
Assert.Equal(new ServerFacts(null, null), ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" })));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -832,6 +832,236 @@ public class MetricsWireParityTests
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageOverview ------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageOverviewResponse_matches_the_overview_shape()
|
||||
{
|
||||
// One tenant with one account, one tool with one route, one quota refusal, one per-minute row — every
|
||||
// optional slot filled, so every key is pinned in its wire position.
|
||||
var at = new DateTime(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
|
||||
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
|
||||
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 35, MonthlyUsed = 60 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
dateFrom = "20261103",
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = false,
|
||||
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
|
||||
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
quota,
|
||||
calls = 7L,
|
||||
requests = 50L,
|
||||
quotaHits = 1,
|
||||
lastRequestAt = at,
|
||||
accounts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
userId = "oid-1",
|
||||
principal = "admin@contoso.com",
|
||||
role = "Admin",
|
||||
isPlatformAdmin = false,
|
||||
quota = new { plan = "community", dailyLimit = 50, monthlyLimit = 250, dailyUsed = 30L, monthlyUsed = 60L },
|
||||
calls = 7L,
|
||||
requests = 50L,
|
||||
peakDay = "20261105",
|
||||
peakDayRequests = 30L,
|
||||
quotaHits = 1,
|
||||
topTools = new[] { new { tool = "get_session_events", calls = 3L } },
|
||||
lastRequestAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = "get_session_summary",
|
||||
calls = 3L,
|
||||
accounts = 2,
|
||||
tenants = 1,
|
||||
requestsPerCall = 5.0,
|
||||
errorRate = 0.0,
|
||||
p95DurationMs = 600L,
|
||||
avgResultChars = 19000L,
|
||||
overCapCalls = 0L,
|
||||
routes = new[] { new { route = "GET sessions/{id}", requests = 3L } },
|
||||
},
|
||||
},
|
||||
quotaHits = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
date = "20261105", tenantId, principal = "admin@contoso.com", level = "user", scope = "daily", plan = "community",
|
||||
limit = 50L, used = 50L, refused = 3L, firstAt = at, lastAt = at,
|
||||
},
|
||||
},
|
||||
minuteLimits = new
|
||||
{
|
||||
mcpServerLimit = 60,
|
||||
accounts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId, principal = "admin@contoso.com",
|
||||
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
|
||||
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = "20261103",
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = false,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
|
||||
Daily = new List<McpUsageDayItem>
|
||||
{
|
||||
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
|
||||
},
|
||||
Tenants = new List<McpUsageTenantItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Quota = quota,
|
||||
Calls = 7,
|
||||
Requests = 50,
|
||||
QuotaHits = 1,
|
||||
LastRequestAt = at,
|
||||
Accounts = new List<McpUsageAccountItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UserId = "oid-1",
|
||||
Principal = "admin@contoso.com",
|
||||
Role = "Admin",
|
||||
IsPlatformAdmin = false,
|
||||
Quota = new McpUsageAccountQuotaNode { Plan = "community", DailyLimit = 50, MonthlyLimit = 250, DailyUsed = 30, MonthlyUsed = 60 },
|
||||
Calls = 7,
|
||||
Requests = 50,
|
||||
PeakDay = "20261105",
|
||||
PeakDayRequests = 30,
|
||||
QuotaHits = 1,
|
||||
TopTools = new List<McpUsageToolCallsItem> { new() { Tool = "get_session_events", Calls = 3 } },
|
||||
LastRequestAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools = new List<McpUsageToolItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Tool = "get_session_summary",
|
||||
Calls = 3,
|
||||
Accounts = 2,
|
||||
Tenants = 1,
|
||||
RequestsPerCall = 5.0,
|
||||
ErrorRate = 0.0,
|
||||
P95DurationMs = 600,
|
||||
AvgResultChars = 19000,
|
||||
OverCapCalls = 0,
|
||||
Routes = new List<McpUsageRouteItem> { new() { Route = "GET sessions/{id}", Requests = 3 } },
|
||||
},
|
||||
},
|
||||
QuotaHits = new List<McpUsageQuotaHitItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Date = "20261105", TenantId = tenantId, Principal = "admin@contoso.com", Level = "user", Scope = "daily", Plan = "community",
|
||||
Limit = 50, Used = 50, Refused = 3, FirstAt = at, LastAt = at,
|
||||
},
|
||||
},
|
||||
MinuteLimits = new McpUsageMinuteLimits
|
||||
{
|
||||
McpServerLimit = 60,
|
||||
Accounts = new List<McpUsageMinuteItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId, Principal = "admin@contoso.com",
|
||||
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
|
||||
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageOverviewResponse_omits_what_a_store_did_not_answer()
|
||||
{
|
||||
// Telemetry down, an account known only from the telemetry shape (no object id, no quota), an MCP-only
|
||||
// tool (no reach, no quality), no MCP limit reported yet: every nullable key vanishes.
|
||||
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
|
||||
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 0, MonthlyUsed = 0 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
dateFrom = "20261105",
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = true,
|
||||
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
|
||||
daily = Array.Empty<object>(),
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
quota,
|
||||
calls = 4L,
|
||||
requests = 0L,
|
||||
quotaHits = 0,
|
||||
accounts = new[]
|
||||
{
|
||||
new { principal = "app:11111111-2222-3333-4444-555555555555", isPlatformAdmin = false, calls = 4L, requests = 0L, quotaHits = 0, topTools = Array.Empty<object>() },
|
||||
},
|
||||
},
|
||||
},
|
||||
tools = new[] { new { tool = "search_docs", calls = 4L, requestsPerCall = 0.0, routes = Array.Empty<object>() } },
|
||||
quotaHits = Array.Empty<object>(),
|
||||
minuteLimits = new { accounts = Array.Empty<object>() },
|
||||
},
|
||||
new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = "20261105",
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = true,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
|
||||
Daily = new List<McpUsageDayItem>(),
|
||||
Tenants = new List<McpUsageTenantItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Quota = quota,
|
||||
Calls = 4,
|
||||
Requests = 0,
|
||||
QuotaHits = 0,
|
||||
Accounts = new List<McpUsageAccountItem>
|
||||
{
|
||||
new() { Principal = "app:11111111-2222-3333-4444-555555555555", Calls = 4, TopTools = new List<McpUsageToolCallsItem>() },
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools = new List<McpUsageToolItem> { new() { Tool = "search_docs", Calls = 4, RequestsPerCall = 0, Routes = new List<McpUsageRouteItem>() } },
|
||||
QuotaHits = new List<McpUsageQuotaHitItem>(),
|
||||
MinuteLimits = new McpUsageMinuteLimits { Accounts = new List<McpUsageMinuteItem>() },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGrowthMetrics ---------------------------------------------------------------
|
||||
|
||||
private static GrowthMetricsResponse GrowthResponse(double? median, IReadOnlyList<GrowthTenantRow>? tenants) => new()
|
||||
|
||||
@@ -47,8 +47,6 @@ public class TypedRequestGuardTests
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "agent route (D-200)",
|
||||
// Foreign schema: Azure Monitor common alert schema, not our contract.
|
||||
["Functions/Infrastructure/AzureMonitorAlertWebhookFunction.cs"] = "Azure Monitor alert schema",
|
||||
// Parses the UPSTREAM Kusto response, not the request body.
|
||||
["Functions/Raw/AppInsightsQueryFunction.cs"] = "upstream response parse",
|
||||
// Not request bodies at all: telemetry item payloads (agent wire, D-200) and stored table columns.
|
||||
["Functions/Ingest/TelemetryPayloadParser.cs"] = "telemetry item PayloadJson (agent wire, D-200)",
|
||||
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = "stored backup diff column",
|
||||
|
||||
+30
-14
@@ -251,20 +251,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return records;
|
||||
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildTenantCounterFilter(tenantId, dateFrom, dateTo)))
|
||||
{
|
||||
records.Add(new TenantUsageRecord
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
UserId = entity.GetString("UserId") ?? string.Empty,
|
||||
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
|
||||
});
|
||||
}
|
||||
records.Add(MapTenantRecord(entity));
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<TenantUsageRecord>> GetAllTenantUsageAsync(string dateFrom, string dateTo)
|
||||
{
|
||||
var records = new List<TenantUsageRecord>();
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildDayRangeFilter(dateFrom, dateTo)))
|
||||
records.Add(MapTenantRecord(entity));
|
||||
return records;
|
||||
}
|
||||
|
||||
private static TenantUsageRecord MapTenantRecord(TableEntity entity) => new()
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
UserId = entity.GetString("UserId") ?? string.Empty,
|
||||
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Partition + RowKey range filter for McpTenantUsage: the RowKey starts with the yyyyMMdd day, so a
|
||||
/// date window is a key range ("{to}~" sorts after every "{to}_{oid}"); no property scan.
|
||||
@@ -272,11 +280,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static string BuildTenantCounterFilter(string tenantId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
var days = BuildDayRangeFilter(dateFrom, dateTo);
|
||||
return days.Length == 0 ? filter : $"{filter} and {days}";
|
||||
}
|
||||
|
||||
/// <summary>The RowKey day range of McpTenantUsage without a partition — empty when neither bound is set.</summary>
|
||||
internal static string BuildDayRangeFilter(string? dateFrom, string? dateTo)
|
||||
{
|
||||
var clauses = new List<string>(2);
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
filter += $" and RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
|
||||
clauses.Add($"RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'");
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
filter += $" and RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'";
|
||||
return filter;
|
||||
clauses.Add($"RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'");
|
||||
return string.Join(" and ", clauses);
|
||||
}
|
||||
|
||||
public async Task<int> DeleteTenantRecordsOlderThanAsync(string dateCutoff)
|
||||
|
||||
+62
-39
@@ -22,17 +22,70 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
private readonly IUserUsageRepository _userUsageRepo;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly McpQuotaService _quotaService;
|
||||
private readonly McpUsageOverviewService _overviewService;
|
||||
|
||||
public McpUsageMetricsFunction(
|
||||
ILogger<McpUsageMetricsFunction> logger,
|
||||
IUserUsageRepository userUsageRepo,
|
||||
McpUserService mcpUserService,
|
||||
McpQuotaService quotaService)
|
||||
McpQuotaService quotaService,
|
||||
McpUsageOverviewService overviewService)
|
||||
{
|
||||
_logger = logger;
|
||||
_userUsageRepo = userUsageRepo;
|
||||
_mcpUserService = mcpUserService;
|
||||
_quotaService = quotaService;
|
||||
_overviewService = overviewService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage/overview?days=&platformAdmins= — the all-tenant MCP usage overview of
|
||||
/// the Global Admin metrics page: tenants with accounts and quota state, the tool ranking, quota refusals,
|
||||
/// per-minute peaks and the daily trend (see <see cref="McpUsageOverviewService"/>). <c>days</c> = 1–90 (the
|
||||
/// telemetry keeps 90 days), default 30; <c>platformAdmins=include</c> counts Global Admins and Global Readers,
|
||||
/// left out by default.
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsageOverview")]
|
||||
public async Task<HttpResponseData> GetGlobalUsageOverview(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/overview")] HttpRequestData req)
|
||||
{
|
||||
var query = ParseOverviewQuery(req.Query["days"], req.Query["platformAdmins"]);
|
||||
if (query.Error != null)
|
||||
{
|
||||
return await req.BadRequestAsync(query.Error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var overview = await _overviewService.BuildAsync(query.Days, query.IncludePlatformAdmins);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(overview);
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the overview's query string. <c>days</c> defaults to 30 and must be an integer from 1 to
|
||||
/// <see cref="McpUsageOverviewService.MaxDays"/>; <c>platformAdmins</c> is <c>include</c> or <c>exclude</c>
|
||||
/// (the default). Anything else names the problem in <c>Error</c>.
|
||||
/// </summary>
|
||||
internal static (int Days, bool IncludePlatformAdmins, string? Error) ParseOverviewQuery(string? rawDays, string? platformAdmins)
|
||||
{
|
||||
if (!QueryParams.TryInt(rawDays, "days", 1, McpUsageOverviewService.MaxDays, out var days, out var error))
|
||||
{
|
||||
return (0, false, error);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(platformAdmins) && platformAdmins is not ("include" or "exclude"))
|
||||
{
|
||||
return (0, false, "platformAdmins must be include or exclude");
|
||||
}
|
||||
|
||||
return (days ?? 30, platformAdmins == "include", null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,7 +118,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
// Effective quota state: resolved plan (per-user override → tenant edition),
|
||||
// limits (SectionUsagePlans definition → catalog fallback) and current counters.
|
||||
var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
|
||||
// A read: viewing the page is not an MCP request and must not count as one.
|
||||
var quota = await _quotaService.ReadStateAsync(userId, upn, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetMyMcpUsageResponse
|
||||
@@ -86,11 +140,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
TenantMonthlyLimit = quota.TenantMonthlyLimit,
|
||||
TenantDailyUsed = quota.TenantDailyUsed,
|
||||
TenantMonthlyUsed = quota.TenantMonthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
|
||||
SlotTenantDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
|
||||
SlotTenantMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
|
||||
PurchasedDelegatedSlots = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
|
||||
SlotMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
|
||||
SlotTenantDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
|
||||
SlotTenantMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
@@ -164,7 +218,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
DateFrom = dateFrom,
|
||||
DateTo = dateTo,
|
||||
Users = AggregateOrganizationUsage(records, dateFrom, dateTo, today, monthStart),
|
||||
Quota = BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Quota = McpQuotaNodes.BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Daily = BuildOrganizationDaily(records, dateFrom, dateTo),
|
||||
});
|
||||
return response;
|
||||
@@ -190,37 +244,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over
|
||||
/// every account charged to the tenant, against the tenant plan's limits (0 = unlimited).
|
||||
/// </summary>
|
||||
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
|
||||
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
|
||||
{
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (record.Date == today) dailyUsed += record.RequestCount;
|
||||
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
|
||||
monthlyUsed += record.RequestCount;
|
||||
}
|
||||
|
||||
return new McpOrganizationQuotaNode
|
||||
{
|
||||
TenantPlan = limits.TenantPlan,
|
||||
DailyLimit = limits.TenantDailyLimit,
|
||||
MonthlyLimit = limits.TenantMonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(limits.PurchasedDelegatedSlots, limits.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantMonthlyLimit),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The slot breakdown is only worth sending when a slot was actually purchased.</summary>
|
||||
private static int? WithSlots(int purchasedSlots, int value) => purchasedSlots > 0 ? value : null;
|
||||
|
||||
/// <summary>
|
||||
/// Pure: folds the organization counter rows into one item per account, ordered by this month's usage.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,7 +21,10 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
/// </summary>
|
||||
public class AccessProbeFunction
|
||||
{
|
||||
[Function("GlobalAccessProbe")]
|
||||
/// <summary>Its request rows carry a tool name for a call that never ran; usage counts leave them out by it.</summary>
|
||||
internal const string FunctionName = "GlobalAccessProbe";
|
||||
|
||||
[Function(FunctionName)]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/access-probe")] HttpRequestData req,
|
||||
FunctionContext context)
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
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 AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure.Core;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -43,19 +37,16 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
|
||||
private readonly ILogger<AppInsightsQueryFunction> _logger;
|
||||
private readonly LogQuerySourceCatalog _sources;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly LogQueryExecutor _executor;
|
||||
|
||||
public AppInsightsQueryFunction(
|
||||
ILogger<AppInsightsQueryFunction> logger,
|
||||
LogQuerySourceCatalog sources,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
TokenCredential credential)
|
||||
LogQueryExecutor executor)
|
||||
{
|
||||
_logger = logger;
|
||||
_sources = sources;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credential = credential;
|
||||
_executor = executor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,63 +79,49 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
|
||||
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 outcome = await _executor.ExecuteAsync(source!, body.Query, timespan, 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)
|
||||
if (!outcome.Succeeded)
|
||||
{
|
||||
var upstreamStatus = outcome.UpstreamStatus ?? HttpStatusCode.BadGateway;
|
||||
var responseText = outcome.UpstreamBody ?? string.Empty;
|
||||
_logger.LogWarning("Log query ({Source}) failed: {StatusCode} {Response}",
|
||||
source.Name, upstreamResponse.StatusCode, LogSanitizer.Clean(Cap(responseText, 2048)));
|
||||
source!.Name, upstreamStatus, LogSanitizer.Clean(Cap(responseText, 2048)));
|
||||
|
||||
var (errorMessage, errorCode) = ExtractError(responseText);
|
||||
var (errorMessage, errorCode) = LogQueryExecutor.ExtractError(responseText);
|
||||
// A query the store rejected is the CALLER's error (400): the MCP renders 4xx bodies in
|
||||
// full (message, hint, upstream) but sanitizes every 5xx down to the error code — and a
|
||||
// KQL syntax error hidden behind "retry later" is exactly the parity loss this proxy
|
||||
// exists to avoid. Store-side failures (5xx) and a missing read grant (401/403 — the
|
||||
// managed identity, not the caller) stay 502.
|
||||
var status = MapUpstreamFailure(upstreamResponse.StatusCode);
|
||||
var status = MapUpstreamFailure(upstreamStatus);
|
||||
return await req.ErrorAsync(status, new QueryBackendLogsErrorResponse
|
||||
{
|
||||
Error = errorMessage ?? "The telemetry store rejected the query.",
|
||||
Code = ApiErrorWriter.DefaultCode(status),
|
||||
UpstreamCode = errorCode,
|
||||
StatusCode = (int)upstreamResponse.StatusCode,
|
||||
StatusCode = (int)upstreamStatus,
|
||||
Source = source.Name,
|
||||
Hint = UpstreamHint(upstreamResponse.StatusCode, errorCode, source.Name),
|
||||
Hint = UpstreamHint(upstreamStatus, errorCode, source.Name),
|
||||
// Everything the store said, so nothing the CLI would print is lost.
|
||||
Upstream = Cap(responseText, UpstreamErrorCap),
|
||||
});
|
||||
}
|
||||
|
||||
var (tables, partialReason) = ParseKustoBody(responseText);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new QueryBackendLogsResponse
|
||||
{
|
||||
Success = true,
|
||||
Source = source.Name,
|
||||
Source = source!.Name,
|
||||
Timespan = timespan,
|
||||
BudgetSeconds = budget,
|
||||
ElapsedMs = stopwatch.ElapsedMilliseconds,
|
||||
Partial = partialReason != null ? true : null,
|
||||
PartialReason = partialReason,
|
||||
Tables = tables,
|
||||
ElapsedMs = outcome.ElapsedMs,
|
||||
Partial = outcome.PartialReason != null ? true : null,
|
||||
PartialReason = outcome.PartialReason,
|
||||
Tables = outcome.Tables!,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -196,110 +173,6 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
return Math.Clamp(requested.Value, MinBudgetSeconds, MaxBudgetSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kusto REST success body → typed tables plus the store's partial-result note. A 200 can carry
|
||||
/// <c>"error": { "code": "PartialError", ... }</c> next to <c>tables</c> 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.
|
||||
/// </summary>
|
||||
internal static (IReadOnlyList<KqlTable> Tables, string? PartialReason) ParseKustoBody(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var tables = new List<KqlTable>();
|
||||
|
||||
if (root.TryGetProperty("tables", out var tablesEl) && tablesEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in tablesEl.EnumerateArray())
|
||||
{
|
||||
var columns = new List<KqlColumn>();
|
||||
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<IReadOnlyList<JsonElement>>();
|
||||
if (t.TryGetProperty("rows", out var rowsEl) && rowsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in rowsEl.EnumerateArray())
|
||||
{
|
||||
var cells = new List<JsonElement>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message + code from a Kusto error body — <c>innererror</c> first (the specific reason), then the
|
||||
/// outer <c>error</c>. Tolerates non-JSON.
|
||||
/// </summary>
|
||||
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] + "…";
|
||||
/// <summary>Kusto REST request body — both App Insights and Log Analytics accept exactly this.</summary>
|
||||
private sealed record KustoQuery(
|
||||
[property: JsonPropertyName("query")] string Query,
|
||||
[property: JsonPropertyName("timespan")] string Timespan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Names of the request-row dimensions <c>RequestTelemetryMiddleware</c> writes on every API call that the
|
||||
/// backend itself reads back with KQL (the MCP usage overview). Writer and reader use these constants, so a
|
||||
/// rename cannot silently empty a query.
|
||||
/// </summary>
|
||||
internal static class RequestRowDimensions
|
||||
{
|
||||
public const string Source = "Source";
|
||||
/// <summary>The value of <see cref="Source"/> on the worker's canonical request row.</summary>
|
||||
public const string WorkerMiddleware = "WorkerMiddleware";
|
||||
public const string FunctionName = "FunctionName";
|
||||
public const string HttpMethod = "HttpMethod";
|
||||
public const string HttpPath = "HttpPath";
|
||||
public const string ClientSource = "ClientSource";
|
||||
public const string McpToolName = "McpToolName";
|
||||
public const string CorrelationId = "CorrelationId";
|
||||
public const string TenantId = "TenantId";
|
||||
/// <summary>The caller's UPN, or <c>app:<client-id></c> for an application principal.</summary>
|
||||
public const string UserId = "UserId";
|
||||
public const string UserRole = "UserRole";
|
||||
}
|
||||
+48
-13
@@ -35,33 +35,44 @@ namespace AutopilotMonitor.Functions.Middleware;
|
||||
/// request can never be blocked by its OWN in-flight increment. The increment stays
|
||||
/// fire-and-forget (never blocks the request path).
|
||||
///
|
||||
/// <b>The quota boundary is deliberately SOFT, not exact.</b> McpQuotaService caches the usage
|
||||
/// snapshots for 60 seconds (per instance; per user and per tenant), so an ALLOWED decision keeps
|
||||
/// admitting requests inside that window even after the async increments push the stored counters past
|
||||
/// the limit. Worst-case overshoot is bounded: ~60s × the request rate against that counter (× instance
|
||||
/// count on scaled-out Flex Consumption) — the same deliberate posture as the sliding-window
|
||||
/// rate limiter, trading exactness for one counter read per user / per tenant per minute instead of per
|
||||
/// request. Pinned by McpQuotaServiceTests soft-boundary tests; do NOT re-document this as an
|
||||
/// exact limit without reworking the snapshot cache.
|
||||
/// <b>The quota boundary is exact per instance, soft across instances.</b> McpQuotaService reads the
|
||||
/// counters from storage at most every 60 seconds per account and per organization and counts every request
|
||||
/// it admits on them at once, so one instance blocks at the limit. Other instances learn of those requests only
|
||||
/// with their next read, so on scaled-out Flex Consumption the overshoot is what the other instances admitted
|
||||
/// inside 60 seconds. Pinned by the McpQuotaServiceTests boundary tests; the customer docs never call the limit exact.
|
||||
///
|
||||
/// Over-quota requests get 429 with a structured body, <c>Retry-After</c>, and
|
||||
/// <c>X-MCP-Quota-*</c> headers; allowed MCP requests get the quota headers too so the MCP
|
||||
/// client can surface remaining budget. Fail-open on storage/counter errors (handled inside
|
||||
/// <see cref="McpQuotaService"/>), fail-closed on plan resolution (unknown plan → Community).
|
||||
/// client can surface remaining budget. The first refusal per account or organization, window and period
|
||||
/// on an instance raises the ops event <c>McpQuotaExceeded</c>; every refusal writes the Warning line.
|
||||
/// Fail-open on storage/counter errors (handled inside <see cref="McpQuotaService"/>), fail-closed on plan
|
||||
/// resolution (unknown plan → Community).
|
||||
/// </summary>
|
||||
public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
/// <summary>Start of the Warning line every refused request writes; the MCP usage overview finds the refusals by it.</summary>
|
||||
internal const string BlockedLogPrefix = "[McpQuota] BLOCKED";
|
||||
|
||||
private const string BlockedLogTemplate = BlockedLogPrefix
|
||||
+ " oid={Oid} charged={ChargedTenant} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}";
|
||||
|
||||
// The key carries the period (see ExceededAlertKey), so the interval only has to outlast the longest one.
|
||||
private static readonly LogThrottle ExceededAlerts = new(TimeSpan.FromDays(31));
|
||||
|
||||
private readonly McpQuotaService _quotaService;
|
||||
private readonly IUserUsageRepository _userUsageRepo;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
private readonly ILogger<McpQuotaEnforcementMiddleware> _logger;
|
||||
|
||||
public McpQuotaEnforcementMiddleware(
|
||||
McpQuotaService quotaService,
|
||||
IUserUsageRepository userUsageRepo,
|
||||
OpsEventService opsEvents,
|
||||
ILogger<McpQuotaEnforcementMiddleware> logger)
|
||||
{
|
||||
_quotaService = quotaService;
|
||||
_userUsageRepo = userUsageRepo;
|
||||
_opsEvents = opsEvents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -104,7 +115,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
McpQuotaDecision decision;
|
||||
try
|
||||
{
|
||||
decision = await _quotaService.CheckAsync(oid, upn, homeTenantId);
|
||||
decision = await _quotaService.AdmitAsync(oid, upn, homeTenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -119,16 +130,40 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
if (decision.Allowed)
|
||||
{
|
||||
// Check-then-increment: the decision above reflects previously SERVED requests only.
|
||||
// The check already counted this request on the instance's counters; storage gets it here.
|
||||
TrackUsage(httpContext, oid, upn, homeTenantId);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
LogBlocked(oid, homeTenantId, decision);
|
||||
RaiseExceeded(oid, upn, homeTenantId, decision);
|
||||
await WriteExceededAsync(context, httpContext, decision, BuildExceededResponse(decision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: one key per account (level user) or organization (level tenant), window and period. The period is
|
||||
/// the window's reset date, so the next day or month starts a fresh key.
|
||||
/// </summary>
|
||||
internal static string ExceededAlertKey(string oid, string tenantId, McpQuotaDecision decision)
|
||||
{
|
||||
var subject = decision.Level == McpQuotaLevel.Tenant ? tenantId.ToLowerInvariant() : oid;
|
||||
return $"{decision.Level}:{decision.Scope}:{subject}:{decision.ResetUtc:yyyyMMdd}";
|
||||
}
|
||||
|
||||
/// <summary>The ops event for the first refusal of its key on this instance — fire-and-forget, never on the 429's path.</summary>
|
||||
private void RaiseExceeded(string oid, string? upn, string tenantId, McpQuotaDecision decision)
|
||||
{
|
||||
if (!ExceededAlerts.ShouldLog(ExceededAlertKey(oid, tenantId, decision)))
|
||||
return;
|
||||
|
||||
var level = decision.Level ?? McpQuotaLevel.User;
|
||||
_ = _opsEvents.RecordMcpQuotaExceededAsync(
|
||||
tenantId, upn ?? oid, level, decision.Scope ?? "daily",
|
||||
level == McpQuotaLevel.Tenant ? decision.TenantPlan : decision.Plan,
|
||||
decision.ExceededLimit, decision.ExceededUsed, decision.ResetUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: who bypasses the check. Only the Global Admin — platform operations must never be blocked by a
|
||||
/// budget. The Global Reader is a customer-facing read role and stays within its home tenant's windows.
|
||||
@@ -182,7 +217,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
private void LogBlocked(string oid, string tenantId, McpQuotaDecision decision)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[McpQuota] BLOCKED oid={Oid} charged={ChargedTenant} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}",
|
||||
BlockedLogTemplate,
|
||||
oid, LogSanitizer.Clean(tenantId), decision.Level, decision.Scope, decision.Plan, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit,
|
||||
decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit);
|
||||
}
|
||||
|
||||
@@ -136,18 +136,18 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
// See memory project_ingest_request_multiplication_429 for the full diagnosis.
|
||||
|
||||
// Business context from downstream middleware
|
||||
requestTelemetry.Properties["Source"] = "WorkerMiddleware";
|
||||
requestTelemetry.Properties["FunctionName"] = functionName;
|
||||
requestTelemetry.Properties["HttpMethod"] = method;
|
||||
requestTelemetry.Properties["HttpPath"] = httpContext.Request.Path.Value ?? "";
|
||||
requestTelemetry.Properties[RequestRowDimensions.Source] = RequestRowDimensions.WorkerMiddleware;
|
||||
requestTelemetry.Properties[RequestRowDimensions.FunctionName] = functionName;
|
||||
requestTelemetry.Properties[RequestRowDimensions.HttpMethod] = method;
|
||||
requestTelemetry.Properties[RequestRowDimensions.HttpPath] = httpContext.Request.Path.Value ?? "";
|
||||
|
||||
var clientSource = httpContext.Request.Headers["X-Client-Source"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(clientSource))
|
||||
requestTelemetry.Properties["ClientSource"] = clientSource;
|
||||
requestTelemetry.Properties[RequestRowDimensions.ClientSource] = clientSource;
|
||||
|
||||
var mcpToolName = httpContext.Request.Headers["X-MCP-Tool-Name"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(mcpToolName))
|
||||
requestTelemetry.Properties["McpToolName"] = mcpToolName;
|
||||
requestTelemetry.Properties[RequestRowDimensions.McpToolName] = mcpToolName;
|
||||
|
||||
// APPLICATION PRINCIPALS — the request-row dimension that lets KQL separate automation
|
||||
// (app-only tokens) from people: "app" plus the calling client id, nothing for a person.
|
||||
@@ -167,7 +167,7 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
requestTelemetry.Properties["ClientAppId"] = clientAppId;
|
||||
|
||||
if (context.Items.TryGetValue("CorrelationId", out var corrId) && corrId is string correlationId)
|
||||
requestTelemetry.Properties["CorrelationId"] = correlationId;
|
||||
requestTelemetry.Properties[RequestRowDimensions.CorrelationId] = correlationId;
|
||||
|
||||
// THROTTLE SURFACE — set by UserRateLimitMiddleware from the signed client-auth claim
|
||||
// (portal = public client, integration = confidential client / app-only). Next to the
|
||||
@@ -217,11 +217,11 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
httpContext.Request.Path.Value ?? string.Empty,
|
||||
httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault());
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
requestTelemetry.Properties["TenantId"] = tenantId;
|
||||
requestTelemetry.Properties[RequestRowDimensions.TenantId] = tenantId;
|
||||
if (!string.IsNullOrEmpty(reqCtx.UserPrincipalName))
|
||||
requestTelemetry.Properties["UserId"] = reqCtx.UserPrincipalName;
|
||||
requestTelemetry.Properties[RequestRowDimensions.UserId] = reqCtx.UserPrincipalName;
|
||||
if (!string.IsNullOrEmpty(reqCtx.UserRole))
|
||||
requestTelemetry.Properties["UserRole"] = reqCtx.UserRole;
|
||||
requestTelemetry.Properties[RequestRowDimensions.UserRole] = reqCtx.UserRole;
|
||||
|
||||
if (caughtException != null)
|
||||
requestTelemetry.Properties["ExceptionType"] = caughtException.GetType().Name;
|
||||
|
||||
@@ -30,6 +30,12 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
/// </summary>
|
||||
public const string ThrottleSurfaceItemKey = "ThrottleSurface";
|
||||
|
||||
/// <summary>Start of the Warning line every refused request writes; the MCP usage overview finds the refusals by it.</summary>
|
||||
internal const string ThrottledLogPrefix = "[UserRateLimit] THROTTLED";
|
||||
|
||||
private const string ThrottledLogTemplate = ThrottledLogPrefix
|
||||
+ " caller={Caller} surface={Surface} requests={Count}/{Max} retryAfter={RetryAfter}s";
|
||||
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
@@ -163,7 +169,7 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
: 60;
|
||||
|
||||
_logger.LogWarning(
|
||||
"[UserRateLimit] THROTTLED caller={Caller} surface={Surface} requests={Count}/{Max} retryAfter={RetryAfter}s",
|
||||
ThrottledLogTemplate,
|
||||
requestContext.CallerId, surface.ToDimension(), result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
|
||||
|
||||
// Envelope with the retry window; the counters ride in the X-RateLimit-* headers above.
|
||||
|
||||
@@ -250,6 +250,7 @@ builder.Services.AddSingleton<AdminConfigurationService>();
|
||||
builder.Services.AddSingleton<AppHomingService>();
|
||||
builder.Services.AddSingleton<TenantEntitlementService>();
|
||||
builder.Services.AddSingleton<McpQuotaService>();
|
||||
builder.Services.AddSingleton<McpUsageOverviewService>();
|
||||
builder.Services.AddSingleton<ILatestVersionsService, LatestVersionsService>();
|
||||
builder.Services.AddSingleton<RateLimitService>();
|
||||
builder.Services.AddSingleton<DistressRateLimitService>();
|
||||
@@ -316,11 +317,12 @@ builder.Services.AddSingleton<OpsAlertDispatchService>();
|
||||
builder.Services.AddSingleton<OpsEventService>();
|
||||
// Assume-breach observer on the policy middleware's deny path (GlobalAdminOnly 403 → Critical ops event).
|
||||
builder.Services.AddSingleton<IPrivilegedDenialReporter, PrivilegedDenialReporter>();
|
||||
// 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.
|
||||
// Operator KQL proxy (global/raw/logs) and the fixed queries of the MCP usage overview: the three telemetry
|
||||
// stores, the managed-identity credential as an injectable seam, one executor for both, and a client WITHOUT
|
||||
// its own timeout — the per-request budget (CancellationTokenSource + Prefer: wait) is the only clock.
|
||||
builder.Services.AddSingleton<LogQuerySourceCatalog>();
|
||||
builder.Services.AddSingleton<Azure.Core.TokenCredential>(_ => new Azure.Identity.DefaultAzureCredential());
|
||||
builder.Services.AddSingleton<LogQueryExecutor>();
|
||||
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.
|
||||
|
||||
@@ -581,6 +581,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/mcp-usage/overview", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
|
||||
// The operator's report queue: GA-only AdminNote, the submitter's comment and contact data. ?tenantId= is a
|
||||
// filter the handler reads itself; None, so no delegated reader reaches it.
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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.Shared.Models;
|
||||
using Azure.Core;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// What one Kusto query against a telemetry store returned: the tables (plus the store's partial-result
|
||||
/// note) on success, or the store's status code and body when it answered non-2xx.
|
||||
/// </summary>
|
||||
public sealed class LogQueryOutcome
|
||||
{
|
||||
public IReadOnlyList<KqlTable>? Tables { get; init; }
|
||||
public string? PartialReason { get; init; }
|
||||
public HttpStatusCode? UpstreamStatus { get; init; }
|
||||
public string? UpstreamBody { get; init; }
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
public bool Succeeded => Tables != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one Kusto query against a store from <see cref="LogQuerySourceCatalog"/> under the Function App's
|
||||
/// managed identity and a wall-clock budget (<c>Prefer: wait</c> upstream, a cancellation token here, no
|
||||
/// static client timeout). Shared by the GA-only KQL proxy (<c>QueryBackendLogs</c>), which forwards an
|
||||
/// operator's query verbatim, and by the fixed aggregate queries of the MCP usage overview. Throws
|
||||
/// <see cref="OperationCanceledException"/> when the budget runs out and lets credential errors through;
|
||||
/// each caller decides how to answer.
|
||||
/// </summary>
|
||||
public class LogQueryExecutor
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly TokenCredential _credential;
|
||||
|
||||
public LogQueryExecutor(IHttpClientFactory httpClientFactory, TokenCredential credential)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credential = credential;
|
||||
}
|
||||
|
||||
public virtual async Task<LogQueryOutcome> ExecuteAsync(LogQuerySource source, string query, string timespan, int budgetSeconds)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds));
|
||||
|
||||
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={budgetSeconds}");
|
||||
upstreamRequest.Content = new StringContent(
|
||||
JsonSerializer.Serialize(new KustoQuery(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)
|
||||
{
|
||||
return new LogQueryOutcome
|
||||
{
|
||||
UpstreamStatus = upstreamResponse.StatusCode,
|
||||
UpstreamBody = responseText,
|
||||
ElapsedMs = stopwatch.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
var (tables, partialReason) = ParseKustoBody(responseText);
|
||||
return new LogQueryOutcome { Tables = tables, PartialReason = partialReason, ElapsedMs = stopwatch.ElapsedMilliseconds };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kusto REST success body → typed tables plus the store's partial-result note. A 200 can carry
|
||||
/// <c>"error": { "code": "PartialError", ... }</c> next to <c>tables</c> 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.
|
||||
/// </summary>
|
||||
internal static (IReadOnlyList<KqlTable> Tables, string? PartialReason) ParseKustoBody(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var tables = new List<KqlTable>();
|
||||
|
||||
if (root.TryGetProperty("tables", out var tablesEl) && tablesEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in tablesEl.EnumerateArray())
|
||||
{
|
||||
var columns = new List<KqlColumn>();
|
||||
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<IReadOnlyList<JsonElement>>();
|
||||
if (t.TryGetProperty("rows", out var rowsEl) && rowsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in rowsEl.EnumerateArray())
|
||||
{
|
||||
var cells = new List<JsonElement>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message + code from a Kusto error body — <c>innererror</c> first (the specific reason), then the
|
||||
/// outer <c>error</c>. Tolerates non-JSON.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Kusto REST request body — both App Insights and Log Analytics accept exactly this.</summary>
|
||||
private sealed record KustoQuery(
|
||||
[property: JsonPropertyName("query")] string Query,
|
||||
[property: JsonPropertyName("timespan")] string Timespan);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The quota wire nodes the MCP usage views share (the tenant's reporting page, the GA overview), built from the
|
||||
/// quota counters and resolved plan limits — one place, so both views show the same windows the same way.
|
||||
/// </summary>
|
||||
internal static class McpQuotaNodes
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure: the tenant's organization windows from its counter rows — today's and this month's total over every
|
||||
/// account charged to the tenant, against the tenant plan's limits (0 = unlimited).
|
||||
/// </summary>
|
||||
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
|
||||
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
|
||||
{
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (record.Date == today) dailyUsed += record.RequestCount;
|
||||
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
|
||||
monthlyUsed += record.RequestCount;
|
||||
}
|
||||
|
||||
return new McpOrganizationQuotaNode
|
||||
{
|
||||
TenantPlan = limits.TenantPlan,
|
||||
DailyLimit = limits.TenantDailyLimit,
|
||||
MonthlyLimit = limits.TenantMonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(limits.PurchasedDelegatedSlots, limits.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantMonthlyLimit),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The slot breakdown is only worth sending when a slot was actually purchased.</summary>
|
||||
internal static int? WithSlots(int purchasedSlots, int value) => purchasedSlots > 0 ? value : null;
|
||||
}
|
||||
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Caching;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
@@ -38,23 +38,28 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Counters: user counters ride on the UserUsageLog table (PK = oid), tenant counters on the
|
||||
/// McpTenantUsage table (PK = home tenantId, RK = {yyyyMMdd}_{oid} — one partition read per check, no
|
||||
/// hot row); both are written fire-and-forget by McpQuotaEnforcementMiddleware for X-Client-Source: mcp
|
||||
/// requests. Daily = today's rows, monthly = the sum over the month. The usage SNAPSHOTS are cached for
|
||||
/// 60 seconds — per user (oid) and per tenant — so the worst-case overshoot is bounded
|
||||
/// (limit + 60s × request-rate) — the same posture as the sliding-window rate limiter. Limits are
|
||||
/// re-resolved per check from services that carry their own caches, so a plan change applies at once.
|
||||
/// requests. Daily = today's rows, monthly = the sum over the month. Each instance reads an account's and an
|
||||
/// organization's counters from storage at most every 60 seconds and counts every request it admits on
|
||||
/// them at once (<see cref="AdmitAsync"/>), so one instance blocks exactly at the limit. Across scaled-out
|
||||
/// instances the boundary stays soft by what the OTHER instances admitted inside those 60 seconds, plus
|
||||
/// increments still on their way to storage. Limits are re-resolved per check from services that carry their
|
||||
/// own caches, so a plan change applies at once.
|
||||
/// </summary>
|
||||
public class McpQuotaService
|
||||
{
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
|
||||
|
||||
internal static string UserCacheKey(string oid) => $"mcp-quota:user:{oid}";
|
||||
internal static string TenantCacheKey(string tenantId) => $"mcp-quota:tenant:{tenantId.ToLowerInvariant()}";
|
||||
// The UTC day is part of the key: a counter read before midnight never answers a check after it.
|
||||
private static string UserCacheKey(string oid, string day) => $"mcp-quota:user:{oid}:{day}";
|
||||
private static string TenantCacheKey(string tenantId, string day) => $"mcp-quota:tenant:{tenantId.ToLowerInvariant()}:{day}";
|
||||
|
||||
private readonly IUserUsageRepository _usageRepo;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantEntitlementService _entitlementService;
|
||||
private readonly IMemoryCache _cache;
|
||||
// Single-flight: concurrent first checks of one account share ONE counter instead of each counting on a
|
||||
// private copy that the last cache write would discard.
|
||||
private readonly SingleFlightCache<UsageCounter> _counters;
|
||||
private readonly ILogger<McpQuotaService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
@@ -63,19 +68,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
McpUserService mcpUserService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantEntitlementService entitlementService,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpQuotaService> logger)
|
||||
: this(usageRepo, mcpUserService, adminConfigService, entitlementService, cache, logger, TimeProvider.System)
|
||||
: this(usageRepo, mcpUserService, adminConfigService, entitlementService, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math.</summary>
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math and counter expiry.</summary>
|
||||
public McpQuotaService(
|
||||
IUserUsageRepository usageRepo,
|
||||
McpUserService mcpUserService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantEntitlementService entitlementService,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpQuotaService> logger,
|
||||
TimeProvider time)
|
||||
{
|
||||
@@ -83,19 +86,32 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_mcpUserService = mcpUserService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_entitlementService = entitlementService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
_counters = new SingleFlightCache<UsageCounter>(() => time.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller's effective plans + limits and checks the current user AND tenant usage against
|
||||
/// them. <paramref name="tenantId"/> is the token's tid — the identity the per-user override is bound to
|
||||
/// and the tenant whose organization budget every request of this caller draws on, a delegated read
|
||||
/// included. Fail-open on counter/storage errors (a broken quota check must not take down MCP);
|
||||
/// fail-closed on plan resolution (unknown plan → Community).
|
||||
/// The enforcement step for ONE MCP request the caller is about to serve: resolves the caller's effective
|
||||
/// plans + limits, checks the current user AND tenant usage against them and, when allowed, counts the
|
||||
/// request on this instance's counters at once (the middleware also writes it to storage). Only
|
||||
/// McpQuotaEnforcementMiddleware calls this; a view of the quota uses <see cref="ReadStateAsync"/>.
|
||||
/// <paramref name="tenantId"/> is the token's tid — the identity the per-user override is bound to and the
|
||||
/// tenant whose organization budget every request of this caller draws on, a delegated read included. The
|
||||
/// decision's used values are the ones BEFORE this request. Fail-open on counter/storage errors (a broken
|
||||
/// quota check must not take down MCP); fail-closed on plan resolution (unknown plan → Community).
|
||||
/// </summary>
|
||||
public virtual async Task<McpQuotaDecision> CheckAsync(string oid, string? upn, string? tenantId)
|
||||
public virtual Task<McpQuotaDecision> AdmitAsync(string oid, string? upn, string? tenantId)
|
||||
=> EvaluateAsync(oid, upn, tenantId, count: true);
|
||||
|
||||
/// <summary>
|
||||
/// The same decision as <see cref="AdmitAsync"/> for a page that SHOWS the quota: what the caller's next
|
||||
/// request would meet, without counting anything.
|
||||
/// </summary>
|
||||
public virtual Task<McpQuotaDecision> ReadStateAsync(string oid, string? upn, string? tenantId)
|
||||
=> EvaluateAsync(oid, upn, tenantId, count: false);
|
||||
|
||||
private async Task<McpQuotaDecision> EvaluateAsync(string oid, string? upn, string? tenantId, bool count)
|
||||
{
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var limits = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId);
|
||||
@@ -108,7 +124,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (tenant == null)
|
||||
return McpQuotaDecision.FailOpen(limits);
|
||||
|
||||
return BuildDecision(limits, user.DailyUsed, user.MonthlyUsed, tenant.DailyUsed, tenant.MonthlyUsed, nowUtc);
|
||||
return Decide(limits, user, tenant, nowUtc, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision and count in one step, so parallel requests of one caller (a tool call fans out several) can
|
||||
/// never all pass on the same reading. Every check locks the account's counter before the organization's,
|
||||
/// so two members of one tenant never wait on each other in a cycle.
|
||||
/// </summary>
|
||||
private static McpQuotaDecision Decide(McpPlanLimits limits, UsageCounter user, UsageCounter tenant, DateTime nowUtc, bool count)
|
||||
{
|
||||
lock (user)
|
||||
lock (tenant)
|
||||
{
|
||||
var decision = BuildDecision(limits, user.DailyUsed, user.MonthlyUsed, tenant.DailyUsed, tenant.MonthlyUsed, nowUtc);
|
||||
if (count && decision.Allowed)
|
||||
{
|
||||
user.Count();
|
||||
tenant.Count();
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -222,54 +258,49 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The caller's own counters (cached snapshot). Null = read failed (fail-open, nothing cached).</summary>
|
||||
private async Task<UsageSnapshot?> ReadUserUsageAsync(string oid, DateTime nowUtc)
|
||||
/// <summary>The caller's own counter (cached, single-flight). Null = read failed (fail-open, nothing cached).</summary>
|
||||
private async Task<UsageCounter?> ReadUserUsageAsync(string oid, DateTime nowUtc)
|
||||
{
|
||||
var cacheKey = UserCacheKey(oid);
|
||||
if (_cache.TryGetValue<UsageSnapshot>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
try
|
||||
{
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today);
|
||||
var snapshot = new UsageSnapshot(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
_cache.Set(cacheKey, snapshot, CacheDuration);
|
||||
return snapshot;
|
||||
return await _counters.GetOrAddAsync(UserCacheKey(oid, today), CacheDuration, async () =>
|
||||
{
|
||||
var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today);
|
||||
return new UsageCounter(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-open: usage counters unavailable → allow. Do NOT cache — the next request retries the read.
|
||||
// Fail-open: usage counters unavailable → allow. The failed read is not cached — the next request retries it.
|
||||
_logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tenant's organization counters (cached snapshot, shared by every member of that tenant).
|
||||
/// Skipped — zero counters, no read, nothing cached — when nothing could ever block on it (no tenant,
|
||||
/// or both windows lifted). Null = read failed (fail-open, nothing cached).
|
||||
/// The tenant's organization counter (cached, single-flight, shared by every member of that tenant on this
|
||||
/// instance). Skipped — no read, nothing cached — when nothing could ever block on it (no tenant, or both
|
||||
/// windows lifted): the check then gets a zero counter of its own, never a shared one, because
|
||||
/// <see cref="Decide"/> raises whatever it is handed. Null = read failed (fail-open, nothing cached).
|
||||
/// </summary>
|
||||
private async Task<UsageSnapshot?> ReadTenantUsageAsync(string? tenantId, McpPlanLimits limits, DateTime nowUtc)
|
||||
private async Task<UsageCounter?> ReadTenantUsageAsync(string? tenantId, McpPlanLimits limits, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || (limits.TenantDailyLimit <= 0 && limits.TenantMonthlyLimit <= 0))
|
||||
return UsageSnapshot.Zero;
|
||||
|
||||
var cacheKey = TenantCacheKey(tenantId);
|
||||
if (_cache.TryGetValue<UsageSnapshot>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
return new UsageCounter(0, 0);
|
||||
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
try
|
||||
{
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
var records = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today);
|
||||
var snapshot = new UsageSnapshot(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
_cache.Set(cacheKey, snapshot, CacheDuration);
|
||||
return snapshot;
|
||||
return await _counters.GetOrAddAsync(TenantCacheKey(tenantId, today), CacheDuration, async () =>
|
||||
{
|
||||
var records = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today);
|
||||
return new UsageCounter(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -334,10 +365,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private static bool Exceeded(int limit, long used) => limit > 0 && used >= limit;
|
||||
|
||||
/// <summary>Cached counter pair (today / this month) for one user or one tenant.</summary>
|
||||
private sealed record UsageSnapshot(long DailyUsed, long MonthlyUsed)
|
||||
/// <summary>
|
||||
/// Today / this month for one account or one organization as this instance knows it: read from storage at
|
||||
/// most every <see cref="CacheDuration"/>, raised by every request this instance admits in between. Only
|
||||
/// <see cref="Decide"/> touches it, under its monitor.
|
||||
/// </summary>
|
||||
private sealed class UsageCounter
|
||||
{
|
||||
public static readonly UsageSnapshot Zero = new(0, 0);
|
||||
public UsageCounter(long dailyUsed, long monthlyUsed)
|
||||
{
|
||||
DailyUsed = dailyUsed;
|
||||
MonthlyUsed = monthlyUsed;
|
||||
}
|
||||
|
||||
public long DailyUsed { get; private set; }
|
||||
public long MonthlyUsed { get; private set; }
|
||||
|
||||
public void Count()
|
||||
{
|
||||
DailyUsed++;
|
||||
MonthlyUsed++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The home tenant's plan facts a resolution needs, read once per check.</summary>
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the all-tenant MCP usage overview of the Global Admin metrics page (D-416). Two kinds of source, never
|
||||
/// mixed inside one number: the quota counters (<c>McpTenantUsage</c> for tenants, accounts and days; each
|
||||
/// account's quota state through <see cref="McpQuotaService.ReadStateAsync"/>, exactly what its next request
|
||||
/// meets) for every "request" and every window, and the telemetry stores (<see cref="McpUsageTelemetryQueries"/>)
|
||||
/// for calls, tools, refusals and per-minute peaks. A store that does not answer empties only its own parts.
|
||||
/// </summary>
|
||||
public class McpUsageOverviewService
|
||||
{
|
||||
internal const int MaxDays = 90;
|
||||
private const int TelemetryBudgetSeconds = 60;
|
||||
private const int AccountReadParallelism = 8;
|
||||
|
||||
private readonly IUserUsageRepository _usageRepo;
|
||||
private readonly McpQuotaService _quota;
|
||||
private readonly GlobalAdminService _globalAdmins;
|
||||
private readonly AdminConfigurationService _adminConfig;
|
||||
private readonly TenantConfigurationService _tenantConfig;
|
||||
private readonly TenantEntitlementService _entitlements;
|
||||
private readonly LogQuerySourceCatalog _sources;
|
||||
private readonly LogQueryExecutor _executor;
|
||||
private readonly ILogger<McpUsageOverviewService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public McpUsageOverviewService(
|
||||
IUserUsageRepository usageRepo, McpQuotaService quota, GlobalAdminService globalAdmins,
|
||||
AdminConfigurationService adminConfig, TenantConfigurationService tenantConfig, TenantEntitlementService entitlements,
|
||||
LogQuerySourceCatalog sources, LogQueryExecutor executor, ILogger<McpUsageOverviewService> logger)
|
||||
: this(usageRepo, quota, globalAdmins, adminConfig, tenantConfig, entitlements, sources, executor, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
internal McpUsageOverviewService(
|
||||
IUserUsageRepository usageRepo, McpQuotaService quota, GlobalAdminService globalAdmins,
|
||||
AdminConfigurationService adminConfig, TenantConfigurationService tenantConfig, TenantEntitlementService entitlements,
|
||||
LogQuerySourceCatalog sources, LogQueryExecutor executor, ILogger<McpUsageOverviewService> logger, TimeProvider time)
|
||||
{
|
||||
_usageRepo = usageRepo;
|
||||
_quota = quota;
|
||||
_globalAdmins = globalAdmins;
|
||||
_adminConfig = adminConfig;
|
||||
_tenantConfig = tenantConfig;
|
||||
_entitlements = entitlements;
|
||||
_sources = sources;
|
||||
_executor = executor;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async Task<GetGlobalMcpUsageOverviewResponse> BuildAsync(int days, bool includePlatformAdmins)
|
||||
{
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var windowDays = Math.Clamp(days, 1, MaxDays);
|
||||
var window = new Window(nowUtc.Date.AddDays(1 - windowDays), nowUtc.Date.AddDays(1), includePlatformAdmins);
|
||||
var dates = Dates.For(window, nowUtc);
|
||||
|
||||
var countersTask = _usageRepo.GetAllTenantUsageAsync(dates.ReadFrom, dates.Today);
|
||||
var telemetryTask = ReadTelemetryAsync(window);
|
||||
await Task.WhenAll(countersTask, telemetryTask);
|
||||
var counters = countersTask.Result;
|
||||
var telemetry = telemetryTask.Result;
|
||||
|
||||
// Every counter account with use in the window; its quota state and platform role come from the services.
|
||||
var principals = PrincipalsByAccount(counters);
|
||||
var counterAccounts = counters
|
||||
.Where(r => !string.IsNullOrEmpty(r.UserId) && dates.InWindow(r.Date))
|
||||
.Select(r => (Tenant: r.TenantId.ToLowerInvariant(), r.UserId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var telemetryRoles = LatestRoles(telemetry.Calls);
|
||||
|
||||
var facts = new System.Collections.Concurrent.ConcurrentDictionary<(string Tenant, string Principal), AccountFacts>();
|
||||
await Parallel.ForEachAsync(counterAccounts, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (account, _) =>
|
||||
{
|
||||
var principal = principals[account];
|
||||
var platformRole = await _globalAdmins.GetGlobalRoleAsync(AdminIdentity.Create(principal, account.Tenant, account.UserId));
|
||||
var state = await _quota.ReadStateAsync(account.UserId, principal, account.Tenant);
|
||||
facts[(account.Tenant, principal)] = new AccountFacts(account.UserId, platformRole, state);
|
||||
});
|
||||
|
||||
var tenants = counters.Select(r => r.TenantId.ToLowerInvariant())
|
||||
.Concat(telemetry.Calls.Select(r => r.Tenant))
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var tenantLimits = new System.Collections.Concurrent.ConcurrentDictionary<string, McpQuotaService.TenantPlanLimits>(StringComparer.Ordinal);
|
||||
await Parallel.ForEachAsync(tenants, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (tenant, _) =>
|
||||
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant));
|
||||
|
||||
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles);
|
||||
|
||||
return Assemble(new OverviewInputs(
|
||||
dates, includePlatformAdmins, counters, facts, tenantLimits, telemetryRoles, minuteLimits, telemetry));
|
||||
}
|
||||
|
||||
// ── Telemetry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<TelemetryResult> ReadTelemetryAsync(Window window)
|
||||
{
|
||||
var calls = RunAsync(LogQuerySources.Backend, Calls(window), window, nameof(Calls));
|
||||
var routes = RunAsync(LogQuerySources.Backend, Routes(window), window, nameof(Routes));
|
||||
var refusals = RunAsync(LogQuerySources.Backend, Refusals(window), window, nameof(Refusals));
|
||||
var peaks = RunAsync(LogQuerySources.Backend, Peaks(window), window, nameof(Peaks));
|
||||
var quality = RunAsync(LogQuerySources.Mcp, ToolQuality(window), window, nameof(ToolQuality));
|
||||
var mcpRefusals = RunAsync(LogQuerySources.Mcp, McpRefusals(window), window, nameof(McpRefusals));
|
||||
var serverFacts = RunAsync(LogQuerySources.Mcp, McpServerFacts(window), window, nameof(McpServerFacts));
|
||||
await Task.WhenAll(calls, routes, refusals, peaks, quality, mcpRefusals, serverFacts);
|
||||
|
||||
var all = new[] { calls.Result, routes.Result, refusals.Result, peaks.Result, quality.Result, mcpRefusals.Result, serverFacts.Result };
|
||||
return new TelemetryResult(
|
||||
RequestsAvailable: calls.Result.Table != null,
|
||||
McpLogAvailable: quality.Result.Table != null,
|
||||
Partial: all.Any(r => r.Partial),
|
||||
Calls: calls.Result.Table is { } c ? ParseCalls(c) : new(),
|
||||
Routes: routes.Result.Table is { } r ? ParseRoutes(r) : new(),
|
||||
Refusals: refusals.Result.Table is { } f ? ParseRefusals(f) : new(),
|
||||
Peaks: peaks.Result.Table is { } p ? ParsePeaks(p) : new(),
|
||||
ToolQuality: quality.Result.Table is { } q ? ParseToolQuality(q) : new(),
|
||||
McpRefusals: mcpRefusals.Result.Table is { } m ? ParseMcpRefusals(m) : new(),
|
||||
ServerFacts: serverFacts.Result.Table is { } s ? ParseServerFacts(s) : new ServerFacts(null, null));
|
||||
}
|
||||
|
||||
/// <summary>One fixed query; any failure is logged with its cause and leaves the part empty, never the page.</summary>
|
||||
private async Task<(KqlTable? Table, bool Partial)> RunAsync(string sourceName, string query, Window window, string label)
|
||||
{
|
||||
if (!_sources.TryResolve(sourceName, out var source, out var unavailable))
|
||||
{
|
||||
_logger.LogWarning("[McpUsageOverview] {Query} skipped: {Reason}", label, unavailable);
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var outcome = await _executor.ExecuteAsync(source!, query, window.Timespan, TelemetryBudgetSeconds);
|
||||
if (!outcome.Succeeded || outcome.Tables!.Count == 0)
|
||||
{
|
||||
var (message, code) = LogQueryExecutor.ExtractError(outcome.UpstreamBody ?? string.Empty);
|
||||
_logger.LogWarning("[McpUsageOverview] {Query} failed on {Source}: {Status} {Code} {Message}",
|
||||
label, sourceName, outcome.UpstreamStatus, code, message);
|
||||
return (null, false);
|
||||
}
|
||||
if (outcome.PartialReason != null)
|
||||
_logger.LogWarning("[McpUsageOverview] {Query} on {Source} was cut: {Reason}", label, sourceName, outcome.PartialReason);
|
||||
return (outcome.Tables[0], outcome.PartialReason != null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpUsageOverview] {Query} failed on {Source}", label, sourceName);
|
||||
return (null, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-minute limits ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The backend per-minute limit each account with a peak or a refusal meets in its HOME tenant — the same
|
||||
/// precedence as UserRateLimitMiddleware on the integration surface: the Global Admin budget, else the
|
||||
/// tenant override, else the default raised to the edition floor (a Global Reader gets neither override nor
|
||||
/// floor). A delegated reader can meet another tenant's override when it reads there; the page shows home.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<(string Tenant, string Principal), int>> ResolveMinuteLimitsAsync(
|
||||
TelemetryResult telemetry, IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> facts,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles)
|
||||
{
|
||||
var config = await _adminConfig.GetConfigurationAsync();
|
||||
var keys = telemetry.Peaks.Select(p => (p.Tenant, p.Principal))
|
||||
.Concat(telemetry.Refusals.Where(r => r.Gate == RefusalMinute).Select(r => (r.Tenant, r.Principal)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var limits = new Dictionary<(string Tenant, string Principal), int>();
|
||||
var perTenant = new Dictionary<string, (int? Override, int? Floor)>(StringComparer.Ordinal);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var role = facts.TryGetValue(key, out var f) && f.PlatformRole != null
|
||||
? f.PlatformRole
|
||||
: telemetryRoles.GetValueOrDefault(key);
|
||||
var isGlobalAdmin = role == Constants.GlobalRoles.GlobalAdmin;
|
||||
var hasGlobalScope = isGlobalAdmin || role == Constants.GlobalRoles.GlobalReader;
|
||||
|
||||
(int? Override, int? Floor) tenantSide = (null, null);
|
||||
if (!hasGlobalScope && !string.IsNullOrEmpty(key.Tenant))
|
||||
{
|
||||
if (!perTenant.TryGetValue(key.Tenant, out tenantSide))
|
||||
{
|
||||
var tenantConfig = await _tenantConfig.GetConfigurationIfExistsAsync(key.Tenant);
|
||||
var entitlements = await _entitlements.GetEntitlementsAsync(key.Tenant);
|
||||
tenantSide = (tenantConfig?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
|
||||
perTenant[key.Tenant] = tenantSide;
|
||||
}
|
||||
}
|
||||
|
||||
limits[key] = RateLimitResolver.ResolveUserLimit(
|
||||
ThrottleSurface.Integration, isGlobalAdmin, tenantSide.Override,
|
||||
config.UserRateLimitRequestsPerMinute, config.PortalUserRateLimitRequestsPerMinute,
|
||||
config.GlobalAdminRateLimitRequestsPerMinute, tenantSide.Floor);
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
// ── Assembly (pure) ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The principal each counter account (lower-cased tenant, object id) stands for: the UPN of its newest row
|
||||
/// that carries one, lower-cased like the request telemetry; the object id for an account whose rows all
|
||||
/// predate the UPN column.
|
||||
/// </summary>
|
||||
internal static Dictionary<(string Tenant, string UserId), string> PrincipalsByAccount(IEnumerable<TenantUsageRecord> counters)
|
||||
=> counters
|
||||
.Where(r => !string.IsNullOrEmpty(r.UserId))
|
||||
.GroupBy(r => (Tenant: r.TenantId.ToLowerInvariant(), r.UserId))
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (g.OrderByDescending(r => r.Date, StringComparer.Ordinal)
|
||||
.Select(r => r.UserPrincipalName)
|
||||
.FirstOrDefault(u => !string.IsNullOrEmpty(u)) ?? g.Key.UserId).ToLowerInvariant());
|
||||
|
||||
/// <summary>What the I/O part learned about one counter account: its object id, platform role and quota state.</summary>
|
||||
internal sealed record AccountFacts(string UserId, string? PlatformRole, McpQuotaDecision Quota);
|
||||
|
||||
internal sealed record TelemetryResult(
|
||||
bool RequestsAvailable, bool McpLogAvailable, bool Partial,
|
||||
List<CallRow> Calls, List<RouteRow> Routes, List<RefusalRow> Refusals, List<PeakRow> Peaks,
|
||||
List<ToolQualityRow> ToolQuality, List<McpRefusalRow> McpRefusals, ServerFacts ServerFacts);
|
||||
|
||||
/// <summary>The window as yyyyMMdd strings (they sort as text).</summary>
|
||||
internal sealed record Dates(string From, string Today, string MonthStart)
|
||||
{
|
||||
/// <summary>One read covers the window and the current month (the quota windows).</summary>
|
||||
public string ReadFrom => string.CompareOrdinal(From, MonthStart) < 0 ? From : MonthStart;
|
||||
|
||||
public bool InWindow(string day) => string.CompareOrdinal(day, From) >= 0 && string.CompareOrdinal(day, Today) <= 0;
|
||||
|
||||
public IEnumerable<string> Days()
|
||||
{
|
||||
var day = DateTime.ParseExact(From, "yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
var last = DateTime.ParseExact(Today, "yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
for (; day <= last; day = day.AddDays(1))
|
||||
yield return day.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static Dates For(Window window, DateTime nowUtc)
|
||||
=> new(
|
||||
window.FromUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
|
||||
nowUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
|
||||
new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
internal sealed record OverviewInputs(
|
||||
Dates Dates,
|
||||
bool IncludePlatformAdmins,
|
||||
IReadOnlyList<TenantUsageRecord> Counters,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> Accounts,
|
||||
IReadOnlyDictionary<string, McpQuotaService.TenantPlanLimits> TenantLimits,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), string> TelemetryRoles,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), int> MinuteLimits,
|
||||
TelemetryResult Telemetry);
|
||||
|
||||
/// <summary>The role of each account's latest call (lower-cased tenant and principal as key).</summary>
|
||||
internal static Dictionary<(string Tenant, string Principal), string> LatestRoles(IEnumerable<CallRow> calls)
|
||||
=> calls
|
||||
.Where(c => !string.IsNullOrEmpty(c.Role))
|
||||
.GroupBy(c => (c.Tenant, c.Principal))
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(c => c.LastAt ?? DateTime.MinValue).First().Role);
|
||||
|
||||
private static bool IsPlatformRole(string? role)
|
||||
=> role == Constants.GlobalRoles.GlobalAdmin || role == Constants.GlobalRoles.GlobalReader;
|
||||
|
||||
/// <summary>
|
||||
/// Pure: folds counters, account facts and telemetry into the response. Platform admins drop out of every
|
||||
/// number unless included — except a tenant's organization windows, which stay the real counters.
|
||||
/// </summary>
|
||||
internal static GetGlobalMcpUsageOverviewResponse Assemble(OverviewInputs input)
|
||||
{
|
||||
var dates = input.Dates;
|
||||
var telemetry = input.Telemetry;
|
||||
|
||||
string? RoleOf((string Tenant, string Principal) key)
|
||||
=> input.TelemetryRoles.TryGetValue(key, out var role) ? role
|
||||
: input.Accounts.TryGetValue(key, out var facts) ? facts.PlatformRole
|
||||
: null;
|
||||
|
||||
bool IsPlatform((string Tenant, string Principal) key)
|
||||
=> IsPlatformRole(RoleOf(key))
|
||||
|| (input.Accounts.TryGetValue(key, out var facts) && IsPlatformRole(facts.PlatformRole));
|
||||
|
||||
bool Counts((string Tenant, string Principal) key) => input.IncludePlatformAdmins || !IsPlatform(key);
|
||||
|
||||
// Counter rows per account key — the same principal per account as the I/O part used for its facts.
|
||||
var principals = PrincipalsByAccount(input.Counters);
|
||||
var counterRows = input.Counters
|
||||
.Where(r => !string.IsNullOrEmpty(r.UserId))
|
||||
.Select(r =>
|
||||
{
|
||||
var tenant = r.TenantId.ToLowerInvariant();
|
||||
return (Row: r, Key: (Tenant: tenant, Principal: principals[(tenant, r.UserId)]));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var windowRows = counterRows.Where(x => dates.InWindow(x.Row.Date) && Counts(x.Key)).ToList();
|
||||
var calls = telemetry.Calls.Where(c => Counts((c.Tenant, c.Principal))).ToList();
|
||||
var quotaHits = telemetry.Refusals
|
||||
.Where(r => r.Gate == RefusalQuota && Counts((r.Tenant, r.Principal)))
|
||||
.OrderByDescending(r => r.LastAt)
|
||||
.Select(r => new McpUsageQuotaHitItem
|
||||
{
|
||||
Date = r.Day,
|
||||
TenantId = r.Tenant,
|
||||
Principal = string.IsNullOrEmpty(r.Principal) ? null : r.Principal,
|
||||
Level = r.Level,
|
||||
Scope = r.Scope,
|
||||
Plan = r.Plan,
|
||||
Limit = r.Limit,
|
||||
Used = r.Used,
|
||||
Refused = r.Refused,
|
||||
FirstAt = r.FirstAt,
|
||||
LastAt = r.LastAt,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// ── Accounts ──
|
||||
var accountKeys = windowRows.Select(x => x.Key)
|
||||
.Concat(calls.Select(c => (c.Tenant, c.Principal)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var accounts = new Dictionary<(string Tenant, string Principal), McpUsageAccountItem>();
|
||||
foreach (var key in accountKeys)
|
||||
{
|
||||
var rows = windowRows.Where(x => x.Key == key).Select(x => x.Row).ToList();
|
||||
var accountCalls = calls.Where(c => (c.Tenant, c.Principal) == key).ToList();
|
||||
var byDay = rows.GroupBy(r => r.Date).Select(g => (Day: g.Key, Requests: g.Sum(r => r.RequestCount)))
|
||||
.OrderByDescending(d => d.Requests).ThenByDescending(d => d.Day, StringComparer.Ordinal).ToList();
|
||||
input.Accounts.TryGetValue(key, out var facts);
|
||||
var role = RoleOf(key);
|
||||
var lastCounter = rows.Select(r => r.LastRequestAt).Max();
|
||||
var lastCall = accountCalls.Select(c => c.LastAt).Max();
|
||||
|
||||
accounts[key] = new McpUsageAccountItem
|
||||
{
|
||||
UserId = facts?.UserId ?? rows.Select(r => r.UserId).FirstOrDefault(),
|
||||
Principal = key.Principal,
|
||||
Role = string.IsNullOrEmpty(role) ? null : role,
|
||||
IsPlatformAdmin = IsPlatform(key),
|
||||
Quota = facts == null ? null : new McpUsageAccountQuotaNode
|
||||
{
|
||||
Plan = facts.Quota.Plan,
|
||||
DailyLimit = facts.Quota.DailyLimit,
|
||||
MonthlyLimit = facts.Quota.MonthlyLimit,
|
||||
DailyUsed = facts.Quota.DailyUsed,
|
||||
MonthlyUsed = facts.Quota.MonthlyUsed,
|
||||
},
|
||||
Calls = accountCalls.Sum(c => c.Calls),
|
||||
Requests = rows.Sum(r => r.RequestCount),
|
||||
PeakDay = byDay.Count > 0 ? byDay[0].Day : null,
|
||||
PeakDayRequests = byDay.Count > 0 ? byDay[0].Requests : null,
|
||||
QuotaHits = quotaHits.Count(h => h.Level == McpQuotaLevel.User && h.TenantId == key.Tenant && h.Principal == key.Principal),
|
||||
TopTools = accountCalls.GroupBy(c => c.Tool)
|
||||
.Select(g => new McpUsageToolCallsItem { Tool = g.Key, Calls = g.Sum(c => c.Calls) })
|
||||
.OrderByDescending(t => t.Calls).ThenBy(t => t.Tool, StringComparer.Ordinal)
|
||||
.Take(3).ToList(),
|
||||
LastRequestAt = Later(lastCounter, lastCall),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tenants ──
|
||||
var tenants = accounts.Keys.Select(k => k.Tenant).Distinct(StringComparer.Ordinal)
|
||||
.Select(tenant =>
|
||||
{
|
||||
var members = accounts.Where(a => a.Key.Tenant == tenant).Select(a => a.Value)
|
||||
.OrderByDescending(a => a.Calls).ThenByDescending(a => a.Requests)
|
||||
.ThenBy(a => a.Principal, StringComparer.Ordinal).ToList();
|
||||
var limits = input.TenantLimits.TryGetValue(tenant, out var l) ? l : new McpQuotaService.TenantPlanLimits(string.Empty, 0, 0);
|
||||
// The organization windows are the tenant's real counters — every account, platform admins included.
|
||||
var tenantRows = counterRows.Where(x => x.Key.Tenant == tenant).Select(x => x.Row);
|
||||
return new McpUsageTenantItem
|
||||
{
|
||||
TenantId = tenant,
|
||||
Quota = McpQuotaNodes.BuildOrganizationQuota(tenantRows, limits, dates.Today, dates.MonthStart),
|
||||
Calls = members.Sum(a => a.Calls),
|
||||
Requests = members.Sum(a => a.Requests),
|
||||
QuotaHits = quotaHits.Count(h => h.TenantId == tenant),
|
||||
LastRequestAt = members.Select(a => a.LastRequestAt).Max(),
|
||||
Accounts = members,
|
||||
};
|
||||
})
|
||||
.OrderByDescending(t => t.Calls).ThenByDescending(t => t.Requests).ThenBy(t => t.TenantId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// ── Tools ──
|
||||
var routesByTool = telemetry.Routes
|
||||
.GroupBy(r => r.Tool)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<McpUsageRouteItem>)g
|
||||
.OrderByDescending(r => r.Requests).ThenBy(r => r.Route, StringComparer.Ordinal)
|
||||
.Select(r => new McpUsageRouteItem { Route = r.Route, Requests = r.Requests }).ToList());
|
||||
var quality = telemetry.ToolQuality.ToDictionary(q => q.Tool, StringComparer.Ordinal);
|
||||
var backendTools = calls.GroupBy(c => c.Tool).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
|
||||
|
||||
var tools = backendTools.Keys.Concat(quality.Keys).Distinct(StringComparer.Ordinal)
|
||||
.Where(tool => !string.IsNullOrEmpty(tool))
|
||||
.Select(tool =>
|
||||
{
|
||||
backendTools.TryGetValue(tool, out var rows);
|
||||
quality.TryGetValue(tool, out var q);
|
||||
var served = rows?.Sum(c => c.ServedCalls) ?? 0;
|
||||
return new McpUsageToolItem
|
||||
{
|
||||
Tool = tool,
|
||||
// Calls that reached the backend come from the request telemetry, like every account's calls;
|
||||
// a tool the backend never saw is counted from the MCP log.
|
||||
Calls = rows != null ? rows.Sum(c => c.Calls) : q!.Calls,
|
||||
Accounts = rows?.Select(c => (c.Tenant, c.Principal)).Distinct().Count(),
|
||||
Tenants = rows?.Select(c => c.Tenant).Distinct(StringComparer.Ordinal).Count(),
|
||||
RequestsPerCall = served > 0 ? Math.Round((double)rows!.Sum(c => c.Requests) / served, 2) : 0,
|
||||
ErrorRate = q != null && q.Calls > 0 ? Math.Round((double)q.Errors / q.Calls, 4) : null,
|
||||
P95DurationMs = q?.P95Ms,
|
||||
AvgResultChars = q?.AvgChars is double chars ? (long)Math.Round(chars) : null,
|
||||
OverCapCalls = q?.OverCap,
|
||||
Routes = routesByTool.TryGetValue(tool, out var routes) ? routes : Array.Empty<McpUsageRouteItem>(),
|
||||
};
|
||||
})
|
||||
.OrderByDescending(t => t.Calls).ThenBy(t => t.Tool, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// ── Days ──
|
||||
string PlanOf(string tenant) => input.TenantLimits.TryGetValue(tenant, out var l) && !string.IsNullOrEmpty(l.TenantPlan) ? l.TenantPlan : "unknown";
|
||||
var daily = dates.Days().Select(day =>
|
||||
{
|
||||
var dayCalls = calls.Where(c => c.Day == day).ToList();
|
||||
var dayRows = windowRows.Where(x => x.Row.Date == day).ToList();
|
||||
return new McpUsageDayItem
|
||||
{
|
||||
Date = day,
|
||||
Calls = dayCalls.Sum(c => c.Calls),
|
||||
Requests = dayRows.Sum(x => x.Row.RequestCount),
|
||||
Accounts = dayRows.Select(x => x.Key).Concat(dayCalls.Select(c => (c.Tenant, c.Principal))).Distinct().Count(),
|
||||
CallsByPlan = dayCalls.GroupBy(c => PlanOf(c.Tenant))
|
||||
.Select(g => new McpUsagePlanCallsItem { Plan = g.Key, Calls = g.Sum(c => c.Calls) })
|
||||
.OrderByDescending(p => p.Calls).ThenBy(p => p.Plan, StringComparer.Ordinal).ToList(),
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// ── Per-minute limits ──
|
||||
var tenantOfPrincipal = accounts.Keys.GroupBy(k => k.Principal).ToDictionary(g => g.Key, g => g.First().Tenant, StringComparer.Ordinal);
|
||||
var mcpRefusals = telemetry.McpRefusals
|
||||
.Where(m => !string.IsNullOrEmpty(m.Principal))
|
||||
.Select(m => (Key: (Tenant: tenantOfPrincipal.GetValueOrDefault(m.Principal) ?? string.Empty, m.Principal), Row: m))
|
||||
.Where(m => Counts(m.Key))
|
||||
.ToDictionary(m => m.Key, m => m.Row);
|
||||
var backendRefusals = telemetry.Refusals
|
||||
.Where(r => r.Gate == RefusalMinute && Counts((r.Tenant, r.Principal)))
|
||||
.GroupBy(r => (r.Tenant, r.Principal))
|
||||
.ToDictionary(g => g.Key, g => (Refused: g.Sum(r => r.Refused), LastAt: g.Max(r => r.LastAt)));
|
||||
var peaks = telemetry.Peaks.Where(p => Counts((p.Tenant, p.Principal)))
|
||||
.GroupBy(p => (p.Tenant, p.Principal))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var mcpLimit = telemetry.ServerFacts.RateLimitPerMinute;
|
||||
var minuteItems = peaks.Keys.Concat(mcpRefusals.Keys).Concat(backendRefusals.Keys).Distinct()
|
||||
.Select(key =>
|
||||
{
|
||||
peaks.TryGetValue(key, out var accountPeaks);
|
||||
var callPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakCalls);
|
||||
var requestPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakRequests);
|
||||
mcpRefusals.TryGetValue(key, out var mcpRefused);
|
||||
var hasBackendRefusal = backendRefusals.TryGetValue(key, out var backendRefused);
|
||||
return new McpUsageMinuteItem
|
||||
{
|
||||
TenantId = key.Tenant,
|
||||
Principal = key.Principal,
|
||||
PeakCalls = callPeak?.Peak ?? 0,
|
||||
PeakCallsAt = callPeak?.PeakAt,
|
||||
McpRefused = mcpRefused?.Refused ?? 0,
|
||||
McpLastRefusedAt = mcpRefused?.LastAt,
|
||||
PeakRequests = requestPeak?.Peak ?? 0,
|
||||
PeakRequestsAt = requestPeak?.PeakAt,
|
||||
BackendLimit = input.MinuteLimits.GetValueOrDefault(key),
|
||||
BackendRefused = hasBackendRefusal ? backendRefused.Refused : 0,
|
||||
BackendLastRefusedAt = hasBackendRefusal ? backendRefused.LastAt : null,
|
||||
};
|
||||
})
|
||||
.OrderByDescending(m => Math.Max(
|
||||
mcpLimit is int limit && limit > 0 ? (double)m.PeakCalls / limit : 0,
|
||||
m.BackendLimit > 0 ? (double)m.PeakRequests / m.BackendLimit : 0))
|
||||
.ThenBy(m => m.Principal, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var mcpLogFrom = telemetry.ServerFacts.Oldest is DateTime oldest
|
||||
? oldest.ToString("yyyyMMdd", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
return new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = dates.From,
|
||||
DateTo = dates.Today,
|
||||
IncludesPlatformAdmins = input.IncludePlatformAdmins,
|
||||
Telemetry = new McpUsageTelemetryStatus
|
||||
{
|
||||
RequestsAvailable = telemetry.RequestsAvailable,
|
||||
McpLogAvailable = telemetry.McpLogAvailable,
|
||||
McpLogFrom = mcpLogFrom != null && string.CompareOrdinal(mcpLogFrom, dates.From) > 0 ? mcpLogFrom : null,
|
||||
Partial = telemetry.Partial ? true : null,
|
||||
},
|
||||
Daily = daily,
|
||||
Tenants = tenants,
|
||||
Tools = tools,
|
||||
QuotaHits = quotaHits,
|
||||
MinuteLimits = new McpUsageMinuteLimits { McpServerLimit = mcpLimit, Accounts = minuteItems },
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? Later(DateTime? a, DateTime? b) => a == null ? b : b == null ? a : (a > b ? a : b);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The fixed KQL behind the MCP usage overview and the parsers of its result tables. Two stores: the backend's
|
||||
/// App Insights (one request row per API call; the MCP stamps one correlation id per tool call on every request
|
||||
/// of that call) and the MCP Container App's Log Analytics workspace (one <c>tool_call</c> line per call, the
|
||||
/// <c>boot</c> line, the access guard's refusals). Every query covers whole UTC days [from, to) and leaves out
|
||||
/// platform admins (Global Admin, Global Reader) unless the window includes them.
|
||||
/// <para>
|
||||
/// Calls are counted exactly: grouped by correlation id first, then counted — never <c>dcount()</c>, which
|
||||
/// estimates. Log lines are matched by their START (<c>startswith</c>): a query that carries the text as an
|
||||
/// argument shows up inside a <c>tool_call</c> line and would match a <c>has</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class McpUsageTelemetryQueries
|
||||
{
|
||||
internal const string RefusalQuota = "quota";
|
||||
internal const string RefusalMinute = "minute";
|
||||
internal const string PeakRequests = "requests";
|
||||
internal const string PeakCalls = "calls";
|
||||
|
||||
/// <summary>Whole UTC days [FromUtc, ToUtc) plus whether platform admins count.</summary>
|
||||
internal sealed record Window(DateTime FromUtc, DateTime ToUtc, bool IncludePlatformAdmins)
|
||||
{
|
||||
/// <summary>Query-API timespan that covers the window (the where clauses bound it exactly).</summary>
|
||||
public string Timespan => $"P{Math.Max(1, (int)Math.Ceiling((ToUtc - FromUtc).TotalDays)) + 1}D";
|
||||
|
||||
public string From => Literal(FromUtc);
|
||||
public string To => Literal(ToUtc);
|
||||
|
||||
private static string Literal(DateTime utc) => $"datetime({utc.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)})";
|
||||
}
|
||||
|
||||
// ── App Insights (backend requests) ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The MCP request rows of the window with the columns every request query reads: tool, corr, tenant,
|
||||
/// principal, role. Leaves out the access probe (its rows name a tool that never ran) and, unless included,
|
||||
/// the platform admins.
|
||||
/// </summary>
|
||||
private static string McpRequests(Window w) => $$"""
|
||||
requests
|
||||
| where timestamp >= {{w.From}} and timestamp < {{w.To}}
|
||||
| where tostring(customDimensions.{{RequestRowDimensions.Source}}) == '{{RequestRowDimensions.WorkerMiddleware}}'
|
||||
| where tostring(customDimensions.{{RequestRowDimensions.ClientSource}}) =~ '{{ClientSourceHeader.Mcp}}'
|
||||
| where tostring(customDimensions.{{RequestRowDimensions.FunctionName}}) != '{{AccessProbeFunction.FunctionName}}'
|
||||
| extend tool = tostring(customDimensions.{{RequestRowDimensions.McpToolName}}), corr = tostring(customDimensions.{{RequestRowDimensions.CorrelationId}}), tenant = tolower(tostring(customDimensions.{{RequestRowDimensions.TenantId}})), principal = tolower(tostring(customDimensions.{{RequestRowDimensions.UserId}})), role = tostring(customDimensions.{{RequestRowDimensions.UserRole}})
|
||||
{{PlatformFilter(w, "role")}}
|
||||
""";
|
||||
|
||||
private static string PlatformFilter(Window w, string roleColumn) => w.IncludePlatformAdmins
|
||||
? string.Empty
|
||||
: $"| where {roleColumn} !in ('{Constants.GlobalRoles.GlobalAdmin}', '{Constants.GlobalRoles.GlobalReader}')";
|
||||
|
||||
/// <summary>A refused request (401/403/429) never reached the quota counters; everything else did.</summary>
|
||||
private const string Refused = "resultCode in ('401', '403', '429')";
|
||||
|
||||
/// <summary>
|
||||
/// Calls and charged requests per day (of the call's first request), tool, tenant and account, with the time
|
||||
/// and role of the row's latest call (one account can hold two roles: Viewer at home, DelegatedReader in a
|
||||
/// managed tenant). <c>servedCalls</c> = calls with at least one charged request — the denominator of requests per call.
|
||||
/// </summary>
|
||||
internal static string Calls(Window w) => $$"""
|
||||
{{McpRequests(w)}}
|
||||
| where isnotempty(tool) and isnotempty(corr)
|
||||
| summarize served = sum(iff({{Refused}}, 0, itemCount)), firstAt = min(timestamp), role = take_any(role) by corr, tool, tenant, principal
|
||||
| summarize calls = count(), servedCalls = countif(served > 0), charged = sum(served), arg_max(firstAt, role) by day = format_datetime(firstAt, 'yyyyMMdd'), tool, tenant, principal
|
||||
""";
|
||||
|
||||
/// <summary>Charged requests per tool and route (method plus path without /api/, ids as {id}).</summary>
|
||||
internal static string Routes(Window w) => $$"""
|
||||
{{McpRequests(w)}}
|
||||
| where isnotempty(tool) and not({{Refused}})
|
||||
| extend route = strcat(tostring(customDimensions.{{RequestRowDimensions.HttpMethod}}), ' ', tolower(replace_regex(replace_regex(tostring(customDimensions.{{RequestRowDimensions.HttpPath}}), @'^/api/', ''), @'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', '{id}')))
|
||||
| summarize charged = sum(itemCount) by tool, route
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Every 429 an MCP request met, classified by the Warning line the same invocation wrote: the daily/monthly
|
||||
/// quota (<see cref="McpQuotaEnforcementMiddleware.BlockedLogPrefix"/>) or the backend's per-minute limit
|
||||
/// (<see cref="UserRateLimitMiddleware.ThrottledLogPrefix"/>). One row per gate, day, account and window;
|
||||
/// <c>usedCount</c> is the window's count at the first refusal of the day.
|
||||
/// </summary>
|
||||
internal static string Refusals(Window w) => $$"""
|
||||
let rejected = {{McpRequests(w)}}
|
||||
| where resultCode == '429'
|
||||
| project timestamp, operation_Id, tenant, principal;
|
||||
let lines = traces
|
||||
| where timestamp >= {{w.From}} and timestamp < {{w.To}}
|
||||
| extend f = tostring(customDimensions.OriginalFormat)
|
||||
| where f startswith '{{McpQuotaEnforcementMiddleware.BlockedLogPrefix}}' or f startswith '{{UserRateLimitMiddleware.ThrottledLogPrefix}}'
|
||||
| project operation_Id, f, cd = customDimensions;
|
||||
rejected
|
||||
| join kind=inner lines on operation_Id
|
||||
| extend gate = iff(f startswith '{{McpQuotaEnforcementMiddleware.BlockedLogPrefix}}', '{{RefusalQuota}}', '{{RefusalMinute}}'), level = tostring(cd.Level), scope = tostring(cd.Scope), plan = tostring(cd.Plan)
|
||||
| extend usedCount = case(gate == '{{RefusalMinute}}', tolong(cd.Count), level == 'tenant' and scope == 'monthly', tolong(cd.TenantMonthlyUsed), level == 'tenant', tolong(cd.TenantDailyUsed), scope == 'monthly', tolong(cd.MonthlyUsed), tolong(cd.DailyUsed)),
|
||||
limitValue = case(gate == '{{RefusalMinute}}', tolong(cd.Max), level == 'tenant' and scope == 'monthly', tolong(cd.TenantMonthlyLimit), level == 'tenant', tolong(cd.TenantDailyLimit), scope == 'monthly', tolong(cd.MonthlyLimit), tolong(cd.DailyLimit))
|
||||
| summarize refused = count(), firstAt = min(timestamp), lastAt = max(timestamp), usedCount = min(usedCount), limitValue = max(limitValue) by gate, day = format_datetime(timestamp, 'yyyyMMdd'), tenant, principal, level, scope, plan
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Each account's busiest 60 seconds, measured like the sliding-window limiters: counts per 10-second bin,
|
||||
/// every bin added to the six windows it belongs to. <c>requests</c> counts every MCP request (refused ones
|
||||
/// too — they are demand); <c>calls</c> counts tool calls by their first request.
|
||||
/// </summary>
|
||||
internal static string Peaks(Window w) => $$"""
|
||||
let base = {{McpRequests(w)}}
|
||||
| project timestamp, corr, tenant, principal, itemCount;
|
||||
let requestPeaks = base
|
||||
| summarize n = sum(itemCount) by tenant, principal, b = bin(timestamp, 10s)
|
||||
| mv-expand k = range(0, 5, 1) to typeof(long)
|
||||
| summarize w = sum(n) by tenant, principal, e = b + k * 10s
|
||||
| summarize arg_max(w, e) by tenant, principal
|
||||
| extend metric = '{{PeakRequests}}';
|
||||
let callPeaks = base
|
||||
| where isnotempty(corr)
|
||||
| summarize at = min(timestamp) by corr, tenant, principal
|
||||
| summarize n = count() by tenant, principal, b = bin(at, 10s)
|
||||
| mv-expand k = range(0, 5, 1) to typeof(long)
|
||||
| summarize w = sum(n) by tenant, principal, e = b + k * 10s
|
||||
| summarize arg_max(w, e) by tenant, principal
|
||||
| extend metric = '{{PeakCalls}}';
|
||||
union requestPeaks, callPeaks
|
||||
| project metric, tenant, principal, peak = w, peakAt = e
|
||||
""";
|
||||
|
||||
// ── MCP Log Analytics (the MCP server's own lines) ─────────────────────────────
|
||||
|
||||
/// <summary>The <c>tool_call</c> line starts with this (its <c>type</c> key comes first).</summary>
|
||||
internal const string ToolCallLinePrefix = "{\"type\":\"tool_call\"";
|
||||
|
||||
/// <summary>The <c>boot</c> line starts with this.</summary>
|
||||
internal const string BootLinePrefix = "{\"type\":\"boot\"";
|
||||
|
||||
/// <summary>The access guard's per-account refusal line: <c>[mcp-auth] 429 kind=upn key=<principal> method=…</c>.</summary>
|
||||
internal const string McpRefusalLinePrefix = "[mcp-auth] 429 kind=upn ";
|
||||
|
||||
/// <summary>Per tool: calls, errors (soft errors included), cancellations, over-cap pages, p95 duration, mean answer size.</summary>
|
||||
internal static string ToolQuality(Window w) => $$"""
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}}
|
||||
| where Log_s startswith '{{ToolCallLinePrefix}}'
|
||||
| extend d = parse_json(Log_s)
|
||||
{{(w.IncludePlatformAdmins ? string.Empty : "| where tostring(d.scope) != 'ga'")}}
|
||||
| summarize calls = count(), errors = countif(tobool(d.isError)), cancelled = countif(tobool(d.cancelled)), overCap = countif(tobool(d.overCap)), p95Ms = percentile(tolong(d.durationMs), 95), avgChars = avg(todouble(d.resultChars)) by tool = tostring(d.tool)
|
||||
""";
|
||||
|
||||
/// <summary>The MCP server's per-account refusals (its own per-minute limit), per account.</summary>
|
||||
internal static string McpRefusals(Window w) => $$"""
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}}
|
||||
| where Log_s startswith '{{McpRefusalLinePrefix}}'
|
||||
| extend principal = tolower(extract(@'key=(\S+)', 1, Log_s))
|
||||
| summarize refused = count(), lastAt = max(TimeGenerated) by principal
|
||||
""";
|
||||
|
||||
/// <summary>How far back the MCP log reaches inside the window, and the per-account limit of the newest start line.</summary>
|
||||
internal static string McpServerFacts(Window w) => $$"""
|
||||
let lines = ContainerAppConsoleLogs_CL
|
||||
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}};
|
||||
let oldest = toscalar(lines | summarize min(TimeGenerated));
|
||||
let bootLimit = toscalar(lines | where Log_s startswith '{{BootLinePrefix}}' | top 1 by TimeGenerated desc | project toint(parse_json(Log_s).rateLimitPerMinute));
|
||||
print oldest = oldest, rateLimitPerMinute = bootLimit
|
||||
""";
|
||||
|
||||
// ── Parsed rows ────────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record CallRow(string Day, string Tool, string Tenant, string Principal, long Calls, long ServedCalls, long Requests, DateTime? LastAt, string Role);
|
||||
internal sealed record RouteRow(string Tool, string Route, long Requests);
|
||||
internal sealed record RefusalRow(string Gate, string Day, string Tenant, string Principal, string Level, string Scope, string Plan,
|
||||
long Refused, DateTime FirstAt, DateTime LastAt, long? Used, long? Limit);
|
||||
internal sealed record PeakRow(string Metric, string Tenant, string Principal, long Peak, DateTime? PeakAt);
|
||||
internal sealed record ToolQualityRow(string Tool, long Calls, long Errors, long Cancelled, long OverCap, long? P95Ms, double? AvgChars);
|
||||
internal sealed record McpRefusalRow(string Principal, long Refused, DateTime? LastAt);
|
||||
internal sealed record ServerFacts(DateTime? Oldest, int? RateLimitPerMinute);
|
||||
|
||||
internal static List<CallRow> ParseCalls(KqlTable t) => Read(t, r => new CallRow(
|
||||
r.Str("day"), r.Str("tool"), r.Str("tenant"), r.Str("principal"),
|
||||
r.Long("calls"), r.Long("servedCalls"), r.Long("charged"), r.Date("firstAt"), r.Str("role")));
|
||||
|
||||
internal static List<RouteRow> ParseRoutes(KqlTable t) => Read(t, r => new RouteRow(r.Str("tool"), r.Str("route"), r.Long("charged")));
|
||||
|
||||
internal static List<RefusalRow> ParseRefusals(KqlTable t) => Read(t, r => new RefusalRow(
|
||||
r.Str("gate"), r.Str("day"), r.Str("tenant"), r.Str("principal"), r.Str("level"), r.Str("scope"), r.Str("plan"),
|
||||
r.Long("refused"), r.Date("firstAt") ?? DateTime.MinValue, r.Date("lastAt") ?? DateTime.MinValue, r.LongOrNull("usedCount"), r.LongOrNull("limitValue")));
|
||||
|
||||
internal static List<PeakRow> ParsePeaks(KqlTable t) => Read(t, r => new PeakRow(
|
||||
r.Str("metric"), r.Str("tenant"), r.Str("principal"), r.Long("peak"), r.Date("peakAt")));
|
||||
|
||||
internal static List<ToolQualityRow> ParseToolQuality(KqlTable t) => Read(t, r => new ToolQualityRow(
|
||||
r.Str("tool"), r.Long("calls"), r.Long("errors"), r.Long("cancelled"), r.Long("overCap"), r.LongOrNull("p95Ms"), r.DoubleOrNull("avgChars")));
|
||||
|
||||
internal static List<McpRefusalRow> ParseMcpRefusals(KqlTable t) => Read(t, r => new McpRefusalRow(r.Str("principal"), r.Long("refused"), r.Date("lastAt")));
|
||||
|
||||
internal static ServerFacts ParseServerFacts(KqlTable t)
|
||||
{
|
||||
var rows = Read(t, r => new ServerFacts(r.Date("oldest"), (int?)r.LongOrNull("rateLimitPerMinute")));
|
||||
return rows.Count > 0 ? rows[0] : new ServerFacts(null, null);
|
||||
}
|
||||
|
||||
private static List<T> Read<T>(KqlTable table, Func<RowReader, T> map)
|
||||
{
|
||||
var index = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < table.Columns.Count; i++)
|
||||
index[table.Columns[i].Name] = i;
|
||||
|
||||
var result = new List<T>(table.Rows.Count);
|
||||
foreach (var row in table.Rows)
|
||||
result.Add(map(new RowReader(index, row)));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Typed access to one Kusto row by column name; a missing column or a null cell reads as empty.</summary>
|
||||
private readonly struct RowReader
|
||||
{
|
||||
private readonly Dictionary<string, int> _index;
|
||||
private readonly IReadOnlyList<JsonElement> _cells;
|
||||
|
||||
public RowReader(Dictionary<string, int> index, IReadOnlyList<JsonElement> cells)
|
||||
{
|
||||
_index = index;
|
||||
_cells = cells;
|
||||
}
|
||||
|
||||
private JsonElement? Cell(string column)
|
||||
=> _index.TryGetValue(column, out var i) && i < _cells.Count && _cells[i].ValueKind != JsonValueKind.Null
|
||||
? _cells[i]
|
||||
: null;
|
||||
|
||||
public string Str(string column) => Cell(column) is { ValueKind: JsonValueKind.String } c ? c.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
public long Long(string column) => LongOrNull(column) ?? 0;
|
||||
|
||||
public long? LongOrNull(string column) => Cell(column) switch
|
||||
{
|
||||
{ ValueKind: JsonValueKind.Number } c when c.TryGetInt64(out var l) => l,
|
||||
{ ValueKind: JsonValueKind.Number } c => (long)Math.Round(c.GetDouble()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public double? DoubleOrNull(string column) => Cell(column) is { ValueKind: JsonValueKind.Number } c ? c.GetDouble() : null;
|
||||
|
||||
public DateTime? Date(string column)
|
||||
=> Cell(column) is { ValueKind: JsonValueKind.String } c
|
||||
&& DateTime.TryParse(c.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var d)
|
||||
? DateTime.SpecifyKind(d, DateTimeKind.Utc)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -1072,6 +1072,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantId, "System.RuleEngine",
|
||||
new { ruleId, reason, catalogRule = isCatalogRule, endsRun, sessionId, runKind, skippedRules });
|
||||
|
||||
/// <summary>
|
||||
/// An MCP caller ran into a daily or monthly request quota of its home tenant (<paramref name="level"/>
|
||||
/// <c>user</c> = the account's own window, <c>tenant</c> = the organization's) and was answered 429.
|
||||
/// <c>McpQuotaEnforcementMiddleware</c> raises it for the first refusal per instance, account or
|
||||
/// organization, window and period; every refusal is also in the Warning trace.
|
||||
/// </summary>
|
||||
public Task RecordMcpQuotaExceededAsync(
|
||||
string tenantId, string principal, string level, string scope, string plan, long limit, long used, DateTime resetUtc)
|
||||
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.McpQuotaExceeded, OpsEventSeverity.Info,
|
||||
level == McpQuotaLevel.Tenant
|
||||
? $"MCP {scope} request quota of the organization reached ({used}/{limit}, plan '{plan}'); its members get 429 until {resetUtc:yyyy-MM-ddTHH:mm:ss}Z"
|
||||
: $"MCP {scope} request quota of {principal} reached ({used}/{limit}, plan '{plan}'); 429 until {resetUtc:yyyy-MM-ddTHH:mm:ss}Z",
|
||||
tenantId, principal,
|
||||
new { level, scope, plan, limit, used, resetUtc = $"{resetUtc:yyyy-MM-ddTHH:mm:ss}Z" });
|
||||
|
||||
/// <summary>
|
||||
/// App-version duration regression radar: an app's newest version installs with a median
|
||||
/// duration ≥2× (and ≥5 min absolute) over the previous version's median. Fired ONCE per
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.13",
|
||||
"jose": "^6.2.14",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -4289,9 +4289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.13",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
|
||||
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
|
||||
"version": "6.2.14",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz",
|
||||
"integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.13",
|
||||
"jose": "^6.2.14",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
|
||||
@@ -12,10 +12,19 @@
|
||||
* No live backend or real token is needed: fetch is stubbed and JWTs are
|
||||
* hand-built (the proxy does not verify signatures — the backend does).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// RATE_LIMIT is read from the environment at module import. Set a low cap so
|
||||
/** The refusal line start the backend's usage overview matches (tests/fixtures/mcp-log-lines). */
|
||||
function refusalLinePrefix(): string {
|
||||
const contract = JSON.parse(
|
||||
readFileSync(new URL('../../../../../tests/fixtures/mcp-log-lines/fields.json', import.meta.url), 'utf8'),
|
||||
) as { prefixes: { mcp_refusal: string } };
|
||||
return contract.prefixes.mcp_refusal;
|
||||
}
|
||||
|
||||
// RATE_LIMIT_PER_MINUTE is read from the environment at module import. Set a low cap so
|
||||
// the 429 path is cheap to drive, BEFORE the dynamic import resolves.
|
||||
process.env.MCP_RATE_LIMIT_PER_MINUTE = '2';
|
||||
|
||||
@@ -155,6 +164,7 @@ function uniqueUpn(): string {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('accessGuard — 401 (authentication failures)', () => {
|
||||
@@ -387,6 +397,7 @@ describe('accessGuard — 429 (rate limit) and caching', () => {
|
||||
const upn = uniqueUpn();
|
||||
const token = validToken(upn);
|
||||
stubBackend({ body: { allowed: true } });
|
||||
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const first = await runGuard(mockReq(`Bearer ${token}`));
|
||||
const second = await runGuard(mockReq(`Bearer ${token}`));
|
||||
@@ -397,6 +408,9 @@ describe('accessGuard — 429 (rate limit) and caching', () => {
|
||||
expect(third.nextCalled).toBe(false);
|
||||
expect(third.status).toBe(429);
|
||||
expect((third.body as { retryAfterSeconds: number }).retryAfterSeconds).toBe(60);
|
||||
// The GA usage overview counts these refusals by the line start and the key (tests/fixtures/mcp-log-lines).
|
||||
const refusal = logSpy.mock.calls.map((call) => String(call[0])).find((line) => line.startsWith('[mcp-auth] 429'));
|
||||
expect(refusal?.startsWith(`${refusalLinePrefix()}key=${upn} `)).toBe(true);
|
||||
});
|
||||
|
||||
it('serves the cached access decision (one backend call across repeat requests)', async () => {
|
||||
|
||||
@@ -327,3 +327,19 @@ describe('attachToolCallRejectionSniffer', () => {
|
||||
expect(res.end).toBe(originalEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootLine', () => {
|
||||
it('starts with the type marker and carries the enforced per-account budget', async () => {
|
||||
// The GA usage page finds the line by its start and reads rateLimitPerMinute from the newest one.
|
||||
const { bootLine } = await loadTelemetry(false);
|
||||
const line = bootLine(
|
||||
{ readyMs: 812, version: '1.2.3', commit: 'abc1234', docsCommit: 'def5678', rateLimitPerMinute: 60 },
|
||||
new Date('2026-11-05T12:00:00.000Z'),
|
||||
);
|
||||
expect(line.startsWith('{"type":"boot"')).toBe(true);
|
||||
expect(JSON.parse(line)).toEqual({
|
||||
type: 'boot', readyMs: 812, version: '1.2.3', commit: 'abc1234', docsCommit: 'def5678',
|
||||
rateLimitPerMinute: 60, timestamp: '2026-11-05T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The backend's GA MCP usage overview reads three kinds of line from this server's console log with KQL:
|
||||
* the tool_call line, the boot line and the access guard's per-account refusal. tests/fixtures/mcp-log-lines
|
||||
* names the line starts and fields it reads; this suite proves the server writes every one of them (the
|
||||
* backend suite proves its queries read nothing else). A rename here would otherwise empty a column there.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
interface LogLineContract {
|
||||
prefixes: { tool_call: string; boot: string; mcp_refusal: string };
|
||||
fields: { tool_call: string[]; boot: string[] };
|
||||
}
|
||||
|
||||
const contract = JSON.parse(
|
||||
readFileSync(new URL('../../../../../tests/fixtures/mcp-log-lines/fields.json', import.meta.url), 'utf8'),
|
||||
) as LogLineContract;
|
||||
|
||||
async function loadTelemetry() {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('MCP_TOOL_LOGGING', 'true');
|
||||
return await import('../telemetry.js');
|
||||
}
|
||||
|
||||
function loggedLines(spy: ReturnType<typeof vi.spyOn>): string[] {
|
||||
return spy.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MCP log lines the usage overview reads', () => {
|
||||
it('writes the tool_call line with its type first and every field the overview reads', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry();
|
||||
const { runWithCaller } = await import('../client.js');
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// A completed call carries every field but `cancelled`, which only an abandoned call writes.
|
||||
await withToolTelemetry('get_session', { sessionId: 's' }, () => ({ content: [{ type: 'text', text: '{}' }] }));
|
||||
const controller = new AbortController();
|
||||
await runWithCaller({ token: 't', isGlobalAdmin: false, signal: controller.signal }, () =>
|
||||
withToolTelemetry('get_session', { sessionId: 's' }, () => {
|
||||
controller.abort();
|
||||
return { isError: true, content: [{ type: 'text', text: '**Timeout in get_session**' }] };
|
||||
}),
|
||||
);
|
||||
|
||||
const lines = loggedLines(spy);
|
||||
expect(lines).toHaveLength(2);
|
||||
const written = new Set<string>();
|
||||
for (const line of lines) {
|
||||
expect(line.startsWith(contract.prefixes.tool_call)).toBe(true);
|
||||
for (const key of Object.keys(JSON.parse(line) as object)) written.add(key);
|
||||
}
|
||||
for (const field of contract.fields.tool_call) expect(written).toContain(field);
|
||||
});
|
||||
|
||||
it('writes the boot line with its type first and every field the overview reads', async () => {
|
||||
const { bootLine } = await loadTelemetry();
|
||||
const line = bootLine({ readyMs: 1, version: 'v', commit: 'c', docsCommit: 'd', rateLimitPerMinute: 60 });
|
||||
|
||||
expect(line.startsWith(contract.prefixes.boot)).toBe(true);
|
||||
const keys = Object.keys(JSON.parse(line) as object);
|
||||
for (const field of contract.fields.boot) expect(keys).toContain(field);
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,8 @@ const BASE_URL = API_BASE_URL;
|
||||
// Per-UPN request budget (sliding 60s window), applied AFTER a token is
|
||||
// validated. parsePositiveInt guards against a non-numeric override: a bare
|
||||
// parseInt would yield NaN, and `count >= NaN` is always false — silently
|
||||
// disabling the limiter.
|
||||
const RATE_LIMIT = parsePositiveInt(process.env.MCP_RATE_LIMIT_PER_MINUTE, 60);
|
||||
// disabling the limiter. The boot line reports it, so usage views read the live value.
|
||||
export const RATE_LIMIT_PER_MINUTE = parsePositiveInt(process.env.MCP_RATE_LIMIT_PER_MINUTE, 60);
|
||||
|
||||
// Per-source-IP budget for UNvalidated requests (sliding 60s window), applied
|
||||
// only on the cache-MISS path that would call the backend. Bounds forged- /
|
||||
@@ -357,7 +357,7 @@ function getClientIp(req: Request): string {
|
||||
}
|
||||
|
||||
function isRateLimited(upn: string): boolean {
|
||||
return isWindowExceeded(rateBuckets, upn, RATE_LIMIT);
|
||||
return isWindowExceeded(rateBuckets, upn, RATE_LIMIT_PER_MINUTE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2285,6 +2285,26 @@ export interface GetGlobalMcpUsageDailyResponse {
|
||||
summaries: UserUsageDailySummary[];
|
||||
}
|
||||
|
||||
/** The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks and the daily trend. "Calls" are tool calls (request telemetry, one MCP correlation id per call); "requests" are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is empty and named in Telemetry. */
|
||||
export interface GetGlobalMcpUsageOverviewResponse {
|
||||
/** First day of the window (yyyyMMdd, UTC). */
|
||||
dateFrom: string;
|
||||
/** Last day of the window (yyyyMMdd, UTC) — today. */
|
||||
dateTo: string;
|
||||
/** False when Global Admins and Global Readers are left out of every number. */
|
||||
includesPlatformAdmins: boolean;
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
/** One item per day of the window, oldest first. */
|
||||
daily: McpUsageDayItem[];
|
||||
/** Every tenant with MCP use in the window, most calls first. */
|
||||
tenants: McpUsageTenantItem[];
|
||||
/** Every tool called in the window, most calls first. */
|
||||
tools: McpUsageToolItem[];
|
||||
/** Refusals by the daily or monthly quota, one item per day, account and window, newest first. */
|
||||
quotaHits: McpUsageQuotaHitItem[];
|
||||
minuteLimits: McpUsageMinuteLimits;
|
||||
}
|
||||
|
||||
/** Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates over the records key — its name is wire-critical. */
|
||||
export interface GetGlobalMcpUsageResponse {
|
||||
/** Echo of the request filter; absent when the caller passed no tenantId. */
|
||||
@@ -3362,6 +3382,109 @@ export interface McpQuotaExceededResponse {
|
||||
resetUtc: string;
|
||||
}
|
||||
|
||||
/** One account (person or service principal) of a tenant in the overview. */
|
||||
export interface McpUsageAccountItem {
|
||||
/** Entra object id; absent for an account known only from request telemetry (every request refused). */
|
||||
userId?: string;
|
||||
/** UPN, or app:<client-id> for a service principal. */
|
||||
principal: string;
|
||||
/** Role on its latest request (Admin, Operator, Viewer, DelegatedReader, GlobalReader, GlobalAdmin); absent without request telemetry. */
|
||||
role?: string;
|
||||
/** A Global Admin or Global Reader; listed only when platform admins are included. */
|
||||
isPlatformAdmin: boolean;
|
||||
/** The account's plan and windows as its next request meets them; absent without an object id. */
|
||||
quota?: McpUsageAccountQuotaNode;
|
||||
calls: number;
|
||||
requests: number;
|
||||
/** The day (yyyyMMdd) with the most charged requests in the window; absent without requests. */
|
||||
peakDay?: string;
|
||||
/** Charged requests on PeakDay; absent without requests. */
|
||||
peakDayRequests?: number;
|
||||
/** Days on which the account met a quota refusal of its own window. */
|
||||
quotaHits: number;
|
||||
/** The account's most called tools, at most three, most first. */
|
||||
topTools: McpUsageToolCallsItem[];
|
||||
/** Absent when neither the counters nor the telemetry carry a timestamp. */
|
||||
lastRequestAt?: string;
|
||||
}
|
||||
|
||||
/** The account's own windows (0 = unlimited; purchased-slot growth included). */
|
||||
export interface McpUsageAccountQuotaNode {
|
||||
/** The account's usage plan: its personal override, else the tenant plan. */
|
||||
plan: string;
|
||||
dailyLimit: number;
|
||||
monthlyLimit: number;
|
||||
dailyUsed: number;
|
||||
monthlyUsed: number;
|
||||
}
|
||||
|
||||
/** One day of the overview trend. */
|
||||
export interface McpUsageDayItem {
|
||||
/** yyyyMMdd. */
|
||||
date: string;
|
||||
calls: number;
|
||||
requests: number;
|
||||
/** Accounts with a call or a charged request that day. */
|
||||
accounts: number;
|
||||
/** The day's calls by the usage plan of the caller's tenant, most first. */
|
||||
callsByPlan: McpUsagePlanCallsItem[];
|
||||
}
|
||||
|
||||
/** One account's busiest 60 seconds against both per-minute limits, and its refusals. */
|
||||
export interface McpUsageMinuteItem {
|
||||
tenantId: string;
|
||||
principal: string;
|
||||
/** Most tool calls the account started inside 60 seconds (measured in 10-second steps). */
|
||||
peakCalls: number;
|
||||
peakCallsAt?: string;
|
||||
/** Messages the MCP server refused because of its per-minute limit. */
|
||||
mcpRefused: number;
|
||||
mcpLastRefusedAt?: string;
|
||||
/** Most backend requests the account sent inside 60 seconds (measured in 10-second steps). */
|
||||
peakRequests: number;
|
||||
peakRequestsAt?: string;
|
||||
/** Backend requests per minute the account may send in its home tenant (override, else the default raised to the edition floor). */
|
||||
backendLimit: number;
|
||||
/** Requests the backend refused because of its per-minute limit. */
|
||||
backendRefused: number;
|
||||
backendLastRefusedAt?: string;
|
||||
}
|
||||
|
||||
/** The two per-minute limits an MCP account meets and how close the accounts came to them. */
|
||||
export interface McpUsageMinuteLimits {
|
||||
/** MCP messages per minute the MCP server admits per account, from its newest start line; absent until a server reported it. */
|
||||
mcpServerLimit?: number;
|
||||
/** Every account with request telemetry in the window, closest to a limit first. */
|
||||
accounts: McpUsageMinuteItem[];
|
||||
}
|
||||
|
||||
/** Calls of one usage plan on one day. */
|
||||
export interface McpUsagePlanCallsItem {
|
||||
plan: string;
|
||||
calls: number;
|
||||
}
|
||||
|
||||
/** Quota refusals of one account (level user) or organization (level tenant) on one day and window. */
|
||||
export interface McpUsageQuotaHitItem {
|
||||
/** yyyyMMdd. */
|
||||
date: string;
|
||||
tenantId: string;
|
||||
/** The refused account; absent when the request row carried no principal. */
|
||||
principal?: string;
|
||||
/** user (the account's window) or tenant (the organization's). */
|
||||
level: string;
|
||||
/** daily or monthly. */
|
||||
scope: string;
|
||||
plan: string;
|
||||
/** The window's limit; absent when the refusal line carried none. */
|
||||
limit?: number;
|
||||
/** The window's count at the first refusal of the day; absent when the refusal line carried none. */
|
||||
used?: number;
|
||||
refused: number;
|
||||
firstAt: string;
|
||||
lastAt: string;
|
||||
}
|
||||
|
||||
/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their HOME tenant (shared by every member; 0 = unlimited). Every MCP request the caller makes — reads into managed tenants included — draws on these windows. The limits already contain the growth from purchased delegation slots; the slot fields let a client show the breakdown (base = limit − slots × slot value) and are absent when the tenant has no purchased slot. */
|
||||
export interface McpUsageQuotaNode {
|
||||
dailyLimit: number;
|
||||
@@ -3384,6 +3507,67 @@ export interface McpUsageQuotaNode {
|
||||
slotTenantMonthlyLimit?: number;
|
||||
}
|
||||
|
||||
/** One backend route of a tool: method plus path with ids as {id}. */
|
||||
export interface McpUsageRouteItem {
|
||||
route: string;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** Which telemetry store answered, nested in GetGlobalMcpUsageOverviewResponse. */
|
||||
export interface McpUsageTelemetryStatus {
|
||||
/** The backend's request telemetry answered: calls, reach, routes, refusals and per-minute peaks are filled. */
|
||||
requestsAvailable: boolean;
|
||||
/** The MCP server's log answered: tool quality, MCP-only tools, the MCP limit and its refusals are filled. */
|
||||
mcpLogAvailable: boolean;
|
||||
/** First day (yyyyMMdd) the MCP log covers when that lies after the window start; absent when it covers the window. */
|
||||
mcpLogFrom?: string;
|
||||
/** True when a store cut a result (size cap, shard timeout), so its numbers are a lower bound; absent otherwise. */
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
/** One tenant of the overview with its organization windows and its accounts. */
|
||||
export interface McpUsageTenantItem {
|
||||
tenantId: string;
|
||||
/** The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters. */
|
||||
quota: McpOrganizationQuotaNode;
|
||||
calls: number;
|
||||
requests: number;
|
||||
/** Quota refusals of the tenant's accounts and of the organization window in the window. */
|
||||
quotaHits: number;
|
||||
/** Absent when no counter row carries a timestamp. */
|
||||
lastRequestAt?: string;
|
||||
/** Accounts with MCP use in the window, most calls first. */
|
||||
accounts: McpUsageAccountItem[];
|
||||
}
|
||||
|
||||
/** Calls of one tool by one account. */
|
||||
export interface McpUsageToolCallsItem {
|
||||
tool: string;
|
||||
calls: number;
|
||||
}
|
||||
|
||||
/** One MCP tool in the overview ranking. */
|
||||
export interface McpUsageToolItem {
|
||||
tool: string;
|
||||
calls: number;
|
||||
/** Accounts that called it; absent for a tool that never reaches the backend. */
|
||||
accounts?: number;
|
||||
/** Tenants whose accounts called it; absent for a tool that never reaches the backend. */
|
||||
tenants?: number;
|
||||
/** Backend requests one served call costs the quota on average; 0 for a tool that runs in the MCP server alone. */
|
||||
requestsPerCall: number;
|
||||
/** Share of calls (0–1) that ended as an error, soft errors included; absent without the MCP log. */
|
||||
errorRate?: number;
|
||||
/** 95th-percentile duration of a call; absent without the MCP log. */
|
||||
p95DurationMs?: number;
|
||||
/** Mean size of the answer in characters; absent without the MCP log. */
|
||||
avgResultChars?: number;
|
||||
/** Calls whose page exceeded the tool's size cap; absent without the MCP log. */
|
||||
overCapCalls?: number;
|
||||
/** The backend routes its calls requested, most requests first. */
|
||||
routes: McpUsageRouteItem[];
|
||||
}
|
||||
|
||||
export interface McpUserEntry {
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
|
||||
@@ -144,6 +144,7 @@ export const OPS_EVENT_TYPES = [
|
||||
"DiagnosticsUploadEnabled",
|
||||
"DiagnosticsUploadDisabled",
|
||||
"RuleEvaluationStopped",
|
||||
"McpQuotaExceeded",
|
||||
"SessionActionQueued",
|
||||
"SessionTimeouts",
|
||||
"AgentEmergencyBreak",
|
||||
|
||||
@@ -14,9 +14,9 @@ import { MODEL_NAME, VectorSearchProvider, embed } from './vector-search-provide
|
||||
import { validatePrecomputedIndex } from './precomputed-index.js';
|
||||
import { buildEventTypeSearchDocs } from './resource-catalog.js';
|
||||
import { createOAuthRouter } from './oauth.js';
|
||||
import { accessGuard } from './access-guard.js';
|
||||
import { accessGuard, RATE_LIMIT_PER_MINUTE } from './access-guard.js';
|
||||
import { originGuard } from './origin-guard.js';
|
||||
import { toolLoggingEnabled, attachToolCallRejectionSniffer } from './telemetry.js';
|
||||
import { toolLoggingEnabled, attachToolCallRejectionSniffer, bootLine } from './telemetry.js';
|
||||
import { API_BASE_URL } from './config.js';
|
||||
import { SERVER_VERSION, BUILD_COMMIT, BUILD_UTC, DOCS_COMMIT } from './build-info.js';
|
||||
|
||||
@@ -376,16 +376,13 @@ const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
// The number a user actually waits for on a cold start, minus the platform's
|
||||
// own activation (pod scheduling, container start), which is not visible here.
|
||||
console.error(`[boot ${bootMark()}] Autopilot-Monitor MCP Server listening on port ${PORT} — ready to serve.`);
|
||||
// Same number as a structured line so it is a trend, not prose: queryable in
|
||||
// ContainerAppConsoleLogs_CL next to the tool_call lines (see telemetry.ts),
|
||||
// e.g. `| where Log_s startswith '{"type":"boot"'`. Always on — one line per start.
|
||||
console.error(JSON.stringify({
|
||||
type: 'boot',
|
||||
// Same number as a structured line so it is a trend, not prose (see bootLine).
|
||||
console.error(bootLine({
|
||||
readyMs: Math.round(process.uptime() * 1000),
|
||||
version: SERVER_VERSION,
|
||||
commit: BUILD_COMMIT,
|
||||
docsCommit: DOCS_COMMIT,
|
||||
timestamp: new Date().toISOString(),
|
||||
rateLimitPerMinute: RATE_LIMIT_PER_MINUTE,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ import { OVERFLOW_META_KEY } from './tools/shared.js';
|
||||
|
||||
export const toolLoggingEnabled = process.env.MCP_TOOL_LOGGING === 'true';
|
||||
|
||||
/**
|
||||
* The structured start line, one per process and always on: readiness as a trend, queryable next to the
|
||||
* tool_call lines (`| where Log_s startswith '{"type":"boot"'`), plus the per-account message budget the
|
||||
* access guard enforces — the GA usage page reads that value back instead of retyping it.
|
||||
*/
|
||||
export function bootLine(
|
||||
fields: { readyMs: number; version: string; commit: string; docsCommit: string; rateLimitPerMinute: number },
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
return JSON.stringify({ type: 'boot', ...fields, timestamp: now.toISOString() });
|
||||
}
|
||||
|
||||
// Caps keep a single log line small no matter what the model sends (validate_rule
|
||||
// takes whole rule JSON objects; queries are free text).
|
||||
const ARG_VALUE_CAP = 200;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.1" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -48,6 +48,13 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// <summary>Tenant-wide MCP counters (one row per user and day) within an optional yyyyMMdd range.</summary>
|
||||
Task<List<TenantUsageRecord>> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null);
|
||||
|
||||
/// <summary>
|
||||
/// The tenant-wide MCP counters of EVERY tenant within a yyyyMMdd range — the source of the all-tenant
|
||||
/// usage overview. One row per account and day (no endpoint dimension), so this reads a fraction of the
|
||||
/// per-user log for the same window.
|
||||
/// </summary>
|
||||
Task<List<TenantUsageRecord>> GetAllTenantUsageAsync(string dateFrom, string dateTo);
|
||||
|
||||
/// <summary>Deletes tenant-wide MCP counters older than the specified date (yyyyMMdd). Returns the count deleted.</summary>
|
||||
Task<int> DeleteTenantRecordsOlderThanAsync(string dateCutoff);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,13 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// hour and 20 per instance and hour; every stop is in the Warning trace.
|
||||
/// </summary>
|
||||
public const string RuleEvaluationStopped = "RuleEvaluationStopped";
|
||||
/// <summary>
|
||||
/// An MCP caller ran into a daily or monthly request quota of its home tenant and was answered
|
||||
/// 429 (details: <c>level</c> account or organization, <c>scope</c>, <c>plan</c>, <c>limit</c>,
|
||||
/// <c>used</c>, <c>resetUtc</c>). Info; at most one per instance, account or organization,
|
||||
/// window and period — every refused request stays in the Warning trace.
|
||||
/// </summary>
|
||||
public const string McpQuotaExceeded = "McpQuotaExceeded";
|
||||
|
||||
// ── Agent ── Agent + device-side health signals.
|
||||
public const string SessionActionQueued = "SessionActionQueued";
|
||||
@@ -231,7 +238,7 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles, GraphAccessRejected, GraphAccessRecovered, GraphAppCredentialRejected, AddOnPermissionMissing, AddOnPermissionResolved,
|
||||
MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, MaintenanceStarted, MaintenanceSkippedLocked, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, OrphanSweepCompleted, OrphanSweepFailed, OrphanSweepSkippedLocked, OrphanReconcileCompleted, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift, SoftwareInventoryCounterWriteFailed,
|
||||
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied, McpServicePrincipalFirstSeen, ReportUploadLimitReached, RuleEvaluationLimitReached,
|
||||
OffboardingFeedbackReceived, FeedbackReceived, FeedbackRatingReceived, TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted, RuleSubmissionReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled, RuleEvaluationStopped,
|
||||
OffboardingFeedbackReceived, FeedbackReceived, FeedbackRatingReceived, TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted, RuleSubmissionReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled, RuleEvaluationStopped, McpQuotaExceeded,
|
||||
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, TelemetryItemsRejected,
|
||||
SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted,
|
||||
AzureMonitorAlert, PushDeliveryFailed, PushEndpointRefused,
|
||||
|
||||
@@ -199,6 +199,222 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public DateTime? LastRequestAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC
|
||||
/// days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks
|
||||
/// and the daily trend. "Calls" are tool calls (request telemetry, one MCP correlation id per call); "requests"
|
||||
/// are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is
|
||||
/// empty and named in <see cref="Telemetry"/>.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class GetGlobalMcpUsageOverviewResponse : IApiResponse
|
||||
{
|
||||
/// <summary>First day of the window (yyyyMMdd, UTC).</summary>
|
||||
public string DateFrom { get; set; } = default!;
|
||||
/// <summary>Last day of the window (yyyyMMdd, UTC) — today.</summary>
|
||||
public string DateTo { get; set; } = default!;
|
||||
/// <summary>False when Global Admins and Global Readers are left out of every number.</summary>
|
||||
public bool IncludesPlatformAdmins { get; set; }
|
||||
public McpUsageTelemetryStatus Telemetry { get; set; } = default!;
|
||||
/// <summary>One item per day of the window, oldest first.</summary>
|
||||
public IReadOnlyList<McpUsageDayItem> Daily { get; set; } = default!;
|
||||
/// <summary>Every tenant with MCP use in the window, most calls first.</summary>
|
||||
public IReadOnlyList<McpUsageTenantItem> Tenants { get; set; } = default!;
|
||||
/// <summary>Every tool called in the window, most calls first.</summary>
|
||||
public IReadOnlyList<McpUsageToolItem> Tools { get; set; } = default!;
|
||||
/// <summary>Refusals by the daily or monthly quota, one item per day, account and window, newest first.</summary>
|
||||
public IReadOnlyList<McpUsageQuotaHitItem> QuotaHits { get; set; } = default!;
|
||||
public McpUsageMinuteLimits MinuteLimits { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>Which telemetry store answered, nested in <see cref="GetGlobalMcpUsageOverviewResponse"/>.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageTelemetryStatus
|
||||
{
|
||||
/// <summary>The backend's request telemetry answered: calls, reach, routes, refusals and per-minute peaks are filled.</summary>
|
||||
public bool RequestsAvailable { get; set; }
|
||||
/// <summary>The MCP server's log answered: tool quality, MCP-only tools, the MCP limit and its refusals are filled.</summary>
|
||||
public bool McpLogAvailable { get; set; }
|
||||
/// <summary>First day (yyyyMMdd) the MCP log covers when that lies after the window start; absent when it covers the window.</summary>
|
||||
public string? McpLogFrom { get; set; }
|
||||
/// <summary>True when a store cut a result (size cap, shard timeout), so its numbers are a lower bound; absent otherwise.</summary>
|
||||
public bool? Partial { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One day of the overview trend.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageDayItem
|
||||
{
|
||||
/// <summary>yyyyMMdd.</summary>
|
||||
public string Date { get; set; } = default!;
|
||||
public long Calls { get; set; }
|
||||
public long Requests { get; set; }
|
||||
/// <summary>Accounts with a call or a charged request that day.</summary>
|
||||
public int Accounts { get; set; }
|
||||
/// <summary>The day's calls by the usage plan of the caller's tenant, most first.</summary>
|
||||
public IReadOnlyList<McpUsagePlanCallsItem> CallsByPlan { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>Calls of one usage plan on one day.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsagePlanCallsItem
|
||||
{
|
||||
public string Plan { get; set; } = default!;
|
||||
public long Calls { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One tenant of the overview with its organization windows and its accounts.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageTenantItem
|
||||
{
|
||||
public string TenantId { get; set; } = default!;
|
||||
/// <summary>The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters.</summary>
|
||||
public McpOrganizationQuotaNode Quota { get; set; } = default!;
|
||||
public long Calls { get; set; }
|
||||
public long Requests { get; set; }
|
||||
/// <summary>Quota refusals of the tenant's accounts and of the organization window in the window.</summary>
|
||||
public int QuotaHits { get; set; }
|
||||
/// <summary>Absent when no counter row carries a timestamp.</summary>
|
||||
public DateTime? LastRequestAt { get; set; }
|
||||
/// <summary>Accounts with MCP use in the window, most calls first.</summary>
|
||||
public IReadOnlyList<McpUsageAccountItem> Accounts { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>One account (person or service principal) of a tenant in the overview.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageAccountItem
|
||||
{
|
||||
/// <summary>Entra object id; absent for an account known only from request telemetry (every request refused).</summary>
|
||||
public string? UserId { get; set; }
|
||||
/// <summary>UPN, or <c>app:<client-id></c> for a service principal.</summary>
|
||||
public string Principal { get; set; } = default!;
|
||||
/// <summary>Role on its latest request (Admin, Operator, Viewer, DelegatedReader, GlobalReader, GlobalAdmin); absent without request telemetry.</summary>
|
||||
public string? Role { get; set; }
|
||||
/// <summary>A Global Admin or Global Reader; listed only when platform admins are included.</summary>
|
||||
public bool IsPlatformAdmin { get; set; }
|
||||
/// <summary>The account's plan and windows as its next request meets them; absent without an object id.</summary>
|
||||
public McpUsageAccountQuotaNode? Quota { get; set; }
|
||||
public long Calls { get; set; }
|
||||
public long Requests { get; set; }
|
||||
/// <summary>The day (yyyyMMdd) with the most charged requests in the window; absent without requests.</summary>
|
||||
public string? PeakDay { get; set; }
|
||||
/// <summary>Charged requests on <see cref="PeakDay"/>; absent without requests.</summary>
|
||||
public long? PeakDayRequests { get; set; }
|
||||
/// <summary>Days on which the account met a quota refusal of its own window.</summary>
|
||||
public int QuotaHits { get; set; }
|
||||
/// <summary>The account's most called tools, at most three, most first.</summary>
|
||||
public IReadOnlyList<McpUsageToolCallsItem> TopTools { get; set; } = default!;
|
||||
/// <summary>Absent when neither the counters nor the telemetry carry a timestamp.</summary>
|
||||
public DateTime? LastRequestAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The account's own windows (0 = unlimited; purchased-slot growth included).</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageAccountQuotaNode
|
||||
{
|
||||
/// <summary>The account's usage plan: its personal override, else the tenant plan.</summary>
|
||||
public string Plan { get; set; } = default!;
|
||||
public int DailyLimit { get; set; }
|
||||
public int MonthlyLimit { get; set; }
|
||||
public long DailyUsed { get; set; }
|
||||
public long MonthlyUsed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Calls of one tool by one account.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageToolCallsItem
|
||||
{
|
||||
public string Tool { get; set; } = default!;
|
||||
public long Calls { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One MCP tool in the overview ranking.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageToolItem
|
||||
{
|
||||
public string Tool { get; set; } = default!;
|
||||
public long Calls { get; set; }
|
||||
/// <summary>Accounts that called it; absent for a tool that never reaches the backend.</summary>
|
||||
public int? Accounts { get; set; }
|
||||
/// <summary>Tenants whose accounts called it; absent for a tool that never reaches the backend.</summary>
|
||||
public int? Tenants { get; set; }
|
||||
/// <summary>Backend requests one served call costs the quota on average; 0 for a tool that runs in the MCP server alone.</summary>
|
||||
public double RequestsPerCall { get; set; }
|
||||
/// <summary>Share of calls (0–1) that ended as an error, soft errors included; absent without the MCP log.</summary>
|
||||
public double? ErrorRate { get; set; }
|
||||
/// <summary>95th-percentile duration of a call; absent without the MCP log.</summary>
|
||||
public long? P95DurationMs { get; set; }
|
||||
/// <summary>Mean size of the answer in characters; absent without the MCP log.</summary>
|
||||
public long? AvgResultChars { get; set; }
|
||||
/// <summary>Calls whose page exceeded the tool's size cap; absent without the MCP log.</summary>
|
||||
public long? OverCapCalls { get; set; }
|
||||
/// <summary>The backend routes its calls requested, most requests first.</summary>
|
||||
public IReadOnlyList<McpUsageRouteItem> Routes { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>One backend route of a tool: method plus path with ids as <c>{id}</c>.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageRouteItem
|
||||
{
|
||||
public string Route { get; set; } = default!;
|
||||
public long Requests { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Quota refusals of one account (level user) or organization (level tenant) on one day and window.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageQuotaHitItem
|
||||
{
|
||||
/// <summary>yyyyMMdd.</summary>
|
||||
public string Date { get; set; } = default!;
|
||||
public string TenantId { get; set; } = default!;
|
||||
/// <summary>The refused account; absent when the request row carried no principal.</summary>
|
||||
public string? Principal { get; set; }
|
||||
/// <summary><c>user</c> (the account's window) or <c>tenant</c> (the organization's).</summary>
|
||||
public string Level { get; set; } = default!;
|
||||
/// <summary><c>daily</c> or <c>monthly</c>.</summary>
|
||||
public string Scope { get; set; } = default!;
|
||||
public string Plan { get; set; } = default!;
|
||||
/// <summary>The window's limit; absent when the refusal line carried none.</summary>
|
||||
public long? Limit { get; set; }
|
||||
/// <summary>The window's count at the first refusal of the day; absent when the refusal line carried none.</summary>
|
||||
public long? Used { get; set; }
|
||||
public long Refused { get; set; }
|
||||
public DateTime FirstAt { get; set; }
|
||||
public DateTime LastAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The two per-minute limits an MCP account meets and how close the accounts came to them.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageMinuteLimits
|
||||
{
|
||||
/// <summary>MCP messages per minute the MCP server admits per account, from its newest start line; absent until a server reported it.</summary>
|
||||
public int? McpServerLimit { get; set; }
|
||||
/// <summary>Every account with request telemetry in the window, closest to a limit first.</summary>
|
||||
public IReadOnlyList<McpUsageMinuteItem> Accounts { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>One account's busiest 60 seconds against both per-minute limits, and its refusals.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageMinuteItem
|
||||
{
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Principal { get; set; } = default!;
|
||||
/// <summary>Most tool calls the account started inside 60 seconds (measured in 10-second steps).</summary>
|
||||
public long PeakCalls { get; set; }
|
||||
public DateTime? PeakCallsAt { get; set; }
|
||||
/// <summary>Messages the MCP server refused because of its per-minute limit.</summary>
|
||||
public long McpRefused { get; set; }
|
||||
public DateTime? McpLastRefusedAt { get; set; }
|
||||
/// <summary>Most backend requests the account sent inside 60 seconds (measured in 10-second steps).</summary>
|
||||
public long PeakRequests { get; set; }
|
||||
public DateTime? PeakRequestsAt { get; set; }
|
||||
/// <summary>Backend requests per minute the account may send in its home tenant (override, else the default raised to the edition floor).</summary>
|
||||
public int BackendLimit { get; set; }
|
||||
/// <summary>Requests the backend refused because of its per-minute limit.</summary>
|
||||
public long BackendRefused { get; set; }
|
||||
public DateTime? BackendLastRefusedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates
|
||||
/// over the <c>records</c> key — its name is wire-critical.
|
||||
|
||||
@@ -256,6 +256,9 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
// out in it. Warning for a tenant's custom rule, Error for a built-in or community rule — route
|
||||
// Error to a push channel. At most one per instance, tenant and rule per hour, 20 per hour.
|
||||
"RuleEvaluationStopped",
|
||||
// An MCP caller ran into its home tenant's daily or monthly request quota (account or organization).
|
||||
// Info; at most one per instance, account or organization, window and period.
|
||||
"McpQuotaExceeded",
|
||||
],
|
||||
Agent: [
|
||||
"BlobStorageMissing",
|
||||
|
||||
@@ -2,7 +2,7 @@ export const METRICS_NAV_SECTIONS = [
|
||||
{ id: "customers", label: "Customers", description: "New tenants per day and the growth over time" },
|
||||
{ id: "platform-metrics", label: "Platform Metrics", description: "Agent performance, delivery latency, crash rates, and platform health" },
|
||||
{ id: "usage", label: "Platform Usage", description: "Platform usage statistics across all tenants" },
|
||||
{ id: "mcp-usage", label: "MCP Usage", description: "MCP API usage metrics across all users" },
|
||||
{ id: "mcp-usage", label: "MCP Usage", description: "MCP usage by tenant, account and tool, and where the limits bite" },
|
||||
{ id: "verdict-calibration", label: "Verdict Calibration", description: "Which code path produced each session verdict, overrides and re-enrollment — classifier diagnostics" },
|
||||
{ id: "ime-pattern-health", label: "IME Pattern Health", description: "Which IME log patterns still match on which IME version — pattern-drift alerts and tracker health" },
|
||||
] as const;
|
||||
|
||||
@@ -1,275 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { SegmentedControl } from "@/components/SegmentedControl";
|
||||
import { WINDOW_PRESET_OPTIONS } from "@/lib/timeWindow";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import { api } from "@/lib/api";
|
||||
import { isApplicationKey, principalLabel } from "@/lib/principalKeys";
|
||||
import type { GetGlobalMcpUsageDailyResponse, GetGlobalMcpUsageResponse, UserUsageDailySummary } from "@/lib/generated/wire-types.generated";
|
||||
import { SegmentedControl } from "@/components/SegmentedControl";
|
||||
import { CalculatingDialog } from "@/components/CalculatingCard";
|
||||
import { chartColors } from "@/components/charts/chartTheme";
|
||||
import { useFetchProgress } from "@/hooks/useFetchProgress";
|
||||
import { useWindowDays } from "@/hooks/useWindowDays";
|
||||
import { WINDOW_PRESET_OPTIONS } from "@/lib/timeWindow";
|
||||
import type {
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
PlanTierDefinitionsResponse,
|
||||
TenantConfiguration,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import { Stat } from "./Stat";
|
||||
import {
|
||||
dailyChart,
|
||||
formatDayKey,
|
||||
MCP_USAGE_MAX_DAYS,
|
||||
MCP_USAGE_VIEW_PARAM,
|
||||
overviewTotals,
|
||||
parseView,
|
||||
planLegend,
|
||||
tenantInfoMap,
|
||||
type McpUsageView,
|
||||
type TenantInfo,
|
||||
} from "./mcpUsage/mcpUsageLogic";
|
||||
import { TenantsTab } from "./mcpUsage/TenantsTab";
|
||||
import { ToolsTab } from "./mcpUsage/ToolsTab";
|
||||
import { LimitsTab } from "./mcpUsage/LimitsTab";
|
||||
|
||||
interface UsageRecord {
|
||||
userId: string;
|
||||
userPrincipalName: string;
|
||||
tenantId: string;
|
||||
endpoint: string;
|
||||
date: string;
|
||||
requestCount: number;
|
||||
lastRequestAt: string;
|
||||
function ChartPlaceholder() {
|
||||
return <div className="flex h-56 items-center justify-center text-sm text-gray-400">Loading chart…</div>;
|
||||
}
|
||||
|
||||
/** One per-day usage row (wire shape). */
|
||||
type DailySummary = UserUsageDailySummary;
|
||||
// recharts (~300 kB) loads only here: SectionClient imports every metrics section statically.
|
||||
const AppStackedBarChart = dynamic(() => import("@/components/charts/AppStackedBarChart"), { ssr: false, loading: ChartPlaceholder });
|
||||
|
||||
function formatDate(yyyymmdd: string): string {
|
||||
if (yyyymmdd.length !== 8) return yyyymmdd;
|
||||
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function getDateFrom(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
|
||||
function getDateTo(): string {
|
||||
return new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
|
||||
interface UserAggregate {
|
||||
upn: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
totalRequests: number;
|
||||
lastActive: string;
|
||||
}
|
||||
const DEFAULT_WINDOW_DAYS = 30;
|
||||
const RANGE_DAY: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" };
|
||||
const PLAN_COLORS: Record<string, string> = { community: chartColors.success, pro: chartColors.primary, msp: chartColors.warning };
|
||||
const NO_TENANT_INFO: ReadonlyMap<string, TenantInfo> = new Map();
|
||||
|
||||
/**
|
||||
* GA metrics: MCP usage over every tenant (GET global/metrics/mcp-usage/overview). Who uses MCP (tenants,
|
||||
* accounts, service principals), which tools and at what cost per call, and where the quota and the two
|
||||
* per-minute limits bite. The window lives in ?days= (at most 90, the telemetry's reach), the tab in ?view=.
|
||||
*/
|
||||
export function SectionMcpUsage() {
|
||||
// useSearchParams (?days=, ?view=) requires a Suspense boundary under the static export.
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-gray-50" />}>
|
||||
<McpUsageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function McpUsageContent() {
|
||||
const { getAccessToken } = useAuth();
|
||||
const [records, setRecords] = useState<UsageRecord[]>([]);
|
||||
const [dailySummaries, setDailySummaries] = useState<DailySummary[]>([]);
|
||||
const searchParams = useSearchParams();
|
||||
// Platform roles may ask for more; the telemetry keeps 90 days, so the page shows at most that.
|
||||
const { days: requestedDays, setDays } = useWindowDays({ defaultDays: DEFAULT_WINDOW_DAYS, tenantId: undefined });
|
||||
const days = Math.min(requestedDays, MCP_USAGE_MAX_DAYS);
|
||||
const view = parseView(searchParams?.get(MCP_USAGE_VIEW_PARAM));
|
||||
const [includePlatformAdmins, setIncludePlatformAdmins] = useState(false);
|
||||
const [overview, setOverview] = useState<GetGlobalMcpUsageOverviewResponse | null>(null);
|
||||
const [tenantRows, setTenantRows] = useState<{ tenants: TenantConfiguration[]; nowMs: number } | null>(null);
|
||||
const [planTiers, setPlanTiers] = useState<PlanTierDefinitionsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [days, setDays] = useState(30);
|
||||
const { begin, finish, elapsedMs, estimateMs } = useFetchProgress("mcpUsageOverview.estimateMs", 10_000);
|
||||
// A slower answer for an older window must never replace a newer one.
|
||||
const requestRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(async (windowDays: number) => {
|
||||
const setView = useCallback((next: McpUsageView) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (next === "tenants") url.searchParams.delete(MCP_USAGE_VIEW_PARAM);
|
||||
else url.searchParams.set(MCP_USAGE_VIEW_PARAM, next);
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}, []);
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
const request = ++requestRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
begin();
|
||||
let fresh = false;
|
||||
try {
|
||||
const dateFrom = getDateFrom(windowDays);
|
||||
const dateTo = getDateTo();
|
||||
|
||||
const [globalData, dailyData] = await Promise.all([
|
||||
fetchJson<GetGlobalMcpUsageResponse>(api.mcpUsage.global(undefined, dateFrom, dateTo), getAccessToken),
|
||||
fetchJson<GetGlobalMcpUsageDailyResponse>(api.mcpUsage.daily(undefined, dateFrom, dateTo), getAccessToken),
|
||||
]);
|
||||
|
||||
setRecords(globalData.records || []);
|
||||
setDailySummaries(dailyData.summaries || []);
|
||||
const data = await fetchJson<GetGlobalMcpUsageOverviewResponse>(
|
||||
api.mcpUsage.overview(days, includePlatformAdmins ? "include" : "exclude"),
|
||||
getAccessToken,
|
||||
);
|
||||
if (request === requestRef.current) {
|
||||
setOverview(data);
|
||||
fresh = true;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(apiErrorText(err, "Failed to fetch usage data"));
|
||||
if (request === requestRef.current) setError(apiErrorText(err, "Failed to load MCP usage"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (request === requestRef.current) {
|
||||
setLoading(false);
|
||||
finish(fresh);
|
||||
}
|
||||
}
|
||||
}, [days, includePlatformAdmins, getAccessToken, begin, finish]);
|
||||
|
||||
// Names, editions and the plan legend: once per visit. Without them the page shows tenant ids and no legend.
|
||||
const fetchReferenceData = useCallback(async () => {
|
||||
const [tenants, tiers] = await Promise.allSettled([
|
||||
fetchJson<TenantConfiguration[]>(api.config.all(), getAccessToken),
|
||||
fetchJson<PlanTierDefinitionsResponse>(api.mcpUsage.planTiers(), getAccessToken),
|
||||
]);
|
||||
if (tenants.status === "fulfilled") setTenantRows({ tenants: tenants.value, nowMs: Date.now() });
|
||||
if (tiers.status === "fulfilled") setPlanTiers(tiers.value);
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
await fetchData(days);
|
||||
await fetchOverview();
|
||||
};
|
||||
void run();
|
||||
}, [fetchData, days]);
|
||||
}, [fetchOverview]);
|
||||
|
||||
// Aggregate by user
|
||||
const userAggregates: UserAggregate[] = (() => {
|
||||
const byUser = new Map<string, UserAggregate>();
|
||||
for (const r of records) {
|
||||
const key = r.userId || r.userPrincipalName;
|
||||
const existing = byUser.get(key);
|
||||
if (existing) {
|
||||
existing.totalRequests += r.requestCount;
|
||||
if (r.lastRequestAt > existing.lastActive) existing.lastActive = r.lastRequestAt;
|
||||
} else {
|
||||
byUser.set(key, {
|
||||
upn: r.userPrincipalName,
|
||||
userId: r.userId,
|
||||
tenantId: r.tenantId,
|
||||
totalRequests: r.requestCount,
|
||||
lastActive: r.lastRequestAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(byUser.values()).sort((a, b) => b.totalRequests - a.totalRequests);
|
||||
})();
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
await fetchReferenceData();
|
||||
};
|
||||
void run();
|
||||
}, [fetchReferenceData]);
|
||||
|
||||
// Sorted daily summaries (newest first)
|
||||
const sortedDaily = [...dailySummaries].sort((a, b) => b.date.localeCompare(a.date));
|
||||
const info = useMemo(
|
||||
() => (tenantRows ? tenantInfoMap(tenantRows.tenants, tenantRows.nowMs) : NO_TENANT_INFO),
|
||||
[tenantRows],
|
||||
);
|
||||
const totals = useMemo(() => (overview ? overviewTotals(overview, info) : null), [overview, info]);
|
||||
const chart = useMemo(() => (overview ? dailyChart(overview) : null), [overview]);
|
||||
const plans = useMemo(() => planLegend(planTiers), [planTiers]);
|
||||
|
||||
const totalRequests = sortedDaily.reduce((sum, d) => sum + d.totalRequests, 0);
|
||||
const uniqueUsersTotal = new Set(records.map(r => r.userId || r.userPrincipalName)).size;
|
||||
const todayStr = getDateTo();
|
||||
const todayRequests = sortedDaily.find(d => d.date === todayStr)?.totalRequests ?? 0;
|
||||
const maxDaily = Math.max(...sortedDaily.map(d => d.totalRequests), 1);
|
||||
const viewOptions: { value: McpUsageView; label: string }[] = [
|
||||
{ value: "tenants", label: "Tenants & accounts" },
|
||||
{ value: "tools", label: "Tools" },
|
||||
{ value: "limits", label: totals && totals.limitAccounts > 0 ? `Limits (${totals.limitAccounts})` : "Limits" },
|
||||
];
|
||||
|
||||
const telemetryDark = overview && !overview.telemetry.requestsAvailable && !overview.telemetry.mcpLogAvailable;
|
||||
|
||||
return (
|
||||
// Self-provides the max-w container the metrics layout used to supply (the other sections
|
||||
// self-wrap with their own min-h-screen layout; this one is a plain content block).
|
||||
// Self-provides the max-w container the metrics layout used to supply.
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Global MCP Usage</h2>
|
||||
<p className="text-sm text-gray-500">API usage across all MCP-enabled users</p>
|
||||
<h2 className="text-lg font-semibold text-gray-900">MCP Usage</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{overview
|
||||
? `${formatDayKey(overview.dateFrom, RANGE_DAY)} – ${formatDayKey(overview.dateTo, RANGE_DAY)} · ${overview.includesPlatformAdmins ? "platform admins included" : "customers only"}`
|
||||
: "MCP usage across all tenants"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl
|
||||
options={WINDOW_PRESET_OPTIONS}
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
/>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-600" title="Global Admins and Global Readers">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includePlatformAdmins}
|
||||
onChange={(e) => setIncludePlatformAdmins(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Include platform admins
|
||||
</label>
|
||||
<SegmentedControl options={WINDOW_PRESET_OPTIONS} value={days} onChange={setDays} />
|
||||
<button
|
||||
onClick={() => fetchData(days)}
|
||||
onClick={() => void fetchOverview()}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Today</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-indigo-600">{todayRequests.toLocaleString()}</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<CalculatingDialog
|
||||
title="Reading MCP usage"
|
||||
subtitle="Quota counters and the request and MCP telemetry of every tenant"
|
||||
elapsedMs={elapsedMs}
|
||||
estimateMs={estimateMs}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Total ({days}d)</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-blue-600">{totalRequests.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Unique Users</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-green-600">{uniqueUsersTotal}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Active Days</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-purple-600">{sortedDaily.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : overview && totals && chart ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Stat
|
||||
label="Tool calls"
|
||||
value={telemetryDark ? "—" : totals.calls.toLocaleString()}
|
||||
hint={totals.mcpOnlyCalls > 0 ? `${totals.toolCount} tools · ${totals.mcpOnlyCalls.toLocaleString()} MCP-only` : `${totals.toolCount} tools`}
|
||||
/>
|
||||
<Stat label="Requests" value={totals.requests.toLocaleString()} hint="charged to the quota" />
|
||||
<Stat label="Tenants" value={totals.tenants.toLocaleString()} hint={`${totals.communityTenants} Community · ${totals.proTenants} Pro`} />
|
||||
<Stat
|
||||
label="Accounts"
|
||||
value={totals.accounts.toLocaleString()}
|
||||
hint={`${totals.accounts - totals.apps} people · ${totals.apps} ${totals.apps === 1 ? "app" : "apps"}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("limits")}
|
||||
title="Show the limits"
|
||||
className="text-left rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Stat
|
||||
label="Limit hits"
|
||||
value={totals.limitAccounts.toLocaleString()}
|
||||
hint={`accounts · ${totals.quotaHitAccounts} quota · ${totals.minuteRefusedAccounts} per minute`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Daily Usage Chart */}
|
||||
{sortedDaily.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-4">Daily Request Volume</h3>
|
||||
<div className="space-y-2">
|
||||
{sortedDaily.slice(0, 30).map((day) => (
|
||||
<div key={day.date} className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="w-20 sm:w-24 text-xs text-gray-500 font-mono shrink-0">
|
||||
{formatDate(day.date)}
|
||||
</div>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-5 relative">
|
||||
<div
|
||||
className="bg-indigo-500 h-5 rounded-full transition-all"
|
||||
style={{ width: `${Math.max((day.totalRequests / maxDaily) * 100, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 sm:w-24 text-xs text-gray-600 text-right shrink-0">
|
||||
{day.totalRequests.toLocaleString()}
|
||||
<span className="hidden sm:inline"> / {day.uniqueUsers} users</span>
|
||||
</div>
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Calls per day</h3>
|
||||
{!overview.telemetry.requestsAvailable ? (
|
||||
<p className="py-10 text-center text-sm text-gray-500">The request telemetry did not answer: the calls per day are missing.</p>
|
||||
) : chart.plans.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-gray-500">No tool calls in this window.</p>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<AppStackedBarChart
|
||||
data={chart.rows}
|
||||
xKey="date"
|
||||
stacks={chart.plans.map((plan) => ({ dataKey: `plan:${plan}`, label: plan, color: PLAN_COLORS[plan] ?? chartColors.muted }))}
|
||||
height={220}
|
||||
formatXTick={(value) => formatDayKey(String(value))}
|
||||
renderTooltip={(row) => <DayTooltip row={row} plans={chart.plans} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Endpoints (aggregated across all users) */}
|
||||
{records.length > 0 && (() => {
|
||||
const endpointTotals = new Map<string, number>();
|
||||
for (const r of records) {
|
||||
endpointTotals.set(r.endpoint, (endpointTotals.get(r.endpoint) || 0) + r.requestCount);
|
||||
}
|
||||
const topEndpoints = Array.from(endpointTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 15);
|
||||
return topEndpoints.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-900">Top Endpoints</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{topEndpoints.map(([endpoint, count]) => {
|
||||
// MCP tool calls are recorded as "toolname:/api/route" — show the
|
||||
// tool name prominently and the route as a secondary line.
|
||||
const sep = endpoint.indexOf(":");
|
||||
const tool = sep > 0 ? endpoint.slice(0, sep) : endpoint;
|
||||
const route = sep > 0 ? endpoint.slice(sep + 1) : null;
|
||||
return (
|
||||
<div key={endpoint} className="px-4 sm:px-6 py-3 flex items-center justify-between gap-3 hover:bg-gray-50">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-mono text-gray-700 truncate">{tool}</div>
|
||||
{route && (
|
||||
<div className="text-xs font-mono text-gray-400 truncate">{route}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 shrink-0">
|
||||
{count.toLocaleString()}
|
||||
<span className="hidden sm:inline"> requests</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<SegmentedControl options={viewOptions} value={view} onChange={setView} className="w-fit max-w-full overflow-x-auto" />
|
||||
|
||||
{/* Top Users */}
|
||||
{userAggregates.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-900">Top Users by Request Count</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{userAggregates.slice(0, 25).map((user) => (
|
||||
<div key={user.userId || user.upn} className="px-4 sm:px-6 py-3 flex items-center justify-between gap-3 hover:bg-gray-50">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-gray-900 truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{user.upn ? principalLabel(user.upn) : user.userId}</span>
|
||||
{isApplicationKey(user.upn) && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 shrink-0" title="Service principal — automation calling with an app-only token">
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
<span className="font-mono">{user.tenantId ? user.tenantId.slice(0, 8) : "—"}</span>
|
||||
{" · "}
|
||||
{user.lastActive ? new Date(user.lastActive).toLocaleDateString() : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 shrink-0">
|
||||
{user.totalRequests.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && records.length === 0 && !error && (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-500">No MCP usage data found for the selected period.</p>
|
||||
</div>
|
||||
)}
|
||||
{view === "tenants" && <TenantsTab tenants={overview.tenants} info={info} />}
|
||||
{view === "tools" && <ToolsTab tools={overview.tools} telemetry={overview.telemetry} dateTo={overview.dateTo} />}
|
||||
{view === "limits" && (
|
||||
<LimitsTab
|
||||
quotaHits={overview.quotaHits}
|
||||
minuteLimits={overview.minuteLimits}
|
||||
telemetry={overview.telemetry}
|
||||
plans={plans}
|
||||
info={info}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayTooltip({ row, plans }: { row: Record<string, unknown>; plans: readonly string[] }) {
|
||||
const line = (label: string, value: unknown, indent = false) => (
|
||||
<p key={label} className={`flex justify-between gap-4 ${indent ? "pl-3 text-gray-300" : ""}`}>
|
||||
<span>{label}</span>
|
||||
<span className="font-semibold tabular-nums">{Number(value ?? 0).toLocaleString()}</span>
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{formatDayKey(String(row.date), { weekday: "short", day: "numeric", month: "short" })}</p>
|
||||
{line("Tool calls", row.calls)}
|
||||
{plans.map((plan) => line(plan, row[`plan:${plan}`], true))}
|
||||
{line("Requests", row.requests)}
|
||||
{line("Accounts", row.accounts)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
McpUsageAccountItem,
|
||||
McpUsageMinuteItem,
|
||||
McpUsageTenantItem,
|
||||
McpUsageToolItem,
|
||||
PlanTierDefinitionsResponse,
|
||||
TenantConfiguration,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import {
|
||||
accountNearLimit,
|
||||
dailyChart,
|
||||
formatChars,
|
||||
formatDayKey,
|
||||
formatDuration,
|
||||
formatShare,
|
||||
minuteRowsToShow,
|
||||
minuteShare,
|
||||
overviewTotals,
|
||||
parseView,
|
||||
planLegend,
|
||||
searchTenants,
|
||||
sortTenants,
|
||||
sortTools,
|
||||
tenantInfoMap,
|
||||
tenantLabel,
|
||||
tenantNearLimit,
|
||||
usagePercent,
|
||||
} from "../mcpUsage/mcpUsageLogic";
|
||||
|
||||
const TENANT_A = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
const TENANT_B = "bbbbbbbb-0000-0000-0000-00000000000b";
|
||||
|
||||
function account(principal: string, over: Partial<McpUsageAccountItem> = {}): McpUsageAccountItem {
|
||||
return {
|
||||
principal,
|
||||
isPlatformAdmin: false,
|
||||
quota: { plan: "community", dailyLimit: 50, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 20 },
|
||||
calls: 5,
|
||||
requests: 10,
|
||||
quotaHits: 0,
|
||||
topTools: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function tenant(tenantId: string, accounts: McpUsageAccountItem[], over: Partial<McpUsageTenantItem> = {}): McpUsageTenantItem {
|
||||
return {
|
||||
tenantId,
|
||||
quota: { tenantPlan: "community", dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 20 },
|
||||
calls: accounts.reduce((s, a) => s + a.calls, 0),
|
||||
requests: accounts.reduce((s, a) => s + a.requests, 0),
|
||||
quotaHits: 0,
|
||||
accounts,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function config(tenantId: string, domainName: string, planTier: string, over: Partial<TenantConfiguration> = {}): TenantConfiguration {
|
||||
return { tenantId, domainName, planTier, trialConsumed: false, payingCustomer: false, disabled: false, ...over } as TenantConfiguration;
|
||||
}
|
||||
|
||||
const INFO = tenantInfoMap(
|
||||
[config(TENANT_A.toUpperCase(), "contoso.com", "free", { companyName: "Contoso Ltd" }), config(TENANT_B, "fabrikam.com", "pro")],
|
||||
Date.UTC(2026, 10, 5),
|
||||
);
|
||||
|
||||
describe("parseView", () => {
|
||||
it("keeps the two other tabs and falls back to tenants", () => {
|
||||
expect(parseView("tools")).toBe("tools");
|
||||
expect(parseView("limits")).toBe("limits");
|
||||
expect(parseView(null)).toBe("tenants");
|
||||
expect(parseView("Tools")).toBe("tenants");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenantInfoMap", () => {
|
||||
it("keys by the lower-cased id and resolves the edition", () => {
|
||||
expect(INFO.get(TENANT_A)?.domainName).toBe("contoso.com");
|
||||
expect(INFO.get(TENANT_A)?.edition).toBe("community");
|
||||
expect(INFO.get(TENANT_B)?.edition).toBe("pro");
|
||||
expect(tenantLabel(TENANT_B, INFO)).toBe("fabrikam.com");
|
||||
expect(tenantLabel("cccccccc-0000-0000-0000-00000000000c", INFO)).toBe("cccccccc…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usagePercent and near-limit", () => {
|
||||
it("reads unlimited and unavailable windows as no share", () => {
|
||||
expect(usagePercent(40, 50)).toBe(80);
|
||||
expect(usagePercent(71, 50)).toBe(142);
|
||||
expect(usagePercent(5, 0)).toBeNull();
|
||||
expect(usagePercent(-1, 50)).toBeNull();
|
||||
});
|
||||
|
||||
it("flags an account at 80 % of a window or with a refusal", () => {
|
||||
expect(accountNearLimit(account("a@contoso.com", { quota: { plan: "community", dailyLimit: 50, monthlyLimit: 250, dailyUsed: 40, monthlyUsed: 40 } }))).toBe(true);
|
||||
expect(accountNearLimit(account("a@contoso.com", { quotaHits: 1 }))).toBe(true);
|
||||
expect(accountNearLimit(account("a@contoso.com"))).toBe(false);
|
||||
expect(accountNearLimit(account("a@contoso.com", { quota: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a tenant through its organization window or any account", () => {
|
||||
expect(tenantNearLimit(tenant(TENANT_A, [account("a@contoso.com")]))).toBe(false);
|
||||
expect(tenantNearLimit(tenant(TENANT_A, [account("a@contoso.com")], { quota: { tenantPlan: "community", dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 200 } }))).toBe(true);
|
||||
expect(tenantNearLimit(tenant(TENANT_A, [account("a@contoso.com", { quotaHits: 2 })]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortTenants", () => {
|
||||
it("puts an unlimited window below every real share", () => {
|
||||
const limited = tenant(TENANT_A, [account("a@contoso.com")]);
|
||||
const unlimited = tenant(TENANT_B, [account("b@fabrikam.com")], { quota: { tenantPlan: "pro", dailyLimit: 0, monthlyLimit: 0, dailyUsed: 900, monthlyUsed: 900 } });
|
||||
expect(sortTenants([unlimited, limited], "today", "desc").map((t) => t.tenantId)).toEqual([TENANT_A, TENANT_B]);
|
||||
expect(sortTenants([limited, unlimited], "requests", "asc").map((t) => t.tenantId)).toEqual([TENANT_A, TENANT_B]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchTenants", () => {
|
||||
const alice = account("alice@contoso.com");
|
||||
const app = account("app:11111111-2222-3333-4444-555555555555");
|
||||
const bob = account("bob@fabrikam.com", { quotaHits: 1 });
|
||||
const tenants = [tenant(TENANT_A, [alice, app]), tenant(TENANT_B, [bob])];
|
||||
|
||||
it("keeps every tenant and account without a query", () => {
|
||||
expect(searchTenants(tenants, INFO, " ", false).map((m) => m.accounts.length)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it("keeps all accounts of a tenant matched by domain, company or id", () => {
|
||||
for (const q of ["CONTOSO.COM", "contoso ltd", "aaaaaaaa"]) {
|
||||
const [match] = searchTenants(tenants, INFO, q, false);
|
||||
expect(match.tenant.tenantId).toBe(TENANT_A);
|
||||
expect(match.accounts).toHaveLength(2);
|
||||
expect(match.matchedByAccount).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps only the matching accounts of a tenant matched by an account, and opens it", () => {
|
||||
const [match] = searchTenants(tenants, INFO, "11111111-2222", false);
|
||||
expect(match.accounts.map((a) => a.principal)).toEqual([app.principal]);
|
||||
expect(match.matchedByAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("near a limit only keeps tenants and accounts at a limit", () => {
|
||||
const matches = searchTenants(tenants, INFO, "", true);
|
||||
expect(matches.map((m) => m.tenant.tenantId)).toEqual([TENANT_B]);
|
||||
expect(matches[0].accounts).toEqual([bob]);
|
||||
});
|
||||
});
|
||||
|
||||
function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlobalMcpUsageOverviewResponse {
|
||||
return {
|
||||
dateFrom: "20261103",
|
||||
dateTo: "20261105",
|
||||
includesPlatformAdmins: false,
|
||||
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
|
||||
daily: [],
|
||||
tenants: [],
|
||||
tools: [],
|
||||
quotaHits: [],
|
||||
minuteLimits: { accounts: [] },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function tool(name: string, calls: number, over: Partial<McpUsageToolItem> = {}): McpUsageToolItem {
|
||||
return { tool: name, calls, accounts: 1, tenants: 1, requestsPerCall: 1, routes: [], ...over };
|
||||
}
|
||||
|
||||
function minute(principal: string, over: Partial<McpUsageMinuteItem> = {}): McpUsageMinuteItem {
|
||||
return { tenantId: TENANT_A, principal, peakCalls: 0, mcpRefused: 0, peakRequests: 0, backendLimit: 120, backendRefused: 0, ...over };
|
||||
}
|
||||
|
||||
describe("overviewTotals", () => {
|
||||
it("counts MCP-only calls in the total and every limited account once", () => {
|
||||
const totals = overviewTotals(
|
||||
overview({
|
||||
tenants: [tenant(TENANT_A, [account("alice@contoso.com"), account("app:11111111-2222-3333-4444-555555555555")]), tenant(TENANT_B, [account("bob@fabrikam.com")])],
|
||||
tools: [tool("get_session_summary", 10), tool("search_docs", 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0 })],
|
||||
quotaHits: [
|
||||
{ date: "20261105", tenantId: TENANT_A, principal: "alice@contoso.com", level: "user", scope: "daily", plan: "community", refused: 3, firstAt: "", lastAt: "" },
|
||||
{ date: "20261104", tenantId: TENANT_A, principal: "alice@contoso.com", level: "user", scope: "daily", plan: "community", refused: 1, firstAt: "", lastAt: "" },
|
||||
],
|
||||
minuteLimits: { accounts: [minute("alice@contoso.com", { backendRefused: 2 }), minute("bob@fabrikam.com", { tenantId: TENANT_B, mcpRefused: 7 })] },
|
||||
}),
|
||||
INFO,
|
||||
);
|
||||
|
||||
expect(totals.calls).toBe(14);
|
||||
expect(totals.mcpOnlyCalls).toBe(4);
|
||||
expect(totals.toolCount).toBe(2);
|
||||
expect(totals.requests).toBe(30);
|
||||
expect([totals.tenants, totals.communityTenants, totals.proTenants]).toEqual([2, 1, 1]);
|
||||
expect([totals.accounts, totals.apps]).toEqual([3, 1]);
|
||||
expect([totals.limitAccounts, totals.quotaHitAccounts, totals.minuteRefusedAccounts]).toEqual([2, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyChart", () => {
|
||||
it("stacks the plans by their total calls and fills days without calls with 0", () => {
|
||||
const chart = dailyChart(
|
||||
overview({
|
||||
daily: [
|
||||
{ date: "20261104", calls: 3, requests: 9, accounts: 1, callsByPlan: [{ plan: "pro", calls: 3 }] },
|
||||
{ date: "20261105", calls: 12, requests: 20, accounts: 2, callsByPlan: [{ plan: "community", calls: 10 }, { plan: "pro", calls: 2 }] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(chart.plans).toEqual(["community", "pro"]);
|
||||
expect(chart.rows[0]).toEqual({ date: "20261104", calls: 3, requests: 9, accounts: 1, "plan:community": 0, "plan:pro": 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-minute rows", () => {
|
||||
it("takes the higher share of the two limits", () => {
|
||||
expect(minuteShare(minute("a", { peakCalls: 30, peakRequests: 108 }), 60)).toBe(90);
|
||||
expect(minuteShare(minute("a", { peakCalls: 45, peakRequests: 12 }), 60)).toBe(75);
|
||||
// No MCP limit reported yet: only the backend share counts.
|
||||
expect(minuteShare(minute("a", { peakCalls: 59, peakRequests: 12 }), undefined)).toBe(10);
|
||||
});
|
||||
|
||||
it("shows a refusal at any peak, else a peak at half a limit", () => {
|
||||
const rows = [
|
||||
minute("low", { peakCalls: 5, peakRequests: 10 }),
|
||||
minute("refused", { peakRequests: 10, backendRefused: 1 }),
|
||||
minute("close", { peakCalls: 30 }),
|
||||
];
|
||||
expect(minuteRowsToShow(rows, 60).map((r) => r.principal)).toEqual(["refused", "close"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortTools", () => {
|
||||
it("puts a figure the MCP log did not deliver below every real value", () => {
|
||||
const tools = [tool("a", 5, { errorRate: undefined }), tool("b", 1, { errorRate: 0.2 }), tool("c", 9, { errorRate: 0 })];
|
||||
expect(sortTools(tools, "errorRate", "desc").map((t) => t.tool)).toEqual(["b", "c", "a"]);
|
||||
expect(sortTools(tools, "calls", "desc").map((t) => t.tool)).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planLegend", () => {
|
||||
it("fills a blank organization window of community or pro from the catalog only", () => {
|
||||
const response = {
|
||||
tiers: [
|
||||
{ name: "community", dailyRequestLimit: 50, monthlyRequestLimit: 250, description: "" },
|
||||
{ name: "msp", dailyRequestLimit: 1500, monthlyRequestLimit: 15000, description: "" },
|
||||
],
|
||||
catalog: [
|
||||
{ edition: "community", dailyRequestLimit: 100, monthlyRequestLimit: 3000, tenantDailyRequestLimit: 300, tenantMonthlyRequestLimit: 9000, includedDelegatedSlots: 0, slotDailyRequestLimit: 0, slotMonthlyRequestLimit: 0, slotTenantDailyRequestLimit: 0, slotTenantMonthlyRequestLimit: 0 },
|
||||
],
|
||||
} as PlanTierDefinitionsResponse;
|
||||
|
||||
expect(planLegend(response)).toEqual([
|
||||
{ name: "community", daily: 50, monthly: 250, tenantDaily: 300, tenantMonthly: 9000 },
|
||||
{ name: "msp", daily: 1500, monthly: 15000, tenantDaily: undefined, tenantMonthly: undefined },
|
||||
]);
|
||||
expect(planLegend(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatting", () => {
|
||||
it("formats durations, sizes and shares", () => {
|
||||
expect(formatDuration(95)).toBe("95 ms");
|
||||
expect(formatDuration(812)).toBe("812 ms");
|
||||
expect(formatDuration(1029)).toBe("1.0 s");
|
||||
expect(formatDuration(undefined)).toBe("—");
|
||||
expect(formatChars(167)).toBe("167 B");
|
||||
expect(formatChars(19277)).toBe("19.3 KB");
|
||||
expect(formatShare(0)).toBe("0 %");
|
||||
expect(formatShare(0.0321)).toBe("3.2 %");
|
||||
expect(formatShare(0.25)).toBe("25 %");
|
||||
expect(formatShare(undefined)).toBe("—");
|
||||
});
|
||||
|
||||
it("leaves anything but a yyyyMMdd day unchanged", () => {
|
||||
expect(formatDayKey("2026-11-05")).toBe("2026-11-05");
|
||||
expect(formatDayKey("20261105")).not.toBe("20261105");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { QuotaBar } from "@/components/QuotaBar";
|
||||
import { applicationIdOf } from "@/lib/principalKeys";
|
||||
import type {
|
||||
McpUsageMinuteLimits,
|
||||
McpUsageQuotaHitItem,
|
||||
McpUsageTelemetryStatus,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import { formatDayKey, minuteRowsToShow, minuteShare, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
|
||||
import { DataNote, Pill } from "./parts";
|
||||
|
||||
function limitText(value: number | undefined): string {
|
||||
if (value == null) return "edition default";
|
||||
return value <= 0 ? "unlimited" : value.toLocaleString();
|
||||
}
|
||||
|
||||
function timeOfDay(iso: string | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function Principal({ principal }: { principal: string }) {
|
||||
const appId = applicationIdOf(principal);
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{appId && <Pill title="Service principal: an application calling with an app-only token">App</Pill>}
|
||||
<span className={`break-all sm:break-normal sm:truncate ${appId ? "font-mono" : ""}`}>{appId ?? principal}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LimitsTab({
|
||||
quotaHits,
|
||||
minuteLimits,
|
||||
telemetry,
|
||||
plans,
|
||||
info,
|
||||
}: {
|
||||
quotaHits: readonly McpUsageQuotaHitItem[];
|
||||
minuteLimits: McpUsageMinuteLimits;
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
plans: readonly PlanLegendRow[];
|
||||
info: ReadonlyMap<string, TenantInfo>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<QuotaSection quotaHits={quotaHits} telemetry={telemetry} plans={plans} info={info} />
|
||||
<MinuteSection minuteLimits={minuteLimits} telemetry={telemetry} info={info} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaSection({
|
||||
quotaHits,
|
||||
telemetry,
|
||||
plans,
|
||||
info,
|
||||
}: {
|
||||
quotaHits: readonly McpUsageQuotaHitItem[];
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
plans: readonly PlanLegendRow[];
|
||||
info: ReadonlyMap<string, TenantInfo>;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-3 border-b border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-900">Daily and monthly quota</h3>
|
||||
</div>
|
||||
{plans.length > 0 && (
|
||||
<div className="px-4 sm:px-6 py-3 border-b border-gray-200 overflow-x-auto">
|
||||
<table className="text-xs text-gray-600">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500">
|
||||
<th className="pr-6 font-medium">Plan</th>
|
||||
<th className="pr-6 font-medium">Account per day / month</th>
|
||||
<th className="font-medium">Organization per day / month</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.name}>
|
||||
<td className="pr-6 py-0.5 font-medium text-gray-900">{plan.name}</td>
|
||||
<td className="pr-6 py-0.5 tabular-nums">{limitText(plan.daily)} / {limitText(plan.monthly)}</td>
|
||||
<td className="py-0.5 tabular-nums">{limitText(plan.tenantDaily)} / {limitText(plan.tenantMonthly)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!telemetry.requestsAvailable ? (
|
||||
<DataNote>The request telemetry did not answer: the refusals are missing.</DataNote>
|
||||
) : quotaHits.length === 0 ? (
|
||||
<p className="px-6 py-8 text-center text-sm text-gray-500">No request was refused by a daily or monthly quota in this window.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{quotaHits.map((hit) => (
|
||||
<li
|
||||
key={`${hit.date}|${hit.tenantId}|${hit.principal ?? ""}|${hit.level}|${hit.scope}`}
|
||||
className="px-4 sm:px-6 py-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
|
||||
>
|
||||
<span className="w-16 shrink-0 text-gray-500 tabular-nums">{formatDayKey(hit.date)}</span>
|
||||
<span className="min-w-0 basis-full sm:basis-auto sm:flex-1">
|
||||
<span className="text-gray-900">{hit.principal ? <Principal principal={hit.principal} /> : "—"}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">{tenantLabel(hit.tenantId, info)}</span>
|
||||
</span>
|
||||
<Pill>{hit.level === "tenant" ? "Organization" : "Account"} · {hit.scope}</Pill>
|
||||
<Pill>plan {hit.plan}</Pill>
|
||||
<span className="tabular-nums text-red-700" title="The window's count at the first refusal of the day, of its limit">
|
||||
{hit.used?.toLocaleString() ?? "?"} / {hit.limit?.toLocaleString() ?? "?"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{hit.refused === 1 ? "1 request refused" : `${hit.refused.toLocaleString()} requests refused`}, {timeOfDay(hit.firstAt)}
|
||||
{hit.lastAt !== hit.firstAt && ` – ${timeOfDay(hit.lastAt)}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinuteSection({
|
||||
minuteLimits,
|
||||
telemetry,
|
||||
info,
|
||||
}: {
|
||||
minuteLimits: McpUsageMinuteLimits;
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
info: ReadonlyMap<string, TenantInfo>;
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const mcpLimit = minuteLimits.mcpServerLimit;
|
||||
const close = minuteRowsToShow(minuteLimits.accounts, mcpLimit);
|
||||
const rows = showAll ? minuteLimits.accounts : close;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-3 border-b border-gray-200 flex flex-wrap items-center gap-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Per-minute limits</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
MCP server: {mcpLimit ? `${mcpLimit} messages per minute per account` : "limit not reported yet"} · Backend: per account, shown in each row
|
||||
</span>
|
||||
{minuteLimits.accounts.length > close.length && (
|
||||
<label className="ml-auto inline-flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} className="rounded border-gray-300" />
|
||||
Show all {minuteLimits.accounts.length} accounts
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{!telemetry.requestsAvailable && <DataNote>The request telemetry did not answer: the peaks and the backend refusals are missing.</DataNote>}
|
||||
{!telemetry.mcpLogAvailable && <DataNote>The MCP log did not answer: the MCP server's refusals and its limit are missing.</DataNote>}
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-6 py-8 text-center text-sm text-gray-500">
|
||||
{minuteLimits.accounts.length === 0
|
||||
? "No MCP requests in this window."
|
||||
: "No account came within half of a per-minute limit, and none was refused."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
key={`${row.tenantId}|${row.principal}`}
|
||||
className="px-4 sm:px-6 py-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_12rem_12rem_6rem] lg:items-center"
|
||||
>
|
||||
<div className="min-w-0 text-sm text-gray-900">
|
||||
<Principal principal={row.principal} />
|
||||
<div className="text-xs text-gray-500">
|
||||
{tenantLabel(row.tenantId, info)} · busiest 60 s at {minuteShare(row, mcpLimit)} % of a limit
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{mcpLimit ? (
|
||||
<QuotaBar label="Tool calls / 60 s" used={row.peakCalls} limit={mcpLimit} />
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">Tool calls / 60 s: {row.peakCalls.toLocaleString()}</span>
|
||||
)}
|
||||
{row.mcpRefused > 0 && (
|
||||
<p className="mt-1 text-xs text-red-700">{row.mcpRefused.toLocaleString()} refused by the MCP server, last {timeOfDay(row.mcpLastRefusedAt)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<QuotaBar label="Requests / 60 s" used={row.peakRequests} limit={row.backendLimit} />
|
||||
{row.backendRefused > 0 && (
|
||||
<p className="mt-1 text-xs text-red-700">{row.backendRefused.toLocaleString()} refused by the backend, last {timeOfDay(row.backendLastRefusedAt)}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{timeOfDay(row.peakRequestsAt ?? row.peakCallsAt)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { QuotaBar } from "@/components/QuotaBar";
|
||||
import { applicationIdOf } from "@/lib/principalKeys";
|
||||
import { tenantManagementUrl } from "@/lib/routes";
|
||||
import type { McpUsageAccountItem, McpUsageTenantItem } from "@/lib/generated/wire-types.generated";
|
||||
import {
|
||||
formatDayKey,
|
||||
searchTenants,
|
||||
sortAccounts,
|
||||
sortTenants,
|
||||
type SortDir,
|
||||
type TenantInfo,
|
||||
type UsageSortKey,
|
||||
} from "./mcpUsageLogic";
|
||||
import { Chevron, EditionBadge, Figure, HitsPill, Pill, SortHeader } from "./parts";
|
||||
import { useOpenSet, useSortState } from "./useListState";
|
||||
|
||||
// One grid for the header and every tenant row from lg up; below lg the rows wrap into cards.
|
||||
const COLS = "lg:grid lg:grid-cols-[minmax(0,1fr)_4.5rem_4.5rem_5rem_9rem_9rem_7rem] lg:items-center lg:gap-4";
|
||||
|
||||
const DEFAULT_DIR: Record<UsageSortKey, SortDir> = { calls: "desc", requests: "desc", today: "desc", month: "desc", lastActive: "desc" };
|
||||
|
||||
function formatLastActive(iso: string | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** The plan an edition gets without an override — anything else is worth naming next to the badge. */
|
||||
function isDefaultPlan(plan: string, info: TenantInfo | undefined): boolean {
|
||||
if (!info) return true;
|
||||
return plan === (info.edition === "community" ? "community" : "pro");
|
||||
}
|
||||
|
||||
export function TenantsTab({ tenants, info }: { tenants: readonly McpUsageTenantItem[]; info: ReadonlyMap<string, TenantInfo> }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
const [nearLimitOnly, setNearLimitOnly] = useState(false);
|
||||
const { sortKey, sortDir, onSort } = useSortState<UsageSortKey>("calls", DEFAULT_DIR);
|
||||
const { open, toggle } = useOpenSet();
|
||||
|
||||
const matches = useMemo(
|
||||
() => searchTenants(sortTenants(tenants, sortKey, sortDir), info, deferredQuery, nearLimitOnly),
|
||||
[tenants, info, deferredQuery, nearLimitOnly, sortKey, sortDir],
|
||||
);
|
||||
|
||||
const searching = deferredQuery.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-3 border-b border-gray-200 flex flex-wrap items-center gap-3">
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Tenant, user or app id"
|
||||
ariaLabel="Search tenants, users and apps"
|
||||
name="mcp-usage-search"
|
||||
tone="slate"
|
||||
/>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={nearLimitOnly} onChange={(e) => setNearLimitOnly(e.target.checked)} className="rounded border-gray-300" />
|
||||
Near a limit
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
{matches.length === tenants.length ? `${tenants.length} tenants` : `${matches.length} of ${tenants.length} tenants`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`hidden ${COLS} px-4 sm:px-6 py-2 border-b border-gray-200 bg-gray-50`}>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-500">Tenant</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right">Accounts</span>
|
||||
<span className="text-right"><SortHeader sortKey="calls" current={sortKey} dir={sortDir} onSort={onSort} align="right">Calls</SortHeader></span>
|
||||
<span className="text-right"><SortHeader sortKey="requests" current={sortKey} dir={sortDir} onSort={onSort} align="right">Requests</SortHeader></span>
|
||||
<SortHeader sortKey="today" current={sortKey} dir={sortDir} onSort={onSort}>Today</SortHeader>
|
||||
<SortHeader sortKey="month" current={sortKey} dir={sortDir} onSort={onSort}>Month</SortHeader>
|
||||
<SortHeader sortKey="lastActive" current={sortKey} dir={sortDir} onSort={onSort}>Last active</SortHeader>
|
||||
</div>
|
||||
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-gray-500">
|
||||
{tenants.length === 0
|
||||
? "No MCP use in this window."
|
||||
: searching
|
||||
? `No tenant, user or app matches “${deferredQuery.trim()}”.`
|
||||
: "No tenant is near a limit."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{matches.map(({ tenant, accounts, matchedByAccount }) => {
|
||||
const t = info.get(tenant.tenantId.toLowerCase());
|
||||
const isOpen = open.has(tenant.tenantId) || matchedByAccount;
|
||||
const apps = tenant.accounts.filter((a) => applicationIdOf(a.principal)).length;
|
||||
return (
|
||||
<li key={tenant.tenantId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(tenant.tenantId)}
|
||||
aria-expanded={isOpen}
|
||||
className={`w-full text-left px-4 sm:px-6 py-3 hover:bg-gray-50 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}
|
||||
>
|
||||
<div className="flex min-w-0 basis-full items-start gap-2 lg:basis-auto">
|
||||
<span className="mt-0.5"><Chevron open={isOpen} /></span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-gray-900 break-all sm:break-normal sm:truncate">
|
||||
{t?.domainName ?? tenant.tenantId}
|
||||
</span>
|
||||
{t && <EditionBadge edition={t.edition} />}
|
||||
{!isDefaultPlan(tenant.quota.tenantPlan, t) && <Pill title="Usage plan set for this tenant">plan {tenant.quota.tenantPlan}</Pill>}
|
||||
{t?.mcpDisabled && <Pill title="MCP is closed for this tenant">MCP closed</Pill>}
|
||||
<HitsPill count={tenant.quotaHits} title="Quota refusals in this window" />
|
||||
</div>
|
||||
{t?.companyName && <div className="text-xs text-gray-500 truncate">{t.companyName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<Figure label="Accounts" value={apps > 0 ? `${tenant.accounts.length - apps} + ${apps} app${apps === 1 ? "" : "s"}` : String(tenant.accounts.length)} />
|
||||
<Figure label="Calls" value={tenant.calls.toLocaleString()} />
|
||||
<Figure label="Requests" value={tenant.requests.toLocaleString()} />
|
||||
<div className="w-full sm:w-40 lg:w-auto">
|
||||
<QuotaBar label="Today" used={tenant.quota.dailyUsed} limit={tenant.quota.dailyLimit} />
|
||||
</div>
|
||||
<div className="w-full sm:w-40 lg:w-auto">
|
||||
<QuotaBar label="Month" used={tenant.quota.monthlyUsed} limit={tenant.quota.monthlyLimit} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500"><span className="lg:hidden">Last active </span>{formatLastActive(tenant.lastRequestAt)}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<AccountList accounts={sortAccounts(accounts, sortKey, sortDir)} tenantPlan={tenant.quota.tenantPlan} tenantId={tenant.tenantId} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountList({ accounts, tenantPlan, tenantId }: { accounts: readonly McpUsageAccountItem[]; tenantPlan: string; tenantId: string }) {
|
||||
return (
|
||||
<div className="bg-gray-50 border-t border-gray-200">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{accounts.map((account) => (
|
||||
<AccountRow key={account.principal} account={account} tenantPlan={tenantPlan} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="px-4 sm:px-6 py-2 text-right">
|
||||
<Link href={tenantManagementUrl(tenantId)} className="text-xs text-blue-700 hover:underline">
|
||||
Open in Tenant Management
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, tenantPlan }: { account: McpUsageAccountItem; tenantPlan: string }) {
|
||||
const appId = applicationIdOf(account.principal);
|
||||
return (
|
||||
<li className={`px-4 sm:px-6 py-3 pl-10 sm:pl-12 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}>
|
||||
<div className="min-w-0 basis-full lg:basis-auto">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{appId && <Pill title="Service principal: an application calling with an app-only token">App</Pill>}
|
||||
<span className={`text-sm text-gray-900 break-all sm:break-normal sm:truncate ${appId ? "font-mono" : ""}`}>
|
||||
{appId ?? account.principal}
|
||||
</span>
|
||||
{account.role && <Pill title="Role on the latest request">{account.role}</Pill>}
|
||||
{account.isPlatformAdmin && <Pill title="Global Admin or Global Reader">platform</Pill>}
|
||||
{account.quota && account.quota.plan !== tenantPlan && <Pill title="Personal usage plan">plan {account.quota.plan}</Pill>}
|
||||
<HitsPill count={account.quotaHits} title="Days this account met its own quota" />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{account.topTools.map((t) => (
|
||||
<span key={t.tool} className="rounded bg-white border border-gray-200 px-1.5 py-0.5 text-xs font-mono text-gray-600" title={`${t.calls} calls`}>
|
||||
{t.tool} <span className="text-gray-400">{t.calls}</span>
|
||||
</span>
|
||||
))}
|
||||
{account.peakDay && (
|
||||
<span className="text-xs text-gray-500">
|
||||
busiest day {formatDayKey(account.peakDay)} with {account.peakDayRequests?.toLocaleString()} requests
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="hidden lg:block" />
|
||||
<Figure label="Calls" value={account.calls.toLocaleString()} />
|
||||
<Figure label="Requests" value={account.requests.toLocaleString()} />
|
||||
<div className="w-full sm:w-40 lg:w-auto">
|
||||
{account.quota ? <QuotaBar label="Today" used={account.quota.dailyUsed} limit={account.quota.dailyLimit} /> : <span className="text-xs text-gray-400">no quota state</span>}
|
||||
</div>
|
||||
<div className="w-full sm:w-40 lg:w-auto">
|
||||
{account.quota && <QuotaBar label="Month" used={account.quota.monthlyUsed} limit={account.quota.monthlyLimit} />}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500"><span className="lg:hidden">Last active </span>{formatLastActive(account.lastRequestAt)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { McpUsageTelemetryStatus, McpUsageToolItem } from "@/lib/generated/wire-types.generated";
|
||||
import { formatChars, formatDayKey, formatDuration, formatShare, sortTools, type SortDir, type ToolSortKey } from "./mcpUsageLogic";
|
||||
import { Chevron, DataNote, Figure, Pill, SortHeader } from "./parts";
|
||||
import { useOpenSet, useSortState } from "./useListState";
|
||||
|
||||
const COLS = "lg:grid lg:grid-cols-[minmax(0,1fr)_10rem_4.5rem_4.5rem_6rem_4.5rem_4.5rem_5rem_4.5rem] lg:items-center lg:gap-4";
|
||||
|
||||
const DEFAULT_DIR: Record<ToolSortKey, SortDir> = { calls: "desc", requestsPerCall: "desc", errorRate: "desc", p95: "desc", size: "desc" };
|
||||
|
||||
export function ToolsTab({
|
||||
tools,
|
||||
telemetry,
|
||||
dateTo,
|
||||
}: {
|
||||
tools: readonly McpUsageToolItem[];
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
dateTo: string;
|
||||
}) {
|
||||
const { sortKey, sortDir, onSort } = useSortState<ToolSortKey>("calls", DEFAULT_DIR);
|
||||
const { open, toggle } = useOpenSet();
|
||||
|
||||
const sorted = useMemo(() => sortTools(tools, sortKey, sortDir), [tools, sortKey, sortDir]);
|
||||
const maxCalls = Math.max(1, ...tools.map((t) => t.calls));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{!telemetry.requestsAvailable && (
|
||||
<DataNote>The request telemetry did not answer: calls, reach and the cost per call are missing.</DataNote>
|
||||
)}
|
||||
{!telemetry.mcpLogAvailable && (
|
||||
<DataNote>The MCP log did not answer: error rate, duration, answer size and the MCP-only tools are missing.</DataNote>
|
||||
)}
|
||||
{telemetry.mcpLogFrom && (
|
||||
<DataNote>
|
||||
Error rate, duration, answer size and the MCP-only tools cover {formatDayKey(telemetry.mcpLogFrom)} – {formatDayKey(dateTo)}.
|
||||
</DataNote>
|
||||
)}
|
||||
|
||||
<div className={`hidden ${COLS} px-4 sm:px-6 py-2 border-b border-gray-200 bg-gray-50`}>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-500">Tool</span>
|
||||
<SortHeader sortKey="calls" current={sortKey} dir={sortDir} onSort={onSort}>Calls</SortHeader>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right">Accounts</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right">Tenants</span>
|
||||
<span className="text-right" title="Backend requests one call costs the quota on average">
|
||||
<SortHeader sortKey="requestsPerCall" current={sortKey} dir={sortDir} onSort={onSort} align="right">Req./call</SortHeader>
|
||||
</span>
|
||||
<span className="text-right" title="Calls that ended as an error, soft errors included">
|
||||
<SortHeader sortKey="errorRate" current={sortKey} dir={sortDir} onSort={onSort} align="right">Errors</SortHeader>
|
||||
</span>
|
||||
<span className="text-right" title="95 % of the calls took at most this long">
|
||||
<SortHeader sortKey="p95" current={sortKey} dir={sortDir} onSort={onSort} align="right">p95</SortHeader>
|
||||
</span>
|
||||
<span className="text-right" title="Mean size of the answer the model reads">
|
||||
<SortHeader sortKey="size" current={sortKey} dir={sortDir} onSort={onSort} align="right">Size</SortHeader>
|
||||
</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right" title="Calls whose page exceeded the tool's size cap">
|
||||
Over cap
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-gray-500">No tool calls in this window.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{sorted.map((tool) => {
|
||||
const mcpOnly = tool.accounts == null;
|
||||
const expandable = tool.routes.length > 0;
|
||||
const isOpen = expandable && open.has(tool.tool);
|
||||
const share = Math.max(2, Math.round((tool.calls / maxCalls) * 100));
|
||||
const row = (
|
||||
<>
|
||||
<div className="flex min-w-0 basis-full items-center gap-2 lg:basis-auto">
|
||||
{expandable ? <Chevron open={isOpen} /> : <span className="w-4 shrink-0" />}
|
||||
<span className="font-mono text-sm text-gray-900 break-all sm:break-normal sm:truncate">{tool.tool}</span>
|
||||
{mcpOnly && <Pill title="Runs in the MCP server alone: no backend request, no quota cost">MCP only</Pill>}
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2 sm:w-48 lg:w-auto">
|
||||
<div className="h-2 flex-1 rounded-full bg-gray-100">
|
||||
<div className="h-2 rounded-full bg-indigo-500" style={{ width: `${share}%` }} />
|
||||
</div>
|
||||
<span className="w-12 text-right text-sm tabular-nums text-gray-900">{tool.calls.toLocaleString()}</span>
|
||||
</div>
|
||||
<Figure label="Accounts" value={tool.accounts?.toLocaleString() ?? "—"} />
|
||||
<Figure label="Tenants" value={tool.tenants?.toLocaleString() ?? "—"} />
|
||||
<Figure label="Req./call" value={mcpOnly ? "0" : tool.requestsPerCall.toLocaleString(undefined, { maximumFractionDigits: 1 })} />
|
||||
<Figure label="Errors" value={formatShare(tool.errorRate)} tone={tool.errorRate != null && tool.errorRate >= 0.05 ? "warn" : undefined} />
|
||||
<Figure label="p95" value={formatDuration(tool.p95DurationMs)} />
|
||||
<Figure label="Size" value={formatChars(tool.avgResultChars)} />
|
||||
<Figure label="Over cap" value={tool.overCapCalls?.toLocaleString() ?? "—"} tone={tool.overCapCalls ? "warn" : undefined} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={tool.tool}>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(tool.tool)}
|
||||
aria-expanded={isOpen}
|
||||
className={`w-full text-left px-4 sm:px-6 py-3 hover:bg-gray-50 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}
|
||||
>
|
||||
{row}
|
||||
</button>
|
||||
) : (
|
||||
<div className={`px-4 sm:px-6 py-3 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}>{row}</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div className="bg-gray-50 border-t border-gray-200 px-4 sm:px-6 py-2 pl-10 sm:pl-12">
|
||||
<p className="text-xs text-gray-500 mb-1">Backend requests of its calls</p>
|
||||
<ul className="space-y-0.5">
|
||||
{tool.routes.map((route) => (
|
||||
<li key={route.route} className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="font-mono text-gray-700 break-all">{route.route}</span>
|
||||
<span className="tabular-nums text-gray-600">{route.requests.toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Pure folds behind the GA MCP Usage page (/admin/metrics/mcp-usage). The route
|
||||
* (GET global/metrics/mcp-usage/overview) delivers the numbers; this module only joins tenant names,
|
||||
* filters, sorts and formats. "Calls" are tool calls, "requests" are backend requests charged to the
|
||||
* quota — the page never mixes the two in one figure.
|
||||
*/
|
||||
import { effectiveEdition, type PlanEdition } from "../../../components/tenantFilters";
|
||||
import { isApplicationKey } from "@/lib/principalKeys";
|
||||
import type {
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
McpUsageAccountItem,
|
||||
McpUsageMinuteItem,
|
||||
McpUsageTenantItem,
|
||||
McpUsageToolItem,
|
||||
PlanTierDefinitionsResponse,
|
||||
TenantConfiguration,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
|
||||
// ── View (?view=) ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type McpUsageView = "tenants" | "tools" | "limits";
|
||||
|
||||
export const MCP_USAGE_VIEW_PARAM = "view";
|
||||
|
||||
/** The tab a link asks for; anything unknown is the default tab. */
|
||||
export function parseView(raw: string | null | undefined): McpUsageView {
|
||||
return raw === "tools" || raw === "limits" ? raw : "tenants";
|
||||
}
|
||||
|
||||
/** The telemetry keeps 90 days; a longer ?days= window shows 90. */
|
||||
export const MCP_USAGE_MAX_DAYS = 90;
|
||||
|
||||
// ── Tenant names ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** What the page shows about a tenant beyond the overview: name and edition from config/all. */
|
||||
export interface TenantInfo {
|
||||
domainName?: string;
|
||||
companyName?: string;
|
||||
edition: PlanEdition;
|
||||
mcpDisabled: boolean;
|
||||
}
|
||||
|
||||
/** config/all by lower-cased tenant id — the overview's ids are lower-cased too. */
|
||||
export function tenantInfoMap(tenants: readonly TenantConfiguration[], nowMs: number): Map<string, TenantInfo> {
|
||||
const map = new Map<string, TenantInfo>();
|
||||
for (const t of tenants) {
|
||||
map.set(t.tenantId.toLowerCase(), {
|
||||
domainName: t.domainName || undefined,
|
||||
companyName: t.companyName || undefined,
|
||||
edition: effectiveEdition(t, nowMs),
|
||||
mcpDisabled: !!t.mcpDisabled,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Display name of a tenant: its domain, else the first block of its id. */
|
||||
export function tenantLabel(tenantId: string, info: ReadonlyMap<string, TenantInfo>): string {
|
||||
return info.get(tenantId.toLowerCase())?.domainName ?? `${tenantId.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
// ── Windows and limits ────────────────────────────────────────────────────────
|
||||
|
||||
/** Share of a window in whole percent; null when the window is unlimited (0) or the counter was unavailable (−1). */
|
||||
export function usagePercent(used: number, limit: number): number | null {
|
||||
if (limit <= 0 || used < 0) return null;
|
||||
return Math.round((used / limit) * 100);
|
||||
}
|
||||
|
||||
/** The bars turn amber here (QuotaBar) — the same threshold marks "near a limit". */
|
||||
export const NEAR_LIMIT_PERCENT = 80;
|
||||
|
||||
interface WindowPair {
|
||||
dailyUsed: number;
|
||||
dailyLimit: number;
|
||||
monthlyUsed: number;
|
||||
monthlyLimit: number;
|
||||
}
|
||||
|
||||
function dailyShare(q: WindowPair | undefined): number {
|
||||
return q ? usagePercent(q.dailyUsed, q.dailyLimit) ?? -1 : -1;
|
||||
}
|
||||
|
||||
function monthlyShare(q: WindowPair | undefined): number {
|
||||
return q ? usagePercent(q.monthlyUsed, q.monthlyLimit) ?? -1 : -1;
|
||||
}
|
||||
|
||||
export function accountNearLimit(a: McpUsageAccountItem): boolean {
|
||||
return a.quotaHits > 0 || Math.max(dailyShare(a.quota), monthlyShare(a.quota)) >= NEAR_LIMIT_PERCENT;
|
||||
}
|
||||
|
||||
export function tenantNearLimit(t: McpUsageTenantItem): boolean {
|
||||
return t.quotaHits > 0
|
||||
|| Math.max(dailyShare(t.quota), monthlyShare(t.quota)) >= NEAR_LIMIT_PERCENT
|
||||
|| t.accounts.some(accountNearLimit);
|
||||
}
|
||||
|
||||
// ── Sorting ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type UsageSortKey = "calls" | "requests" | "today" | "month" | "lastActive";
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
function lastActiveMs(iso: string | undefined): number {
|
||||
return iso ? new Date(iso).getTime() : -1;
|
||||
}
|
||||
|
||||
function sortValue(item: { calls: number; requests: number; lastRequestAt?: string }, quota: WindowPair | undefined, key: UsageSortKey): number {
|
||||
switch (key) {
|
||||
case "calls": return item.calls;
|
||||
case "requests": return item.requests;
|
||||
case "today": return dailyShare(quota);
|
||||
case "month": return monthlyShare(quota);
|
||||
case "lastActive": return lastActiveMs(item.lastRequestAt);
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a: number, b: number, dir: SortDir): number {
|
||||
return dir === "asc" ? a - b : b - a;
|
||||
}
|
||||
|
||||
export function sortTenants(tenants: readonly McpUsageTenantItem[], key: UsageSortKey, dir: SortDir): McpUsageTenantItem[] {
|
||||
return [...tenants].sort((a, b) => compare(sortValue(a, a.quota, key), sortValue(b, b.quota, key), dir) || b.calls - a.calls);
|
||||
}
|
||||
|
||||
export function sortAccounts(accounts: readonly McpUsageAccountItem[], key: UsageSortKey, dir: SortDir): McpUsageAccountItem[] {
|
||||
return [...accounts].sort((a, b) => compare(sortValue(a, a.quota, key), sortValue(b, b.quota, key), dir) || b.calls - a.calls);
|
||||
}
|
||||
|
||||
export type ToolSortKey = "calls" | "requestsPerCall" | "errorRate" | "p95" | "size";
|
||||
|
||||
/** Tools by a column; a figure the MCP log did not deliver sorts below every real value. */
|
||||
export function sortTools(tools: readonly McpUsageToolItem[], key: ToolSortKey, dir: SortDir): McpUsageToolItem[] {
|
||||
const value = (t: McpUsageToolItem): number => {
|
||||
switch (key) {
|
||||
case "calls": return t.calls;
|
||||
case "requestsPerCall": return t.requestsPerCall;
|
||||
case "errorRate": return t.errorRate ?? -1;
|
||||
case "p95": return t.p95DurationMs ?? -1;
|
||||
case "size": return t.avgResultChars ?? -1;
|
||||
}
|
||||
};
|
||||
return [...tools].sort((a, b) => compare(value(a), value(b), dir) || b.calls - a.calls || a.tool.localeCompare(b.tool));
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TenantMatch {
|
||||
tenant: McpUsageTenantItem;
|
||||
/** The accounts to list: all of them when the tenant matched, else only the matching ones. */
|
||||
accounts: McpUsageAccountItem[];
|
||||
/** The query matched an account, not the tenant — the row opens by itself. */
|
||||
matchedByAccount: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenants matching the query by domain, company or id; or holding an account whose principal (UPN or
|
||||
* app:<client-id>) matches. Case-insensitive substring; an empty query keeps everything.
|
||||
*/
|
||||
export function searchTenants(
|
||||
tenants: readonly McpUsageTenantItem[],
|
||||
info: ReadonlyMap<string, TenantInfo>,
|
||||
query: string,
|
||||
nearLimitOnly: boolean,
|
||||
): TenantMatch[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const result: TenantMatch[] = [];
|
||||
for (const tenant of tenants) {
|
||||
if (nearLimitOnly && !tenantNearLimit(tenant)) continue;
|
||||
const accounts = nearLimitOnly ? tenant.accounts.filter(accountNearLimit) : tenant.accounts;
|
||||
if (!q) {
|
||||
result.push({ tenant, accounts: [...accounts], matchedByAccount: false });
|
||||
continue;
|
||||
}
|
||||
const t = info.get(tenant.tenantId.toLowerCase());
|
||||
const tenantHit = [tenant.tenantId, t?.domainName, t?.companyName].some((v) => v?.toLowerCase().includes(q));
|
||||
if (tenantHit) {
|
||||
result.push({ tenant, accounts: [...accounts], matchedByAccount: false });
|
||||
continue;
|
||||
}
|
||||
const hits = accounts.filter((a) => a.principal.toLowerCase().includes(q));
|
||||
if (hits.length > 0) result.push({ tenant, accounts: hits, matchedByAccount: true });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Totals (the tiles) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface OverviewTotals {
|
||||
calls: number;
|
||||
/** Calls of tools that run in the MCP server alone — counted in `calls`, attributed to no account. */
|
||||
mcpOnlyCalls: number;
|
||||
toolCount: number;
|
||||
requests: number;
|
||||
tenants: number;
|
||||
communityTenants: number;
|
||||
proTenants: number;
|
||||
accounts: number;
|
||||
apps: number;
|
||||
/** Accounts that met any limit in the window. */
|
||||
limitAccounts: number;
|
||||
quotaHitAccounts: number;
|
||||
minuteRefusedAccounts: number;
|
||||
}
|
||||
|
||||
export function overviewTotals(overview: GetGlobalMcpUsageOverviewResponse, info: ReadonlyMap<string, TenantInfo>): OverviewTotals {
|
||||
const accounts = overview.tenants.flatMap((t) => t.accounts);
|
||||
const editionOf = (tenantId: string) => info.get(tenantId.toLowerCase())?.edition;
|
||||
const quotaHit = new Set(overview.quotaHits.map((h) => `${h.tenantId}|${h.principal ?? ""}`));
|
||||
const minuteRefused = new Set(
|
||||
overview.minuteLimits.accounts.filter((m) => m.mcpRefused > 0 || m.backendRefused > 0).map((m) => `${m.tenantId}|${m.principal}`),
|
||||
);
|
||||
return {
|
||||
calls: overview.tools.reduce((sum, t) => sum + t.calls, 0),
|
||||
mcpOnlyCalls: overview.tools.filter((t) => t.accounts == null).reduce((sum, t) => sum + t.calls, 0),
|
||||
toolCount: overview.tools.length,
|
||||
requests: overview.tenants.reduce((sum, t) => sum + t.requests, 0),
|
||||
tenants: overview.tenants.length,
|
||||
communityTenants: overview.tenants.filter((t) => editionOf(t.tenantId) === "community").length,
|
||||
proTenants: overview.tenants.filter((t) => editionOf(t.tenantId)?.startsWith("pro")).length,
|
||||
accounts: accounts.length,
|
||||
apps: accounts.filter((a) => isApplicationKey(a.principal)).length,
|
||||
limitAccounts: new Set([...quotaHit, ...minuteRefused]).size,
|
||||
quotaHitAccounts: quotaHit.size,
|
||||
minuteRefusedAccounts: minuteRefused.size,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Daily chart ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DailyChart {
|
||||
rows: Array<Record<string, string | number>>;
|
||||
/** Usage plans with calls in the window, most calls first — one stack each. */
|
||||
plans: string[];
|
||||
}
|
||||
|
||||
/** One row per day: date, calls, requests, accounts and one column per usage plan (its calls). */
|
||||
export function dailyChart(overview: GetGlobalMcpUsageOverviewResponse): DailyChart {
|
||||
const totals = new Map<string, number>();
|
||||
for (const day of overview.daily) for (const p of day.callsByPlan) totals.set(p.plan, (totals.get(p.plan) ?? 0) + p.calls);
|
||||
const plans = [...totals.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([plan]) => plan);
|
||||
const rows = overview.daily.map((day) => {
|
||||
const row: Record<string, string | number> = { date: day.date, calls: day.calls, requests: day.requests, accounts: day.accounts };
|
||||
for (const plan of plans) row[`plan:${plan}`] = day.callsByPlan.find((p) => p.plan === plan)?.calls ?? 0;
|
||||
return row;
|
||||
});
|
||||
return { rows, plans };
|
||||
}
|
||||
|
||||
// ── Per-minute limits ─────────────────────────────────────────────────────────
|
||||
|
||||
/** A peak counts as close from here; below it an account only shows with a refusal. */
|
||||
export const MINUTE_SHOW_PERCENT = 50;
|
||||
|
||||
/** The higher of an account's two per-minute shares (MCP server, backend), in whole percent. */
|
||||
export function minuteShare(row: McpUsageMinuteItem, mcpLimit: number | undefined): number {
|
||||
const mcp = mcpLimit && mcpLimit > 0 ? (row.peakCalls / mcpLimit) * 100 : 0;
|
||||
const backend = row.backendLimit > 0 ? (row.peakRequests / row.backendLimit) * 100 : 0;
|
||||
return Math.round(Math.max(mcp, backend));
|
||||
}
|
||||
|
||||
/** Rows worth a look: a refusal, or a peak at half a limit or more. */
|
||||
export function minuteRowsToShow(rows: readonly McpUsageMinuteItem[], mcpLimit: number | undefined): McpUsageMinuteItem[] {
|
||||
return rows.filter((r) => r.mcpRefused > 0 || r.backendRefused > 0 || minuteShare(r, mcpLimit) >= MINUTE_SHOW_PERCENT);
|
||||
}
|
||||
|
||||
// ── Plan legend ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PlanLegendRow {
|
||||
name: string;
|
||||
daily: number;
|
||||
monthly: number;
|
||||
/** Organization windows; absent when the plan leaves them to the tenant's edition. */
|
||||
tenantDaily?: number;
|
||||
tenantMonthly?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base windows of each usage plan (0 = unlimited). A blank organization window of the community
|
||||
* or pro plan falls back to that edition's catalog value; another plan's blank window follows the
|
||||
* tenant's edition, so it stays open here.
|
||||
*/
|
||||
export function planLegend(response: PlanTierDefinitionsResponse | null): PlanLegendRow[] {
|
||||
if (!response) return [];
|
||||
return response.tiers.map((tier) => {
|
||||
const catalog = response.catalog.find((c) => c.edition === tier.name.toLowerCase());
|
||||
return {
|
||||
name: tier.name,
|
||||
daily: tier.dailyRequestLimit,
|
||||
monthly: tier.monthlyRequestLimit,
|
||||
tenantDaily: tier.tenantDailyRequestLimit ?? catalog?.tenantDailyRequestLimit,
|
||||
tenantMonthly: tier.tenantMonthlyRequestLimit ?? catalog?.tenantMonthlyRequestLimit,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Formatting ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A UTC day (yyyyMMdd) in the viewer's locale, never shifted by the viewer's time zone. */
|
||||
export function formatDayKey(key: string, options: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" }): string {
|
||||
if (!/^\d{8}$/.test(key)) return key;
|
||||
const iso = `${key.slice(0, 4)}-${key.slice(4, 6)}-${key.slice(6, 8)}T00:00:00Z`;
|
||||
return new Date(iso).toLocaleDateString(undefined, { ...options, timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** "0.8 s" from 812 ms, "95 ms" below a second, "—" without a value. */
|
||||
export function formatDuration(ms: number | undefined): string {
|
||||
if (ms == null) return "—";
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
/** Answer size in KB (one character ≈ one byte of JSON), "—" without a value. */
|
||||
export function formatChars(chars: number | undefined): string {
|
||||
if (chars == null) return "—";
|
||||
return chars < 1000 ? `${chars} B` : `${(chars / 1000).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
/** A 0–1 share as "12.5 %", "0 %" for none, "—" without a value. */
|
||||
export function formatShare(share: number | undefined): string {
|
||||
if (share == null) return "—";
|
||||
if (share === 0) return "0 %";
|
||||
const pct = share * 100;
|
||||
return `${pct < 10 ? pct.toFixed(1) : Math.round(pct)} %`;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { editionLabel, facetOption, type PlanEdition } from "../../../components/tenantFilters";
|
||||
import type { SortDir } from "./mcpUsageLogic";
|
||||
|
||||
/** A disclosure arrow that turns when its row opens. */
|
||||
export function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A column label that sorts: ▲/▼ on the active column, ↕ on the others. */
|
||||
export function SortHeader<K extends string>({
|
||||
sortKey,
|
||||
current,
|
||||
dir,
|
||||
onSort,
|
||||
children,
|
||||
align = "left",
|
||||
}: {
|
||||
sortKey: K;
|
||||
current: K;
|
||||
dir: SortDir;
|
||||
onSort: (key: K) => void;
|
||||
children: ReactNode;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const active = current === sortKey;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(sortKey)}
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 ${align === "right" ? "justify-end" : ""}`}
|
||||
>
|
||||
{children}
|
||||
<span className={`text-[10px] ${active ? "text-indigo-600" : "text-gray-300"}`} aria-hidden="true">
|
||||
{active ? (dir === "asc" ? "▲" : "▼") : "↕"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A neutral context label (role, plan, origin) in the gray-outline pill style. */
|
||||
export function Pill({ children, title }: { children: ReactNode; title?: string }) {
|
||||
return (
|
||||
<span title={title} className="inline-flex shrink-0 items-center rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tenant's edition in the colors the tenant lists use. */
|
||||
export function EditionBadge({ edition }: { edition: PlanEdition }) {
|
||||
return (
|
||||
<span className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${facetOption("plan", edition)?.badgeClass ?? ""}`}>
|
||||
{editionLabel(edition)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A count of refusals: red when there were any, nothing otherwise. */
|
||||
export function HitsPill({ count, title }: { count: number; title: string }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<span title={title} className="inline-flex shrink-0 items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
|
||||
{count === 1 ? "1 limit hit" : `${count} limit hits`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A number with its own label below lg, where the column header row is hidden; amber for a figure worth a look. */
|
||||
export function Figure({ label, value, tone }: { label: string; value: string; tone?: "warn" }) {
|
||||
return (
|
||||
<span className={`text-sm tabular-nums lg:text-right ${tone === "warn" ? "text-amber-700" : "text-gray-900"}`}>
|
||||
<span className="text-xs text-gray-500 lg:hidden">{label} </span>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A grey line naming a gap in the data (a store that did not answer, a log that reaches back less far). */
|
||||
export function DataNote({ children }: { children: ReactNode }) {
|
||||
return <p className="px-4 sm:px-6 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">{children}</p>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { SortDir } from "./mcpUsageLogic";
|
||||
|
||||
/** A sortable column set: a click on the active column flips the direction, another column starts at its default. */
|
||||
export function useSortState<K extends string>(initial: K, defaultDir: Record<K, SortDir>) {
|
||||
const [sortKey, setSortKey] = useState<K>(initial);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(defaultDir[initial]);
|
||||
const onSort = (key: K) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(defaultDir[key]);
|
||||
}
|
||||
};
|
||||
return { sortKey, sortDir, onSort };
|
||||
}
|
||||
|
||||
/** Which rows of a list are open. */
|
||||
export function useOpenSet() {
|
||||
const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const toggle = (id: string) =>
|
||||
setOpen((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
return { open, toggle };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
McpUsageQuotaNode,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import { isApplicationKey, principalLabel } from "@/lib/principalKeys";
|
||||
import { QuotaBar } from "@/components/QuotaBar";
|
||||
|
||||
interface UsageRecord {
|
||||
userId: string;
|
||||
@@ -51,33 +52,6 @@ function breakdownLabel(limit: number, slots: number | undefined, perSlot: numbe
|
||||
return b && `${b.base.toLocaleString()} + ${b.slots} × ${b.perSlot.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function QuotaBar({ label, used, limit, breakdown }: { label: string; used: number; limit: number; breakdown?: string | null }) {
|
||||
const unavailable = used < 0;
|
||||
const unlimited = limit <= 0;
|
||||
const pct = unavailable || unlimited ? 0 : Math.min(100, Math.round((used / limit) * 100));
|
||||
const tone = pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{label}</span>
|
||||
<span className="font-mono text-right">
|
||||
{unavailable ? "n/a" : used.toLocaleString()}
|
||||
{" / "}
|
||||
{unlimited ? "unlimited" : limit.toLocaleString()}
|
||||
{breakdown && (
|
||||
<span className="block text-gray-400" title="Plan base + purchased delegation slots × growth per slot">
|
||||
{breakdown}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-full h-2">
|
||||
<div className={`${tone} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(yyyymmdd: string): string {
|
||||
if (yyyymmdd.length !== 8) return yyyymmdd;
|
||||
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* One MCP quota window as a bar: used of limit, indigo up to 80 %, amber from 80 %, red at the limit.
|
||||
* A used value below 0 means the counter was unavailable ("n/a"); a limit of 0 means unlimited. The
|
||||
* optional breakdown names the plan base plus purchased delegation slots behind a grown limit.
|
||||
*/
|
||||
export function QuotaBar({ label, used, limit, breakdown }: { label: string; used: number; limit: number; breakdown?: string | null }) {
|
||||
const unavailable = used < 0;
|
||||
const unlimited = limit <= 0;
|
||||
const pct = unavailable || unlimited ? 0 : Math.min(100, Math.round((used / limit) * 100));
|
||||
const tone = pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{label}</span>
|
||||
<span className="font-mono text-right">
|
||||
{unavailable ? "n/a" : used.toLocaleString()}
|
||||
{" / "}
|
||||
{unlimited ? "unlimited" : limit.toLocaleString()}
|
||||
{breakdown && (
|
||||
<span className="block text-gray-400" title="Plan base + purchased delegation slots × growth per slot">
|
||||
{breakdown}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-full h-2">
|
||||
<div className={`${tone} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { chartColors, tooltipStyle, axisTick, axisLine } from "./chartTheme";
|
||||
import { RenderedTooltip } from "./RenderedTooltip";
|
||||
|
||||
export interface AppBarChartProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
@@ -33,20 +34,6 @@ export interface AppBarChartProps {
|
||||
integerTicks?: boolean;
|
||||
}
|
||||
|
||||
interface RenderedTooltipProps {
|
||||
render: (row: Record<string, unknown>) => ReactNode;
|
||||
// Injected by recharts when it clones the content element.
|
||||
active?: boolean;
|
||||
payload?: ReadonlyArray<{ payload?: unknown }>;
|
||||
}
|
||||
|
||||
// Module level on purpose: recharts clones this element per hover, so its type must stay stable.
|
||||
function RenderedTooltip({ render, active, payload }: RenderedTooltipProps) {
|
||||
const row = active ? payload?.[0]?.payload : undefined;
|
||||
if (!row || typeof row !== "object") return null;
|
||||
return <div style={tooltipStyle}>{render(row as Record<string, unknown>)}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable bar chart used by the App Dashboard detail page and the GA Customers section.
|
||||
* Used for "Top Failure Codes" and "Installer Phase Breakdown" etc.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { chartColors, tooltipStyle, axisTick, axisLine, legendStyle } from "./chartTheme";
|
||||
import { RenderedTooltip } from "./RenderedTooltip";
|
||||
|
||||
export interface StackedSeries {
|
||||
dataKey: string;
|
||||
@@ -24,6 +26,8 @@ export interface AppStackedBarChartProps {
|
||||
stacks: StackedSeries[];
|
||||
height?: number;
|
||||
formatXTick?: (value: unknown) => string;
|
||||
/** Replaces the tooltip content for the hovered row. */
|
||||
renderTooltip?: (row: Record<string, unknown>) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +43,7 @@ export default function AppStackedBarChart({
|
||||
stacks,
|
||||
height = 260,
|
||||
formatXTick,
|
||||
renderTooltip,
|
||||
}: AppStackedBarChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
@@ -64,6 +69,7 @@ export default function AppStackedBarChart({
|
||||
labelStyle={{ color: chartColors.tooltipText, marginBottom: 4 }}
|
||||
cursor={{ fill: chartColors.gridLine, opacity: 0.3 }}
|
||||
labelFormatter={formatXTick ? (l: unknown) => formatXTick(l) : undefined}
|
||||
{...(renderTooltip ? { content: <RenderedTooltip render={renderTooltip} /> } : {})}
|
||||
/>
|
||||
<Legend wrapperStyle={legendStyle} iconType="square" />
|
||||
{stacks.map((s) => (
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { tooltipStyle } from "./chartTheme";
|
||||
|
||||
interface RenderedTooltipProps {
|
||||
render: (row: Record<string, unknown>) => ReactNode;
|
||||
// Injected by recharts when it clones the content element.
|
||||
active?: boolean;
|
||||
payload?: ReadonlyArray<{ payload?: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip content that hands the hovered row to a caller's render function. Module level on purpose:
|
||||
* recharts clones this element per hover, so its type must stay stable.
|
||||
*/
|
||||
export function RenderedTooltip({ render, active, payload }: RenderedTooltipProps) {
|
||||
const row = active ? payload?.[0]?.payload : undefined;
|
||||
if (!row || typeof row !== "object") return null;
|
||||
return <div style={tooltipStyle}>{render(row as Record<string, unknown>)}</div>;
|
||||
}
|
||||
@@ -834,10 +834,9 @@ export const api = {
|
||||
/** One tenant's organization budget by account for a GA / Global Reader; tenantId is required (no aggregate). */
|
||||
globalOrganization: (tenantId: string, dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/global/metrics/mcp-usage/organization${qs({ tenantId, dateFrom, dateTo })}`,
|
||||
global: (tenantId?: string, dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/global/metrics/mcp-usage${qs({ tenantId, dateFrom, dateTo })}`,
|
||||
daily: (tenantId?: string, dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/global/metrics/mcp-usage/daily${qs({ tenantId, dateFrom, dateTo })}`,
|
||||
/** All-tenant overview of the GA metrics page: tenants, accounts, tools, refusals, per-minute peaks over 1–90 days. */
|
||||
overview: (days: number, platformAdmins: "include" | "exclude") =>
|
||||
`${API_BASE_URL}/api/global/metrics/mcp-usage/overview${qs({ days: String(days), platformAdmins })}`,
|
||||
planTiers: () => `${API_BASE_URL}/api/global/config/plan-tiers`,
|
||||
},
|
||||
|
||||
|
||||
@@ -693,6 +693,7 @@ export const SHARED_MANIFEST = {
|
||||
"DiagnosticsUploadEnabled",
|
||||
"DiagnosticsUploadDisabled",
|
||||
"RuleEvaluationStopped",
|
||||
"McpQuotaExceeded",
|
||||
"SessionActionQueued",
|
||||
"SessionTimeouts",
|
||||
"AgentEmergencyBreak",
|
||||
|
||||
@@ -689,6 +689,7 @@
|
||||
"DiagnosticsUploadEnabled",
|
||||
"DiagnosticsUploadDisabled",
|
||||
"RuleEvaluationStopped",
|
||||
"McpQuotaExceeded",
|
||||
"SessionActionQueued",
|
||||
"SessionTimeouts",
|
||||
"AgentEmergencyBreak",
|
||||
@@ -10960,6 +10961,94 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"GetGlobalMcpUsageOverviewResponse": {
|
||||
"kind": "object",
|
||||
"doc": "The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks and the daily trend. \"Calls\" are tool calls (request telemetry, one MCP correlation id per call); \"requests\" are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is empty and named in Telemetry.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "dateFrom",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "First day of the window (yyyyMMdd, UTC)."
|
||||
},
|
||||
{
|
||||
"name": "dateTo",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Last day of the window (yyyyMMdd, UTC) — today."
|
||||
},
|
||||
{
|
||||
"name": "includesPlatformAdmins",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "False when Global Admins and Global Readers are left out of every number."
|
||||
},
|
||||
{
|
||||
"name": "telemetry",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageTelemetryStatus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "daily",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageDayItem"
|
||||
}
|
||||
},
|
||||
"doc": "One item per day of the window, oldest first."
|
||||
},
|
||||
{
|
||||
"name": "tenants",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageTenantItem"
|
||||
}
|
||||
},
|
||||
"doc": "Every tenant with MCP use in the window, most calls first."
|
||||
},
|
||||
{
|
||||
"name": "tools",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageToolItem"
|
||||
}
|
||||
},
|
||||
"doc": "Every tool called in the window, most calls first."
|
||||
},
|
||||
{
|
||||
"name": "quotaHits",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageQuotaHitItem"
|
||||
}
|
||||
},
|
||||
"doc": "Refusals by the daily or monthly quota, one item per day, account and window, newest first."
|
||||
},
|
||||
{
|
||||
"name": "minuteLimits",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageMinuteLimits"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"GetGlobalMcpUsageResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates over the records key — its name is wire-critical.",
|
||||
@@ -15851,6 +15940,434 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageAccountItem": {
|
||||
"kind": "object",
|
||||
"doc": "One account (person or service principal) of a tenant in the overview.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "userId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Entra object id; absent for an account known only from request telemetry (every request refused)."
|
||||
},
|
||||
{
|
||||
"name": "principal",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "UPN, or app:<client-id> for a service principal."
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Role on its latest request (Admin, Operator, Viewer, DelegatedReader, GlobalReader, GlobalAdmin); absent without request telemetry."
|
||||
},
|
||||
{
|
||||
"name": "isPlatformAdmin",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "A Global Admin or Global Reader; listed only when platform admins are included."
|
||||
},
|
||||
{
|
||||
"name": "quota",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageAccountQuotaNode"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The account's plan and windows as its next request meets them; absent without an object id."
|
||||
},
|
||||
{
|
||||
"name": "calls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "requests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "peakDay",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The day (yyyyMMdd) with the most charged requests in the window; absent without requests."
|
||||
},
|
||||
{
|
||||
"name": "peakDayRequests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Charged requests on PeakDay; absent without requests."
|
||||
},
|
||||
{
|
||||
"name": "quotaHits",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Days on which the account met a quota refusal of its own window."
|
||||
},
|
||||
{
|
||||
"name": "topTools",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageToolCallsItem"
|
||||
}
|
||||
},
|
||||
"doc": "The account's most called tools, at most three, most first."
|
||||
},
|
||||
{
|
||||
"name": "lastRequestAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Absent when neither the counters nor the telemetry carry a timestamp."
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageAccountQuotaNode": {
|
||||
"kind": "object",
|
||||
"doc": "The account's own windows (0 = unlimited; purchased-slot growth included).",
|
||||
"fields": [
|
||||
{
|
||||
"name": "plan",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "The account's usage plan: its personal override, else the tenant plan."
|
||||
},
|
||||
{
|
||||
"name": "dailyLimit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "monthlyLimit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dailyUsed",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "monthlyUsed",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageDayItem": {
|
||||
"kind": "object",
|
||||
"doc": "One day of the overview trend.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "date",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "yyyyMMdd."
|
||||
},
|
||||
{
|
||||
"name": "calls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "requests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accounts",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Accounts with a call or a charged request that day."
|
||||
},
|
||||
{
|
||||
"name": "callsByPlan",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsagePlanCallsItem"
|
||||
}
|
||||
},
|
||||
"doc": "The day's calls by the usage plan of the caller's tenant, most first."
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageMinuteItem": {
|
||||
"kind": "object",
|
||||
"doc": "One account's busiest 60 seconds against both per-minute limits, and its refusals.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tenantId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "principal",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "peakCalls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Most tool calls the account started inside 60 seconds (measured in 10-second steps)."
|
||||
},
|
||||
{
|
||||
"name": "peakCallsAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "mcpRefused",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Messages the MCP server refused because of its per-minute limit."
|
||||
},
|
||||
{
|
||||
"name": "mcpLastRefusedAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "peakRequests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Most backend requests the account sent inside 60 seconds (measured in 10-second steps)."
|
||||
},
|
||||
{
|
||||
"name": "peakRequestsAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "backendLimit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Backend requests per minute the account may send in its home tenant (override, else the default raised to the edition floor)."
|
||||
},
|
||||
{
|
||||
"name": "backendRefused",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Requests the backend refused because of its per-minute limit."
|
||||
},
|
||||
{
|
||||
"name": "backendLastRefusedAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageMinuteLimits": {
|
||||
"kind": "object",
|
||||
"doc": "The two per-minute limits an MCP account meets and how close the accounts came to them.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "mcpServerLimit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "MCP messages per minute the MCP server admits per account, from its newest start line; absent until a server reported it."
|
||||
},
|
||||
{
|
||||
"name": "accounts",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageMinuteItem"
|
||||
}
|
||||
},
|
||||
"doc": "Every account with request telemetry in the window, closest to a limit first."
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsagePlanCallsItem": {
|
||||
"kind": "object",
|
||||
"doc": "Calls of one usage plan on one day.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "plan",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageQuotaHitItem": {
|
||||
"kind": "object",
|
||||
"doc": "Quota refusals of one account (level user) or organization (level tenant) on one day and window.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "date",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "yyyyMMdd."
|
||||
},
|
||||
{
|
||||
"name": "tenantId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "principal",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The refused account; absent when the request row carried no principal."
|
||||
},
|
||||
{
|
||||
"name": "level",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "user (the account's window) or tenant (the organization's)."
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "daily or monthly."
|
||||
},
|
||||
{
|
||||
"name": "plan",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The window's limit; absent when the refusal line carried none."
|
||||
},
|
||||
{
|
||||
"name": "used",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The window's count at the first refusal of the day; absent when the refusal line carried none."
|
||||
},
|
||||
{
|
||||
"name": "refused",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "firstAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lastAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageQuotaNode": {
|
||||
"kind": "object",
|
||||
"doc": "Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their HOME tenant (shared by every member; 0 = unlimited). Every MCP request the caller makes — reads into managed tenants included — draws on these windows. The limits already contain the growth from purchased delegation slots; the slot fields let a client show the breakdown (base = limit − slots × slot value) and are absent when the tenant has no purchased slot.",
|
||||
@@ -15970,6 +16487,242 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageRouteItem": {
|
||||
"kind": "object",
|
||||
"doc": "One backend route of a tool: method plus path with ids as {id}.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "route",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "requests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageTelemetryStatus": {
|
||||
"kind": "object",
|
||||
"doc": "Which telemetry store answered, nested in GetGlobalMcpUsageOverviewResponse.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "requestsAvailable",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "The backend's request telemetry answered: calls, reach, routes, refusals and per-minute peaks are filled."
|
||||
},
|
||||
{
|
||||
"name": "mcpLogAvailable",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "The MCP server's log answered: tool quality, MCP-only tools, the MCP limit and its refusals are filled."
|
||||
},
|
||||
{
|
||||
"name": "mcpLogFrom",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "First day (yyyyMMdd) the MCP log covers when that lies after the window start; absent when it covers the window."
|
||||
},
|
||||
{
|
||||
"name": "partial",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "True when a store cut a result (size cap, shard timeout), so its numbers are a lower bound; absent otherwise."
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageTenantItem": {
|
||||
"kind": "object",
|
||||
"doc": "One tenant of the overview with its organization windows and its accounts.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tenantId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "quota",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "McpOrganizationQuotaNode"
|
||||
},
|
||||
"doc": "The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters."
|
||||
},
|
||||
{
|
||||
"name": "calls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "requests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "quotaHits",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Quota refusals of the tenant's accounts and of the organization window in the window."
|
||||
},
|
||||
{
|
||||
"name": "lastRequestAt",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Absent when no counter row carries a timestamp."
|
||||
},
|
||||
{
|
||||
"name": "accounts",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageAccountItem"
|
||||
}
|
||||
},
|
||||
"doc": "Accounts with MCP use in the window, most calls first."
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageToolCallsItem": {
|
||||
"kind": "object",
|
||||
"doc": "Calls of one tool by one account.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tool",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageToolItem": {
|
||||
"kind": "object",
|
||||
"doc": "One MCP tool in the overview ranking.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tool",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accounts",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Accounts that called it; absent for a tool that never reaches the backend."
|
||||
},
|
||||
{
|
||||
"name": "tenants",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Tenants whose accounts called it; absent for a tool that never reaches the backend."
|
||||
},
|
||||
{
|
||||
"name": "requestsPerCall",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Backend requests one served call costs the quota on average; 0 for a tool that runs in the MCP server alone."
|
||||
},
|
||||
{
|
||||
"name": "errorRate",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Share of calls (0–1) that ended as an error, soft errors included; absent without the MCP log."
|
||||
},
|
||||
{
|
||||
"name": "p95DurationMs",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "95th-percentile duration of a call; absent without the MCP log."
|
||||
},
|
||||
{
|
||||
"name": "avgResultChars",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Mean size of the answer in characters; absent without the MCP log."
|
||||
},
|
||||
{
|
||||
"name": "overCapCalls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Calls whose page exceeded the tool's size cap; absent without the MCP log."
|
||||
},
|
||||
{
|
||||
"name": "routes",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageRouteItem"
|
||||
}
|
||||
},
|
||||
"doc": "The backend routes its calls requested, most requests first."
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUserEntry": {
|
||||
"kind": "object",
|
||||
"fields": [
|
||||
|
||||
@@ -2285,6 +2285,26 @@ export interface GetGlobalMcpUsageDailyResponse {
|
||||
summaries: UserUsageDailySummary[];
|
||||
}
|
||||
|
||||
/** The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks and the daily trend. "Calls" are tool calls (request telemetry, one MCP correlation id per call); "requests" are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is empty and named in Telemetry. */
|
||||
export interface GetGlobalMcpUsageOverviewResponse {
|
||||
/** First day of the window (yyyyMMdd, UTC). */
|
||||
dateFrom: string;
|
||||
/** Last day of the window (yyyyMMdd, UTC) — today. */
|
||||
dateTo: string;
|
||||
/** False when Global Admins and Global Readers are left out of every number. */
|
||||
includesPlatformAdmins: boolean;
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
/** One item per day of the window, oldest first. */
|
||||
daily: McpUsageDayItem[];
|
||||
/** Every tenant with MCP use in the window, most calls first. */
|
||||
tenants: McpUsageTenantItem[];
|
||||
/** Every tool called in the window, most calls first. */
|
||||
tools: McpUsageToolItem[];
|
||||
/** Refusals by the daily or monthly quota, one item per day, account and window, newest first. */
|
||||
quotaHits: McpUsageQuotaHitItem[];
|
||||
minuteLimits: McpUsageMinuteLimits;
|
||||
}
|
||||
|
||||
/** Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates over the records key — its name is wire-critical. */
|
||||
export interface GetGlobalMcpUsageResponse {
|
||||
/** Echo of the request filter; absent when the caller passed no tenantId. */
|
||||
@@ -3362,6 +3382,109 @@ export interface McpQuotaExceededResponse {
|
||||
resetUtc: string;
|
||||
}
|
||||
|
||||
/** One account (person or service principal) of a tenant in the overview. */
|
||||
export interface McpUsageAccountItem {
|
||||
/** Entra object id; absent for an account known only from request telemetry (every request refused). */
|
||||
userId?: string;
|
||||
/** UPN, or app:<client-id> for a service principal. */
|
||||
principal: string;
|
||||
/** Role on its latest request (Admin, Operator, Viewer, DelegatedReader, GlobalReader, GlobalAdmin); absent without request telemetry. */
|
||||
role?: string;
|
||||
/** A Global Admin or Global Reader; listed only when platform admins are included. */
|
||||
isPlatformAdmin: boolean;
|
||||
/** The account's plan and windows as its next request meets them; absent without an object id. */
|
||||
quota?: McpUsageAccountQuotaNode;
|
||||
calls: number;
|
||||
requests: number;
|
||||
/** The day (yyyyMMdd) with the most charged requests in the window; absent without requests. */
|
||||
peakDay?: string;
|
||||
/** Charged requests on PeakDay; absent without requests. */
|
||||
peakDayRequests?: number;
|
||||
/** Days on which the account met a quota refusal of its own window. */
|
||||
quotaHits: number;
|
||||
/** The account's most called tools, at most three, most first. */
|
||||
topTools: McpUsageToolCallsItem[];
|
||||
/** Absent when neither the counters nor the telemetry carry a timestamp. */
|
||||
lastRequestAt?: string;
|
||||
}
|
||||
|
||||
/** The account's own windows (0 = unlimited; purchased-slot growth included). */
|
||||
export interface McpUsageAccountQuotaNode {
|
||||
/** The account's usage plan: its personal override, else the tenant plan. */
|
||||
plan: string;
|
||||
dailyLimit: number;
|
||||
monthlyLimit: number;
|
||||
dailyUsed: number;
|
||||
monthlyUsed: number;
|
||||
}
|
||||
|
||||
/** One day of the overview trend. */
|
||||
export interface McpUsageDayItem {
|
||||
/** yyyyMMdd. */
|
||||
date: string;
|
||||
calls: number;
|
||||
requests: number;
|
||||
/** Accounts with a call or a charged request that day. */
|
||||
accounts: number;
|
||||
/** The day's calls by the usage plan of the caller's tenant, most first. */
|
||||
callsByPlan: McpUsagePlanCallsItem[];
|
||||
}
|
||||
|
||||
/** One account's busiest 60 seconds against both per-minute limits, and its refusals. */
|
||||
export interface McpUsageMinuteItem {
|
||||
tenantId: string;
|
||||
principal: string;
|
||||
/** Most tool calls the account started inside 60 seconds (measured in 10-second steps). */
|
||||
peakCalls: number;
|
||||
peakCallsAt?: string;
|
||||
/** Messages the MCP server refused because of its per-minute limit. */
|
||||
mcpRefused: number;
|
||||
mcpLastRefusedAt?: string;
|
||||
/** Most backend requests the account sent inside 60 seconds (measured in 10-second steps). */
|
||||
peakRequests: number;
|
||||
peakRequestsAt?: string;
|
||||
/** Backend requests per minute the account may send in its home tenant (override, else the default raised to the edition floor). */
|
||||
backendLimit: number;
|
||||
/** Requests the backend refused because of its per-minute limit. */
|
||||
backendRefused: number;
|
||||
backendLastRefusedAt?: string;
|
||||
}
|
||||
|
||||
/** The two per-minute limits an MCP account meets and how close the accounts came to them. */
|
||||
export interface McpUsageMinuteLimits {
|
||||
/** MCP messages per minute the MCP server admits per account, from its newest start line; absent until a server reported it. */
|
||||
mcpServerLimit?: number;
|
||||
/** Every account with request telemetry in the window, closest to a limit first. */
|
||||
accounts: McpUsageMinuteItem[];
|
||||
}
|
||||
|
||||
/** Calls of one usage plan on one day. */
|
||||
export interface McpUsagePlanCallsItem {
|
||||
plan: string;
|
||||
calls: number;
|
||||
}
|
||||
|
||||
/** Quota refusals of one account (level user) or organization (level tenant) on one day and window. */
|
||||
export interface McpUsageQuotaHitItem {
|
||||
/** yyyyMMdd. */
|
||||
date: string;
|
||||
tenantId: string;
|
||||
/** The refused account; absent when the request row carried no principal. */
|
||||
principal?: string;
|
||||
/** user (the account's window) or tenant (the organization's). */
|
||||
level: string;
|
||||
/** daily or monthly. */
|
||||
scope: string;
|
||||
plan: string;
|
||||
/** The window's limit; absent when the refusal line carried none. */
|
||||
limit?: number;
|
||||
/** The window's count at the first refusal of the day; absent when the refusal line carried none. */
|
||||
used?: number;
|
||||
refused: number;
|
||||
firstAt: string;
|
||||
lastAt: string;
|
||||
}
|
||||
|
||||
/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their HOME tenant (shared by every member; 0 = unlimited). Every MCP request the caller makes — reads into managed tenants included — draws on these windows. The limits already contain the growth from purchased delegation slots; the slot fields let a client show the breakdown (base = limit − slots × slot value) and are absent when the tenant has no purchased slot. */
|
||||
export interface McpUsageQuotaNode {
|
||||
dailyLimit: number;
|
||||
@@ -3384,6 +3507,67 @@ export interface McpUsageQuotaNode {
|
||||
slotTenantMonthlyLimit?: number;
|
||||
}
|
||||
|
||||
/** One backend route of a tool: method plus path with ids as {id}. */
|
||||
export interface McpUsageRouteItem {
|
||||
route: string;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** Which telemetry store answered, nested in GetGlobalMcpUsageOverviewResponse. */
|
||||
export interface McpUsageTelemetryStatus {
|
||||
/** The backend's request telemetry answered: calls, reach, routes, refusals and per-minute peaks are filled. */
|
||||
requestsAvailable: boolean;
|
||||
/** The MCP server's log answered: tool quality, MCP-only tools, the MCP limit and its refusals are filled. */
|
||||
mcpLogAvailable: boolean;
|
||||
/** First day (yyyyMMdd) the MCP log covers when that lies after the window start; absent when it covers the window. */
|
||||
mcpLogFrom?: string;
|
||||
/** True when a store cut a result (size cap, shard timeout), so its numbers are a lower bound; absent otherwise. */
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
/** One tenant of the overview with its organization windows and its accounts. */
|
||||
export interface McpUsageTenantItem {
|
||||
tenantId: string;
|
||||
/** The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters. */
|
||||
quota: McpOrganizationQuotaNode;
|
||||
calls: number;
|
||||
requests: number;
|
||||
/** Quota refusals of the tenant's accounts and of the organization window in the window. */
|
||||
quotaHits: number;
|
||||
/** Absent when no counter row carries a timestamp. */
|
||||
lastRequestAt?: string;
|
||||
/** Accounts with MCP use in the window, most calls first. */
|
||||
accounts: McpUsageAccountItem[];
|
||||
}
|
||||
|
||||
/** Calls of one tool by one account. */
|
||||
export interface McpUsageToolCallsItem {
|
||||
tool: string;
|
||||
calls: number;
|
||||
}
|
||||
|
||||
/** One MCP tool in the overview ranking. */
|
||||
export interface McpUsageToolItem {
|
||||
tool: string;
|
||||
calls: number;
|
||||
/** Accounts that called it; absent for a tool that never reaches the backend. */
|
||||
accounts?: number;
|
||||
/** Tenants whose accounts called it; absent for a tool that never reaches the backend. */
|
||||
tenants?: number;
|
||||
/** Backend requests one served call costs the quota on average; 0 for a tool that runs in the MCP server alone. */
|
||||
requestsPerCall: number;
|
||||
/** Share of calls (0–1) that ended as an error, soft errors included; absent without the MCP log. */
|
||||
errorRate?: number;
|
||||
/** 95th-percentile duration of a call; absent without the MCP log. */
|
||||
p95DurationMs?: number;
|
||||
/** Mean size of the answer in characters; absent without the MCP log. */
|
||||
avgResultChars?: number;
|
||||
/** Calls whose page exceeded the tool's size cap; absent without the MCP log. */
|
||||
overCapCalls?: number;
|
||||
/** The backend routes its calls requested, most requests first. */
|
||||
routes: McpUsageRouteItem[];
|
||||
}
|
||||
|
||||
export interface McpUserEntry {
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"@next/swc-win32-x64-msvc": "npm:null@*"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.12",
|
||||
"postcss": "$postcss",
|
||||
"@nevware21/ts-utils": "^0.14.0",
|
||||
"sharp": "^0.35.5",
|
||||
"postcss-selector-parser": "^7.1.6"
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"_comment": "Lines of the MCP server's console log that the backend's MCP usage overview reads with KQL (McpUsageTelemetryQueries). The MCP suite proves the server writes every field and every line start listed here; the backend suite proves its queries read nothing that is not listed.",
|
||||
"prefixes": {
|
||||
"tool_call": "{\"type\":\"tool_call\"",
|
||||
"boot": "{\"type\":\"boot\"",
|
||||
"mcp_refusal": "[mcp-auth] 429 kind=upn "
|
||||
},
|
||||
"fields": {
|
||||
"tool_call": ["type", "tool", "durationMs", "isError", "cancelled", "resultChars", "overCap", "scope"],
|
||||
"boot": ["type", "rateLimitPerMinute"]
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@
|
||||
"a3b1b3898c247667": 1,
|
||||
"a777f9e57bac53d5": 1,
|
||||
"a8ea1228aa75b506": 1,
|
||||
"ab6861a9ea29854d": 1,
|
||||
"af270234f673d876": 1,
|
||||
"b26235044348e7ba": 1,
|
||||
"b62f5f7b96567483": 1,
|
||||
|
||||
+8
-11
@@ -806,13 +806,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
|
||||
"integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",
|
||||
"integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
@@ -4270,16 +4270,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"os-tmpdir": "~1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6.0"
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "autopilot-monitor-swa-cli",
|
||||
"private": true,
|
||||
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token.",
|
||||
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token. The overrides lift adm-zip and tmp above versions with known advisories that the CLI still pins.",
|
||||
"devDependencies": {
|
||||
"@azure/static-web-apps-cli": "2.0.10"
|
||||
},
|
||||
"overrides": {
|
||||
"adm-zip": "^0.6.1",
|
||||
"tmp": "^0.2.7"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user