Files
biss 551f1143b7 超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:
请求量、5xx 比例、HTTP/数据库 P95
请求速率与延迟趋势
最慢接口排行
慢查询与数据库查询排行
Grafana 原始调用链入口
2026-07-31 09:34:31 +08:00

189 lines
6.8 KiB
C#

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.");
}
}