backend: serve single-agent task templates with their status and results, rename the kinds in storage

This commit is contained in:
justme committed 2026-10-22 07:02:28 +08:00
1 parent e77880d372
commit 3a9ea46d6f
45 files changed
+763 -216

No files matched your search

@@ -31,7 +31,7 @@ public sealed class TestResult
/// <summary>
/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。
/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
/// quic_stream 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
/// </summary>
public static class TestEvaluator
{
@@ -43,7 +43,7 @@ public static class TestEvaluator
{
var result = new TestResult();
EvaluateMetrics(chunks, result);
if (chunks.Any(c => c.Header.Kind == Kinds.UdpTrain.Id))
if (chunks.Any(c => c.Header.Kind == Kinds.QuicStream.Id))
{
EvaluateLadder(chunks, result);
}
@@ -95,7 +95,7 @@ public static class TestEvaluator
// 一个阶段(一档码率)可能被切成好几个块(一个块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块):
// 阶梯里的一步是一个阶段,不是一个块——同一阶段的块先合并,再算丢包、抖动和实际吞吐。
foreach (IGrouping<int, RawChunk> phase in chunks.Where(c => c.Header.Kind == Kinds.UdpTrain.Id).GroupBy(c => c.Header.Phase ?? 0).OrderBy(g => g.Key))
foreach (IGrouping<int, RawChunk> phase in chunks.Where(c => c.Header.Kind == Kinds.QuicStream.Id).GroupBy(c => c.Header.Phase ?? 0).OrderBy(g => g.Key))
{
RawChunk[] parts = [.. phase.Where(c => c.Find("pkt") is not null).OrderBy(c => c.Header.StartUtcMs)];
if (parts.Length == 0)
@@ -0,0 +1,48 @@
namespace MoShiro.Backend.Analysis;
/// <summary>http 任务(单个 Agent 请求一个 URL)的指标。耗时只统计成功的请求。</summary>
public static class HttpMetrics
{
private static readonly string[] s_req = ["http_request.req"];
public static readonly MetricDef Success = new(
"http.success", AccKind.Ratio, s_req, Ops.HttpSuccess, "HTTP success", "Share of requests that got an answer with an accepted status.", Better.Higher,
[new MetricExport("http.success", Stat.Pct, Unit.Pct, 99, 90)]);
public static readonly MetricDef Dns = Metric.Histogram(
"http.dns_ms", s_req, Ops.HttpPhaseMs("dns_us"), "DNS time", "Time to resolve the target host name.", Unit.Ms, [Stat.P95], good: 50, poor: 300);
public static readonly MetricDef Tcp = Metric.Histogram(
"http.tcp_ms", s_req, Ops.HttpPhaseMs("tcp_us"), "TCP connect time", "Time to establish the TCP connection (HTTP/1.1 and HTTP/2).", Unit.Ms, [Stat.P95], good: 100, poor: 400);
public static readonly MetricDef Ttfb = Metric.Histogram(
"http.ttfb_ms", s_req, Ops.HttpPhaseMs("ttfb_us"), "Time to first byte", "Time from starting the request until the response headers arrive, including connection setup.", Unit.Ms,
[Stat.P50, Stat.P95, Stat.P99], good: 300, poor: 1000);
public static readonly MetricDef Total = Metric.Histogram(
"http.total_ms", s_req, Ops.HttpPhaseMs("total_us"), "Total time", "Time until the response body was read.", Unit.Ms, [Stat.P50, Stat.P95, Stat.P99], good: 500, poor: 2000);
public static readonly MetricDef FailTimeout = Metric.Counter(
"http.fail.timeout", s_req, Ops.HttpOutcomeCount(HttpOutcome.Timeout), "HTTP timeouts", "Requests that timed out.", new MetricExport("http.fail.timeout", Stat.Count, Unit.Count));
public static readonly MetricDef FailDns = Metric.Counter(
"http.fail.dns", s_req, Ops.HttpOutcomeCount(HttpOutcome.Dns), "DNS failures", "Requests whose host name did not resolve.", new MetricExport("http.fail.dns", Stat.Count, Unit.Count));
public static readonly MetricDef FailReset = Metric.Counter(
"http.fail.rst", s_req, Ops.HttpOutcomeCount(HttpOutcome.Reset), "HTTP resets", "Requests answered with a reset or refused.", new MetricExport("http.fail.rst", Stat.Count, Unit.Count));
public static readonly MetricDef FailTls = Metric.Counter(
"http.fail.tls", s_req, Ops.HttpOutcomeCount(HttpOutcome.Tls), "TLS failures", "Requests that ended in a TLS error.", new MetricExport("http.fail.tls", Stat.Count, Unit.Count));
public static readonly MetricDef FailStatus = Metric.Counter(
"http.fail.status", s_req, Ops.HttpOutcomeCount(HttpOutcome.BadStatus), "Bad status", "Requests that got an answer with an unaccepted status.", new MetricExport("http.fail.status", Stat.Count, Unit.Count));
public static readonly MetricDef FailUnsupported = Metric.Counter(
"http.fail.unsupported", s_req, Ops.HttpOutcomeCount(HttpOutcome.Unsupported), "Unsupported version", "Requests that could not use the requested HTTP version (for HTTP/3: no QUIC library, or the server does not offer it).", new MetricExport("http.fail.unsupported", Stat.Count, Unit.Count));
public static readonly MetricDef FailOther = Metric.Counter(
"http.fail.other", s_req, Ops.HttpOutcomeCount(HttpOutcome.Other), "Other failures", "Requests that failed for a reason not listed separately.", new MetricExport("http.fail.other", Stat.Count, Unit.Count));
public static readonly MetricDef Samples = Metric.Counter(
"n.http", s_req, Ops.RowCount, "http samples", "Number of http requests.", new MetricExport("n.http", Stat.Count, Unit.Count), Better.None);
}
@@ -1,10 +1,10 @@
namespace MoShiro.Backend.Analysis;
/// <summary>线路基础指标(udp_echo / icmp_echo 的回显流)。新增指标 = 加一行声明。</summary>
/// <summary>线路基础指标(quic_ping / icmp_ping 的回显流)。新增指标 = 加一行声明。</summary>
public static class LinkMetrics
{
private static readonly string[] s_udpMsg = ["udp_echo.msg"];
private static readonly string[] s_udpOrIcmpMsg = ["udp_echo.msg", "icmp_echo.msg"];
private static readonly string[] s_udpMsg = ["quic_ping.msg"];
private static readonly string[] s_udpOrIcmpMsg = ["quic_ping.msg", "icmp_ping.msg"];
public static readonly MetricDef Rtt = Metric.Histogram(
"rtt", s_udpOrIcmpMsg, Ops.Rtt, "RTT", "Round-trip time with the Agent processing time removed.", Unit.Ms,
@@ -45,5 +45,5 @@ public static class LinkMetrics
"dup", s_udpMsg, Ops.Dup, "Duplicates", "Replies received more than once.", new MetricExport("dup", Stat.Count, Unit.Count));
public static readonly MetricDef SamplesUdp = Metric.Counter(
"n.udp_echo", s_udpMsg, Ops.RowCount, "udp_echo samples", "Number of udp_echo records.", new MetricExport("n.udp_echo", Stat.Count, Unit.Count), Better.None);
"n.quic_ping", s_udpMsg, Ops.RowCount, "quic_ping samples", "Number of quic_ping records.", new MetricExport("n.quic_ping", Stat.Count, Unit.Count), Better.None);
}
@@ -15,6 +15,8 @@ public static class MetricRegistry
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.RetransUp, TcpMetrics.RetransDown, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
HttpMetrics.Success, HttpMetrics.Dns, HttpMetrics.Tcp, HttpMetrics.Ttfb, HttpMetrics.Total,
HttpMetrics.FailTimeout, HttpMetrics.FailDns, HttpMetrics.FailReset, HttpMetrics.FailTls, HttpMetrics.FailStatus, HttpMetrics.FailUnsupported, HttpMetrics.FailOther, HttpMetrics.Samples,
SessionMetrics.HoldResets, SessionMetrics.HoldLifetime,
SessionMetrics.TrainLoss, SessionMetrics.TrainJitter, SessionMetrics.TrainRate, SessionMetrics.SamplesConnect,
];
@@ -26,6 +28,7 @@ public static class MetricRegistry
new("udp", "QUIC Datagram (legacy alias)", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.retrans.up", "tcp.retrans.down", "tcp.srtt.p95"]),
new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]),
new("http", "HTTP", ["http.success", "http.dns_ms.p95", "http.tcp_ms.p95", "http.ttfb_ms.p50", "http.ttfb_ms.p95", "http.ttfb_ms.p99", "http.total_ms.p50", "http.total_ms.p95", "http.total_ms.p99", "http.fail.timeout", "http.fail.dns", "http.fail.rst", "http.fail.tls", "http.fail.status", "http.fail.unsupported", "http.fail.other"]),
new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
new("scores", "Scores", ["score.base", "score.mc", "score.hy2", "score.reality"]),
];
@@ -1,11 +1,11 @@
namespace MoShiro.Backend.Analysis;
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary>
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind quic_stream)的指标。</summary>
public static class SessionMetrics
{
private static readonly string[] s_conn = ["connect.conn"];
private static readonly string[] s_hold = ["hold.hold"];
private static readonly string[] s_train = ["udp_train.pkt"];
private static readonly string[] s_conn = ["tcp_connect.conn"];
private static readonly string[] s_hold = ["tls_hold.hold"];
private static readonly string[] s_train = ["quic_stream.pkt"];
public static readonly MetricDef ConnectSuccess = new(
"connect.success", AccKind.Ratio, s_conn, Ops.OutcomeRatio(ConnectOutcome.Ok), "Connect success", "Share of connection attempts that completed.", Better.Higher,
@@ -3,9 +3,9 @@ namespace MoShiro.Backend.Analysis;
/// <summary>TCP 指标:回显的 RTT 与卡顿、内核采样的重传、批量传输的吞吐。</summary>
public static class TcpMetrics
{
private static readonly string[] s_tcpMsg = ["tcp_echo.msg"];
private static readonly string[] s_tcpInfo = ["tcp_echo.tcp_info", "tcp_bulk.tcp_info"];
private static readonly string[] s_bulkBytes = ["tcp_bulk.bytes"];
private static readonly string[] s_tcpMsg = ["tcp_ping.msg"];
private static readonly string[] s_tcpInfo = ["tcp_ping.tcp_info", "tcp_transfer.tcp_info"];
private static readonly string[] s_bulkBytes = ["tcp_transfer.bytes"];
public static readonly MetricDef Rtt = Metric.Histogram(
"tcp.rtt", s_tcpMsg, Ops.Rtt, "TCP RTT", "Application-level echo round trip over TCP (includes retransmission delay).", Unit.Ms,
@@ -47,5 +47,5 @@ public static class TcpMetrics
[Stat.Max], Better.Higher);
public static readonly MetricDef SamplesTcp = Metric.Counter(
"n.tcp_echo", s_tcpMsg, Ops.RowCount, "tcp_echo samples", "Number of tcp_echo records.", new MetricExport("n.tcp_echo", Stat.Count, Unit.Count), Better.None);
"n.tcp_ping", s_tcpMsg, Ops.RowCount, "tcp_ping samples", "Number of tcp_ping records.", new MetricExport("n.tcp_ping", Stat.Count, Unit.Count), Better.None);
}
@@ -32,7 +32,7 @@ public sealed class BaseModel : ServiceModelBase
/// <summary>
/// Minecraft:TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。
/// 没有 tcp_echo 数据时,用 udp_echo 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
/// 没有 tcp_ping 数据时,用 quic_ping 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
/// </summary>
public sealed class McModel : ServiceModelBase
{
@@ -99,7 +99,7 @@ public sealed class McModel : ServiceModelBase
/// <summary>
/// Hysteria2(Brutal 固定码率):有 hy2_ladder 测试结果时,用可持续码率、有效吞吐和限速开始的位置评分;
/// 没有测试时,只能用持续的 udp_echo 趋势粗略估算,并标记为 estimated。
/// 没有测试时,只能用持续的 quic_ping 趋势粗略估算,并标记为 estimated。
/// </summary>
public sealed class Hy2Model : ServiceModelBase
{
@@ -0,0 +1,119 @@
using MoShiro.Protocol;
namespace MoShiro.Backend.Analysis;
/// <summary>http 请求的结果,编码与 Agent 的 <c>HttpOutcomes</c> 一致。</summary>
public enum HttpOutcome
{
Ok = 0,
Timeout = 1,
Reset = 2,
Tls = 3,
Other = 4,
Dns = 5,
BadStatus = 6,
Unsupported = 7,
NotAllowed = 8,
}
/// <summary>http 流(req)的公共读法:权重 <c>w</c> 是这一行代表的网格间隔数(自适应降频后每次请求代表更长的时间)。</summary>
internal readonly struct HttpColumns
{
public HttpColumns(RawStream stream)
{
Stream = stream;
Ts = stream.Find("ts");
Outcome = stream.Find("outcome");
Weights = stream.Find("w");
}
public RawStream Stream { get; }
public RawColumn? Ts { get; }
public RawColumn? Outcome { get; }
private RawColumn? Weights { get; }
public bool Has => Ts is not null && Outcome is not null;
public int Weight(int row) => Weights is not null && Weights.IsValid(row) ? (int)Math.Clamp(Weights.Values[row], 1, 65535) : 1;
}
/// <summary>成功率(分子是按权重累计的成功次数)或某种结果的次数。</summary>
internal sealed class HttpOutcomeOp : MetricOp
{
private readonly HttpOutcome _code;
private readonly bool _asRatio;
public HttpOutcomeOp(HttpOutcome code, bool asRatio)
{
_code = code;
_asRatio = asRatio;
}
public override string Signature => $"http_outcome/{_code}/{(_asRatio ? "ratio" : "count")}/1";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
var c = new HttpColumns(stream);
if (!c.Has)
{
return;
}
for (int i = 0; i < stream.Count; i++)
{
bool hit = c.Outcome!.Values[i] == (long)_code;
long at = view.ColumnToUtcMs(c.Ts!, c.Ts!.Values[i]);
if (_asRatio)
{
int w = c.Weight(i);
sink.Emit(at, hit ? w : 0, w);
}
else if (hit)
{
sink.Emit(at, 1);
}
}
}
}
/// <summary>一个阶段的耗时(毫秒):只统计成功的请求,按权重累计,所以分位数是按时间平均的。</summary>
internal sealed class HttpPhaseMsOp : MetricOp
{
private const int MaxRepeat = 256;
private readonly string _column;
public HttpPhaseMsOp(string column)
{
_column = column;
}
public override string Signature => $"http_phase_ms/{_column}/1";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
var c = new HttpColumns(stream);
RawColumn? column = stream.Find(_column);
if (!c.Has || column is null)
{
return;
}
for (int i = 0; i < stream.Count; i++)
{
if (c.Outcome!.Values[i] != (long)HttpOutcome.Ok || !column.IsValid(i))
{
continue;
}
long at = view.ColumnToUtcMs(c.Ts!, c.Ts!.Values[i]);
for (int k = Math.Min(c.Weight(i), MaxRepeat); k > 0; k--)
{
sink.Emit(at, column.Values[i] / 1000.0);
}
}
}
}
@@ -37,6 +37,12 @@ public static class Ops
public static MetricOp OutcomeCount(ConnectOutcome code) => new OutcomeOp(code, asRatio: false);
public static MetricOp HttpSuccess { get; } = new HttpOutcomeOp(HttpOutcome.Ok, asRatio: true);
public static MetricOp HttpOutcomeCount(HttpOutcome code) => new HttpOutcomeOp(code, asRatio: false);
public static MetricOp HttpPhaseMs(string column) => new HttpPhaseMsOp(column);
public static MetricOp HoldEvents(HoldEvent holdEvent) => new HoldEventOp(holdEvent);
public static MetricOp HoldLifetime { get; } = new HoldLifetimeOp();
@@ -330,7 +330,7 @@ internal sealed class HoldLifetimeOp : MetricOp
}
}
/// <summary>单向 UDP 流(udp_train)的丢包:接收端没有记录到 t_recv 即为丢失。</summary>
/// <summary>单向 UDP 流(quic_stream)的丢包:接收端没有记录到 t_recv 即为丢失。</summary>
internal sealed class TrainLossOp : MetricOp
{
public override string Signature => "train_loss/1";