超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:
请求量、5xx 比例、HTTP/数据库 P95 请求速率与延迟趋势 最慢接口排行 慢查询与数据库查询排行 Grafana 原始调用链入口
This commit is contained in:
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@@ -201,6 +202,7 @@ public sealed class OperationsControllerTests
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.AddMemoryCache()
|
||||
.BuildServiceProvider();
|
||||
var logger = services.GetRequiredService<
|
||||
ILogger<DatabaseBackupService>>();
|
||||
@@ -224,10 +226,19 @@ public sealed class OperationsControllerTests
|
||||
configuration,
|
||||
environment,
|
||||
logger);
|
||||
var performance = new PerformanceReportService(
|
||||
new HttpClient(),
|
||||
services.GetRequiredService<
|
||||
Microsoft.Extensions.Caching.Memory.IMemoryCache>(),
|
||||
new PerformanceReportingOptions(),
|
||||
new ObservabilityOptions(),
|
||||
services.GetRequiredService<
|
||||
ILogger<PerformanceReportService>>());
|
||||
var controller = new OperationsController(
|
||||
db,
|
||||
health,
|
||||
backups,
|
||||
performance,
|
||||
operationsOptions);
|
||||
return new OperationsFixture(services, db, controller, backups);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class PerformanceReportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Configured_source_returns_cached_native_report()
|
||||
{
|
||||
var handler = new PrometheusHandler();
|
||||
using var httpClient = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
httpClient,
|
||||
cache,
|
||||
new PerformanceReportingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PrometheusBaseUrl = "https://prometheus.test/",
|
||||
BearerToken = "read-only-token",
|
||||
GrafanaBaseUrl = "https://grafana.test/",
|
||||
CacheSeconds = 30
|
||||
},
|
||||
new ObservabilityOptions { ServiceName = "jiaowu-api" },
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
var first = await service.GetAsync("1h", CancellationToken.None);
|
||||
var second = await service.GetAsync("1h", CancellationToken.None);
|
||||
|
||||
Assert.Equal("ready", first.Status);
|
||||
Assert.Equal("prometheus", first.DataSource);
|
||||
Assert.Equal("https://grafana.test/", first.DashboardUrl);
|
||||
Assert.NotNull(first.Headline);
|
||||
Assert.Equal(5, first.Headline.RequestCount);
|
||||
Assert.Equal(2, first.Timeline.Count);
|
||||
Assert.Equal(
|
||||
"/api/timetables/classes/{classId}",
|
||||
Assert.Single(first.Endpoints).Name);
|
||||
Assert.Equal(
|
||||
"Timetable.LoadScheduleEntries",
|
||||
Assert.Single(first.DatabaseQueries).Name);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(14, handler.RequestCount);
|
||||
Assert.True(handler.AllRequestsAuthenticated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_source_returns_directed_empty_state()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
new HttpClient(new RejectingHandler()),
|
||||
cache,
|
||||
new PerformanceReportingOptions(),
|
||||
new ObservabilityOptions(),
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
var report = await service.GetAsync("24h", CancellationToken.None);
|
||||
|
||||
Assert.Equal("not_configured", report.Status);
|
||||
Assert.Contains("Prometheus", report.Detail);
|
||||
Assert.Empty(report.Timeline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsupported_range_is_rejected_before_querying_source()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
new HttpClient(new RejectingHandler()),
|
||||
cache,
|
||||
new PerformanceReportingOptions(),
|
||||
new ObservabilityOptions(),
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
service.GetAsync("30d", CancellationToken.None));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
service.GetAsync(null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Source_timeout_returns_unavailable_report()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
new HttpClient(new TimeoutHandler()),
|
||||
cache,
|
||||
new PerformanceReportingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PrometheusBaseUrl = "https://prometheus.test/"
|
||||
},
|
||||
new ObservabilityOptions(),
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
var report = await service.GetAsync("1h", CancellationToken.None);
|
||||
|
||||
Assert.Equal("unavailable", report.Status);
|
||||
Assert.Contains("暂时不可用", report.Detail);
|
||||
}
|
||||
|
||||
private sealed class PrometheusHandler : HttpMessageHandler
|
||||
{
|
||||
private int requestCount;
|
||||
private int authenticatedCount;
|
||||
|
||||
public int RequestCount => requestCount;
|
||||
public bool AllRequestsAuthenticated =>
|
||||
authenticatedCount == requestCount;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
if (request.Headers.Authorization?.Scheme == "Bearer" &&
|
||||
request.Headers.Authorization.Parameter == "read-only-token")
|
||||
{
|
||||
Interlocked.Increment(ref authenticatedCount);
|
||||
}
|
||||
|
||||
var isRange = request.RequestUri!.AbsolutePath.EndsWith(
|
||||
"/query_range",
|
||||
StringComparison.Ordinal);
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var json = isRange
|
||||
? $$"""
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"resultType": "matrix",
|
||||
"result": [{
|
||||
"metric": {},
|
||||
"values": [
|
||||
[{{now - 60}}, "2"],
|
||||
[{{now}}, "3"]
|
||||
]
|
||||
}]
|
||||
}
|
||||
}
|
||||
"""
|
||||
: $$"""
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"resultType": "vector",
|
||||
"result": [{
|
||||
"metric": {
|
||||
"http_route": "/api/timetables/classes/{classId}",
|
||||
"db_query_name": "Timetable.LoadScheduleEntries"
|
||||
},
|
||||
"value": [{{now}}, "5"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
""";
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
json,
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RejectingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException(
|
||||
"未配置时不应访问外部数据源。");
|
||||
}
|
||||
|
||||
private sealed class TimeoutHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new TaskCanceledException("Prometheus query timed out.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user