mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-06 10:19:34 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f43462567e | |||
| b5ac4651a0 | |||
| 3bc8b87a19 | |||
| 950b3cc679 | |||
| 0559919a9e | |||
| e8f8b876e3 | |||
| 83afd4f1c9 | |||
| d695c9b8c4 | |||
| 1ee7c48c92 | |||
| 98ac414208 | |||
| 5153e90e7c | |||
| 5b139f90e7 | |||
| 05d21cb240 | |||
| 5cc69a696a | |||
| fd3ce44c0c |
+1
-1
Submodule .claude/commands updated: 823bbac0c8...935f869d09
@@ -24,6 +24,11 @@ updates:
|
||||
dotnet-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
|
||||
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
|
||||
ignore:
|
||||
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
|
||||
+1
-1
Submodule internal updated: ca09da503e...de9fd428e5
+3
-3
@@ -18,9 +18,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<Import Project="V2Isolation.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
|
||||
|
||||
+3
-3
@@ -13,9 +13,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@
|
||||
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
|
||||
which would otherwise surface NU1903/NU1904 advisories here. -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="Moq" Version="4.21.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -451,7 +451,7 @@ public class GraphTokenServiceTests
|
||||
logger.Verify(l => l.Log(
|
||||
Microsoft.Extensions.Logging.LogLevel.Warning,
|
||||
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state != null && state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.IsAny<Exception?>(),
|
||||
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
@@ -102,9 +105,11 @@ public class McpUsageOverviewAssemblyTests
|
||||
[TenantA] = new("community", 100, 250),
|
||||
[TenantB] = new("pro", 1500, 15000),
|
||||
};
|
||||
// TenantB has no readable configuration: no name.
|
||||
var names = new Dictionary<string, string> { [TenantA] = "contoso.com" };
|
||||
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));
|
||||
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, names, LatestRoles(result.Calls), minute, result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,6 +230,62 @@ public class McpUsageOverviewAssemblyTests
|
||||
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));
|
||||
// The share the list ranks by, in whole percent: the higher of calls/MCP limit and requests/backend limit.
|
||||
Assert.Equal(new[] { 80, 33 }, minute.Select(m => m.SharePercent)); // 120/150; max(12/60, 40/120)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Totals_SumTheLists_OnceForEveryConsumer()
|
||||
{
|
||||
var totals = Overview().Totals;
|
||||
|
||||
Assert.Equal(18, totals.Calls); // every tool of the ranking, search_docs' 4 MCP-only calls included
|
||||
Assert.Equal(4, totals.McpOnlyCalls);
|
||||
Assert.Equal(7, totals.Tools);
|
||||
Assert.Equal(62, totals.Requests); // tenant A 55, tenant B 7 — the operator's 100 left out
|
||||
Assert.Equal(2, totals.Tenants);
|
||||
Assert.Equal(4, totals.Accounts); // alice, carol, the app, bob
|
||||
Assert.Equal(1, totals.ServicePrincipals);
|
||||
Assert.Equal(1, totals.QuotaHitAccounts); // alice's daily window
|
||||
Assert.Equal(2, totals.MinuteRefusedAccounts); // alice at the MCP server, bob at the backend
|
||||
Assert.Equal(2, totals.LimitAccounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Totals_WithoutTheirStore_AreAbsent_NotAPartialSum()
|
||||
{
|
||||
var totals = Overview(telemetry: false).Totals;
|
||||
|
||||
Assert.Null(totals.Calls);
|
||||
Assert.Null(totals.McpOnlyCalls);
|
||||
Assert.Equal(0, totals.Tools);
|
||||
Assert.Equal(62, totals.Requests); // the counters still answer
|
||||
Assert.Equal(3, totals.Accounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenants_CarryTheirDomain_WhenTheConfigurationNamesOne()
|
||||
{
|
||||
var tenants = Overview().Tenants;
|
||||
|
||||
Assert.Equal("contoso.com", tenants.Single(t => t.TenantId == TenantA).DomainName);
|
||||
Assert.Null(tenants.Single(t => t.TenantId == TenantB).DomainName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnreadableTenantConfiguration_LeavesOnlyTheNameOut()
|
||||
{
|
||||
var config = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantA)).ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantB)).ReturnsAsync(new TenantConfiguration { TenantId = TenantB, DomainName = "fabrikam.com" });
|
||||
// Only the configuration read is exercised; the other collaborators stay unused.
|
||||
var service = new McpUsageOverviewService(
|
||||
null!, null!, null!, null!, config.Object, null!, null!, null!, NullLogger<McpUsageOverviewService>.Instance, TimeProvider.System);
|
||||
|
||||
Assert.Null(await service.ReadTenantConfigurationAsync(TenantA));
|
||||
Assert.Equal("fabrikam.com", (await service.ReadTenantConfigurationAsync(TenantB))?.DomainName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -108,16 +108,10 @@ public class MetricsFilterInjectionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTenantUsageFilter_TenantInjection_StaysInsideLiteral()
|
||||
public void BuildUserUsageFilter_BothDates_NormalizeTheShapeAndBoundTheRange()
|
||||
{
|
||||
var filter = TableUserUsageRepository.BuildTenantUsageFilter("' or TenantId ne '", null, null);
|
||||
var filter = TableUserUsageRepository.BuildUserUsageFilter("user1", "2026-11-01", "2026-11-05");
|
||||
|
||||
Assert.Equal("TenantId eq ''' or TenantId ne '''", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTenantUsageFilter_NoScope_ReturnsNull()
|
||||
{
|
||||
Assert.Null(TableUserUsageRepository.BuildTenantUsageFilter(null, null, null));
|
||||
Assert.Equal("PartitionKey eq 'user1' and Date ge '20261101' and Date le '20261105'", filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,64 +548,6 @@ public class MetricsWireParityTests
|
||||
new GetMcpUserUsageResponse { UserId = userId, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var records = new List<UserUsageRecord>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var summaries = new List<UserUsageDailySummary>
|
||||
{
|
||||
new UserUsageDailySummary
|
||||
{
|
||||
Date = "20260830",
|
||||
TenantId = tenantId,
|
||||
TotalRequests = 321,
|
||||
UniqueUsers = 4,
|
||||
UniqueEndpoints = 11,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var summaries = new List<UserUsageDailySummary>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
|
||||
}
|
||||
|
||||
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
@@ -850,12 +792,18 @@ public class MetricsWireParityTests
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = false,
|
||||
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
|
||||
totals = new
|
||||
{
|
||||
calls = 10L, mcpOnlyCalls = 4L, tools = 2, requests = 50L, tenants = 1, accounts = 1, servicePrincipals = 0,
|
||||
quotaHitAccounts = 1, minuteRefusedAccounts = 1, limitAccounts = 1,
|
||||
},
|
||||
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
domainName = "contoso.com",
|
||||
quota,
|
||||
calls = 7L,
|
||||
requests = 50L,
|
||||
@@ -912,7 +860,7 @@ public class MetricsWireParityTests
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId, principal = "admin@contoso.com",
|
||||
tenantId, principal = "admin@contoso.com", sharePercent = 33,
|
||||
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
|
||||
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
|
||||
},
|
||||
@@ -925,6 +873,11 @@ public class MetricsWireParityTests
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = false,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
|
||||
Totals = new McpUsageTotalsNode
|
||||
{
|
||||
Calls = 10, McpOnlyCalls = 4, Tools = 2, Requests = 50, Tenants = 1, Accounts = 1, ServicePrincipals = 0,
|
||||
QuotaHitAccounts = 1, MinuteRefusedAccounts = 1, LimitAccounts = 1,
|
||||
},
|
||||
Daily = new List<McpUsageDayItem>
|
||||
{
|
||||
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
|
||||
@@ -934,6 +887,7 @@ public class MetricsWireParityTests
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DomainName = "contoso.com",
|
||||
Quota = quota,
|
||||
Calls = 7,
|
||||
Requests = 50,
|
||||
@@ -990,7 +944,7 @@ public class MetricsWireParityTests
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId, Principal = "admin@contoso.com",
|
||||
TenantId = tenantId, Principal = "admin@contoso.com", SharePercent = 33,
|
||||
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
|
||||
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
|
||||
},
|
||||
@@ -1014,6 +968,7 @@ public class MetricsWireParityTests
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = true,
|
||||
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
|
||||
totals = new { tools = 1, requests = 0L, tenants = 1, accounts = 1, servicePrincipals = 1, quotaHitAccounts = 0, minuteRefusedAccounts = 0, limitAccounts = 0 },
|
||||
daily = Array.Empty<object>(),
|
||||
tenants = new[]
|
||||
{
|
||||
@@ -1040,6 +995,7 @@ public class MetricsWireParityTests
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = true,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
|
||||
Totals = new McpUsageTotalsNode { Tools = 1, Requests = 0, Tenants = 1, Accounts = 1, ServicePrincipals = 1 },
|
||||
Daily = new List<McpUsageDayItem>(),
|
||||
Tenants = new List<McpUsageTenantItem>
|
||||
{
|
||||
|
||||
@@ -1201,16 +1201,17 @@ public class PolicyEnforcementMiddlewareTests
|
||||
|
||||
/// <summary>
|
||||
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
|
||||
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
|
||||
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
|
||||
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
|
||||
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
|
||||
/// telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// </summary>
|
||||
public static TheoryData<string> OperatorFilterReads => new()
|
||||
{
|
||||
"/api/global/session-reports",
|
||||
"/api/global/rule-submissions",
|
||||
"/api/global/metrics/mcp-usage",
|
||||
"/api/global/metrics/mcp-usage/daily",
|
||||
"/api/global/metrics/mcp-usage/organization",
|
||||
"/api/global/metrics/mcp-usage/overview",
|
||||
"/api/global/metrics/verdict-calibration",
|
||||
};
|
||||
|
||||
|
||||
+5
-4
@@ -126,20 +126,21 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
[Fact]
|
||||
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
|
||||
{
|
||||
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
|
||||
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
|
||||
// sleep outlasting a fixed wall-clock threshold.
|
||||
// Deterministic: the body blocks until BOTH watchdogs have recorded their OpsEvents. The two
|
||||
// delays are independent timers, so under load the severe one can complete first; the cancel
|
||||
// in the finally would then drop a warn delay whose callback had not run yet.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
|
||||
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
|
||||
// Both watchdogs fired while the body was still running.
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
|
||||
}
|
||||
|
||||
@@ -39,23 +39,26 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="Azure.Monitor.Query" Version="1.5.0" />
|
||||
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.51.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.25.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.5" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.1.0" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.13.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.28.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.16.1" />
|
||||
<!-- Transitive via Microsoft.Identity.Web's token cache, lifted past GHSA-846f-f764-76mx and
|
||||
GHSA-2fmv-rv2c-59x7. Remove once Microsoft.Identity.Web is 4.17.0 or later (it brings 10.0.13). -->
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+5
-70
@@ -88,44 +88,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
|
||||
|
||||
var records = new List<UserUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
|
||||
|
||||
var records = new List<UserUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
|
||||
var grouped = records
|
||||
.GroupBy(r => r.Date)
|
||||
.Select(g => new UserUsageDailySummary
|
||||
{
|
||||
Date = g.Key,
|
||||
TenantId = tenantId,
|
||||
TotalRequests = g.Sum(r => r.RequestCount),
|
||||
UniqueUsers = g.Select(r => r.UserId).Distinct().Count(),
|
||||
UniqueEndpoints = g.Select(r => r.Endpoint).Distinct().Count(),
|
||||
})
|
||||
.OrderByDescending(s => s.Date)
|
||||
.ToList();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OData filter for a per-user usage query. <paramref name="userId"/> is a route
|
||||
/// segment (AAD oid) and the dates are query params, so all values MUST be OData-escaped before
|
||||
@@ -135,42 +97,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static string BuildUserUsageFilter(string userId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(userId)}'";
|
||||
return AppendDateFilter(filter, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OData filter for a per-tenant usage query. Same escaping requirement as
|
||||
/// <see cref="BuildUserUsageFilter"/>; returns null when no tenant/date scope is supplied.
|
||||
/// </summary>
|
||||
internal static string? BuildTenantUsageFilter(string? tenantId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
string? filter = !string.IsNullOrEmpty(tenantId)
|
||||
? $"TenantId eq '{ODataSanitizer.EscapeValue(tenantId)}'"
|
||||
: null;
|
||||
return AppendDateFilter(filter, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
private static string AppendDateFilter(string? existingFilter, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = existingFilter ?? "";
|
||||
|
||||
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
|
||||
// so the value must still be OData-escaped before interpolation.
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
{
|
||||
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
|
||||
// so the value must still be OData-escaped before interpolation.
|
||||
var dateVal = ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""));
|
||||
var clause = $"Date ge '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
|
||||
filter += $" and Date ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
{
|
||||
var dateVal = ODataSanitizer.EscapeValue(dateTo.Replace("-", ""));
|
||||
var clause = $"Date le '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
filter += $" and Date le '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}'";
|
||||
|
||||
return string.IsNullOrEmpty(filter) ? null! : filter;
|
||||
return filter;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteRecordsOlderThanAsync(string dateCutoff)
|
||||
|
||||
@@ -341,59 +341,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage?tenantId=&dateFrom=&dateTo= — Global usage
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsage")]
|
||||
public async Task<HttpResponseData> GetGlobalUsage(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("Global MCP usage requested");
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var records = await _userUsageRepo.GetUsageByTenantAsync(tenantId ?? "", dateFrom, dateTo);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage/daily?tenantId=&dateFrom=&dateTo= — Daily summaries
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsageDaily")]
|
||||
public async Task<HttpResponseData> GetGlobalUsageDaily(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/daily")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("Global MCP daily usage requested");
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var summaries = await _userUsageRepo.GetDailySummaryAsync(tenantId, dateFrom, dateTo);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,11 +575,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
// A named tenant's MCP use — its accounts (UPNs) and their tools, the daily counts, the organization view.
|
||||
// The handlers read ?tenantId= themselves: deliberately None, not QueryParam, so the delegated path never
|
||||
// reaches them.
|
||||
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
|
||||
// MCP use across tenants: a named tenant's organization budget by account (UPNs) and the all-tenant overview
|
||||
// with every tenant's accounts and tools. The organization handler reads ?tenantId= itself: deliberately None,
|
||||
// not QueryParam, so the delegated path never reaches either.
|
||||
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),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -88,19 +89,47 @@ public class McpUsageOverviewService
|
||||
facts[(account.Tenant, principal)] = new AccountFacts(account.UserId, platformRole, state);
|
||||
});
|
||||
|
||||
// Every tenant the overview names — the per-minute rows included — with its plan and its configuration, read once.
|
||||
var tenants = counters.Select(r => r.TenantId.ToLowerInvariant())
|
||||
.Concat(telemetry.Calls.Select(r => r.Tenant))
|
||||
.Concat(telemetry.Peaks.Select(p => p.Tenant))
|
||||
.Concat(telemetry.Refusals.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);
|
||||
var tenantConfigs = new System.Collections.Concurrent.ConcurrentDictionary<string, TenantConfiguration?>(StringComparer.Ordinal);
|
||||
await Parallel.ForEachAsync(tenants, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (tenant, _) =>
|
||||
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant));
|
||||
{
|
||||
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant);
|
||||
tenantConfigs[tenant] = await ReadTenantConfigurationAsync(tenant);
|
||||
});
|
||||
|
||||
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles);
|
||||
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles, tenantConfigs);
|
||||
var tenantNames = tenantConfigs
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Value?.DomainName))
|
||||
.ToDictionary(c => c.Key, c => c.Value!.DomainName, StringComparer.Ordinal);
|
||||
|
||||
return Assemble(new OverviewInputs(
|
||||
dates, includePlatformAdmins, counters, facts, tenantLimits, telemetryRoles, minuteLimits, telemetry));
|
||||
dates, includePlatformAdmins, counters, facts, tenantLimits, tenantNames, telemetryRoles, minuteLimits, telemetry));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tenant's configuration (cached) for its name and its per-minute override. A storage failure leaves both
|
||||
/// out and is logged; it never fails the overview, which is about usage, not configuration.
|
||||
/// </summary>
|
||||
internal async Task<TenantConfiguration?> ReadTenantConfigurationAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _tenantConfig.GetConfigurationIfExistsAsync(tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpUsageOverview] Configuration of tenant {TenantId} unreadable: name and per-minute override left out",
|
||||
LogSanitizer.Clean(tenantId));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telemetry ──────────────────────────────────────────────────────────────────
|
||||
@@ -170,7 +199,8 @@ public class McpUsageOverviewService
|
||||
/// </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)
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles,
|
||||
IReadOnlyDictionary<string, TenantConfiguration?> tenantConfigs)
|
||||
{
|
||||
var config = await _adminConfig.GetConfigurationAsync();
|
||||
var keys = telemetry.Peaks.Select(p => (p.Tenant, p.Principal))
|
||||
@@ -193,9 +223,8 @@ public class McpUsageOverviewService
|
||||
{
|
||||
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);
|
||||
tenantSide = (tenantConfigs.GetValueOrDefault(key.Tenant)?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
|
||||
perTenant[key.Tenant] = tenantSide;
|
||||
}
|
||||
}
|
||||
@@ -262,6 +291,7 @@ public class McpUsageOverviewService
|
||||
IReadOnlyList<TenantUsageRecord> Counters,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> Accounts,
|
||||
IReadOnlyDictionary<string, McpQuotaService.TenantPlanLimits> TenantLimits,
|
||||
IReadOnlyDictionary<string, string> TenantNames,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), string> TelemetryRoles,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), int> MinuteLimits,
|
||||
TelemetryResult Telemetry);
|
||||
@@ -386,6 +416,7 @@ public class McpUsageOverviewService
|
||||
return new McpUsageTenantItem
|
||||
{
|
||||
TenantId = tenant,
|
||||
DomainName = input.TenantNames.GetValueOrDefault(tenant),
|
||||
Quota = McpQuotaNodes.BuildOrganizationQuota(tenantRows, limits, dates.Today, dates.MonthStart),
|
||||
Calls = members.Sum(a => a.Calls),
|
||||
Requests = members.Sum(a => a.Requests),
|
||||
@@ -466,6 +497,10 @@ public class McpUsageOverviewService
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var mcpLimit = telemetry.ServerFacts.RateLimitPerMinute;
|
||||
// The higher of the two shares of a minute limit (0–1+); the list ranks by it and shows it in whole percent.
|
||||
double MinuteShare(long peakCalls, long peakRequests, int backendLimit) => Math.Max(
|
||||
mcpLimit is int limit && limit > 0 ? (double)peakCalls / limit : 0,
|
||||
backendLimit > 0 ? (double)peakRequests / backendLimit : 0);
|
||||
var minuteItems = peaks.Keys.Concat(mcpRefusals.Keys).Concat(backendRefusals.Keys).Distinct()
|
||||
.Select(key =>
|
||||
{
|
||||
@@ -474,25 +509,27 @@ public class McpUsageOverviewService
|
||||
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
|
||||
var backendLimit = input.MinuteLimits.GetValueOrDefault(key);
|
||||
var share = MinuteShare(callPeak?.Peak ?? 0, requestPeak?.Peak ?? 0, backendLimit);
|
||||
return (Share: share, Item: new McpUsageMinuteItem
|
||||
{
|
||||
TenantId = key.Tenant,
|
||||
Principal = key.Principal,
|
||||
SharePercent = (int)Math.Round(share * 100, MidpointRounding.AwayFromZero),
|
||||
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),
|
||||
BackendLimit = backendLimit,
|
||||
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)
|
||||
.OrderByDescending(m => m.Share)
|
||||
.ThenBy(m => m.Item.Principal, StringComparer.Ordinal)
|
||||
.Select(m => m.Item)
|
||||
.ToList();
|
||||
|
||||
var mcpLogFrom = telemetry.ServerFacts.Oldest is DateTime oldest
|
||||
@@ -511,6 +548,7 @@ public class McpUsageOverviewService
|
||||
McpLogFrom = mcpLogFrom != null && string.CompareOrdinal(mcpLogFrom, dates.From) > 0 ? mcpLogFrom : null,
|
||||
Partial = telemetry.Partial ? true : null,
|
||||
},
|
||||
Totals = BuildTotals(tenants, tools, quotaHits, minuteItems, telemetry),
|
||||
Daily = daily,
|
||||
Tenants = tenants,
|
||||
Tools = tools,
|
||||
@@ -519,5 +557,36 @@ public class McpUsageOverviewService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the totals over the assembled lists. Calls need the request telemetry and MCP-only calls the MCP log —
|
||||
/// without the store the figure is absent, never a misleading partial sum. An account is a tenant and principal;
|
||||
/// an organization-level refusal counts for the account that met it.
|
||||
/// </summary>
|
||||
private static McpUsageTotalsNode BuildTotals(
|
||||
IReadOnlyList<McpUsageTenantItem> tenants, IReadOnlyList<McpUsageToolItem> tools,
|
||||
IReadOnlyList<McpUsageQuotaHitItem> quotaHits, IReadOnlyList<McpUsageMinuteItem> minuteItems, TelemetryResult telemetry)
|
||||
{
|
||||
var accounts = tenants.SelectMany(t => t.Accounts).ToList();
|
||||
var quotaHitAccounts = quotaHits.Select(h => (h.TenantId, Principal: h.Principal ?? string.Empty)).ToHashSet();
|
||||
var minuteRefusedAccounts = minuteItems
|
||||
.Where(m => m.McpRefused > 0 || m.BackendRefused > 0)
|
||||
.Select(m => (m.TenantId, m.Principal))
|
||||
.ToHashSet();
|
||||
|
||||
return new McpUsageTotalsNode
|
||||
{
|
||||
Calls = telemetry.RequestsAvailable ? tools.Sum(t => t.Calls) : null,
|
||||
McpOnlyCalls = telemetry.McpLogAvailable ? tools.Where(t => t.Accounts == null).Sum(t => t.Calls) : null,
|
||||
Tools = tools.Count,
|
||||
Requests = tenants.Sum(t => t.Requests),
|
||||
Tenants = tenants.Count,
|
||||
Accounts = accounts.Count,
|
||||
ServicePrincipals = accounts.Count(a => Constants.PrincipalKeys.IsApplication(a.Principal)),
|
||||
QuotaHitAccounts = quotaHitAccounts.Count,
|
||||
MinuteRefusedAccounts = minuteRefusedAccounts.Count,
|
||||
LimitAccounts = quotaHitAccounts.Union(minuteRefusedAccounts).Count(),
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? Later(DateTime? a, DateTime? b) => a == null ? b : b == null ? a : (a > b ? a : b);
|
||||
}
|
||||
|
||||
+4
-4
@@ -30,7 +30,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.43.0",
|
||||
"knip": "^6.44.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
@@ -4351,9 +4351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
|
||||
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
|
||||
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.43.0",
|
||||
"knip": "^6.44.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* get_api_usage: the shaping of the MCP usage views (api-usage-shape.ts) and the tool's routing.
|
||||
* - the summary cuts every list to its head and states the full length; nothing is cut silently;
|
||||
* - absent wire values become explicit nulls (calls without request telemetry, unreadable counters);
|
||||
* - tenants are named on refusal and minute rows; an account's counter keys fold into tool and day sums;
|
||||
* - organization and account need their id; a UPN is not an object id.
|
||||
*
|
||||
* apiFetch is mocked so these run with no backend / token.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
|
||||
|
||||
vi.mock('../client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../client.js')>();
|
||||
return { ...actual, apiFetch: apiFetchMock };
|
||||
});
|
||||
|
||||
import { registerAdminTools } from '../tools/admin.js';
|
||||
import { ObjectIdSchema } from '../tools/shared.js';
|
||||
import { NO_TOOL, SUMMARY_HEAD, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
|
||||
import type {
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
GetMcpOrganizationUsageResponse,
|
||||
GetMcpUserUsageResponse,
|
||||
McpUsageAccountItem,
|
||||
McpUsageMinuteItem,
|
||||
McpUsageQuotaHitItem,
|
||||
McpUsageTenantItem,
|
||||
McpUsageToolItem,
|
||||
} from '../generated/wire-types.generated.js';
|
||||
|
||||
const TENANT_A = 'aaaaaaaa-0000-4000-8000-00000000000a';
|
||||
const TENANT_B = 'bbbbbbbb-0000-4000-8000-00000000000b';
|
||||
const OID = '11111111-2222-4333-8444-555555555555';
|
||||
const AT = '2026-11-05T10:00:00Z';
|
||||
|
||||
function account(principal: string, over: Partial<McpUsageAccountItem> = {}): McpUsageAccountItem {
|
||||
return {
|
||||
userId: OID,
|
||||
principal,
|
||||
isPlatformAdmin: false,
|
||||
quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: 12, monthlyUsed: 40 },
|
||||
calls: 5,
|
||||
requests: 12,
|
||||
quotaHits: 0,
|
||||
topTools: [{ tool: 'get_session_summary', calls: 3 }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function tenant(tenantId: string, domainName: string | undefined, accounts: McpUsageAccountItem[]): McpUsageTenantItem {
|
||||
return {
|
||||
tenantId,
|
||||
...(domainName ? { domainName } : {}),
|
||||
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 30, monthlyUsed: 90 },
|
||||
calls: accounts.reduce((s, a) => s + a.calls, 0),
|
||||
requests: accounts.reduce((s, a) => s + a.requests, 0),
|
||||
quotaHits: 1,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
|
||||
const tool = (name: string, calls: number, over: Partial<McpUsageToolItem> = {}): McpUsageToolItem =>
|
||||
({ tool: name, calls, accounts: 2, tenants: 1, requestsPerCall: 4.5, errorRate: 0.1, p95DurationMs: 900, routes: [{ route: 'GET sessions/{id}', requests: 9 }], ...over });
|
||||
|
||||
const hit = (tenantId: string, principal: string): McpUsageQuotaHitItem =>
|
||||
({ date: '20261105', tenantId, principal, level: 'user', scope: 'daily', plan: 'community', limit: 50, used: 50, refused: 3, firstAt: AT, lastAt: AT });
|
||||
|
||||
const minute = (tenantId: string, principal: string, sharePercent: number): McpUsageMinuteItem =>
|
||||
({ tenantId, principal, sharePercent, peakCalls: 30, peakCallsAt: AT, mcpRefused: 0, peakRequests: 60, peakRequestsAt: AT, backendLimit: 120, backendRefused: 0 });
|
||||
|
||||
function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlobalMcpUsageOverviewResponse {
|
||||
return {
|
||||
dateFrom: '20261007',
|
||||
dateTo: '20261105',
|
||||
includesPlatformAdmins: false,
|
||||
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
|
||||
totals: {
|
||||
calls: 40, mcpOnlyCalls: 4, tools: 2, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0,
|
||||
quotaHitAccounts: 1, minuteRefusedAccounts: 0, limitAccounts: 1,
|
||||
},
|
||||
daily: [{ date: '20261105', calls: 40, requests: 24, accounts: 2, callsByPlan: [{ plan: 'community', calls: 40 }] }],
|
||||
tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com')]), tenant(TENANT_B, undefined, [account('bob@fabrikam.com')])],
|
||||
tools: [tool('get_session_summary', 36), tool('search_docs', 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0, routes: [] })],
|
||||
quotaHits: [hit(TENANT_A, 'alice@contoso.com')],
|
||||
minuteLimits: { mcpServerLimit: 60, accounts: [minute(TENANT_A, 'alice@contoso.com', 50), minute(TENANT_B, 'bob@fabrikam.com', 25)] },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usageWindow', () => {
|
||||
it('is the trailing window of whole UTC days ending today, as yyyyMMdd', () => {
|
||||
const now = new Date('2026-11-05T23:30:00Z');
|
||||
expect(usageWindow(1, now)).toEqual({ from: '20261105', to: '20261105' });
|
||||
expect(usageWindow(30, now)).toEqual({ from: '20261007', to: '20261105' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeOverview — summary', () => {
|
||||
it('carries the backend totals and every list head with its full length', () => {
|
||||
const out = shapeOverview(overview(), 'summary');
|
||||
|
||||
expect(out.view).toBe('summary');
|
||||
expect(out.window).toEqual({ from: '20261007', to: '20261105' });
|
||||
expect(out.telemetry).toEqual({ requestsAvailable: true, mcpLogAvailable: true, mcpLogFrom: null, partial: false });
|
||||
expect(out.totals).toMatchObject({ calls: 40, mcpOnlyCalls: 4, requests: 24, limitAccounts: 1 });
|
||||
expect(out.lists).toEqual({ tools: 2, tenants: 2, quotaHits: 1, minuteLimits: 2 });
|
||||
expect(out).not.toHaveProperty('note'); // nothing was cut
|
||||
|
||||
const [summaryTool, docs] = out.tools as Array<Record<string, unknown>>;
|
||||
expect(summaryTool).toEqual({ tool: 'get_session_summary', calls: 36, requestsPerCall: 4.5, accounts: 2, tenants: 1, errorRate: 0.1, p95DurationMs: 900 });
|
||||
expect(docs).toMatchObject({ tool: 'search_docs', accounts: null, tenants: null }); // runs in the MCP server alone
|
||||
|
||||
const [contoso, unnamed] = out.tenants as Array<Record<string, unknown>>;
|
||||
expect(contoso).toMatchObject({ domainName: 'contoso.com', plan: 'community', accounts: 1, today: { used: 30, limit: 100 }, month: { used: 90, limit: 250 } });
|
||||
expect(unnamed.domainName).toBeNull();
|
||||
|
||||
expect((out.quotaHits as Array<Record<string, unknown>>)[0]).toMatchObject({ domainName: 'contoso.com', principal: 'alice@contoso.com', used: 50, limit: 50 });
|
||||
const minuteLimits = out.minuteLimits as { mcpServerLimit: number; accounts: Array<Record<string, unknown>> };
|
||||
expect(minuteLimits.mcpServerLimit).toBe(60);
|
||||
expect(minuteLimits.accounts.map((m) => [m.principal, m.domainName, m.sharePercent])).toEqual([
|
||||
['alice@contoso.com', 'contoso.com', 50],
|
||||
['bob@fabrikam.com', null, 25],
|
||||
]);
|
||||
});
|
||||
|
||||
it('cuts a long list to its head and says so', () => {
|
||||
const tools = Array.from({ length: SUMMARY_HEAD.tools + 3 }, (_, i) => tool(`tool_${i}`, 100 - i));
|
||||
const out = shapeOverview(overview({ tools }), 'summary');
|
||||
|
||||
expect(out.tools).toHaveLength(SUMMARY_HEAD.tools);
|
||||
expect((out.lists as Record<string, number>).tools).toBe(SUMMARY_HEAD.tools + 3);
|
||||
expect(out.note).toMatch(/cut to their first rows/);
|
||||
});
|
||||
|
||||
it('shows calls as null, not 0, when the request telemetry did not answer', () => {
|
||||
const out = shapeOverview(overview({
|
||||
telemetry: { requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true },
|
||||
totals: { tools: 0, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0, quotaHitAccounts: 0, minuteRefusedAccounts: 0, limitAccounts: 0 },
|
||||
}), 'summary');
|
||||
|
||||
expect(out.totals).toMatchObject({ calls: null, mcpOnlyCalls: null, requests: 24 });
|
||||
expect(out.telemetry).toEqual({ requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeOverview — one list in full', () => {
|
||||
it('tenants carry every account with explicit windows; an unreadable counter is null', () => {
|
||||
const unreadable = account('carol@contoso.com', { quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: -1, monthlyUsed: -1 } });
|
||||
const out = shapeOverview(overview({ tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com'), unreadable])] }), 'tenants');
|
||||
|
||||
const [only] = out.tenants as Array<{ accounts: Array<Record<string, unknown>> }>;
|
||||
expect(only.accounts.map((a) => a.principal)).toEqual(['alice@contoso.com', 'carol@contoso.com']);
|
||||
expect(only.accounts[0]).toMatchObject({ plan: 'community', today: { used: 12, limit: 50 }, month: { used: 40, limit: 250 }, role: null });
|
||||
expect(only.accounts[1]).toMatchObject({ today: { used: null, limit: 50 }, month: { used: null, limit: 250 } });
|
||||
});
|
||||
|
||||
it('tools keep their routes and quality figures; the other views return their one list', () => {
|
||||
const data = overview();
|
||||
const tools = shapeOverview(data, 'tools').tools as Array<Record<string, unknown>>;
|
||||
expect(tools[0]).toMatchObject({ routes: [{ route: 'GET sessions/{id}', requests: 9 }], avgResultChars: null, overCapCalls: null });
|
||||
|
||||
expect(shapeOverview(data, 'quotaHits').quotaHits).toHaveLength(1);
|
||||
expect(shapeOverview(data, 'minuteLimits')).toMatchObject({ mcpServerLimit: 60 });
|
||||
expect(shapeOverview(data, 'daily').daily).toEqual(data.daily);
|
||||
for (const view of ['tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const) {
|
||||
expect(shapeOverview(data, view)).not.toHaveProperty('totals');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeOrganization', () => {
|
||||
it('names the windows and sums the accounts of the range', () => {
|
||||
const data: GetMcpOrganizationUsageResponse = {
|
||||
tenantId: TENANT_A,
|
||||
dateFrom: '20261101',
|
||||
dateTo: '20261105',
|
||||
users: [
|
||||
{ userId: OID, userPrincipalName: 'alice@contoso.com', requestsToday: 10, requestsThisMonth: 40, requestsInRange: 40, lastRequestAt: AT },
|
||||
{ userId: 'oid-2', requestsToday: 0, requestsThisMonth: 2, requestsInRange: 2 },
|
||||
],
|
||||
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 42 },
|
||||
daily: [{ date: '20261105', requests: 10 }],
|
||||
};
|
||||
|
||||
const out = shapeOrganization(data);
|
||||
expect(out).toMatchObject({ view: 'organization', plan: 'community', today: { used: 10, limit: 100 }, month: { used: 42, limit: 250 }, requests: 42 });
|
||||
expect((out.accounts as Array<Record<string, unknown>>)[1]).toEqual(
|
||||
{ principal: null, userId: 'oid-2', requestsInRange: 2, requestsToday: 0, requestsThisMonth: 2, lastRequestAt: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeAccount', () => {
|
||||
it('folds the counter rows into tools with their paths and into days', () => {
|
||||
const row = (date: string, endpoint: string, requestCount: number, lastRequestAt = AT) =>
|
||||
({ userId: OID, userPrincipalName: 'alice@contoso.com', tenantId: TENANT_A, endpoint, date, requestCount, lastRequestAt });
|
||||
const data: GetMcpUserUsageResponse = {
|
||||
userId: OID,
|
||||
records: [
|
||||
row('20261104', 'get_session_summary:sessions/{id}', 6),
|
||||
row('20261105', 'get_session_summary:sessions/{id}/events', 9),
|
||||
row('20261105', 'get_session_summary:sessions/{id}', 3),
|
||||
row('20261105', 'auth/me', 1),
|
||||
],
|
||||
};
|
||||
|
||||
const out = shapeAccount(data, { from: '20261101', to: '20261105' });
|
||||
expect(out).toMatchObject({ userId: OID, principal: 'alice@contoso.com', tenantId: TENANT_A, requests: 19 });
|
||||
expect(out.byTool).toEqual([
|
||||
{ tool: 'get_session_summary', requests: 18, paths: [{ path: 'sessions/{id}', requests: 9 }, { path: 'sessions/{id}/events', requests: 9 }] },
|
||||
{ tool: NO_TOOL, requests: 1, paths: [{ path: 'auth/me', requests: 1 }] },
|
||||
]);
|
||||
expect(out.byDay).toEqual([{ date: '20261104', requests: 6 }, { date: '20261105', requests: 13 }]);
|
||||
});
|
||||
|
||||
it('answers an account without rows with zeros, not an error', () => {
|
||||
expect(shapeAccount({ userId: OID, records: [] }, { from: '20261105', to: '20261105' }))
|
||||
.toEqual({ view: 'account', userId: OID, principal: null, tenantId: null, window: { from: '20261105', to: '20261105' }, requests: 0, byTool: [], byDay: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ── The tool ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
|
||||
|
||||
function handler(): Handler {
|
||||
const handlers: Record<string, Handler> = {};
|
||||
const fake = { registerTool: (name: string, _def: unknown, h: Handler) => { handlers[name] = h; } };
|
||||
registerAdminTools(fake as never, true, true, false);
|
||||
return handlers.get_api_usage;
|
||||
}
|
||||
|
||||
describe('get_api_usage — routing', () => {
|
||||
beforeEach(() => apiFetchMock.mockReset());
|
||||
|
||||
it('reads the overview for the overview views, with the platform-admin switch and a long timeout', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(overview());
|
||||
const result = await handler()({ view: 'summary', days: 7, includePlatformAdmins: true });
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
const [path, init] = apiFetchMock.mock.calls[0] as [string, { signal?: AbortSignal }];
|
||||
expect(path).toBe('/api/global/metrics/mcp-usage/overview?days=7&platformAdmins=include');
|
||||
expect(init.signal).toBeDefined();
|
||||
expect(JSON.parse(result.content[0].text).view).toBe('summary');
|
||||
});
|
||||
|
||||
it('reads one tenant and one account over the trailing window', async () => {
|
||||
apiFetchMock.mockResolvedValue({ tenantId: TENANT_A, dateFrom: '20261105', dateTo: '20261105', users: [], quota: { tenantPlan: 'pro', dailyLimit: 0, monthlyLimit: 0, dailyUsed: 0, monthlyUsed: 0 }, daily: [] });
|
||||
await handler()({ view: 'organization', days: 1, tenantId: TENANT_A });
|
||||
expect(apiFetchMock.mock.calls[0][0]).toMatch(new RegExp(`^/api/global/metrics/mcp-usage/organization\\?tenantId=${TENANT_A}&dateFrom=\\d{8}&dateTo=\\d{8}$`));
|
||||
|
||||
apiFetchMock.mockResolvedValue({ userId: OID, records: [] });
|
||||
await handler()({ view: 'account', days: 1, userId: OID });
|
||||
expect(apiFetchMock.mock.calls[1][0]).toMatch(new RegExp(`^/api/metrics/mcp-usage/user/${OID}\\?dateFrom=\\d{8}&dateTo=\\d{8}$`));
|
||||
});
|
||||
|
||||
it('refuses organization without tenantId and account without userId, before any request', async () => {
|
||||
const noTenant = await handler()({ view: 'organization', days: 30 });
|
||||
const noUser = await handler()({ view: 'account', days: 30 });
|
||||
|
||||
expect(noTenant.isError).toBe(true);
|
||||
expect(noTenant.content[0].text).toMatch(/tenantId is required for view=organization/);
|
||||
expect(noUser.isError).toBe(true);
|
||||
expect(noUser.content[0].text).toMatch(/userId .* is required for view=account/);
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a UPN is not an object id', () => {
|
||||
expect(ObjectIdSchema.safeParse(OID).success).toBe(true);
|
||||
expect(ObjectIdSchema.safeParse('alice@contoso.com').success).toBe(false);
|
||||
expect(ObjectIdSchema.safeParse('../global/metrics').success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,9 @@ interface Role {
|
||||
const ROLES: Role[] = [
|
||||
// Raised 2026-10-28 by +1,100 each: the platform-only get_growth_metrics (measured 1,106 chars).
|
||||
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 93_100 },
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_100 },
|
||||
// Raised 2026-11-05 by +200: get_api_usage reads the MCP usage overview — eight views, the window, the
|
||||
// platform-admin switch and two drill-down ids (measured 78,205 chars; the Global Admin catalog stayed under).
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_300 },
|
||||
// Raised 2026-11-04 by +400: search_sessions gained the groupTag filter and five Autopilot
|
||||
// projection keys (measured 53,318 chars; the other roles stayed under their ceilings).
|
||||
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_400 },
|
||||
@@ -56,8 +58,9 @@ const ROLES: Role[] = [
|
||||
* Shared-mechanics arguments should read the same everywhere (shared.ts helpers); each distinct
|
||||
* wording is one more contract the model has to reconcile. Ratchet on the variant count in the
|
||||
* Global Admin catalog (2026-09-22 post-diet baseline; pre-diet: tenantId 28, pageSize 14, continuation 10).
|
||||
* days 10 since 2026-11-05: get_api_usage's window is bounded by the 90 days the request telemetry keeps.
|
||||
*/
|
||||
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 9 };
|
||||
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 10 };
|
||||
|
||||
/**
|
||||
* Domains a description may name. Anything else that looks like a domain is treated as a
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* sessions; the cap and a truncated flag are now surfaced.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateUsage, paginateInventory } from '../tools/admin.js';
|
||||
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateInventory } from '../tools/admin.js';
|
||||
|
||||
describe('inferGeoGroupBy', () => {
|
||||
it('treats a single-segment key as country', () => {
|
||||
@@ -90,51 +90,6 @@ describe('platformWindowEcho', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('paginateUsage', () => {
|
||||
const records = Array.from({ length: 5 }, (_, i) => ({ endpoint: `e${i}`, count: i }));
|
||||
|
||||
it('pages the global per-record breakdown and emits a nextLink while more remain', () => {
|
||||
const out = paginateUsage({ tenantId: 't1', records }, 2);
|
||||
expect(out.tenantId).toBe('t1'); // top-level fields echoed verbatim
|
||||
expect(out.totalCount).toBe(5);
|
||||
expect(out.count).toBe(2);
|
||||
expect(out.offset).toBe(0);
|
||||
expect(out.records).toEqual(records.slice(0, 2));
|
||||
expect(out.nextLink).toBe('usage-offset:2');
|
||||
});
|
||||
|
||||
it('follows a continuation cursor to the next slice', () => {
|
||||
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:2');
|
||||
expect(out.offset).toBe(2);
|
||||
expect(out.records).toEqual(records.slice(2, 4));
|
||||
expect(out.nextLink).toBe('usage-offset:4');
|
||||
});
|
||||
|
||||
it('drops nextLink on the final page', () => {
|
||||
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:4');
|
||||
expect(out.count).toBe(1);
|
||||
expect(out.records).toEqual(records.slice(4));
|
||||
expect(out.nextLink).toBeNull();
|
||||
});
|
||||
|
||||
it('pages the daily "summaries" array too, preserving the key', () => {
|
||||
const summaries = [{ date: '2026-06-01' }, { date: '2026-06-02' }];
|
||||
const out = paginateUsage({ tenantId: 't1', summaries }, 1);
|
||||
expect(out.summaries).toEqual(summaries.slice(0, 1));
|
||||
expect(Object.prototype.hasOwnProperty.call(out, 'records')).toBe(false);
|
||||
expect(out.nextLink).toBe('usage-offset:1');
|
||||
});
|
||||
|
||||
it('passes an unexpected shape through untouched', () => {
|
||||
const weird = { tenantId: 't1', somethingElse: 42 };
|
||||
expect(paginateUsage(weird, 200)).toEqual(weird);
|
||||
});
|
||||
|
||||
it('handles a null/non-object payload defensively', () => {
|
||||
expect(paginateUsage(null, 200)).toEqual({ records: [], totalCount: 0, count: 0, offset: 0, nextLink: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('paginateInventory', () => {
|
||||
const inventory = Array.from({ length: 5 }, (_, i) => ({ normalizedName: `app-${i}`, cpeUri: '' }));
|
||||
const payload = { success: true, tenantId: 't1', total: 5, matched: 0, unmatched: 5, inventory };
|
||||
|
||||
@@ -105,19 +105,13 @@ suite('get_agent_efficiency_metrics', () => {
|
||||
});
|
||||
|
||||
suite('get_api_usage', () => {
|
||||
it('should return daily usage summary', async () => {
|
||||
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/daily`);
|
||||
expect(Array.isArray(data.summaries)).toBe(true);
|
||||
if (data.summaries.length > 0) {
|
||||
expect(data.summaries[0]).toHaveProperty('date');
|
||||
expect(data.summaries[0]).toHaveProperty('totalRequests');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return global per-record usage', async () => {
|
||||
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage`);
|
||||
// Response structure depends on implementation, just verify no error
|
||||
expect(data).toBeDefined();
|
||||
it('should return the all-tenant overview with its totals', async () => {
|
||||
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/overview?days=7`);
|
||||
expect(data).toHaveProperty('totals');
|
||||
expect(typeof data.totals.requests).toBe('number');
|
||||
expect(Array.isArray(data.tenants)).toBe(true);
|
||||
expect(Array.isArray(data.tools)).toBe(true);
|
||||
expect(Array.isArray(data.minuteLimits.accounts)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ const ALLOWED_INLINE_ENUMS: Record<string, string> = {
|
||||
'search_knowledge corpus selector — MCP-side, spans two backend catalogs plus the error-code catalog.',
|
||||
"'inventory', 'unmatched'":
|
||||
'get_software_inventory scope selector — an MCP-side view switch.',
|
||||
"...OVERVIEW_VIEWS, 'organization', 'account'":
|
||||
'get_api_usage view selector — an MCP-side view switch over three backend routes (api-usage-shape.ts).',
|
||||
"'cmtrace', 'text'":
|
||||
'test_log_pattern input format — an MCP-side parser switch.',
|
||||
"'analyze', 'gather'":
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* LLM-facing shaping of the MCP usage views of get_api_usage:
|
||||
* - the all-tenant overview (GET /api/global/metrics/mcp-usage/overview, D-416) — one list per view, or the summary;
|
||||
* - one tenant's organization budget (GET /api/global/metrics/mcp-usage/organization?tenantId=);
|
||||
* - one account's quota counters (GET /api/metrics/mcp-usage/user/{userId}).
|
||||
*
|
||||
* The backend computes every figure (totals, minute shares, rankings) once for the admin page and this tool. This
|
||||
* layer only picks what a view shows, states the full length of every list the summary cuts, names tenants, folds an
|
||||
* account's counter rows into sums per tool and per day, and turns absent values into explicit nulls (the wire omits
|
||||
* them, which reads as "not applicable"). A quota window is { used, limit }: limit 0 = unlimited, used null = the
|
||||
* counter could not be read.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
GetMcpOrganizationUsageResponse,
|
||||
GetMcpUserUsageResponse,
|
||||
McpUsageAccountItem,
|
||||
McpUsageMinuteItem,
|
||||
McpUsageQuotaHitItem,
|
||||
McpUsageTenantItem,
|
||||
McpUsageToolItem,
|
||||
} from './generated/wire-types.generated.js';
|
||||
|
||||
export const OVERVIEW_VIEWS = ['summary', 'tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const;
|
||||
export type OverviewView = (typeof OVERVIEW_VIEWS)[number];
|
||||
|
||||
/** How many rows of each list the summary shows; the full list is one view away. */
|
||||
export const SUMMARY_HEAD = { tools: 10, tenants: 10, quotaHits: 10, minuteLimits: 5 } as const;
|
||||
|
||||
/** The tool of an account's counter key that carries none (a request sent without a tool name). */
|
||||
export const NO_TOOL = '(no tool)';
|
||||
|
||||
interface QuotaWindow {
|
||||
used: number | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** The wire marks an unreadable counter with -1. */
|
||||
function quotaWindow(used: number, limit: number): QuotaWindow {
|
||||
return { used: used < 0 ? null : used, limit };
|
||||
}
|
||||
|
||||
const dayKey = (d: Date) => d.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
/** The trailing window of `days` whole UTC days ending today, as yyyyMMdd — the overview's own window. */
|
||||
export function usageWindow(days: number, now: Date = new Date()): { from: string; to: string } {
|
||||
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)));
|
||||
return { from: dayKey(from), to: dayKey(now) };
|
||||
}
|
||||
|
||||
// ── Overview ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function overviewHead(data: GetGlobalMcpUsageOverviewResponse) {
|
||||
return {
|
||||
window: { from: data.dateFrom, to: data.dateTo },
|
||||
includesPlatformAdmins: data.includesPlatformAdmins,
|
||||
telemetry: {
|
||||
requestsAvailable: data.telemetry.requestsAvailable,
|
||||
mcpLogAvailable: data.telemetry.mcpLogAvailable,
|
||||
mcpLogFrom: data.telemetry.mcpLogFrom ?? null,
|
||||
partial: data.telemetry.partial ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolRow(t: McpUsageToolItem) {
|
||||
return {
|
||||
tool: t.tool,
|
||||
calls: t.calls,
|
||||
requestsPerCall: t.requestsPerCall,
|
||||
// null: the tool runs in the MCP server alone and never reaches the backend.
|
||||
accounts: t.accounts ?? null,
|
||||
tenants: t.tenants ?? null,
|
||||
errorRate: t.errorRate ?? null,
|
||||
p95DurationMs: t.p95DurationMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function tenantRow(t: McpUsageTenantItem) {
|
||||
return {
|
||||
tenantId: t.tenantId,
|
||||
domainName: t.domainName ?? null,
|
||||
plan: t.quota.tenantPlan,
|
||||
today: quotaWindow(t.quota.dailyUsed, t.quota.dailyLimit),
|
||||
month: quotaWindow(t.quota.monthlyUsed, t.quota.monthlyLimit),
|
||||
calls: t.calls,
|
||||
requests: t.requests,
|
||||
quotaHits: t.quotaHits,
|
||||
lastRequestAt: t.lastRequestAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function accountRow(a: McpUsageAccountItem) {
|
||||
return {
|
||||
principal: a.principal,
|
||||
userId: a.userId ?? null,
|
||||
role: a.role ?? null,
|
||||
isPlatformAdmin: a.isPlatformAdmin,
|
||||
plan: a.quota?.plan ?? null,
|
||||
today: a.quota ? quotaWindow(a.quota.dailyUsed, a.quota.dailyLimit) : null,
|
||||
month: a.quota ? quotaWindow(a.quota.monthlyUsed, a.quota.monthlyLimit) : null,
|
||||
calls: a.calls,
|
||||
requests: a.requests,
|
||||
peakDay: a.peakDay ?? null,
|
||||
peakDayRequests: a.peakDayRequests ?? null,
|
||||
quotaHits: a.quotaHits,
|
||||
topTools: a.topTools,
|
||||
lastRequestAt: a.lastRequestAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function quotaHitRow(h: McpUsageQuotaHitItem, domainName: string | null) {
|
||||
return {
|
||||
date: h.date,
|
||||
tenantId: h.tenantId,
|
||||
domainName,
|
||||
principal: h.principal ?? null,
|
||||
level: h.level,
|
||||
scope: h.scope,
|
||||
plan: h.plan,
|
||||
used: h.used ?? null,
|
||||
limit: h.limit ?? null,
|
||||
refused: h.refused,
|
||||
firstAt: h.firstAt,
|
||||
lastAt: h.lastAt,
|
||||
};
|
||||
}
|
||||
|
||||
function minuteRow(m: McpUsageMinuteItem, domainName: string | null) {
|
||||
return {
|
||||
tenantId: m.tenantId,
|
||||
domainName,
|
||||
principal: m.principal,
|
||||
sharePercent: m.sharePercent,
|
||||
peakCalls: m.peakCalls,
|
||||
peakCallsAt: m.peakCallsAt ?? null,
|
||||
mcpRefused: m.mcpRefused,
|
||||
peakRequests: m.peakRequests,
|
||||
peakRequestsAt: m.peakRequestsAt ?? null,
|
||||
backendLimit: m.backendLimit,
|
||||
backendRefused: m.backendRefused,
|
||||
};
|
||||
}
|
||||
|
||||
/** One view of the overview: the summary (every list cut to its head, full lengths stated) or one list in full. */
|
||||
export function shapeOverview(data: GetGlobalMcpUsageOverviewResponse, view: OverviewView): Record<string, unknown> {
|
||||
const names = new Map(data.tenants.filter((t) => t.domainName).map((t) => [t.tenantId.toLowerCase(), t.domainName!]));
|
||||
const nameOf = (tenantId: string) => names.get(tenantId.toLowerCase()) ?? null;
|
||||
const head = { view, ...overviewHead(data) };
|
||||
const minute = data.minuteLimits;
|
||||
|
||||
switch (view) {
|
||||
case 'summary': {
|
||||
const lists = {
|
||||
tools: data.tools.length,
|
||||
tenants: data.tenants.length,
|
||||
quotaHits: data.quotaHits.length,
|
||||
minuteLimits: minute.accounts.length,
|
||||
};
|
||||
const cut = (Object.keys(lists) as Array<keyof typeof lists>).some((k) => lists[k] > SUMMARY_HEAD[k]);
|
||||
return {
|
||||
...head,
|
||||
totals: { ...data.totals, calls: data.totals.calls ?? null, mcpOnlyCalls: data.totals.mcpOnlyCalls ?? null },
|
||||
tools: data.tools.slice(0, SUMMARY_HEAD.tools).map(toolRow),
|
||||
tenants: data.tenants.slice(0, SUMMARY_HEAD.tenants).map((t) => ({ ...tenantRow(t), accounts: t.accounts.length })),
|
||||
quotaHits: data.quotaHits.slice(0, SUMMARY_HEAD.quotaHits).map((h) => quotaHitRow(h, nameOf(h.tenantId))),
|
||||
minuteLimits: {
|
||||
mcpServerLimit: minute.mcpServerLimit ?? null,
|
||||
accounts: minute.accounts.slice(0, SUMMARY_HEAD.minuteLimits).map((m) => minuteRow(m, nameOf(m.tenantId))),
|
||||
},
|
||||
lists,
|
||||
...(cut ? { note: 'Lists are cut to their first rows; lists gives each full length, the view of the same name shows it in full.' } : {}),
|
||||
};
|
||||
}
|
||||
case 'tools':
|
||||
return { ...head, tools: data.tools.map((t) => ({ ...toolRow(t), avgResultChars: t.avgResultChars ?? null, overCapCalls: t.overCapCalls ?? null, routes: t.routes })) };
|
||||
case 'tenants':
|
||||
return { ...head, tenants: data.tenants.map((t) => ({ ...tenantRow(t), accounts: t.accounts.map(accountRow) })) };
|
||||
case 'quotaHits':
|
||||
return { ...head, quotaHits: data.quotaHits.map((h) => quotaHitRow(h, nameOf(h.tenantId))) };
|
||||
case 'minuteLimits':
|
||||
return {
|
||||
...head,
|
||||
mcpServerLimit: minute.mcpServerLimit ?? null,
|
||||
accounts: minute.accounts.map((m) => ({
|
||||
...minuteRow(m, nameOf(m.tenantId)),
|
||||
mcpLastRefusedAt: m.mcpLastRefusedAt ?? null,
|
||||
backendLastRefusedAt: m.backendLastRefusedAt ?? null,
|
||||
})),
|
||||
};
|
||||
case 'daily':
|
||||
return { ...head, daily: data.daily };
|
||||
}
|
||||
}
|
||||
|
||||
// ── One tenant ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One tenant's organization budget: its windows, every account charged to it, and the days. */
|
||||
export function shapeOrganization(data: GetMcpOrganizationUsageResponse): Record<string, unknown> {
|
||||
return {
|
||||
view: 'organization',
|
||||
tenantId: data.tenantId,
|
||||
window: { from: data.dateFrom, to: data.dateTo },
|
||||
plan: data.quota.tenantPlan,
|
||||
today: quotaWindow(data.quota.dailyUsed, data.quota.dailyLimit),
|
||||
month: quotaWindow(data.quota.monthlyUsed, data.quota.monthlyLimit),
|
||||
requests: data.users.reduce((sum, u) => sum + u.requestsInRange, 0),
|
||||
accounts: data.users.map((u) => ({
|
||||
principal: u.userPrincipalName ?? null,
|
||||
userId: u.userId,
|
||||
requestsInRange: u.requestsInRange,
|
||||
requestsToday: u.requestsToday,
|
||||
requestsThisMonth: u.requestsThisMonth,
|
||||
lastRequestAt: u.lastRequestAt ?? null,
|
||||
})),
|
||||
daily: data.daily,
|
||||
};
|
||||
}
|
||||
|
||||
// ── One account ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** A counter key is `tool:path`, or the bare path when the request carried no tool name. */
|
||||
function splitEndpoint(endpoint: string): { tool: string; path: string } {
|
||||
const colon = endpoint.indexOf(':');
|
||||
return colon > 0 ? { tool: endpoint.slice(0, colon), path: endpoint.slice(colon + 1) } : { tool: NO_TOOL, path: endpoint };
|
||||
}
|
||||
|
||||
const byRequestsThenName = <T extends { requests: number }>(name: (row: T) => string) =>
|
||||
(a: T, b: T) => b.requests - a.requests || name(a).localeCompare(name(b));
|
||||
|
||||
/** One account's charged requests in the window, by tool (with the paths it requested) and by day. */
|
||||
export function shapeAccount(data: GetMcpUserUsageResponse, window: { from: string; to: string }): Record<string, unknown> {
|
||||
const tools = new Map<string, Map<string, number>>();
|
||||
const days = new Map<string, number>();
|
||||
let newest: (typeof data.records)[number] | undefined;
|
||||
|
||||
for (const record of data.records) {
|
||||
const { tool, path } = splitEndpoint(record.endpoint);
|
||||
const paths = tools.get(tool) ?? new Map<string, number>();
|
||||
paths.set(path, (paths.get(path) ?? 0) + record.requestCount);
|
||||
tools.set(tool, paths);
|
||||
days.set(record.date, (days.get(record.date) ?? 0) + record.requestCount);
|
||||
if (!newest || record.lastRequestAt > newest.lastRequestAt) newest = record;
|
||||
}
|
||||
|
||||
const byTool = [...tools.entries()]
|
||||
.map(([tool, paths]) => ({
|
||||
tool,
|
||||
requests: [...paths.values()].reduce((sum, n) => sum + n, 0),
|
||||
paths: [...paths.entries()].map(([path, requests]) => ({ path, requests })).sort(byRequestsThenName((p) => p.path)),
|
||||
}))
|
||||
.sort(byRequestsThenName((t) => t.tool));
|
||||
|
||||
return {
|
||||
view: 'account',
|
||||
userId: data.userId,
|
||||
principal: newest?.userPrincipalName || null,
|
||||
tenantId: newest?.tenantId || null,
|
||||
window,
|
||||
requests: byTool.reduce((sum, t) => sum + t.requests, 0),
|
||||
byTool,
|
||||
byDay: [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, requests]) => ({ date, requests })),
|
||||
};
|
||||
}
|
||||
@@ -326,7 +326,7 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
|
||||
* nextLink's embedded tenantId when present (it is what actually gets sent), else the explicit arg, and
|
||||
* validate THAT against the managed set — which also blocks a hand-crafted continuation pointing at an
|
||||
* unmanaged tenant (defense in depth on top of the backend bound). No-op for non-delegated callers and
|
||||
* for offset-only continuations (geo-offset:/inv-offset:/usage-offset:), which re-send the explicit
|
||||
* for offset-only continuations (geo-offset:/inv-offset:), which re-send the explicit
|
||||
* tenantId every page anyway.
|
||||
*/
|
||||
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
|
||||
|
||||
@@ -2278,13 +2278,6 @@ export interface GetGlobalAdminsResponse {
|
||||
admins: GlobalAdminRow[];
|
||||
}
|
||||
|
||||
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
|
||||
export interface GetGlobalMcpUsageDailyResponse {
|
||||
/** Echo of the request filter; absent when the caller passed no tenantId. */
|
||||
tenantId?: string;
|
||||
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). */
|
||||
@@ -2294,6 +2287,8 @@ export interface GetGlobalMcpUsageOverviewResponse {
|
||||
/** False when Global Admins and Global Readers are left out of every number. */
|
||||
includesPlatformAdmins: boolean;
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
/** The window's totals over the lists below, computed once for every consumer. */
|
||||
totals: McpUsageTotalsNode;
|
||||
/** One item per day of the window, oldest first. */
|
||||
daily: McpUsageDayItem[];
|
||||
/** Every tenant with MCP use in the window, most calls first. */
|
||||
@@ -2305,13 +2300,6 @@ export interface GetGlobalMcpUsageOverviewResponse {
|
||||
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. */
|
||||
tenantId?: string;
|
||||
records: UserUsageRecord[];
|
||||
}
|
||||
|
||||
/** Response of GET tenants/{tenantId}/graph-permissions/status: the client id of the app homed for the tenant, the transient flag, the granted Graph app roles and the per-feature verdict matrix. */
|
||||
export interface GetGraphPermissionsStatusResponse {
|
||||
/** ClientId of the app registration that acts for this tenant (empty string when unresolved, never null). */
|
||||
@@ -3434,6 +3422,8 @@ export interface McpUsageDayItem {
|
||||
export interface McpUsageMinuteItem {
|
||||
tenantId: string;
|
||||
principal: string;
|
||||
/** How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known. */
|
||||
sharePercent: number;
|
||||
/** Most tool calls the account started inside 60 seconds (measured in 10-second steps). */
|
||||
peakCalls: number;
|
||||
peakCallsAt?: string;
|
||||
@@ -3528,6 +3518,8 @@ export interface McpUsageTelemetryStatus {
|
||||
/** One tenant of the overview with its organization windows and its accounts. */
|
||||
export interface McpUsageTenantItem {
|
||||
tenantId: string;
|
||||
/** The tenant's domain from its configuration; absent when it has none or the configuration was unreadable. */
|
||||
domainName?: string;
|
||||
/** The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters. */
|
||||
quota: McpOrganizationQuotaNode;
|
||||
calls: number;
|
||||
@@ -3568,6 +3560,27 @@ export interface McpUsageToolItem {
|
||||
routes: McpUsageRouteItem[];
|
||||
}
|
||||
|
||||
/** The overview's totals, nested in GetGlobalMcpUsageOverviewResponse. "Calls" are tool calls, "requests" are backend requests charged to the quota; an account is a person or a service principal of one tenant. */
|
||||
export interface McpUsageTotalsNode {
|
||||
/** Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer. */
|
||||
calls?: number;
|
||||
/** Calls of tools that run in the MCP server alone (part of Calls, attributed to no account); absent without the MCP log. */
|
||||
mcpOnlyCalls?: number;
|
||||
/** Tools called in the window. */
|
||||
tools: number;
|
||||
requests: number;
|
||||
tenants: number;
|
||||
accounts: number;
|
||||
/** Accounts that are service principals (app:<client-id>). */
|
||||
servicePrincipals: number;
|
||||
/** Accounts with at least one quota refusal in the window (daily or monthly, own or organization window). */
|
||||
quotaHitAccounts: number;
|
||||
/** Accounts with at least one per-minute refusal in the window (MCP server or backend). */
|
||||
minuteRefusedAccounts: number;
|
||||
/** Accounts that met any limit in the window — the union of the two above. */
|
||||
limitAccounts: number;
|
||||
}
|
||||
|
||||
export interface McpUserEntry {
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
@@ -6295,15 +6308,6 @@ export interface UserMetrics {
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Aggregated daily usage summary across endpoints (and optionally users). */
|
||||
export interface UserUsageDailySummary {
|
||||
date: string;
|
||||
tenantId?: string;
|
||||
totalRequests: number;
|
||||
uniqueUsers: number;
|
||||
uniqueEndpoints: number;
|
||||
}
|
||||
|
||||
/** A single usage record: one user, one day, one endpoint. */
|
||||
export interface UserUsageRecord {
|
||||
userId: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanWithTimeoutFallback, jsonBody } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { getResourceContent, withEventTypeNote, RESOURCE_NAMES } from '../resource-catalog.js';
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, ObjectIdSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { API_BASE_URL } from '../config.js';
|
||||
import { collectDeploymentState } from '../deployment-state.js';
|
||||
@@ -93,6 +93,7 @@ export function buildFleetOverview(
|
||||
};
|
||||
}
|
||||
import { shapeVerdictCalibration } from '../verdict-calibration-shape.js';
|
||||
import { OVERVIEW_VIEWS, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
|
||||
import type {
|
||||
AgentEfficiencyMetricsResponse,
|
||||
AppMetricsResponse,
|
||||
@@ -105,10 +106,9 @@ import type {
|
||||
GeographicMetricsResponse,
|
||||
GetAllTenantConfigurationsResponse,
|
||||
GetDeviceHistoryResponse,
|
||||
GetGlobalMcpUsageDailyResponse,
|
||||
GetGlobalMcpUsageResponse,
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
GetMcpOrganizationUsageResponse,
|
||||
GetMcpUserUsageResponse,
|
||||
GetMyMcpUsageResponse,
|
||||
GetSessionTimeAttributionResponse,
|
||||
GetTenantConfigFieldsSchemaResponse,
|
||||
GetUnmatchedSoftwareResponse,
|
||||
@@ -332,49 +332,6 @@ export function platformWindowEcho(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Page client-side over an MCP-usage payload. The mcp-usage endpoints return the
|
||||
* full set in one shot (no server-side cursor): the global per-record breakdown
|
||||
* alone can run to thousands of rows and overflow the inline response budget. We
|
||||
* slice whichever array the payload carries ("records" for the per-user and
|
||||
* global breakdowns, "summaries" for the daily view) and echo every other
|
||||
* top-level field (tenantId/userId/usagePlan) verbatim. The "continuation"
|
||||
* carries an integer offset (usage-offset:N); each page re-reads the same set,
|
||||
* so raise pageSize to amortize round-trips on a full sweep. Mirrors the
|
||||
* get_geographic_sessions client-side pager. Exported for unit testing.
|
||||
*/
|
||||
export function paginateUsage(
|
||||
data: unknown,
|
||||
pageSize: number,
|
||||
continuation?: string,
|
||||
): Record<string, unknown> {
|
||||
if (!data || typeof data !== 'object') return { records: [], totalCount: 0, count: 0, offset: 0, nextLink: null };
|
||||
const obj = data as Record<string, unknown>;
|
||||
// The `satisfies` pins tie the sliced key names to the generated wire types —
|
||||
// a backend rename of these wire-critical keys goes tsc-red here, not silent.
|
||||
const arrayKey = Array.isArray(obj.records)
|
||||
? ('records' satisfies keyof GetGlobalMcpUsageResponse & keyof GetMcpUserUsageResponse & keyof GetMyMcpUsageResponse)
|
||||
: Array.isArray(obj.summaries)
|
||||
? ('summaries' satisfies keyof GetGlobalMcpUsageDailyResponse)
|
||||
: null;
|
||||
if (!arrayKey) return obj; // unexpected shape — pass through untouched rather than mangle it
|
||||
const all = obj[arrayKey] as unknown[];
|
||||
const m = continuation ? /^usage-offset:(\d+)$/.exec(continuation) : null;
|
||||
const offset = m ? parseInt(m[1], 10) : 0;
|
||||
const slice = all.slice(offset, offset + pageSize);
|
||||
const nextOffset = offset + pageSize;
|
||||
const nextLink = nextOffset < all.length ? `usage-offset:${nextOffset}` : null;
|
||||
const { records: _records, summaries: _summaries, ...rest } = obj;
|
||||
return {
|
||||
...rest,
|
||||
totalCount: all.length,
|
||||
count: slice.length,
|
||||
offset,
|
||||
[arrayKey]: slice,
|
||||
nextLink,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Page client-side over a software-inventory payload. The inventory endpoints
|
||||
* ({@code /api/metrics/software-inventory}, {@code /api/vulnerability/software-inventory})
|
||||
@@ -383,7 +340,8 @@ export function paginateUsage(
|
||||
* the {@code inventory} array and echo every other top-level field (success/tenantId/
|
||||
* total/matched/unmatched) verbatim. The "continuation" carries an integer offset
|
||||
* (inv-offset:N); each page re-reads the same set, so raise pageSize to amortize
|
||||
* round-trips on a full sweep. Mirrors {@link paginateUsage}. Exported for unit testing.
|
||||
* round-trips on a full sweep. Mirrors the get_geographic_sessions client-side pager.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function paginateInventory(
|
||||
data: unknown,
|
||||
@@ -392,7 +350,7 @@ export function paginateInventory(
|
||||
): Record<string, unknown> {
|
||||
if (!data || typeof data !== 'object') return { inventory: [], total: 0, count: 0, offset: 0, nextLink: null };
|
||||
const obj = data as Record<string, unknown>;
|
||||
// Same wire-key pin as paginateUsage: renaming `inventory` on the wire goes tsc-red here.
|
||||
// The `satisfies` pin ties the sliced key to the generated wire type: renaming `inventory` on the wire goes tsc-red here.
|
||||
const inventoryKey = 'inventory' satisfies keyof SoftwareInventoryResponse;
|
||||
if (!Array.isArray(obj[inventoryKey])) return obj; // unexpected shape — pass through untouched rather than mangle it
|
||||
const all = obj[inventoryKey] as unknown[];
|
||||
@@ -412,42 +370,61 @@ export function paginateInventory(
|
||||
}
|
||||
|
||||
export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boolean = ga, delegated: boolean = false): void {
|
||||
// Tool 11: get_api_usage — Global Admin only; not registered for normal users
|
||||
// (the `if (ga)` guards the whole single server.registerTool(...) statement).
|
||||
// Tool 11: get_api_usage — platform scope only (GA and Global Reader); not registered for tenant users
|
||||
// (the `if (ga)` guards the whole single server.registerTool(...) statement). The shaping lives in
|
||||
// api-usage-shape.ts; every figure is computed by the backend.
|
||||
if (ga) server.registerTool(
|
||||
'get_api_usage',
|
||||
{
|
||||
title: 'API Usage',
|
||||
title: 'MCP Usage',
|
||||
description:
|
||||
'API/MCP usage statistics: request counts per endpoint per day. Use to monitor platform usage, identify ' +
|
||||
'heavy users, or debug rate limiting. userId gives one user\'s view, daily=true a compact daily summary, ' +
|
||||
'neither the global per-record breakdown (thousands of verbose rows — paged client-side over the full set, ' +
|
||||
'so a larger pageSize saves round-trips). dateFrom/dateTo default to the full retention window.',
|
||||
'MCP usage across all tenants, as on the GA page MCP Usage. A call is one tool call; a request is one backend ' +
|
||||
'request charged to the daily/monthly quota (requestsPerCall = cost of a call). view=summary: totals, top tools ' +
|
||||
'and tenants, newest quota refusals, accounts closest to a per-minute limit, each list cut to its first rows ' +
|
||||
'(full lengths in `lists`). view=tools|tenants|quotaHits|minuteLimits|daily: that list in full. A quota hit is ' +
|
||||
'one refused day per account or organization and window. Platform admins count only with ' +
|
||||
'includePlatformAdmins=true. view=organization + tenantId: one tenant\'s budget by account and day (platform ' +
|
||||
'admins included). view=account + userId: one account\'s requests by tool, path and day. Windows are ' +
|
||||
'{used, limit}: limit 0 = unlimited, used null = unreadable. `telemetry` names a store that did not answer.',
|
||||
inputSchema: {
|
||||
userId: z.string().optional().describe('Specific user object ID to query usage for'),
|
||||
tenantId: z.string().optional().describe('Filter usage by tenant ID'),
|
||||
dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
||||
dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
||||
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
|
||||
pageSize: z.coerce.number().int().min(1).max(2000).optional().default(50)
|
||||
.describe(pageSizeDescription(50, 2000)),
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
|
||||
view: z.enum([...OVERVIEW_VIEWS, 'organization', 'account']).optional().default('summary')
|
||||
.describe('Default summary. organization needs tenantId, account needs userId.'),
|
||||
days: z.coerce.number().int().min(1).max(90).optional().default(30)
|
||||
.describe(daysDescription(30, 90)),
|
||||
includePlatformAdmins: z.boolean().optional().default(false)
|
||||
.describe('Overview views only: count Global Admins and Global Readers too.'),
|
||||
tenantId: TenantGuidSchema.optional().describe('The tenant of view=organization.'),
|
||||
userId: ObjectIdSchema.optional().describe('The account of view=account: its Entra object id (view=tenants lists them).'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_api_usage', args, async () => {
|
||||
try {
|
||||
let data: GetMcpUserUsageResponse | GetGlobalMcpUsageDailyResponse | GetGlobalMcpUsageResponse;
|
||||
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
|
||||
if (args.userId) {
|
||||
data = await apiFetch<GetMcpUserUsageResponse>(`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery(params)}`);
|
||||
} else if (args.daily) {
|
||||
data = await apiFetch<GetGlobalMcpUsageDailyResponse>(`/api/global/metrics/mcp-usage/daily${buildQuery(params)}`);
|
||||
} else {
|
||||
data = await apiFetch<GetGlobalMcpUsageResponse>(`/api/global/metrics/mcp-usage${buildQuery(params)}`);
|
||||
const view = args.view;
|
||||
const window = usageWindow(args.days);
|
||||
if (view === 'organization') {
|
||||
if (!args.tenantId) {
|
||||
return toolError('get_api_usage', args, new Error(
|
||||
'tenantId is required for view=organization; list_tenants resolves a tenant name to its id.'));
|
||||
}
|
||||
const data = await apiFetch<GetMcpOrganizationUsageResponse>(
|
||||
`/api/global/metrics/mcp-usage/organization${buildQuery({ tenantId: args.tenantId, dateFrom: window.from, dateTo: window.to })}`);
|
||||
return toolResultText(shapeOrganization(data), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
}
|
||||
return toolResultText(paginateUsage(data, args.pageSize, args.continuation), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
if (view === 'account') {
|
||||
if (!args.userId) {
|
||||
return toolError('get_api_usage', args, new Error(
|
||||
'userId (the Entra object id) is required for view=account; view=tenants lists every account with its userId.'));
|
||||
}
|
||||
const data = await apiFetch<GetMcpUserUsageResponse>(
|
||||
`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery({ dateFrom: window.from, dateTo: window.to })}`);
|
||||
return toolResultText(shapeAccount(data, window), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
}
|
||||
// Seven telemetry queries with a 60-s budget each run behind this route: outlast them.
|
||||
const data = await apiFetch<GetGlobalMcpUsageOverviewResponse>(
|
||||
`/api/global/metrics/mcp-usage/overview${buildQuery({ days: args.days, platformAdmins: args.includePlatformAdmins ? 'include' : 'exclude' })}`,
|
||||
{ signal: AbortSignal.timeout(90_000) });
|
||||
return toolResultText(shapeOverview(data, view), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
} catch (error: unknown) {
|
||||
return toolError('get_api_usage', args, error);
|
||||
}
|
||||
|
||||
@@ -92,12 +92,11 @@ export const SUMMARY_EVENT_FIELDS =
|
||||
* to a different endpoint. The strict GUID pattern blocks both that path-
|
||||
* traversal vector and accidental garbage inputs.
|
||||
*/
|
||||
const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const SessionIdSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
'sessionId must be a UUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")',
|
||||
);
|
||||
.regex(GUID_PATTERN, 'sessionId must be a UUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")');
|
||||
|
||||
/**
|
||||
* Read-side guard for the historical / in-flight `script_failed` rows that the agent
|
||||
@@ -192,10 +191,16 @@ export function tenantIdDescription(
|
||||
*/
|
||||
export const TenantGuidSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
'tenantId must be a GUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")',
|
||||
);
|
||||
.regex(GUID_PATTERN, 'tenantId must be a GUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")');
|
||||
|
||||
/**
|
||||
* Zod validator for an account's Entra object id interpolated into a backend URL path
|
||||
* (`/api/metrics/mcp-usage/user/{userId}`) — same path-traversal rationale as SessionIdSchema. It also
|
||||
* turns a UPN passed by mistake into an error instead of an empty answer that reads as "no usage".
|
||||
*/
|
||||
export const ObjectIdSchema = z
|
||||
.string()
|
||||
.regex(GUID_PATTERN, 'userId must be an Entra object id (a GUID such as "e259c121-1234-4abc-9def-0123456789ab"), not a UPN');
|
||||
|
||||
/** Read-only query tool — no side effects, idempotent, closed-world (our backend only). */
|
||||
export const READ_ONLY: ToolAnnotations = {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Plan §2.8 — Newtonsoft.Json on both sides (Agent net48 + Backend net8.0) for DecisionCore DTOs. -->
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,16 +21,6 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// </summary>
|
||||
Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets usage records for all users belonging to a tenant within an optional date range.
|
||||
/// </summary>
|
||||
Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated daily usage summaries, optionally filtered by tenant.
|
||||
/// </summary>
|
||||
Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all usage records older than the specified date (yyyyMMdd format).
|
||||
/// Returns the number of records deleted.
|
||||
@@ -84,16 +74,4 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public long RequestCount { get; set; }
|
||||
public DateTime LastRequestAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated daily usage summary across endpoints (and optionally users).
|
||||
/// </summary>
|
||||
public class UserUsageDailySummary
|
||||
{
|
||||
public string Date { get; set; } = string.Empty;
|
||||
public string? TenantId { get; set; }
|
||||
public long TotalRequests { get; set; }
|
||||
public int UniqueUsers { get; set; }
|
||||
public int UniqueEndpoints { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <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>The window's totals over the lists below, computed once for every consumer.</summary>
|
||||
public McpUsageTotalsNode Totals { 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>
|
||||
@@ -241,6 +243,33 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool? Partial { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The overview's totals, nested in <see cref="GetGlobalMcpUsageOverviewResponse"/>. "Calls" are tool calls,
|
||||
/// "requests" are backend requests charged to the quota; an account is a person or a service principal of one
|
||||
/// tenant.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageTotalsNode
|
||||
{
|
||||
/// <summary>Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer.</summary>
|
||||
public long? Calls { get; set; }
|
||||
/// <summary>Calls of tools that run in the MCP server alone (part of <see cref="Calls"/>, attributed to no account); absent without the MCP log.</summary>
|
||||
public long? McpOnlyCalls { get; set; }
|
||||
/// <summary>Tools called in the window.</summary>
|
||||
public int Tools { get; set; }
|
||||
public long Requests { get; set; }
|
||||
public int Tenants { get; set; }
|
||||
public int Accounts { get; set; }
|
||||
/// <summary>Accounts that are service principals (<c>app:<client-id></c>).</summary>
|
||||
public int ServicePrincipals { get; set; }
|
||||
/// <summary>Accounts with at least one quota refusal in the window (daily or monthly, own or organization window).</summary>
|
||||
public int QuotaHitAccounts { get; set; }
|
||||
/// <summary>Accounts with at least one per-minute refusal in the window (MCP server or backend).</summary>
|
||||
public int MinuteRefusedAccounts { get; set; }
|
||||
/// <summary>Accounts that met any limit in the window — the union of the two above.</summary>
|
||||
public int LimitAccounts { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One day of the overview trend.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpUsageDayItem
|
||||
@@ -268,6 +297,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public class McpUsageTenantItem
|
||||
{
|
||||
public string TenantId { get; set; } = default!;
|
||||
/// <summary>The tenant's domain from its configuration; absent when it has none or the configuration was unreadable.</summary>
|
||||
public string? DomainName { get; set; }
|
||||
/// <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; }
|
||||
@@ -399,6 +430,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Principal { get; set; } = default!;
|
||||
/// <summary>
|
||||
/// How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP
|
||||
/// server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known.
|
||||
/// </summary>
|
||||
public int SharePercent { get; set; }
|
||||
/// <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; }
|
||||
@@ -415,32 +451,6 @@ namespace AutopilotMonitor.Shared.Models
|
||||
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.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class GetGlobalMcpUsageResponse : IApiResponse
|
||||
{
|
||||
/// <summary>Echo of the request filter; absent when the caller passed no tenantId.</summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
public IReadOnlyList<UserUsageRecord> Records { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server
|
||||
/// paginates over the <c>summaries</c> key — its name is wire-critical.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class GetGlobalMcpUsageDailyResponse : IApiResponse
|
||||
{
|
||||
/// <summary>Echo of the request filter; absent when the caller passed no tenantId.</summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
public IReadOnlyList<UserUsageDailySummary> Summaries { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant session-status tally envelope shared by MetricsSummary and
|
||||
/// MetricsSummaryGlobal.
|
||||
|
||||
@@ -20,10 +20,10 @@ import type {
|
||||
import { Stat } from "./Stat";
|
||||
import {
|
||||
dailyChart,
|
||||
editionCounts,
|
||||
formatDayKey,
|
||||
MCP_USAGE_MAX_DAYS,
|
||||
MCP_USAGE_VIEW_PARAM,
|
||||
overviewTotals,
|
||||
parseView,
|
||||
planLegend,
|
||||
tenantInfoMap,
|
||||
@@ -137,9 +137,10 @@ function McpUsageContent() {
|
||||
() => (tenantRows ? tenantInfoMap(tenantRows.tenants, tenantRows.nowMs) : NO_TENANT_INFO),
|
||||
[tenantRows],
|
||||
);
|
||||
const totals = useMemo(() => (overview ? overviewTotals(overview, info) : null), [overview, info]);
|
||||
const editions = useMemo(() => (overview ? editionCounts(overview, info) : null), [overview, info]);
|
||||
const chart = useMemo(() => (overview ? dailyChart(overview) : null), [overview]);
|
||||
const plans = useMemo(() => planLegend(planTiers), [planTiers]);
|
||||
const totals = overview?.totals;
|
||||
|
||||
const viewOptions: { value: McpUsageView; label: string }[] = [
|
||||
{ value: "tenants", label: "Tenants & accounts" },
|
||||
@@ -147,8 +148,6 @@ function McpUsageContent() {
|
||||
{ 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.
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
@@ -195,20 +194,20 @@ function McpUsageContent() {
|
||||
estimateMs={estimateMs}
|
||||
/>
|
||||
</div>
|
||||
) : overview && totals && chart ? (
|
||||
) : overview && totals && editions && 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`}
|
||||
value={totals.calls == null ? "—" : totals.calls.toLocaleString()}
|
||||
hint={totals.mcpOnlyCalls ? `${totals.tools} tools · ${totals.mcpOnlyCalls.toLocaleString()} MCP-only` : `${totals.tools} 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="Tenants" value={totals.tenants.toLocaleString()} hint={`${editions.community} Community · ${editions.pro} Pro`} />
|
||||
<Stat
|
||||
label="Accounts"
|
||||
value={totals.accounts.toLocaleString()}
|
||||
hint={`${totals.accounts - totals.apps} people · ${totals.apps} ${totals.apps === 1 ? "app" : "apps"}`}
|
||||
hint={`${totals.accounts - totals.servicePrincipals} people · ${totals.servicePrincipals} ${totals.servicePrincipals === 1 ? "app" : "apps"}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+16
-36
@@ -15,9 +15,8 @@ import {
|
||||
formatDayKey,
|
||||
formatDuration,
|
||||
formatShare,
|
||||
editionCounts,
|
||||
minuteRowsToShow,
|
||||
minuteShare,
|
||||
overviewTotals,
|
||||
parseView,
|
||||
planLegend,
|
||||
searchTenants,
|
||||
@@ -154,6 +153,10 @@ function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlo
|
||||
dateTo: "20261105",
|
||||
includesPlatformAdmins: false,
|
||||
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
|
||||
totals: {
|
||||
calls: 0, mcpOnlyCalls: 0, tools: 0, requests: 0, tenants: 0, accounts: 0, servicePrincipals: 0,
|
||||
quotaHitAccounts: 0, minuteRefusedAccounts: 0, limitAccounts: 0,
|
||||
},
|
||||
daily: [],
|
||||
tenants: [],
|
||||
tools: [],
|
||||
@@ -168,31 +171,15 @@ function tool(name: string, calls: number, over: Partial<McpUsageToolItem> = {})
|
||||
}
|
||||
|
||||
function minute(principal: string, over: Partial<McpUsageMinuteItem> = {}): McpUsageMinuteItem {
|
||||
return { tenantId: TENANT_A, principal, peakCalls: 0, mcpRefused: 0, peakRequests: 0, backendLimit: 120, backendRefused: 0, ...over };
|
||||
return { tenantId: TENANT_A, principal, sharePercent: 0, 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("editionCounts", () => {
|
||||
it("counts the overview's tenants by their edition from config/all", () => {
|
||||
const tenants = [tenant(TENANT_A, [account("alice@contoso.com")]), tenant(TENANT_B, [account("bob@fabrikam.com")])];
|
||||
expect(editionCounts(overview({ tenants }), INFO)).toEqual({ community: 1, pro: 1 });
|
||||
// A tenant missing from config/all counts toward neither edition.
|
||||
expect(editionCounts(overview({ tenants: [tenant("cccccccc-0000-0000-0000-00000000000c", [])] }), INFO)).toEqual({ community: 0, pro: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,20 +200,13 @@ describe("dailyChart", () => {
|
||||
});
|
||||
|
||||
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 }),
|
||||
minute("low", { sharePercent: 9 }),
|
||||
minute("refused", { sharePercent: 8, backendRefused: 1 }),
|
||||
minute("close", { sharePercent: 50 }),
|
||||
];
|
||||
expect(minuteRowsToShow(rows, 60).map((r) => r.principal)).toEqual(["refused", "close"]);
|
||||
expect(minuteRowsToShow(rows).map((r) => r.principal)).toEqual(["refused", "close"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
McpUsageQuotaHitItem,
|
||||
McpUsageTelemetryStatus,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import { formatDayKey, minuteRowsToShow, minuteShare, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
|
||||
import { formatDayKey, minuteRowsToShow, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
|
||||
import { DataNote, Pill } from "./parts";
|
||||
|
||||
function limitText(value: number | undefined): string {
|
||||
@@ -135,7 +135,7 @@ function MinuteSection({
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const mcpLimit = minuteLimits.mcpServerLimit;
|
||||
const close = minuteRowsToShow(minuteLimits.accounts, mcpLimit);
|
||||
const close = minuteRowsToShow(minuteLimits.accounts);
|
||||
const rows = showAll ? minuteLimits.accounts : close;
|
||||
|
||||
return (
|
||||
@@ -170,7 +170,7 @@ function MinuteSection({
|
||||
<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
|
||||
{tenantLabel(row.tenantId, info)} · busiest 60 s at {row.sharePercent} % of a limit
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
+14
-47
@@ -5,7 +5,6 @@
|
||||
* 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,
|
||||
@@ -183,45 +182,20 @@ export function searchTenants(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Totals (the tiles) ────────────────────────────────────────────────────────
|
||||
// ── Editions (the Tenants tile) ───────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
/**
|
||||
* The overview's tenants by edition, trials resolved from config/all. The other tile figures come from
|
||||
* `overview.totals`, which the backend computes once for every consumer; the edition is the one the page adds.
|
||||
*/
|
||||
export function editionCounts(
|
||||
overview: GetGlobalMcpUsageOverviewResponse,
|
||||
info: ReadonlyMap<string, TenantInfo>,
|
||||
): { community: number; pro: number } {
|
||||
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,
|
||||
community: overview.tenants.filter((t) => editionOf(t.tenantId) === "community").length,
|
||||
pro: overview.tenants.filter((t) => editionOf(t.tenantId)?.startsWith("pro")).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,16 +225,9 @@ export function dailyChart(overview: GetGlobalMcpUsageOverviewResponse): DailyCh
|
||||
/** 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);
|
||||
/** Rows worth a look: a refusal, or a peak at half a limit or more (`sharePercent`, computed by the backend). */
|
||||
export function minuteRowsToShow(rows: readonly McpUsageMinuteItem[]): McpUsageMinuteItem[] {
|
||||
return rows.filter((r) => r.mcpRefused > 0 || r.backendRefused > 0 || r.sharePercent >= MINUTE_SHOW_PERCENT);
|
||||
}
|
||||
|
||||
// ── Plan legend ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -826,8 +826,6 @@ export const api = {
|
||||
mcpUsage: {
|
||||
me: (dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/metrics/mcp-usage/me${qs({ dateFrom, dateTo })}`,
|
||||
user: (userId: string, dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/metrics/mcp-usage/user/${encodeURIComponent(userId)}${qs({ dateFrom, dateTo })}`,
|
||||
/** Own tenant's organization budget by account (its members and service principals; delegated reads into it are charged to the reader's home tenant). Tenant Admin / Global Reader. */
|
||||
organization: (dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/metrics/mcp-usage/organization${qs({ dateFrom, dateTo })}`,
|
||||
|
||||
@@ -10936,31 +10936,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"GetGlobalMcpUsageDailyResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tenantId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Echo of the request filter; absent when the caller passed no tenantId."
|
||||
},
|
||||
{
|
||||
"name": "summaries",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "UserUsageDailySummary"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"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.",
|
||||
@@ -10996,6 +10971,14 @@
|
||||
"name": "McpUsageTelemetryStatus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "totals",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "McpUsageTotalsNode"
|
||||
},
|
||||
"doc": "The window's totals over the lists below, computed once for every consumer."
|
||||
},
|
||||
{
|
||||
"name": "daily",
|
||||
"type": {
|
||||
@@ -11049,31 +11032,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tenantId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Echo of the request filter; absent when the caller passed no tenantId."
|
||||
},
|
||||
{
|
||||
"name": "records",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "UserUsageRecord"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"GetGraphPermissionsStatusResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Response of GET tenants/{tenantId}/graph-permissions/status: the client id of the app homed for the tenant, the transient flag, the granted Graph app roles and the per-feature verdict matrix.",
|
||||
@@ -16156,6 +16114,14 @@
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sharePercent",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known."
|
||||
},
|
||||
{
|
||||
"name": "peakCalls",
|
||||
"type": {
|
||||
@@ -16558,6 +16524,15 @@
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "domainName",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The tenant's domain from its configuration; absent when it has none or the configuration was unreadable."
|
||||
},
|
||||
{
|
||||
"name": "quota",
|
||||
"type": {
|
||||
@@ -16723,6 +16698,91 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUsageTotalsNode": {
|
||||
"kind": "object",
|
||||
"doc": "The overview's totals, nested in GetGlobalMcpUsageOverviewResponse. \"Calls\" are tool calls, \"requests\" are backend requests charged to the quota; an account is a person or a service principal of one tenant.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "calls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer."
|
||||
},
|
||||
{
|
||||
"name": "mcpOnlyCalls",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Calls of tools that run in the MCP server alone (part of Calls, attributed to no account); absent without the MCP log."
|
||||
},
|
||||
{
|
||||
"name": "tools",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Tools called in the window."
|
||||
},
|
||||
{
|
||||
"name": "requests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tenants",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accounts",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "servicePrincipals",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Accounts that are service principals (app:<client-id>)."
|
||||
},
|
||||
{
|
||||
"name": "quotaHitAccounts",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Accounts with at least one quota refusal in the window (daily or monthly, own or organization window)."
|
||||
},
|
||||
{
|
||||
"name": "minuteRefusedAccounts",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Accounts with at least one per-minute refusal in the window (MCP server or backend)."
|
||||
},
|
||||
{
|
||||
"name": "limitAccounts",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Accounts that met any limit in the window — the union of the two above."
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpUserEntry": {
|
||||
"kind": "object",
|
||||
"fields": [
|
||||
@@ -28605,48 +28665,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserUsageDailySummary": {
|
||||
"kind": "object",
|
||||
"doc": "Aggregated daily usage summary across endpoints (and optionally users).",
|
||||
"fields": [
|
||||
{
|
||||
"name": "date",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tenantId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "totalRequests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uniqueUsers",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uniqueEndpoints",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserUsageRecord": {
|
||||
"kind": "object",
|
||||
"doc": "A single usage record: one user, one day, one endpoint.",
|
||||
|
||||
@@ -2278,13 +2278,6 @@ export interface GetGlobalAdminsResponse {
|
||||
admins: GlobalAdminRow[];
|
||||
}
|
||||
|
||||
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
|
||||
export interface GetGlobalMcpUsageDailyResponse {
|
||||
/** Echo of the request filter; absent when the caller passed no tenantId. */
|
||||
tenantId?: string;
|
||||
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). */
|
||||
@@ -2294,6 +2287,8 @@ export interface GetGlobalMcpUsageOverviewResponse {
|
||||
/** False when Global Admins and Global Readers are left out of every number. */
|
||||
includesPlatformAdmins: boolean;
|
||||
telemetry: McpUsageTelemetryStatus;
|
||||
/** The window's totals over the lists below, computed once for every consumer. */
|
||||
totals: McpUsageTotalsNode;
|
||||
/** One item per day of the window, oldest first. */
|
||||
daily: McpUsageDayItem[];
|
||||
/** Every tenant with MCP use in the window, most calls first. */
|
||||
@@ -2305,13 +2300,6 @@ export interface GetGlobalMcpUsageOverviewResponse {
|
||||
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. */
|
||||
tenantId?: string;
|
||||
records: UserUsageRecord[];
|
||||
}
|
||||
|
||||
/** Response of GET tenants/{tenantId}/graph-permissions/status: the client id of the app homed for the tenant, the transient flag, the granted Graph app roles and the per-feature verdict matrix. */
|
||||
export interface GetGraphPermissionsStatusResponse {
|
||||
/** ClientId of the app registration that acts for this tenant (empty string when unresolved, never null). */
|
||||
@@ -3434,6 +3422,8 @@ export interface McpUsageDayItem {
|
||||
export interface McpUsageMinuteItem {
|
||||
tenantId: string;
|
||||
principal: string;
|
||||
/** How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known. */
|
||||
sharePercent: number;
|
||||
/** Most tool calls the account started inside 60 seconds (measured in 10-second steps). */
|
||||
peakCalls: number;
|
||||
peakCallsAt?: string;
|
||||
@@ -3528,6 +3518,8 @@ export interface McpUsageTelemetryStatus {
|
||||
/** One tenant of the overview with its organization windows and its accounts. */
|
||||
export interface McpUsageTenantItem {
|
||||
tenantId: string;
|
||||
/** The tenant's domain from its configuration; absent when it has none or the configuration was unreadable. */
|
||||
domainName?: string;
|
||||
/** The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters. */
|
||||
quota: McpOrganizationQuotaNode;
|
||||
calls: number;
|
||||
@@ -3568,6 +3560,27 @@ export interface McpUsageToolItem {
|
||||
routes: McpUsageRouteItem[];
|
||||
}
|
||||
|
||||
/** The overview's totals, nested in GetGlobalMcpUsageOverviewResponse. "Calls" are tool calls, "requests" are backend requests charged to the quota; an account is a person or a service principal of one tenant. */
|
||||
export interface McpUsageTotalsNode {
|
||||
/** Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer. */
|
||||
calls?: number;
|
||||
/** Calls of tools that run in the MCP server alone (part of Calls, attributed to no account); absent without the MCP log. */
|
||||
mcpOnlyCalls?: number;
|
||||
/** Tools called in the window. */
|
||||
tools: number;
|
||||
requests: number;
|
||||
tenants: number;
|
||||
accounts: number;
|
||||
/** Accounts that are service principals (app:<client-id>). */
|
||||
servicePrincipals: number;
|
||||
/** Accounts with at least one quota refusal in the window (daily or monthly, own or organization window). */
|
||||
quotaHitAccounts: number;
|
||||
/** Accounts with at least one per-minute refusal in the window (MCP server or backend). */
|
||||
minuteRefusedAccounts: number;
|
||||
/** Accounts that met any limit in the window — the union of the two above. */
|
||||
limitAccounts: number;
|
||||
}
|
||||
|
||||
export interface McpUserEntry {
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
@@ -6295,15 +6308,6 @@ export interface UserMetrics {
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Aggregated daily usage summary across endpoints (and optionally users). */
|
||||
export interface UserUsageDailySummary {
|
||||
date: string;
|
||||
tenantId?: string;
|
||||
totalRequests: number;
|
||||
uniqueUsers: number;
|
||||
uniqueEndpoints: number;
|
||||
}
|
||||
|
||||
/** A single usage record: one user, one day, one endpoint. */
|
||||
export interface UserUsageRecord {
|
||||
userId: string;
|
||||
|
||||
+13
-13
@@ -33,8 +33,8 @@
|
||||
"autoprefixer": "^10.6.2",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.4.3",
|
||||
"knip": "^6.43.0",
|
||||
"postcss": "^8.5.12",
|
||||
"knip": "^6.44.0",
|
||||
"postcss": "^8.5.32",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.12"
|
||||
@@ -6489,9 +6489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
|
||||
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
|
||||
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -7031,9 +7031,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7623,9 +7623,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"version": "8.5.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7642,9 +7642,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"autoprefixer": "^10.6.2",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.4.3",
|
||||
"knip": "^6.43.0",
|
||||
"postcss": "^8.5.12",
|
||||
"knip": "^6.44.0",
|
||||
"postcss": "^8.5.32",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.12"
|
||||
|
||||
Generated
+4
-4
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "autopilot-monitor-swa-cli",
|
||||
"devDependencies": {
|
||||
"@azure/static-web-apps-cli": "2.0.10"
|
||||
"@azure/static-web-apps-cli": "2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/abort-controller": {
|
||||
@@ -420,9 +420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/static-web-apps-cli": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@azure/static-web-apps-cli/-/static-web-apps-cli-2.0.10.tgz",
|
||||
"integrity": "sha512-AtZj15sG5/qWkqY8l8IyVQpL3jQaBn2V8TcjFLuwJJYZVh0l3Q+o4G6dTt/V5VWiXjyrlnyhJfHC8wygTzMIvw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/static-web-apps-cli/-/static-web-apps-cli-2.1.0.tgz",
|
||||
"integrity": "sha512-RUbHhLaGDQAbW0d0J3HzKIT3jumQtIaQF2g/qiTa1GeWO9/aasdn5k5WOWD+rpL+eUc+LjM1R/xbQpvbp7j3gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"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. 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"
|
||||
"@azure/static-web-apps-cli": "2.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"adm-zip": "^0.6.1",
|
||||
|
||||
Reference in New Issue
Block a user