refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,241 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
public enum AccKind : byte
|
||||
{
|
||||
Counter = 1,
|
||||
Ratio = 2,
|
||||
Sum = 3,
|
||||
Min = 4,
|
||||
Max = 5,
|
||||
Histogram = 6,
|
||||
RunLength = 7,
|
||||
Series = 8,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所有指标的中间状态只能是下面几种累加器之一。合并规则只写一次,
|
||||
/// 存储、降采样、按时间范围聚合都通用处理。
|
||||
/// </summary>
|
||||
public abstract class Accumulator
|
||||
{
|
||||
public abstract AccKind Kind { get; }
|
||||
|
||||
/// <summary>加入一个观测值。含义由累加器决定:Counter 加 <paramref name="a"/>,Ratio 的 (a, b) 是 (分子, 分母),Histogram 记录 a。</summary>
|
||||
public abstract void Add(double a, double b = 0);
|
||||
|
||||
/// <summary>合并同类型的另一个累加器(精确合并,不是平均)。</summary>
|
||||
public abstract void Merge(Accumulator other);
|
||||
|
||||
public abstract Accumulator Clone();
|
||||
|
||||
/// <summary>样本数,用于置信说明。</summary>
|
||||
public abstract ulong SampleCount { get; }
|
||||
|
||||
public static Accumulator Create(AccKind kind) => kind switch
|
||||
{
|
||||
AccKind.Counter => new CounterAcc(),
|
||||
AccKind.Ratio => new RatioAcc(),
|
||||
AccKind.Sum => new SumAcc(),
|
||||
AccKind.Min => new MinAcc(),
|
||||
AccKind.Max => new MaxAcc(),
|
||||
AccKind.Histogram => new HistogramAcc(),
|
||||
AccKind.RunLength => new RunLengthAcc(),
|
||||
AccKind.Series => new SeriesAcc(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
protected T Cast<T>(Accumulator other)
|
||||
where T : Accumulator =>
|
||||
other as T ?? throw new InvalidOperationException($"cannot merge {other.Kind} into {Kind}");
|
||||
}
|
||||
|
||||
public sealed class CounterAcc : Accumulator
|
||||
{
|
||||
public ulong Value { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Counter;
|
||||
|
||||
public override ulong SampleCount => Value;
|
||||
|
||||
public override void Add(double a, double b = 0) => Value += (ulong)Math.Max(0, a);
|
||||
|
||||
public override void Merge(Accumulator other) => Value += Cast<CounterAcc>(other).Value;
|
||||
|
||||
public override Accumulator Clone() => new CounterAcc { Value = Value };
|
||||
}
|
||||
|
||||
public sealed class RatioAcc : Accumulator
|
||||
{
|
||||
public ulong Numerator { get; set; }
|
||||
|
||||
public ulong Denominator { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Ratio;
|
||||
|
||||
public override ulong SampleCount => Denominator;
|
||||
|
||||
/// <summary>百分比(0–100);没有样本时为 null。</summary>
|
||||
public double? Percent => Denominator == 0 ? null : 100.0 * Numerator / Denominator;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Numerator += (ulong)Math.Max(0, a);
|
||||
Denominator += (ulong)Math.Max(0, b);
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<RatioAcc>(other);
|
||||
Numerator += o.Numerator;
|
||||
Denominator += o.Denominator;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new RatioAcc { Numerator = Numerator, Denominator = Denominator };
|
||||
}
|
||||
|
||||
public sealed class SumAcc : Accumulator
|
||||
{
|
||||
public double Sum { get; set; }
|
||||
|
||||
public ulong Count { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Sum;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Mean => Count == 0 ? null : Sum / Count;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Sum += a;
|
||||
Count++;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<SumAcc>(other);
|
||||
Sum += o.Sum;
|
||||
Count += o.Count;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new SumAcc { Sum = Sum, Count = Count };
|
||||
}
|
||||
|
||||
public sealed class MinAcc : Accumulator
|
||||
{
|
||||
public double Value { get; set; } = double.PositiveInfinity;
|
||||
|
||||
public ulong Count { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Min;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Result => Count == 0 ? null : Value;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Value = Math.Min(Value, a);
|
||||
Count++;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<MinAcc>(other);
|
||||
Value = Math.Min(Value, o.Value);
|
||||
Count += o.Count;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new MinAcc { Value = Value, Count = Count };
|
||||
}
|
||||
|
||||
public sealed class MaxAcc : Accumulator
|
||||
{
|
||||
public double Value { get; set; } = double.NegativeInfinity;
|
||||
|
||||
public ulong Count { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.Max;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Result => Count == 0 ? null : Value;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Value = Math.Max(Value, a);
|
||||
Count++;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<MaxAcc>(other);
|
||||
Value = Math.Max(Value, o.Value);
|
||||
Count += o.Count;
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new MaxAcc { Value = Value, Count = Count };
|
||||
}
|
||||
|
||||
/// <summary>连续段长度(例如连续丢包):只保留次数、总和和最大值,合并是精确的。</summary>
|
||||
public sealed class RunLengthAcc : Accumulator
|
||||
{
|
||||
public ulong Runs { get; set; }
|
||||
|
||||
public double Total { get; set; }
|
||||
|
||||
public double Max { get; set; }
|
||||
|
||||
public override AccKind Kind => AccKind.RunLength;
|
||||
|
||||
public override ulong SampleCount => Runs;
|
||||
|
||||
public double? Mean => Runs == 0 ? null : Total / Runs;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
Runs++;
|
||||
Total += a;
|
||||
Max = Math.Max(Max, a);
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<RunLengthAcc>(other);
|
||||
Runs += o.Runs;
|
||||
Total += o.Total;
|
||||
Max = Math.Max(Max, o.Max);
|
||||
}
|
||||
|
||||
public override Accumulator Clone() => new RunLengthAcc { Runs = Runs, Total = Total, Max = Max };
|
||||
}
|
||||
|
||||
/// <summary>固定分辨率的时间序列(仅用于测试结果):Add(a = 序号, b = 值),合并为拼接。</summary>
|
||||
public sealed class SeriesAcc : Accumulator
|
||||
{
|
||||
public List<double> Points { get; } = [];
|
||||
|
||||
public override AccKind Kind => AccKind.Series;
|
||||
|
||||
public override ulong SampleCount => (ulong)Points.Count;
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
int index = (int)a;
|
||||
while (Points.Count <= index)
|
||||
{
|
||||
Points.Add(double.NaN);
|
||||
}
|
||||
|
||||
Points[index] = b;
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other) => Points.AddRange(Cast<SeriesAcc>(other).Points);
|
||||
|
||||
public override Accumulator Clone()
|
||||
{
|
||||
var copy = new SeriesAcc();
|
||||
copy.Points.AddRange(Points);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>一个时间桶里所有指标的累加器:<c>指标 id → 累加器</c>。</summary>
|
||||
public sealed class MetricSet
|
||||
{
|
||||
private readonly Dictionary<string, Accumulator> _items = new(StringComparer.Ordinal);
|
||||
|
||||
public IReadOnlyDictionary<string, Accumulator> Items => _items;
|
||||
|
||||
public int Count => _items.Count;
|
||||
|
||||
public Accumulator GetOrAdd(string metricId, AccKind kind)
|
||||
{
|
||||
if (!_items.TryGetValue(metricId, out Accumulator? acc))
|
||||
{
|
||||
acc = Accumulator.Create(kind);
|
||||
_items[metricId] = acc;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
public bool TryGet(string metricId, out Accumulator acc) => _items.TryGetValue(metricId, out acc!);
|
||||
|
||||
/// <summary>逐个指标精确合并。</summary>
|
||||
public void Merge(MetricSet other)
|
||||
{
|
||||
foreach ((string id, Accumulator acc) in other._items)
|
||||
{
|
||||
if (_items.TryGetValue(id, out Accumulator? mine))
|
||||
{
|
||||
if (mine.Kind == acc.Kind)
|
||||
{
|
||||
mine.Merge(acc);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_items[id] = acc.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string metricId, Accumulator acc) => _items[metricId] = acc;
|
||||
|
||||
public MetricSet Clone()
|
||||
{
|
||||
var copy = new MetricSet();
|
||||
foreach ((string id, Accumulator acc) in _items)
|
||||
{
|
||||
copy._items[id] = acc.Clone();
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 累加器的二进制编码:存储层只把它当作不透明的字节。
|
||||
/// <c>varint 数量 | { varint id 长度 | id | u8 kind | varint 长度 | 状态 }…</c>
|
||||
/// </summary>
|
||||
public static class AccumulatorCodec
|
||||
{
|
||||
public static byte[] Encode(MetricSet set)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
VarInt.Write(stream, (ulong)set.Count);
|
||||
foreach ((string id, Accumulator acc) in set.Items.OrderBy(i => i.Key, StringComparer.Ordinal))
|
||||
{
|
||||
byte[] idBytes = Encoding.UTF8.GetBytes(id);
|
||||
VarInt.Write(stream, (ulong)idBytes.Length);
|
||||
stream.Write(idBytes);
|
||||
stream.WriteByte((byte)acc.Kind);
|
||||
byte[] state = EncodeState(acc);
|
||||
VarInt.Write(stream, (ulong)state.Length);
|
||||
stream.Write(state);
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static Result<MetricSet> Decode(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var set = new MetricSet();
|
||||
if (!VarInt.TryRead(data, out ulong count, out int used))
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
data = data[used..];
|
||||
for (ulong i = 0; i < count; i++)
|
||||
{
|
||||
if (!VarInt.TryRead(data, out ulong idLength, out used) || (ulong)(data.Length - used) < idLength)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
data = data[used..];
|
||||
string id = Encoding.UTF8.GetString(data[..(int)idLength]);
|
||||
data = data[(int)idLength..];
|
||||
if (data.Length < 1)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
var kind = (AccKind)data[0];
|
||||
data = data[1..];
|
||||
if (!VarInt.TryRead(data, out ulong stateLength, out used) || (ulong)(data.Length - used) < stateLength)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
data = data[used..];
|
||||
Accumulator? acc = DecodeState(kind, data[..(int)stateLength]);
|
||||
if (acc is null)
|
||||
{
|
||||
return Bad();
|
||||
}
|
||||
|
||||
set.Set(id, acc);
|
||||
data = data[(int)stateLength..];
|
||||
}
|
||||
|
||||
return Result<MetricSet>.Ok(set);
|
||||
}
|
||||
|
||||
private static Result<MetricSet> Bad() =>
|
||||
Result<MetricSet>.Fail(FleetErrors.Internal, "accumulator data is corrupt");
|
||||
|
||||
public static byte[] EncodeState(Accumulator acc)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
switch (acc)
|
||||
{
|
||||
case CounterAcc c:
|
||||
VarInt.Write(stream, c.Value);
|
||||
break;
|
||||
case RatioAcc r:
|
||||
VarInt.Write(stream, r.Numerator);
|
||||
VarInt.Write(stream, r.Denominator);
|
||||
break;
|
||||
case SumAcc s:
|
||||
WriteDouble(stream, s.Sum);
|
||||
VarInt.Write(stream, s.Count);
|
||||
break;
|
||||
case MinAcc m:
|
||||
WriteDouble(stream, m.Value);
|
||||
VarInt.Write(stream, m.Count);
|
||||
break;
|
||||
case MaxAcc x:
|
||||
WriteDouble(stream, x.Value);
|
||||
VarInt.Write(stream, x.Count);
|
||||
break;
|
||||
case RunLengthAcc rl:
|
||||
VarInt.Write(stream, rl.Runs);
|
||||
WriteDouble(stream, rl.Total);
|
||||
WriteDouble(stream, rl.Max);
|
||||
break;
|
||||
case HistogramAcc h:
|
||||
VarInt.Write(stream, h.Count);
|
||||
WriteDouble(stream, h.Sum);
|
||||
WriteDouble(stream, h.Min);
|
||||
WriteDouble(stream, h.Max);
|
||||
VarInt.Write(stream, (ulong)h.NonEmptyBuckets);
|
||||
int previous = 0;
|
||||
foreach ((int bucket, ulong n) in h.Buckets())
|
||||
{
|
||||
VarInt.Write(stream, (ulong)(bucket - previous));
|
||||
VarInt.Write(stream, n);
|
||||
previous = bucket;
|
||||
}
|
||||
|
||||
break;
|
||||
case SeriesAcc se:
|
||||
VarInt.Write(stream, (ulong)se.Points.Count);
|
||||
foreach (double p in se.Points)
|
||||
{
|
||||
WriteDouble(stream, p);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("unknown accumulator");
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static Accumulator? DecodeState(AccKind kind, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var reader = new Reader(data);
|
||||
switch (kind)
|
||||
{
|
||||
case AccKind.Counter:
|
||||
return reader.ReadVarInt(out ulong v) ? new CounterAcc { Value = v } : null;
|
||||
case AccKind.Ratio:
|
||||
return reader.ReadVarInt(out ulong num) && reader.ReadVarInt(out ulong den)
|
||||
? new RatioAcc { Numerator = num, Denominator = den }
|
||||
: null;
|
||||
case AccKind.Sum:
|
||||
return reader.ReadDouble(out double sum) && reader.ReadVarInt(out ulong sn) ? new SumAcc { Sum = sum, Count = sn } : null;
|
||||
case AccKind.Min:
|
||||
return reader.ReadDouble(out double min) && reader.ReadVarInt(out ulong mn) ? new MinAcc { Value = min, Count = mn } : null;
|
||||
case AccKind.Max:
|
||||
return reader.ReadDouble(out double max) && reader.ReadVarInt(out ulong xn) ? new MaxAcc { Value = max, Count = xn } : null;
|
||||
case AccKind.RunLength:
|
||||
return reader.ReadVarInt(out ulong runs) && reader.ReadDouble(out double total) && reader.ReadDouble(out double rmax)
|
||||
? new RunLengthAcc { Runs = runs, Total = total, Max = rmax }
|
||||
: null;
|
||||
case AccKind.Histogram:
|
||||
{
|
||||
if (!reader.ReadVarInt(out ulong count) || !reader.ReadDouble(out double hsum) || !reader.ReadDouble(out double hmin)
|
||||
|| !reader.ReadDouble(out double hmax) || !reader.ReadVarInt(out ulong buckets) || buckets > (ulong)HistogramAcc.BucketCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var list = new List<(int, ulong)>((int)buckets);
|
||||
int bucket = 0;
|
||||
for (ulong i = 0; i < buckets; i++)
|
||||
{
|
||||
if (!reader.ReadVarInt(out ulong delta) || !reader.ReadVarInt(out ulong n))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bucket += (int)delta;
|
||||
if (bucket >= HistogramAcc.BucketCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
list.Add((bucket, n));
|
||||
}
|
||||
|
||||
var hist = new HistogramAcc();
|
||||
hist.Restore(list, count, hsum, hmin, hmax);
|
||||
return hist;
|
||||
}
|
||||
|
||||
case AccKind.Series:
|
||||
{
|
||||
if (!reader.ReadVarInt(out ulong n) || n > 10_000_000)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var series = new SeriesAcc();
|
||||
for (ulong i = 0; i < n; i++)
|
||||
{
|
||||
if (!reader.ReadDouble(out double p))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
series.Points.Add(p);
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteDouble(MemoryStream stream, double value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(bytes, value);
|
||||
stream.Write(bytes);
|
||||
}
|
||||
|
||||
private ref struct Reader
|
||||
{
|
||||
private ReadOnlySpan<byte> _data;
|
||||
|
||||
public Reader(ReadOnlySpan<byte> data)
|
||||
{
|
||||
_data = data;
|
||||
}
|
||||
|
||||
public bool ReadVarInt(out ulong value)
|
||||
{
|
||||
if (!VarInt.TryRead(_data, out value, out int used))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_data = _data[used..];
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ReadDouble(out double value)
|
||||
{
|
||||
if (_data.Length < 8)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadDoubleLittleEndian(_data);
|
||||
_data = _data[8..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// 固定对数桶直方图:100 µs ~ 30 s,每 2 倍 8 个桶(相对误差 ≤ 4.5%)。
|
||||
/// 稀疏存储(只保留非空桶),因为 1 秒热层里每个桶只有几个样本。数值单位是毫秒。
|
||||
/// </summary>
|
||||
public sealed class HistogramAcc : Accumulator
|
||||
{
|
||||
public const double MinValueMs = 0.1;
|
||||
public const double MaxValueMs = 30_000;
|
||||
public const int BucketsPerOctave = 8;
|
||||
|
||||
/// <summary>桶 0 是下溢桶 [0, 0.1),桶 i≥1 是 [0.1·2^((i−1)/8), 0.1·2^(i/8))。</summary>
|
||||
public static readonly int BucketCount = 1 + (int)Math.Ceiling(BucketsPerOctave * Math.Log2(MaxValueMs / MinValueMs));
|
||||
|
||||
private int[] _index = new int[4];
|
||||
private ulong[] _counts = new ulong[4];
|
||||
private int _used;
|
||||
|
||||
public override AccKind Kind => AccKind.Histogram;
|
||||
|
||||
public ulong Count { get; private set; }
|
||||
|
||||
public double Sum { get; private set; }
|
||||
|
||||
public double Min { get; private set; } = double.PositiveInfinity;
|
||||
|
||||
public double Max { get; private set; } = double.NegativeInfinity;
|
||||
|
||||
public override ulong SampleCount => Count;
|
||||
|
||||
public double? Mean => Count == 0 ? null : Sum / Count;
|
||||
|
||||
public int NonEmptyBuckets => _used;
|
||||
|
||||
public static int BucketOf(double valueMs)
|
||||
{
|
||||
if (!(valueMs >= MinValueMs))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int bucket = 1 + (int)Math.Floor(BucketsPerOctave * Math.Log2(valueMs / MinValueMs));
|
||||
return Math.Min(bucket, BucketCount - 1);
|
||||
}
|
||||
|
||||
public static double LowerBound(int bucket) => bucket == 0 ? 0 : MinValueMs * Math.Pow(2, (bucket - 1) / (double)BucketsPerOctave);
|
||||
|
||||
public static double UpperBound(int bucket) => MinValueMs * Math.Pow(2, bucket / (double)BucketsPerOctave);
|
||||
|
||||
public override void Add(double a, double b = 0)
|
||||
{
|
||||
if (double.IsNaN(a) || a < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddToBucket(BucketOf(a), 1);
|
||||
Count++;
|
||||
Sum += a;
|
||||
Min = Math.Min(Min, a);
|
||||
Max = Math.Max(Max, a);
|
||||
}
|
||||
|
||||
public override void Merge(Accumulator other)
|
||||
{
|
||||
var o = Cast<HistogramAcc>(other);
|
||||
for (int i = 0; i < o._used; i++)
|
||||
{
|
||||
AddToBucket(o._index[i], o._counts[i]);
|
||||
}
|
||||
|
||||
Count += o.Count;
|
||||
Sum += o.Sum;
|
||||
Min = Math.Min(Min, o.Min);
|
||||
Max = Math.Max(Max, o.Max);
|
||||
}
|
||||
|
||||
public override Accumulator Clone()
|
||||
{
|
||||
var copy = new HistogramAcc
|
||||
{
|
||||
_index = (int[])_index.Clone(),
|
||||
_counts = (ulong[])_counts.Clone(),
|
||||
_used = _used,
|
||||
Count = Count,
|
||||
Sum = Sum,
|
||||
Min = Min,
|
||||
Max = Max,
|
||||
};
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>分位数:取累计计数首次达到 q·N 的桶的几何中点,并限制在实际的最小/最大值之内。</summary>
|
||||
public double? Quantile(double q)
|
||||
{
|
||||
if (Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double target = Math.Clamp(q, 0, 1) * Count;
|
||||
ulong cumulative = 0;
|
||||
for (int i = 0; i < _used; i++)
|
||||
{
|
||||
cumulative += _counts[i];
|
||||
if (cumulative >= target && cumulative > 0)
|
||||
{
|
||||
int bucket = _index[i];
|
||||
double estimate = bucket == 0 ? Min : Math.Sqrt(LowerBound(bucket) * UpperBound(bucket));
|
||||
return Math.Clamp(estimate, Min, Max);
|
||||
}
|
||||
}
|
||||
|
||||
return Max;
|
||||
}
|
||||
|
||||
/// <summary>按桶遍历(桶号升序)。</summary>
|
||||
public IEnumerable<(int Bucket, ulong Count)> Buckets()
|
||||
{
|
||||
for (int i = 0; i < _used; i++)
|
||||
{
|
||||
yield return (_index[i], _counts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>用于反序列化:直接设置状态。</summary>
|
||||
internal void Restore(IReadOnlyList<(int Bucket, ulong Count)> buckets, ulong count, double sum, double min, double max)
|
||||
{
|
||||
_used = 0;
|
||||
foreach ((int bucket, ulong n) in buckets)
|
||||
{
|
||||
AddToBucket(bucket, n);
|
||||
}
|
||||
|
||||
Count = count;
|
||||
Sum = sum;
|
||||
Min = min;
|
||||
Max = max;
|
||||
}
|
||||
|
||||
private void AddToBucket(int bucket, ulong n)
|
||||
{
|
||||
int pos = Array.BinarySearch(_index, 0, _used, bucket);
|
||||
if (pos >= 0)
|
||||
{
|
||||
_counts[pos] += n;
|
||||
return;
|
||||
}
|
||||
|
||||
pos = ~pos;
|
||||
if (_used == _index.Length)
|
||||
{
|
||||
Array.Resize(ref _index, _used * 2);
|
||||
Array.Resize(ref _counts, _used * 2);
|
||||
}
|
||||
|
||||
Array.Copy(_index, pos, _index, pos + 1, _used - pos);
|
||||
Array.Copy(_counts, pos, _counts, pos + 1, _used - pos);
|
||||
_index[pos] = bucket;
|
||||
_counts[pos] = n;
|
||||
_used++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// 估计两端的时钟偏移和线性漂移:单向时延 x(t) = 偏移 + 漂移·t + 真实的时延变化。
|
||||
/// 最小二乘拟合出直线,残差再减去残差的低分位(时延“地板”),就是去除偏移和漂移之后的抖动。
|
||||
/// </summary>
|
||||
public static class ClockDriftFit
|
||||
{
|
||||
public const int MinPoints = 8;
|
||||
|
||||
/// <summary>拟合 y = a + b·x,x 先居中以保证数值稳定。样本不足或 x 全部相同时返回 false。</summary>
|
||||
public static bool TryFit(ReadOnlySpan<double> x, ReadOnlySpan<double> y, out double intercept, out double slope, out double xMean)
|
||||
{
|
||||
intercept = 0;
|
||||
slope = 0;
|
||||
xMean = 0;
|
||||
int n = x.Length;
|
||||
if (n < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double sumX = 0;
|
||||
double sumY = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
sumX += x[i];
|
||||
sumY += y[i];
|
||||
}
|
||||
|
||||
xMean = sumX / n;
|
||||
double yMean = sumY / n;
|
||||
double sxx = 0;
|
||||
double sxy = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double dx = x[i] - xMean;
|
||||
sxx += dx * dx;
|
||||
sxy += dx * (y[i] - yMean);
|
||||
}
|
||||
|
||||
if (sxx <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
slope = sxy / sxx;
|
||||
intercept = yMean - (slope * xMean);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 去除偏移和漂移后的时延变化(非负)。样本少于 <see cref="MinPoints"/> 时返回 null(不产生观测值)。
|
||||
/// </summary>
|
||||
public static double[]? Jitter(ReadOnlySpan<double> timesUs, ReadOnlySpan<double> delaysUs)
|
||||
{
|
||||
int n = timesUs.Length;
|
||||
if (n < MinPoints || !TryFit(timesUs, delaysUs, out double intercept, out double slope, out double xMean))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double[] residual = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
residual[i] = delaysUs[i] - (intercept + (slope * (timesUs[i] - xMean)));
|
||||
}
|
||||
|
||||
double[] sorted = (double[])residual.Clone();
|
||||
Array.Sort(sorted);
|
||||
double floor = sorted[(int)(0.05 * (n - 1))];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
residual[i] = Math.Max(0, residual[i] - floor);
|
||||
}
|
||||
|
||||
return residual;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>变点检测(均值漂移):找到使前后两段均值差最显著的位置,用来判断 QoS 从什么时候开始限速。</summary>
|
||||
public static class ChangePoint
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回变点的下标(后一段的第一个点),没有显著变点时返回 -1。
|
||||
/// 显著性:前后两段的均值差 ≥ <paramref name="minShift"/>,并且每段至少 <paramref name="minSegment"/> 个点。
|
||||
/// NaN(缺失)的点会被忽略。
|
||||
/// </summary>
|
||||
public static int Detect(IReadOnlyList<double> values, double minShift, int minSegment = 3)
|
||||
{
|
||||
int n = values.Count;
|
||||
if (n < 2 * minSegment)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
double[] prefix = new double[n + 1];
|
||||
int[] counts = new int[n + 1];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
bool valid = !double.IsNaN(values[i]);
|
||||
prefix[i + 1] = prefix[i] + (valid ? values[i] : 0);
|
||||
counts[i + 1] = counts[i] + (valid ? 1 : 0);
|
||||
}
|
||||
|
||||
int best = -1;
|
||||
double bestScore = 0;
|
||||
for (int k = minSegment; k <= n - minSegment; k++)
|
||||
{
|
||||
int leftN = counts[k];
|
||||
int rightN = counts[n] - counts[k];
|
||||
if (leftN < minSegment || rightN < minSegment)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double shift = Math.Abs(((prefix[n] - prefix[k]) / rightN) - (prefix[k] / leftN));
|
||||
double score = shift * Math.Sqrt((double)leftN * rightN / (leftN + rightN));
|
||||
if (shift >= minShift && score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 第一次显著跳变:第一个比之前所有点的最大值还高出 <paramref name="minJump"/> 的点的下标;没有则返回 -1。
|
||||
/// 用于码率阶梯:“从哪一档开始限速”是第一次出现丢包跳变的那一档,而不是均值差最大的分割点。
|
||||
/// </summary>
|
||||
public static int FirstJump(IReadOnlyList<double> values, double minJump)
|
||||
{
|
||||
double max = double.NegativeInfinity;
|
||||
for (int i = 0; i < values.Count; i++)
|
||||
{
|
||||
if (double.IsNaN(values[i]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i > 0 && !double.IsNegativeInfinity(max) && values[i] - max >= minJump)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
max = Math.Max(max, values[i]);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>算子把观测值交给这里;窗口评估器负责按时间分桶。</summary>
|
||||
public interface IObservationSink
|
||||
{
|
||||
void Emit(long utcMs, double a, double b = 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 原始块的列视图:按列名读取,并把 Probe / Agent 各自的单调时钟换算成 UTC。
|
||||
/// Agent 时钟 → UTC 使用块头的锚点;Probe 时钟 → Agent 时钟使用估计的偏移。
|
||||
/// </summary>
|
||||
public sealed class ChunkView
|
||||
{
|
||||
public ChunkView(RawChunk chunk)
|
||||
{
|
||||
Chunk = chunk;
|
||||
}
|
||||
|
||||
public RawChunk Chunk { get; }
|
||||
|
||||
public RawChunkHeader Header => Chunk.Header;
|
||||
|
||||
public long AgentToUtcMs(long agentUs) => Header.Clock.UtcMs + FloorDiv(agentUs - Header.Clock.AgentMonoUs, 1000);
|
||||
|
||||
public long ProbeToUtcMs(long probeUs) => AgentToUtcMs(probeUs + Header.Clock.ProbeOffsetUs);
|
||||
|
||||
public long ColumnToUtcMs(RawColumn column, long value) => column.Role == ColumnRole.TsProbe ? ProbeToUtcMs(value) : AgentToUtcMs(value);
|
||||
|
||||
public double ParamNumber(string name, double fallback) =>
|
||||
Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out double d) ? d : fallback;
|
||||
|
||||
private static long FloorDiv(long a, long b) => (a / b) - ((a % b != 0 && (a < 0) != (b < 0)) ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
public enum Unit
|
||||
{
|
||||
None,
|
||||
Ms,
|
||||
Pct,
|
||||
Count,
|
||||
PerMin,
|
||||
PerHour,
|
||||
Mbps,
|
||||
Packets,
|
||||
Score,
|
||||
}
|
||||
|
||||
public enum Better
|
||||
{
|
||||
None,
|
||||
Lower,
|
||||
Higher,
|
||||
}
|
||||
|
||||
public enum Stat
|
||||
{
|
||||
P50,
|
||||
P90,
|
||||
P95,
|
||||
P99,
|
||||
Mean,
|
||||
Max,
|
||||
Min,
|
||||
Pct,
|
||||
Count,
|
||||
PerMin,
|
||||
PerHour,
|
||||
P999,
|
||||
|
||||
/// <summary>长尾比:p99 / p50(从同一个直方图导出,所以窗口合并之后仍然精确)。</summary>
|
||||
Tail,
|
||||
}
|
||||
|
||||
/// <summary>算子:把一个流里的原始行变成观测值。算子都是纯函数,每个都有边界用例测试。</summary>
|
||||
public abstract class MetricOp
|
||||
{
|
||||
/// <summary>算子的名称和参数,参与指标指纹;改变语义时必须同时改变它。</summary>
|
||||
public abstract string Signature { get; }
|
||||
|
||||
public abstract void Evaluate(ChunkView view, RawStream stream, IObservationSink sink);
|
||||
}
|
||||
|
||||
/// <summary>导出值:<c>指标id.统计量</c>,例如 <c>rtt.p95</c>。已发布的 id 不能改名,只能新增。</summary>
|
||||
public sealed record MetricExport(string Id, Stat Stat, Unit Unit, double? Good = null, double? Poor = null);
|
||||
|
||||
/// <summary>一行声明 = 一个指标:输入(kind.stream)+ 算子 + 累加器 + 导出。</summary>
|
||||
public sealed class MetricDef
|
||||
{
|
||||
public MetricDef(string id, AccKind kind, IReadOnlyList<string> from, MetricOp op, string label, string description, Better better, IReadOnlyList<MetricExport> exports, int version = 1)
|
||||
{
|
||||
Id = id;
|
||||
Kind = kind;
|
||||
From = from;
|
||||
Op = op;
|
||||
Label = label;
|
||||
Description = description;
|
||||
Better = better;
|
||||
Exports = exports;
|
||||
Version = version;
|
||||
Fingerprint = ComputeFingerprint();
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public AccKind Kind { get; }
|
||||
|
||||
/// <summary><c>kind.stream</c>;<c>*</c> 表示所有具有该 stream 的 Kind。</summary>
|
||||
public IReadOnlyList<string> From { get; }
|
||||
|
||||
public MetricOp Op { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public Better Better { get; }
|
||||
|
||||
public IReadOnlyList<MetricExport> Exports { get; }
|
||||
|
||||
public int Version { get; }
|
||||
|
||||
/// <summary>由声明内容和算子签名计算得出,变化时触发回填。</summary>
|
||||
public string Fingerprint { get; }
|
||||
|
||||
public bool Matches(string kind, string stream)
|
||||
{
|
||||
foreach (string source in From)
|
||||
{
|
||||
int dot = source.IndexOf('.', StringComparison.Ordinal);
|
||||
if (source.AsSpan(dot + 1).SequenceEqual(stream) && (source.AsSpan(0, dot).SequenceEqual("*") || source.AsSpan(0, dot).SequenceEqual(kind)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>从累加器导出一个值;没有样本时返回 null。<paramref name="durationSeconds"/> 用于速率类导出。</summary>
|
||||
public static double? Export(Accumulator acc, MetricExport export, double durationSeconds)
|
||||
{
|
||||
switch (acc)
|
||||
{
|
||||
case HistogramAcc h:
|
||||
return export.Stat switch
|
||||
{
|
||||
Stat.P50 => h.Quantile(0.5),
|
||||
Stat.P90 => h.Quantile(0.9),
|
||||
Stat.P95 => h.Quantile(0.95),
|
||||
Stat.P99 => h.Quantile(0.99),
|
||||
Stat.P999 => h.Quantile(0.999),
|
||||
Stat.Tail => h.Quantile(0.5) is > 0 and double p50 && h.Quantile(0.99) is { } p99 ? p99 / p50 : null,
|
||||
Stat.Mean => h.Mean,
|
||||
Stat.Max => h.Count == 0 ? null : h.Max,
|
||||
Stat.Min => h.Count == 0 ? null : h.Min,
|
||||
_ => null,
|
||||
};
|
||||
case RatioAcc r:
|
||||
return export.Stat == Stat.Pct ? r.Percent : null;
|
||||
case CounterAcc c:
|
||||
return export.Stat switch
|
||||
{
|
||||
Stat.Count => c.Value,
|
||||
Stat.PerMin => durationSeconds > 0 ? c.Value / (durationSeconds / 60.0) : null,
|
||||
Stat.PerHour => durationSeconds > 0 ? c.Value / (durationSeconds / 3600.0) : null,
|
||||
_ => null,
|
||||
};
|
||||
case MaxAcc m:
|
||||
return m.Result;
|
||||
case MinAcc n:
|
||||
return n.Result;
|
||||
case SumAcc s:
|
||||
return export.Stat == Stat.Mean ? s.Mean : s.Count == 0 ? null : s.Sum;
|
||||
case RunLengthAcc rl:
|
||||
return export.Stat switch
|
||||
{
|
||||
Stat.Max => rl.Runs == 0 ? null : rl.Max,
|
||||
Stat.Mean => rl.Mean,
|
||||
_ => null,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string ComputeFingerprint()
|
||||
{
|
||||
var text = new StringBuilder()
|
||||
.Append('v').Append(Version).Append('|').Append(Id).Append('|').Append(Kind).Append('|')
|
||||
.AppendJoin(',', From).Append('|').Append(Op.Signature).Append('|');
|
||||
foreach (MetricExport e in Exports)
|
||||
{
|
||||
text.Append(e.Id).Append(':').Append(e.Stat).Append(';');
|
||||
}
|
||||
|
||||
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString())))[..16];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>声明指标的简写。</summary>
|
||||
public static class Metric
|
||||
{
|
||||
public static MetricDef Histogram(string id, string[] from, MetricOp op, string label, string description, Unit unit, Stat[] exports, Better better = Better.Lower, double? good = null, double? poor = null) =>
|
||||
new(id, AccKind.Histogram, from, op, label, description, better, [.. exports.Select(s => s == Stat.Tail ? new MetricExport(id + "." + StatName(s), s, Unit.None) : new MetricExport(id + "." + StatName(s), s, unit, good, poor))]);
|
||||
|
||||
public static MetricDef Ratio(string id, string[] from, MetricOp op, string label, string description, Better better = Better.Lower, double? good = null, double? poor = null, string? exportId = null) =>
|
||||
new(id, AccKind.Ratio, from, op, label, description, better, [new MetricExport(exportId ?? id, Stat.Pct, Unit.Pct, good, poor)]);
|
||||
|
||||
public static MetricDef Counter(string id, string[] from, MetricOp op, string label, string description, MetricExport export, Better better = Better.Lower) =>
|
||||
new(id, AccKind.Counter, from, op, label, description, better, [export]);
|
||||
|
||||
public static MetricDef Max(string id, string[] from, MetricOp op, string label, string description, Unit unit, string? exportId = null, Better better = Better.Lower, double? good = null, double? poor = null) =>
|
||||
new(id, AccKind.Max, from, op, label, description, better, [new MetricExport(exportId ?? id, Stat.Max, unit, good, poor)]);
|
||||
|
||||
public static MetricDef RunLength(string id, string[] from, MetricOp op, string label, string description, params MetricExport[] exports) =>
|
||||
new(id, AccKind.RunLength, from, op, label, description, Better.Lower, exports);
|
||||
|
||||
public static string StatName(Stat stat) => stat switch
|
||||
{
|
||||
Stat.P50 => "p50",
|
||||
Stat.P90 => "p90",
|
||||
Stat.P95 => "p95",
|
||||
Stat.P99 => "p99",
|
||||
Stat.Mean => "mean",
|
||||
Stat.Max => "max",
|
||||
Stat.Min => "min",
|
||||
Stat.Pct => "pct",
|
||||
Stat.Count => "count",
|
||||
Stat.PerMin => "per_min",
|
||||
Stat.P999 => "p999",
|
||||
Stat.Tail => "tail",
|
||||
_ => "per_h",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Text.Json;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
public sealed class TestStepResult
|
||||
{
|
||||
public int Index { get; set; }
|
||||
|
||||
public double TargetMbps { get; set; }
|
||||
|
||||
public double LossPct { get; set; }
|
||||
|
||||
public double? JitterP95Ms { get; set; }
|
||||
|
||||
public double DeliveredMbps { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TestResult
|
||||
{
|
||||
/// <summary>导出值:与持续指标同一套 id,另有 <c>hy2.*</c> 等测试专用的值。</summary>
|
||||
public Dictionary<string, double> Metrics { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>逐步或逐秒的曲线,例如码率–丢包曲线。</summary>
|
||||
public Dictionary<string, double[]> Series { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public List<TestStepResult> Steps { get; } = [];
|
||||
|
||||
public List<string> Caveats { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。
|
||||
/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
|
||||
/// </summary>
|
||||
public static class TestEvaluator
|
||||
{
|
||||
public const double SustainableLossPct = 5;
|
||||
public const double SustainableLossLoosePct = 10;
|
||||
public const double QosShiftPct = 5;
|
||||
|
||||
public static TestResult Evaluate(string plan, IReadOnlyList<RawChunk> chunks)
|
||||
{
|
||||
var result = new TestResult();
|
||||
EvaluateMetrics(chunks, result);
|
||||
if (chunks.Any(c => c.Header.Kind == Kinds.UdpTrain.Id))
|
||||
{
|
||||
EvaluateLadder(chunks, result);
|
||||
}
|
||||
|
||||
if (chunks.Any(c => c.Header.Partial))
|
||||
{
|
||||
result.Caveats.Add("partial_data");
|
||||
}
|
||||
|
||||
if (chunks.Any(c => c.Header.Port is not (0 or 443)))
|
||||
{
|
||||
result.Caveats.Add("port_differs_from_service");
|
||||
}
|
||||
|
||||
_ = plan;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EvaluateMetrics(IReadOnlyList<RawChunk> chunks, TestResult result)
|
||||
{
|
||||
var buckets = new SortedDictionary<long, MetricSet>();
|
||||
long span = 24L * 3600 * 1000;
|
||||
foreach (RawChunk chunk in chunks)
|
||||
{
|
||||
WindowEvaluator.Default.Evaluate(chunk, span, buckets);
|
||||
}
|
||||
|
||||
var merged = new MetricSet();
|
||||
foreach (MetricSet set in buckets.Values)
|
||||
{
|
||||
merged.Merge(set);
|
||||
}
|
||||
|
||||
double seconds = chunks.Sum(c => c.Header.DurationMs) / 1000.0;
|
||||
foreach ((MetricDef def, MetricExport export) in MetricRegistry.AllExports())
|
||||
{
|
||||
if (merged.TryGet(def.Id, out Accumulator acc) && MetricDef.Export(acc, export, seconds) is { } value)
|
||||
{
|
||||
result.Metrics[export.Id] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvaluateLadder(IReadOnlyList<RawChunk> chunks, TestResult result)
|
||||
{
|
||||
var steps = new List<TestStepResult>();
|
||||
var perSecond = new SortedDictionary<long, double>();
|
||||
int index = 0;
|
||||
|
||||
// 一个阶段(一档码率)可能被切成好几个块(一个块最多 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))
|
||||
{
|
||||
RawChunk[] parts = [.. phase.Where(c => c.Find("pkt") is not null).OrderBy(c => c.Header.StartUtcMs)];
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var merged = new MetricSet();
|
||||
long bytes = 0;
|
||||
foreach (RawChunk chunk in parts)
|
||||
{
|
||||
var buckets = new SortedDictionary<long, MetricSet>();
|
||||
WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, new HashSet<string> { "train.loss", "train.jitter", "train.rate" });
|
||||
foreach (MetricSet set in buckets.Values)
|
||||
{
|
||||
merged.Merge(set);
|
||||
}
|
||||
|
||||
bytes += ReceivedBytes(chunk.Find("pkt")!);
|
||||
|
||||
var seconds = new SortedDictionary<long, MetricSet>();
|
||||
WindowEvaluator.Default.Evaluate(chunk, 1000, seconds, new HashSet<string> { "train.rate" });
|
||||
foreach ((long start, MetricSet set) in seconds)
|
||||
{
|
||||
if (set.TryGet("train.rate", out Accumulator rate) && rate is HistogramAcc rh && rh.Mean is { } mean)
|
||||
{
|
||||
perSecond[start] = mean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var step = new TestStepResult
|
||||
{
|
||||
Index = index++,
|
||||
TargetMbps = TargetMbps(parts[0].Header),
|
||||
};
|
||||
if (merged.TryGet("train.loss", out Accumulator loss) && loss is RatioAcc ratio)
|
||||
{
|
||||
step.LossPct = ratio.Percent ?? 0;
|
||||
}
|
||||
|
||||
if (merged.TryGet("train.jitter", out Accumulator jitter) && jitter is HistogramAcc jh)
|
||||
{
|
||||
step.JitterP95Ms = jh.Quantile(0.95);
|
||||
}
|
||||
|
||||
// 阶段的时长:从发送时间戳算(块头里的时长是 Probe 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集)
|
||||
double spanSeconds = SendSpanSeconds(parts) ?? ((parts.Max(c => c.Header.StartUtcMs + c.Header.DurationMs) - parts.Min(c => c.Header.StartUtcMs)) / 1000.0);
|
||||
step.DeliveredMbps = spanSeconds > 0 ? bytes * 8 / spanSeconds / 1_000_000 : 0;
|
||||
steps.Add(step);
|
||||
}
|
||||
|
||||
if (steps.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result.Steps.AddRange(steps);
|
||||
result.Series["step_mbps"] = [.. steps.Select(s => s.TargetMbps)];
|
||||
result.Series["loss_pct"] = [.. steps.Select(s => s.LossPct)];
|
||||
result.Series["delivered_mbps"] = [.. steps.Select(s => s.DeliveredMbps)];
|
||||
result.Series["jitter_p95_ms"] = [.. steps.Select(s => s.JitterP95Ms ?? double.NaN)];
|
||||
|
||||
TestStepResult? sustainable = steps.Where(s => s.LossPct <= SustainableLossPct).MaxBy(s => s.TargetMbps);
|
||||
TestStepResult? loose = steps.Where(s => s.LossPct <= SustainableLossLoosePct).MaxBy(s => s.TargetMbps);
|
||||
result.Metrics["hy2.sustainable_mbps"] = sustainable?.TargetMbps ?? 0;
|
||||
result.Metrics["hy2.sustainable10_mbps"] = loose?.TargetMbps ?? 0;
|
||||
result.Metrics["hy2.effective_mbps"] = steps.Max(s => s.DeliveredMbps);
|
||||
|
||||
// 限速开始:丢包率随码率阶梯出现显著跳变的那一档。
|
||||
int onset = ChangePoint.FirstJump([.. steps.Select(s => s.LossPct)], QosShiftPct);
|
||||
if (onset >= 0)
|
||||
{
|
||||
result.Metrics["hy2.qos_onset_mbps"] = steps[onset].TargetMbps;
|
||||
}
|
||||
|
||||
if (perSecond.Count >= 6)
|
||||
{
|
||||
double[] rates = [.. perSecond.Values];
|
||||
double peak = rates.Max();
|
||||
int second = ChangePoint.Detect(rates, Math.Max(1, peak * 0.2));
|
||||
if (second >= 0 && rates[second..].Average() < rates[..second].Average())
|
||||
{
|
||||
result.Metrics["hy2.qos_onset_s"] = second;
|
||||
}
|
||||
|
||||
result.Series["delivered_mbps_per_s"] = rates;
|
||||
}
|
||||
}
|
||||
|
||||
private static double TargetMbps(RawChunkHeader header) =>
|
||||
header.Params.TryGetValue("mbps", out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : 0;
|
||||
|
||||
/// <summary>
|
||||
/// 发送端的时间戳(Agent 时钟)在 UTC 上的跨度:所有块里第一个包到最后一个包,再补上一个平均间隔(n 个包有 n−1 个间隔)。
|
||||
/// 没有时间戳就返回 null。
|
||||
/// </summary>
|
||||
private static double? SendSpanSeconds(RawChunk[] parts)
|
||||
{
|
||||
long min = long.MaxValue;
|
||||
long max = long.MinValue;
|
||||
long count = 0;
|
||||
foreach (RawChunk chunk in parts)
|
||||
{
|
||||
RawStream? pkt = chunk.Find("pkt");
|
||||
RawColumn? send = pkt?.Find("t_send");
|
||||
if (pkt is null || send is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ChunkClock clock = chunk.Header.Clock;
|
||||
for (int i = 0; i < pkt.Count; i++)
|
||||
{
|
||||
if (!send.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.AgentMonoUs);
|
||||
min = Math.Min(min, utcUs);
|
||||
max = Math.Max(max, utcUs);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count < 2 || max <= min)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (max - min) / 1_000_000.0 * count / (count - 1);
|
||||
}
|
||||
|
||||
private static long ReceivedBytes(RawStream pkt)
|
||||
{
|
||||
RawColumn? recv = pkt.Find("t_recv");
|
||||
RawColumn? size = pkt.Find("size");
|
||||
if (recv is null || size is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long bytes = 0;
|
||||
for (int i = 0; i < pkt.Count; i++)
|
||||
{
|
||||
if (recv.IsValid(i))
|
||||
{
|
||||
bytes += size.Values[i];
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// 把原始块按时间分桶算成累加器。流式路径(1s 桶)和持久路径(分钟窗口)调用同一份代码,
|
||||
/// 所以实时值与历史值使用的是同一套口径。
|
||||
/// </summary>
|
||||
public sealed class WindowEvaluator
|
||||
{
|
||||
private readonly IReadOnlyList<MetricDef> _metrics;
|
||||
|
||||
public WindowEvaluator(IReadOnlyList<MetricDef> metrics)
|
||||
{
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public static WindowEvaluator Default { get; } = new(MetricRegistry.All);
|
||||
|
||||
/// <summary>
|
||||
/// 评估一个块,结果合并进 <paramref name="buckets"/>(键是桶起点的 UTC 毫秒)。
|
||||
/// <paramref name="only"/> 不为空时只计算这些指标(用于回填新增或变更的指标)。
|
||||
/// </summary>
|
||||
public void Evaluate(RawChunk chunk, long bucketMs, IDictionary<long, MetricSet> buckets, ISet<string>? only = null)
|
||||
{
|
||||
var view = new ChunkView(chunk);
|
||||
foreach (RawStream stream in chunk.Streams)
|
||||
{
|
||||
foreach (MetricDef metric in _metrics)
|
||||
{
|
||||
if ((only is not null && !only.Contains(metric.Id)) || !metric.Matches(chunk.Header.Kind, stream.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
metric.Op.Evaluate(view, stream, new BucketSink(metric, bucketMs, buckets));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BucketSink : IObservationSink
|
||||
{
|
||||
private readonly MetricDef _metric;
|
||||
private readonly long _bucketMs;
|
||||
private readonly IDictionary<long, MetricSet> _buckets;
|
||||
|
||||
public BucketSink(MetricDef metric, long bucketMs, IDictionary<long, MetricSet> buckets)
|
||||
{
|
||||
_metric = metric;
|
||||
_bucketMs = bucketMs;
|
||||
_buckets = buckets;
|
||||
}
|
||||
|
||||
public void Emit(long utcMs, double a, double b = 0)
|
||||
{
|
||||
long start = Math.DivRem(utcMs, _bucketMs, out long rem) * _bucketMs;
|
||||
if (rem < 0)
|
||||
{
|
||||
start -= _bucketMs;
|
||||
}
|
||||
|
||||
if (!_buckets.TryGetValue(start, out MetricSet? set))
|
||||
{
|
||||
set = new MetricSet();
|
||||
_buckets[start] = set;
|
||||
}
|
||||
|
||||
set.GetOrAdd(_metric.Id, _metric.Kind).Add(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>线路基础指标(udp_echo / icmp_echo 的回显流)。新增指标 = 加一行声明。</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"];
|
||||
|
||||
public static readonly MetricDef Rtt = Metric.Histogram(
|
||||
"rtt", s_udpOrIcmpMsg, Ops.Rtt, "RTT", "Round-trip time with the Agent processing time removed.", Unit.Ms,
|
||||
[Stat.P50, Stat.P95, Stat.P99, Stat.Mean], good: 50, poor: 150);
|
||||
|
||||
public static readonly MetricDef LossUp = Metric.Ratio(
|
||||
"loss.up", s_udpMsg, Ops.Loss(Dir.Up), "Loss (up)", "Packets lost between the Probe and the Agent.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef LossDown = Metric.Ratio(
|
||||
"loss.down", s_udpMsg, Ops.Loss(Dir.Down), "Loss (down)", "Replies lost between the Agent and the Probe, among packets that reached the Agent.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef LossTotal = Metric.Ratio(
|
||||
"loss.total", s_udpOrIcmpMsg, Ops.Loss(Dir.Total), "Loss (total)", "Packets whose reply never came back.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef JitterUp = Metric.Histogram(
|
||||
"jitter.up", s_udpMsg, Ops.OwdVar(Dir.Up), "Jitter (up)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||
|
||||
public static readonly MetricDef JitterDown = Metric.Histogram(
|
||||
"jitter.down", s_udpMsg, Ops.OwdVar(Dir.Down), "Jitter (down)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||
|
||||
public static readonly MetricDef LossRun = Metric.RunLength(
|
||||
"loss.run", s_udpMsg, Ops.LossRuns, "Loss runs", "Lengths of consecutive lost packets.",
|
||||
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
|
||||
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
|
||||
|
||||
public static readonly MetricDef Reorder = Metric.Counter(
|
||||
"reorder", s_udpMsg, Ops.Reorder, "Reordered", "Replies that arrived after a higher sequence number.", new MetricExport("reorder", Stat.Count, Unit.Count));
|
||||
|
||||
public static readonly MetricDef Dup = Metric.Counter(
|
||||
"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);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
public sealed record PresetDef(string Id, string Label, IReadOnlyList<string> MetricIds);
|
||||
|
||||
/// <summary>
|
||||
/// 指标注册表:存储、降采样、API、目录都由它驱动。新增指标时,在对应的 Metrics 文件里加一行声明,
|
||||
/// 再把它放进这里的 <see cref="All"/>(显式注册,AOT 安全,不使用反射)。
|
||||
/// </summary>
|
||||
public static class MetricRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<MetricDef> All =
|
||||
[
|
||||
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
|
||||
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
|
||||
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
|
||||
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
|
||||
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
|
||||
SessionMetrics.HoldResets, SessionMetrics.HoldLifetime,
|
||||
SessionMetrics.TrainLoss, SessionMetrics.TrainJitter, SessionMetrics.TrainRate, SessionMetrics.SamplesConnect,
|
||||
];
|
||||
|
||||
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
|
||||
public static readonly IReadOnlyList<PresetDef> Presets =
|
||||
[
|
||||
new("udp", "UDP", ["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.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("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
|
||||
new("scores", "Scores", ["score.base", "score.mc", "score.hy2", "score.reality"]),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, MetricDef> s_byId = All.ToDictionary(m => m.Id, StringComparer.Ordinal);
|
||||
|
||||
private static readonly Dictionary<string, (MetricDef Def, MetricExport Export)> s_byExport = BuildExportIndex();
|
||||
|
||||
public static MetricDef? Find(string metricId) => s_byId.GetValueOrDefault(metricId);
|
||||
|
||||
/// <summary>按导出 id(例如 <c>rtt.p95</c>)查找。</summary>
|
||||
public static bool TryFindExport(string exportId, out MetricDef def, out MetricExport export)
|
||||
{
|
||||
if (s_byExport.TryGetValue(exportId, out (MetricDef Def, MetricExport Export) found))
|
||||
{
|
||||
def = found.Def;
|
||||
export = found.Export;
|
||||
return true;
|
||||
}
|
||||
|
||||
def = null!;
|
||||
export = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static PresetDef? FindPreset(string id) => Presets.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
public static IEnumerable<(MetricDef Def, MetricExport Export)> AllExports() =>
|
||||
All.SelectMany(def => def.Exports.Select(export => (def, export)));
|
||||
|
||||
private static Dictionary<string, (MetricDef, MetricExport)> BuildExportIndex()
|
||||
{
|
||||
var index = new Dictionary<string, (MetricDef, MetricExport)>(StringComparer.Ordinal);
|
||||
foreach (MetricDef def in All)
|
||||
{
|
||||
foreach (MetricExport export in def.Exports)
|
||||
{
|
||||
if (!index.TryAdd(export.Id, (def, export)))
|
||||
{
|
||||
throw new InvalidOperationException($"duplicate export id {export.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</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"];
|
||||
|
||||
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,
|
||||
[new MetricExport("connect.success", Stat.Pct, Unit.Pct, 99, 90)]);
|
||||
|
||||
public static readonly MetricDef ConnectTcp = Metric.Histogram(
|
||||
"connect.tcp_ms", s_conn, Ops.PhaseMs("tcp_us"), "TCP connect time", "Time to establish the TCP connection.", Unit.Ms, [Stat.P95], good: 100, poor: 400);
|
||||
|
||||
public static readonly MetricDef ConnectTls = Metric.Histogram(
|
||||
"connect.tls_ms", s_conn, Ops.PhaseMs("tls_us"), "TLS handshake time", "Time for the TLS handshake after TCP is up.", Unit.Ms, [Stat.P95], good: 200, poor: 800);
|
||||
|
||||
public static readonly MetricDef FailTimeout = Metric.Counter(
|
||||
"connect.fail.timeout", s_conn, Ops.OutcomeCount(ConnectOutcome.Timeout), "Connect timeouts", "Attempts that timed out.", new MetricExport("connect.fail.timeout", Stat.Count, Unit.Count));
|
||||
|
||||
public static readonly MetricDef FailReset = Metric.Counter(
|
||||
"connect.fail.rst", s_conn, Ops.OutcomeCount(ConnectOutcome.Reset), "Connect resets", "Attempts answered with a reset.", new MetricExport("connect.fail.rst", Stat.Count, Unit.Count));
|
||||
|
||||
public static readonly MetricDef FailTls = Metric.Counter(
|
||||
"connect.fail.tls", s_conn, Ops.OutcomeCount(ConnectOutcome.TlsAlert), "TLS failures", "Attempts that ended in a TLS alert.", new MetricExport("connect.fail.tls", Stat.Count, Unit.Count));
|
||||
|
||||
public static readonly MetricDef HoldResets = Metric.Counter(
|
||||
"hold.resets", s_hold, Ops.HoldEvents(HoldEvent.Reset), "Held connection resets", "Times the held connection was reset.", new MetricExport("hold.resets.per_h", Stat.PerHour, Unit.PerHour, 0.5, 4));
|
||||
|
||||
public static readonly MetricDef HoldLifetime = Metric.Max(
|
||||
"hold.lifetime", s_hold, Ops.HoldLifetime, "Longest connection lifetime", "Longest observed lifetime of the held connection (ms).", Unit.Ms, "hold.lifetime_max", Better.Higher);
|
||||
|
||||
public static readonly MetricDef TrainLoss = Metric.Ratio(
|
||||
"train.loss", s_train, Ops.TrainLoss, "Train loss", "Loss of a fixed-rate UDP stream.", good: 1, poor: 10);
|
||||
|
||||
public static readonly MetricDef TrainJitter = Metric.Histogram(
|
||||
"train.jitter", s_train, Ops.TrainJitter, "Train jitter", "One-way delay variation of a fixed-rate UDP stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||
|
||||
public static readonly MetricDef TrainRate = Metric.Histogram(
|
||||
"train.rate", s_train, Ops.TrainRate, "Train rate", "Delivered rate of a fixed-rate UDP stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
|
||||
|
||||
public static readonly MetricDef SamplesConnect = Metric.Counter(
|
||||
"n.connect", s_conn, Ops.RowCount, "connect samples", "Number of connection attempts.", new MetricExport("n.connect", Stat.Count, Unit.Count), Better.None);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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"];
|
||||
|
||||
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,
|
||||
[Stat.P50, Stat.P95, Stat.P99, Stat.P999, Stat.Max, Stat.Tail], good: 60, poor: 200);
|
||||
|
||||
public static readonly MetricDef Slow = Metric.Ratio(
|
||||
"tcp.slow", s_tcpMsg, Ops.SlowShare(minSamples: 20), "TCP long-tail echoes",
|
||||
"Share of echoes slower than the long-tail threshold: max(1.8 x baseline, baseline + max(15 ms, 0.35 x baseline)), where the baseline is the median RTT of the same 10 s chunk.",
|
||||
good: 1, poor: 5);
|
||||
|
||||
public static readonly MetricDef Stall = Metric.Counter(
|
||||
"tcp.stall", s_tcpMsg, Ops.Gap(GapOutput.Count, "rate_hz", thresholdMs: 100), "TCP stalls",
|
||||
"Arrival gaps longer than the expected interval plus 100 ms (head-of-line blocking).",
|
||||
new MetricExport("tcp.stall.per_min", Stat.PerMin, Unit.PerMin, 0.5, 5));
|
||||
|
||||
public static readonly MetricDef StallMax = Metric.Max(
|
||||
"tcp.stall_max", s_tcpMsg, Ops.Gap(GapOutput.MaxMs, "rate_hz", thresholdMs: 100), "Longest TCP stall", "Longest stall in the period.", Unit.Ms, good: 200, poor: 1000);
|
||||
|
||||
public static readonly MetricDef Retrans = Metric.Ratio(
|
||||
"tcp.retrans", s_tcpInfo, Ops.KernelDelta("retrans_total", "segs_out"), "TCP retransmissions", "Retransmitted segments as a share of segments sent (kernel counters).", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef Srtt = Metric.Histogram(
|
||||
"tcp.srtt", s_tcpInfo, Ops.Column("srtt_us", 0.001), "Kernel srtt", "Smoothed RTT sampled from the kernel.", Unit.Ms, [Stat.P95], good: 60, poor: 200);
|
||||
|
||||
public static readonly MetricDef BulkRate = Metric.Histogram(
|
||||
"tcp.bulk.mbps", s_bulkBytes, Ops.BulkRate, "Bulk throughput", "Aggregate throughput of the bulk transfer streams.", Unit.Mbps,
|
||||
[Stat.P50, Stat.Mean, Stat.Max], Better.Higher);
|
||||
|
||||
public static readonly MetricDef BulkPeak = Metric.Histogram(
|
||||
"tcp.bulk.peak", s_bulkBytes, Ops.BulkPeak, "Peak bulk throughput", "Best sustained 1 s throughput of the bulk transfer: how fast this line can go.", Unit.Mbps,
|
||||
[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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\shared\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,193 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>线路基础质量:RTT、丢包、抖动、连续丢包。</summary>
|
||||
public sealed class BaseModel : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_rtt = new((30, 100), (80, 80), (150, 50), (300, 10), (500, 0));
|
||||
private static readonly Curve s_loss = new((0, 100), (0.5, 90), (1, 75), (3, 40), (10, 0));
|
||||
private static readonly Curve s_jitter = new((2, 100), (8, 85), (20, 50), (80, 0));
|
||||
private static readonly Curve s_run = new((1, 100), (3, 80), (10, 30), (30, 0));
|
||||
|
||||
public override string Id => "base";
|
||||
|
||||
public override string Title => "Line quality";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
|
||||
|
||||
public override ModelScore Score(MetricView view) =>
|
||||
Combine(
|
||||
view,
|
||||
[
|
||||
Dim(view, "rtt.p95", "RTT p95", 3, s_rtt),
|
||||
Dim(view, "loss.total", "Packet loss", 4, s_loss),
|
||||
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
|
||||
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
|
||||
Dim(view, "loss.run_max", "Longest loss run", 1, s_run),
|
||||
],
|
||||
estimated: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minecraft:TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。
|
||||
/// 没有 tcp_echo 数据时,用 udp_echo 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
|
||||
/// </summary>
|
||||
public sealed class McModel : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_rtt = new((40, 100), (100, 80), (200, 45), (400, 5), (600, 0));
|
||||
private static readonly Curve s_stallRate = new((0, 100), (1, 90), (5, 60), (20, 20), (60, 0));
|
||||
private static readonly Curve s_stallMax = new((150, 100), (500, 60), (2000, 10), (5000, 0));
|
||||
private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0));
|
||||
|
||||
public override string Id => "mc";
|
||||
|
||||
public override string Title => "Minecraft";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } = ["tcp.rtt.p95", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans"];
|
||||
|
||||
public override ModelScore Score(MetricView view)
|
||||
{
|
||||
bool hasTcp = view.Has("tcp.rtt.p95") || view.Has("tcp.stall.per_min");
|
||||
if (hasTcp)
|
||||
{
|
||||
return Combine(
|
||||
view,
|
||||
[
|
||||
Dim(view, "tcp.rtt.p95", "TCP RTT p95", 3, s_rtt),
|
||||
Dim(view, "tcp.stall.per_min", "Stalls per minute", 3, s_stallRate),
|
||||
Dim(view, "tcp.stall_max", "Longest stall", 2, s_stallMax),
|
||||
Dim(view, "tcp.retrans", "Retransmissions", 2, s_retrans),
|
||||
],
|
||||
estimated: false);
|
||||
}
|
||||
|
||||
if (view.Get("loss.total") is not { } lossPct || view.Get("rtt.p95") is not { } rttMs)
|
||||
{
|
||||
return Combine(view, [], estimated: true);
|
||||
}
|
||||
|
||||
// 一个丢失的包至少造成一次约 max(200 ms, 2·RTT) 的卡顿;约一半能被快速重传恢复。
|
||||
const double messagesPerMinute = 1200;
|
||||
double stallsPerMin = lossPct / 100 * messagesPerMinute * 0.5;
|
||||
double stallMs = lossPct > 0 ? Math.Max(200, 2 * rttMs) * (1 + (view.Get("loss.run_max") ?? 1) / 3) : 0;
|
||||
var estimate = new MetricView(new Dictionary<string, double>
|
||||
{
|
||||
["tcp.rtt.p95"] = rttMs + (lossPct > 0 ? Math.Max(200, 2 * rttMs) * lossPct / 100 * 5 : 0),
|
||||
["tcp.stall.per_min"] = stallsPerMin,
|
||||
["tcp.stall_max"] = stallMs,
|
||||
["tcp.retrans"] = lossPct,
|
||||
})
|
||||
{
|
||||
MeasuredOnServicePort = view.MeasuredOnServicePort,
|
||||
};
|
||||
return Combine(
|
||||
estimate,
|
||||
[
|
||||
Dim(estimate, "tcp.rtt.p95", "TCP RTT p95 (estimated)", 3, s_rtt),
|
||||
Dim(estimate, "tcp.stall.per_min", "Stalls per minute (estimated)", 3, s_stallRate),
|
||||
Dim(estimate, "tcp.stall_max", "Longest stall (estimated)", 2, s_stallMax),
|
||||
Dim(estimate, "tcp.retrans", "Retransmissions (estimated)", 2, s_retrans),
|
||||
],
|
||||
estimated: true,
|
||||
"estimated_from_udp");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hysteria2(Brutal 固定码率):有 hy2_ladder 测试结果时,用可持续码率、有效吞吐和限速开始的位置评分;
|
||||
/// 没有测试时,只能用持续的 udp_echo 趋势粗略估算,并标记为 estimated。
|
||||
/// </summary>
|
||||
public sealed class Hy2Model : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_sustainable = new((0, 0), (10, 30), (25, 60), (50, 80), (100, 100));
|
||||
private static readonly Curve s_qosOnset = new((10, 20), (25, 50), (50, 75), (100, 95));
|
||||
private static readonly Curve s_loss = new((0, 100), (1, 85), (3, 60), (8, 20), (15, 0));
|
||||
private static readonly Curve s_jitter = new((3, 100), (10, 80), (30, 40), (100, 0));
|
||||
private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0));
|
||||
|
||||
public override string Id => "hy2";
|
||||
|
||||
public override string Title => "Hysteria2";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } = ["hy2.sustainable_mbps", "hy2.effective_mbps", "hy2.qos_onset_mbps", "loss.total", "jitter.down.p95", "rtt.p95"];
|
||||
|
||||
public override ModelScore Score(MetricView view)
|
||||
{
|
||||
var dimensions = new List<ModelDimension>();
|
||||
bool hasTest = view.Has("hy2.sustainable_mbps");
|
||||
if (hasTest)
|
||||
{
|
||||
dimensions.Add(Dim(view, "hy2.sustainable_mbps", "Sustainable rate (loss ≤ 5%)", 4, s_sustainable));
|
||||
dimensions.Add(Dim(view, "hy2.effective_mbps", "Effective throughput", 2, s_sustainable));
|
||||
dimensions.Add(view.Get("hy2.qos_onset_mbps") is { } onset
|
||||
? new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, onset, s_qosOnset.Evaluate(onset))
|
||||
: new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, null, 100));
|
||||
}
|
||||
|
||||
dimensions.Add(Dim(view, "loss.total", "Packet loss (trend)", hasTest ? 1.5 : 4, s_loss));
|
||||
dimensions.Add(Dim(view, "jitter.down.p95", "Jitter (down)", hasTest ? 0.5 : 1.5, s_jitter));
|
||||
dimensions.Add(Dim(view, "rtt.p95", "RTT p95", hasTest ? 0.5 : 1.5, s_rtt));
|
||||
return Combine(view, dimensions, estimated: !hasTest, hasTest ? [] : ["no_load_test"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>REALITY(TLS 伪装的 TCP 代理):握手成功率与耗时、长连接稳定性、批量吞吐与重传。</summary>
|
||||
public sealed class RealityModel : ServiceModelBase
|
||||
{
|
||||
private static readonly Curve s_success = new((80, 10), (95, 60), (99, 90), (100, 100));
|
||||
private static readonly Curve s_tls = new((150, 100), (400, 80), (1000, 40), (3000, 0));
|
||||
private static readonly Curve s_resets = new((0, 100), (1, 80), (4, 40), (12, 0));
|
||||
private static readonly Curve s_bulk = new((10, 30), (100, 60), (400, 85), (800, 100));
|
||||
private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0));
|
||||
private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0));
|
||||
|
||||
public override string Id => "reality";
|
||||
|
||||
public override string Title => "REALITY";
|
||||
|
||||
public override int Version => 1;
|
||||
|
||||
public override IReadOnlyList<string> Needs { get; } =
|
||||
["connect.success", "connect.tls_ms.p95", "hold.resets.per_h", "tcp.bulk.peak.max", "tcp.retrans", "tcp.rtt.p95"];
|
||||
|
||||
public override ModelScore Score(MetricView view)
|
||||
{
|
||||
bool hasSession = view.Has("connect.success");
|
||||
return Combine(
|
||||
view,
|
||||
[
|
||||
Dim(view, "connect.success", "Connect success", 3, s_success),
|
||||
Dim(view, "connect.tls_ms.p95", "TLS handshake p95", 2, s_tls),
|
||||
Dim(view, "hold.resets.per_h", "Held connection resets", 2, s_resets),
|
||||
Dim(view, "tcp.bulk.peak.max", "Peak bulk throughput", 2, s_bulk),
|
||||
Dim(view, "tcp.retrans", "Retransmissions", 1, s_retrans),
|
||||
Dim(view, "tcp.rtt.p95", "TCP RTT p95", 1, s_rtt),
|
||||
],
|
||||
estimated: !hasSession,
|
||||
hasSession ? [] : ["no_connect_data"]);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ModelRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<IServiceModel> All = [new BaseModel(), new McModel(), new Hy2Model(), new RealityModel()];
|
||||
|
||||
public static IServiceModel? Find(string id) => All.FirstOrDefault(m => m.Id == id);
|
||||
|
||||
/// <summary>计算全部模型的评分(score.<模型id>)。</summary>
|
||||
public static Dictionary<string, ModelScore> ScoreAll(MetricView view)
|
||||
{
|
||||
var scores = new Dictionary<string, ModelScore>(StringComparer.Ordinal);
|
||||
foreach (IServiceModel model in All)
|
||||
{
|
||||
scores[model.Id] = model.Score(view);
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>模型的输入:一个时间段内的导出值(例如 <c>rtt.p95</c>)。没有数据的指标不存在。</summary>
|
||||
public sealed class MetricView
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, double> _values;
|
||||
|
||||
public MetricView(IReadOnlyDictionary<string, double> values)
|
||||
{
|
||||
_values = values;
|
||||
}
|
||||
|
||||
/// <summary>测量端口与被推算服务的常用端口(443)一致时为 true。默认端口 7443/7444 下总是 false。</summary>
|
||||
public bool MeasuredOnServicePort { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, double> Values => _values;
|
||||
|
||||
public bool Has(string id) => _values.ContainsKey(id);
|
||||
|
||||
public double? Get(string id) => _values.TryGetValue(id, out double v) ? v : null;
|
||||
}
|
||||
|
||||
public sealed record ModelDimension(string Id, string Label, double Weight, double? Value, double? Score);
|
||||
|
||||
/// <summary>评分 0–100;没有任何可用维度时为 null。</summary>
|
||||
public sealed record ModelScore(string ModelId, int Version, double? Score, IReadOnlyList<ModelDimension> Dimensions, IReadOnlyList<string> Caveats, bool Estimated);
|
||||
|
||||
/// <summary>
|
||||
/// 服务体验模型:只读取指标,不读取原始列;在查询时计算,改动评分公式后立即对全部历史生效。
|
||||
/// 评分函数需要有单调性测试(指标变差时,分数不能升高)。
|
||||
/// </summary>
|
||||
public interface IServiceModel
|
||||
{
|
||||
string Id { get; }
|
||||
|
||||
string Title { get; }
|
||||
|
||||
int Version { get; }
|
||||
|
||||
/// <summary>需要的导出值 id(用于目录展示和判断数据是否足够)。</summary>
|
||||
IReadOnlyList<string> Needs { get; }
|
||||
|
||||
ModelScore Score(MetricView view);
|
||||
}
|
||||
|
||||
/// <summary>分段线性评分曲线:点按 x 升序排列,超出两端时取端点的值。</summary>
|
||||
public sealed class Curve
|
||||
{
|
||||
private readonly (double X, double Y)[] _points;
|
||||
|
||||
public Curve(params (double X, double Y)[] points)
|
||||
{
|
||||
_points = points;
|
||||
}
|
||||
|
||||
public double Evaluate(double x)
|
||||
{
|
||||
if (x <= _points[0].X)
|
||||
{
|
||||
return _points[0].Y;
|
||||
}
|
||||
|
||||
for (int i = 1; i < _points.Length; i++)
|
||||
{
|
||||
if (x <= _points[i].X)
|
||||
{
|
||||
(double x0, double y0) = _points[i - 1];
|
||||
(double x1, double y1) = _points[i];
|
||||
return y0 + ((y1 - y0) * (x - x0) / (x1 - x0));
|
||||
}
|
||||
}
|
||||
|
||||
return _points[^1].Y;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class ServiceModelBase : IServiceModel
|
||||
{
|
||||
protected static readonly string[] BaseCaveats = ["traffic_shape_only"];
|
||||
|
||||
public abstract string Id { get; }
|
||||
|
||||
public abstract string Title { get; }
|
||||
|
||||
public abstract int Version { get; }
|
||||
|
||||
public abstract IReadOnlyList<string> Needs { get; }
|
||||
|
||||
public abstract ModelScore Score(MetricView view);
|
||||
|
||||
protected static ModelDimension Dim(MetricView view, string id, string label, double weight, Curve curve) =>
|
||||
view.Get(id) is { } value ? new ModelDimension(id, label, weight, value, curve.Evaluate(value)) : new ModelDimension(id, label, weight, null, null);
|
||||
|
||||
/// <summary>按权重对可用的维度加权平均(缺失的维度不参与)。</summary>
|
||||
protected ModelScore Combine(MetricView view, List<ModelDimension> dimensions, bool estimated, params string[] extraCaveats)
|
||||
{
|
||||
double weight = 0;
|
||||
double sum = 0;
|
||||
foreach (ModelDimension d in dimensions)
|
||||
{
|
||||
if (d.Score is { } score)
|
||||
{
|
||||
weight += d.Weight;
|
||||
sum += d.Weight * score;
|
||||
}
|
||||
}
|
||||
|
||||
var caveats = new List<string>(BaseCaveats);
|
||||
if (!view.MeasuredOnServicePort)
|
||||
{
|
||||
caveats.Add("port_differs_from_service");
|
||||
}
|
||||
|
||||
caveats.AddRange(extraCaveats);
|
||||
if (estimated)
|
||||
{
|
||||
caveats.Add("estimated");
|
||||
}
|
||||
|
||||
return new ModelScore(Id, Version, weight > 0 ? Math.Round(sum / weight, 1) : null, dimensions, caveats, estimated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
public enum Dir
|
||||
{
|
||||
Up,
|
||||
Down,
|
||||
Total,
|
||||
}
|
||||
|
||||
public enum GapOutput
|
||||
{
|
||||
Count,
|
||||
MaxMs,
|
||||
}
|
||||
|
||||
/// <summary>回显流(stream <c>msg</c>)的列:t1/t4 是 Probe 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。</summary>
|
||||
internal readonly struct EchoColumns
|
||||
{
|
||||
public EchoColumns(RawStream stream)
|
||||
{
|
||||
Count = stream.Count;
|
||||
Seq = stream.Find("seq");
|
||||
T1 = stream.Find("t1");
|
||||
T2 = stream.Find("t2");
|
||||
T3 = stream.Find("t3");
|
||||
T4 = stream.Find("t4");
|
||||
}
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
public RawColumn? Seq { get; }
|
||||
|
||||
public RawColumn? T1 { get; }
|
||||
|
||||
public RawColumn? T2 { get; }
|
||||
|
||||
public RawColumn? T3 { get; }
|
||||
|
||||
public RawColumn? T4 { get; }
|
||||
|
||||
public bool HasBasics => T1 is not null && T4 is not null;
|
||||
|
||||
/// <summary>行 i 的发送时刻(UTC 毫秒)。</summary>
|
||||
public long SentUtcMs(ChunkView view, int i) => view.ColumnToUtcMs(T1!, T1!.Values[i]);
|
||||
|
||||
public bool IsUpLost(int i) => T2 is not null && !T2.IsValid(i);
|
||||
|
||||
public bool IsReplyLost(int i) => T4 is null || !T4.IsValid(i);
|
||||
|
||||
/// <summary>按到达时间(t4)排序后的行号,只含收到回复的行。</summary>
|
||||
public int[] ArrivalOrder()
|
||||
{
|
||||
var rows = new List<int>(Count);
|
||||
for (int i = 0; i < Count; i++)
|
||||
{
|
||||
if (!IsReplyLost(i))
|
||||
{
|
||||
rows.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
int[] order = [.. rows];
|
||||
long[] keys = new long[order.Length];
|
||||
for (int i = 0; i < order.Length; i++)
|
||||
{
|
||||
keys[i] = T4!.Values[order[i]];
|
||||
}
|
||||
|
||||
Array.Sort(keys, order);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RttOp : MetricOp
|
||||
{
|
||||
public override string Signature => "rtt/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (c.IsReplyLost(i) || !c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long rttUs = c.T4!.Values[i] - c.T1.Values[i];
|
||||
if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i))
|
||||
{
|
||||
rttUs -= c.T3.Values[i] - c.T2.Values[i];
|
||||
}
|
||||
|
||||
if (rttUs >= 0)
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 长尾回显:每个有回复的回显一个观测(是否"慢")。阈值以同一个块(10 秒)里 RTT 的中位数为基线:
|
||||
/// <c>max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))</c>。基线取自块内数据,所以每个观测只依赖自己所在的块,窗口合并仍然精确;
|
||||
/// 块里的样本少于 <c>minSamples</c> 时基线不可靠,不产生观测。TCP 的长尾通常是丢包后的重传(RTT 聚在 RTO 附近)或队头阻塞。
|
||||
/// </summary>
|
||||
internal sealed class SlowShareOp : MetricOp
|
||||
{
|
||||
private readonly int _minSamples;
|
||||
|
||||
public SlowShareOp(int minSamples)
|
||||
{
|
||||
_minSamples = minSamples;
|
||||
}
|
||||
|
||||
public override string Signature => $"slow_share/{_minSamples}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rtts = new List<(int Row, double Ms)>(c.Count);
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (c.IsReplyLost(i) || !c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long rttUs = c.T4!.Values[i] - c.T1.Values[i];
|
||||
if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i))
|
||||
{
|
||||
rttUs -= c.T3.Values[i] - c.T2.Values[i];
|
||||
}
|
||||
|
||||
if (rttUs >= 0)
|
||||
{
|
||||
rtts.Add((i, rttUs / 1000.0));
|
||||
}
|
||||
}
|
||||
|
||||
if (rtts.Count < _minSamples)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double[] sorted = [.. rtts.Select(r => r.Ms).Order()];
|
||||
double baseline = sorted[sorted.Length / 2];
|
||||
double threshold = Math.Max(baseline * 1.8, baseline + Math.Max(15, baseline * 0.35));
|
||||
foreach ((int row, double ms) in rtts)
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? 1 : 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LossOp : MetricOp
|
||||
{
|
||||
private readonly Dir _dir;
|
||||
|
||||
public LossOp(Dir dir)
|
||||
{
|
||||
_dir = dir;
|
||||
}
|
||||
|
||||
public override string Signature => "loss/" + _dir + "/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics || (_dir != Dir.Total && c.T2 is null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (!c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (_dir)
|
||||
{
|
||||
case Dir.Up:
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? 1 : 0, 1);
|
||||
break;
|
||||
case Dir.Down:
|
||||
// 只统计上行已经到达 Agent 的包:回复没回来就是下行丢失。
|
||||
if (!c.IsUpLost(i))
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>去除偏移和线性漂移后的单向时延变化:上行用 t2−t1,下行用 t4−t3。</summary>
|
||||
internal sealed class OwdVarOp : MetricOp
|
||||
{
|
||||
private readonly Dir _dir;
|
||||
|
||||
public OwdVarOp(Dir dir)
|
||||
{
|
||||
_dir = dir;
|
||||
}
|
||||
|
||||
public override string Signature => "owd_var/" + _dir + "/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
RawColumn? a = _dir == Dir.Up ? c.T1 : c.T3;
|
||||
RawColumn? b = _dir == Dir.Up ? c.T2 : c.T4;
|
||||
if (c.T1 is null || a is null || b is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var times = new List<double>(c.Count);
|
||||
var delays = new List<double>(c.Count);
|
||||
var utc = new List<long>(c.Count);
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (!c.T1.IsValid(i) || !a.IsValid(i) || !b.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
times.Add(c.T1.Values[i]);
|
||||
delays.Add(b.Values[i] - a.Values[i]);
|
||||
utc.Add(c.SentUtcMs(view, i));
|
||||
}
|
||||
|
||||
JitterEmitter.Emit(times, delays, utc, sink);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JitterEmitter
|
||||
{
|
||||
public static void Emit(List<double> timesUs, List<double> delaysUs, List<long> utcMs, IObservationSink sink)
|
||||
{
|
||||
double[]? jitter = ClockDriftFit.Jitter([.. timesUs], [.. delaysUs]);
|
||||
if (jitter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < jitter.Length; i++)
|
||||
{
|
||||
sink.Emit(utcMs[i], jitter[i] / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
|
||||
internal sealed class LossRunsOp : MetricOp
|
||||
{
|
||||
public override string Signature => "loss_runs/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int run = 0;
|
||||
long runStart = 0;
|
||||
for (int i = 0; i < c.Count; i++)
|
||||
{
|
||||
if (!c.T1!.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.IsReplyLost(i))
|
||||
{
|
||||
if (run == 0)
|
||||
{
|
||||
runStart = c.SentUtcMs(view, i);
|
||||
}
|
||||
|
||||
run++;
|
||||
}
|
||||
else if (run > 0)
|
||||
{
|
||||
sink.Emit(runStart, run);
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (run > 0)
|
||||
{
|
||||
sink.Emit(runStart, run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
|
||||
internal sealed class ReorderOp : MetricOp
|
||||
{
|
||||
public override string Signature => "reorder/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics || c.Seq is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<long>();
|
||||
long maxSeq = long.MinValue;
|
||||
foreach (int row in c.ArrivalOrder())
|
||||
{
|
||||
long seq = c.Seq.Values[row];
|
||||
if (!seen.Add(seq))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seq < maxSeq)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1);
|
||||
}
|
||||
|
||||
maxSeq = Math.Max(maxSeq, seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>重复:同一个序号收到不止一次。</summary>
|
||||
internal sealed class DupOp : MetricOp
|
||||
{
|
||||
public override string Signature => "dup/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics || c.Seq is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<long>();
|
||||
foreach (int row in c.ArrivalOrder())
|
||||
{
|
||||
if (!seen.Add(c.Seq.Values[row]))
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>卡顿:到达间隔超过“期望间隔 + 阈值”。</summary>
|
||||
internal sealed class GapOp : MetricOp
|
||||
{
|
||||
private readonly GapOutput _output;
|
||||
private readonly string _rateParam;
|
||||
private readonly double _thresholdMs;
|
||||
|
||||
public GapOp(GapOutput output, string rateParam, double thresholdMs)
|
||||
{
|
||||
_output = output;
|
||||
_rateParam = rateParam;
|
||||
_thresholdMs = thresholdMs;
|
||||
}
|
||||
|
||||
public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
var c = new EchoColumns(stream);
|
||||
if (!c.HasBasics)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double rate = Math.Max(0.1, view.ParamNumber(_rateParam, 1));
|
||||
double expectedMs = 1000.0 / rate;
|
||||
int[] order = c.ArrivalOrder();
|
||||
for (int i = 1; i < order.Length; i++)
|
||||
{
|
||||
double gapMs = (c.T4!.Values[order[i]] - c.T4.Values[order[i - 1]]) / 1000.0;
|
||||
if (gapMs - expectedMs <= _thresholdMs)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long at = view.ColumnToUtcMs(c.T4, c.T4.Values[order[i]]);
|
||||
sink.Emit(at, _output == GapOutput.Count ? 1 : gapMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每一行一个观测值,用于统计样本量。</summary>
|
||||
internal sealed class RowCountOp : MetricOp
|
||||
{
|
||||
public override string Signature => "row_count/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? time = null;
|
||||
foreach (RawColumn column in stream.Columns)
|
||||
{
|
||||
if (column.Role != ColumnRole.None)
|
||||
{
|
||||
time = column;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (time is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (time.IsValid(i))
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(time, time.Values[i]), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>算子工厂:<c>Ops.Rtt</c>、<c>Ops.Loss(Dir.Up)</c> 等,供 <c>MetricDef</c> 声明使用。</summary>
|
||||
public static class Ops
|
||||
{
|
||||
public static MetricOp Rtt { get; } = new RttOp();
|
||||
|
||||
public static MetricOp SlowShare(int minSamples) => new SlowShareOp(minSamples);
|
||||
|
||||
public static MetricOp Loss(Dir dir) => new LossOp(dir);
|
||||
|
||||
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
|
||||
|
||||
public static MetricOp LossRuns { get; } = new LossRunsOp();
|
||||
|
||||
public static MetricOp Reorder { get; } = new ReorderOp();
|
||||
|
||||
public static MetricOp Dup { get; } = new DupOp();
|
||||
|
||||
public static MetricOp Gap(GapOutput output, string expectedFrom, double thresholdMs) => new GapOp(output, expectedFrom, thresholdMs);
|
||||
|
||||
public static MetricOp RowCount { get; } = new RowCountOp();
|
||||
|
||||
public static MetricOp KernelDelta(string field, string over) => new KernelDeltaOp(field, over);
|
||||
|
||||
public static MetricOp Column(string column, double scale) => new ColumnValueOp(column, scale);
|
||||
|
||||
public static MetricOp BulkRate { get; } = new BulkRateOp();
|
||||
|
||||
public static MetricOp BulkPeak { get; } = new BulkRateOp(windowMs: 1000);
|
||||
|
||||
public static MetricOp PhaseMs(string column) => new PhaseMsOp(column);
|
||||
|
||||
public static MetricOp OutcomeRatio(ConnectOutcome code) => new OutcomeOp(code, asRatio: true);
|
||||
|
||||
public static MetricOp OutcomeCount(ConnectOutcome code) => new OutcomeOp(code, asRatio: false);
|
||||
|
||||
public static MetricOp HoldEvents(HoldEvent holdEvent) => new HoldEventOp(holdEvent);
|
||||
|
||||
public static MetricOp HoldLifetime { get; } = new HoldLifetimeOp();
|
||||
|
||||
public static MetricOp TrainLoss { get; } = new TrainLossOp();
|
||||
|
||||
public static MetricOp TrainJitter { get; } = new TrainJitterOp();
|
||||
|
||||
public static MetricOp TrainRate { get; } = new TrainRateOp();
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>tcp_info 流的辅助:<c>side</c> 为 0 表示 Probe 一侧记录(Probe 时钟),否则为 Agent 一侧。</summary>
|
||||
internal static class TcpInfoTime
|
||||
{
|
||||
public static long ToUtcMs(ChunkView view, RawColumn ts, RawColumn? side, int row) =>
|
||||
side is not null && side.Values[row] == 0 ? view.ProbeToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]);
|
||||
}
|
||||
|
||||
/// <summary>两次采样之间累计计数的差值(分子)与另一个累计计数的差值(分母),按 side 分别计算。</summary>
|
||||
internal sealed class KernelDeltaOp : MetricOp
|
||||
{
|
||||
private readonly string _field;
|
||||
private readonly string _over;
|
||||
|
||||
public KernelDeltaOp(string field, string over)
|
||||
{
|
||||
_field = field;
|
||||
_over = over;
|
||||
}
|
||||
|
||||
public override string Signature => $"kernel_delta/{_field}/{_over}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? side = stream.Find("side");
|
||||
RawColumn? field = stream.Find(_field);
|
||||
RawColumn? over = stream.Find(_over);
|
||||
if (ts is null || field is null || over is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var last = new Dictionary<long, (long Field, long Over)>();
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
long key = side?.Values[i] ?? 0;
|
||||
if (last.TryGetValue(key, out (long Field, long Over) previous))
|
||||
{
|
||||
long dField = Math.Max(0, field.Values[i] - previous.Field);
|
||||
long dOver = Math.Max(0, over.Values[i] - previous.Over);
|
||||
if (dOver > 0)
|
||||
{
|
||||
sink.Emit(TcpInfoTime.ToUtcMs(view, ts, side, i), dField, dOver);
|
||||
}
|
||||
}
|
||||
|
||||
last[key] = (field.Values[i], over.Values[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>直接取某一列的值并乘以系数(例如 srtt_us × 0.001 得到毫秒)。</summary>
|
||||
internal sealed class ColumnValueOp : MetricOp
|
||||
{
|
||||
private readonly string _column;
|
||||
private readonly double _scale;
|
||||
|
||||
public ColumnValueOp(string column, double scale)
|
||||
{
|
||||
_column = column;
|
||||
_scale = scale;
|
||||
}
|
||||
|
||||
public override string Signature => $"column/{_column}/{_scale}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? side = stream.Find("side");
|
||||
RawColumn? column = stream.Find(_column);
|
||||
if (ts is null || column is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (column.IsValid(i))
|
||||
{
|
||||
sink.Emit(TcpInfoTime.ToUtcMs(view, ts, side, i), column.Values[i] * _scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量传输的吞吐:每个采样间隔内所有连接的字节增量之和,换算成 Mbit/s。
|
||||
/// <c>windowMs</c> 大于采样间隔时,输出的是滑动窗口(只在窗口填满之后)的平均吞吐:取最大值就是"持续了这么久的最好吞吐",
|
||||
/// 不受慢启动和单个间隔抖动的影响。
|
||||
/// </summary>
|
||||
internal sealed class BulkRateOp : MetricOp
|
||||
{
|
||||
private readonly int _windowMs;
|
||||
|
||||
public BulkRateOp(int windowMs = 0)
|
||||
{
|
||||
_windowMs = windowMs;
|
||||
}
|
||||
|
||||
public override string Signature => $"bulk_rate/{_windowMs}/2";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? conn = stream.Find("conn");
|
||||
RawColumn? bytes = stream.Find("bytes");
|
||||
if (ts is null || conn is null || bytes is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double sampleMs = Math.Max(1, view.ParamNumber("sample_ms", 100));
|
||||
long binUs = (long)(sampleMs * 1000);
|
||||
var last = new Dictionary<long, long>();
|
||||
var bins = new SortedDictionary<long, double>();
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
long key = conn.Values[i];
|
||||
if (last.TryGetValue(key, out long previous) && bytes.Values[i] >= previous)
|
||||
{
|
||||
long bin = ts.Values[i] / binUs;
|
||||
bins[bin] = bins.GetValueOrDefault(bin) + (bytes.Values[i] - previous);
|
||||
}
|
||||
|
||||
last[key] = bytes.Values[i];
|
||||
}
|
||||
|
||||
if (bins.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long width = Math.Max(1, (long)Math.Round(_windowMs / sampleMs));
|
||||
long first = bins.Keys.First();
|
||||
foreach ((long bin, _) in bins)
|
||||
{
|
||||
if (bin - first + 1 < width)
|
||||
{
|
||||
continue; // 窗口还没填满
|
||||
}
|
||||
|
||||
double total = 0;
|
||||
for (long b = bin - width + 1; b <= bin; b++)
|
||||
{
|
||||
total += bins.GetValueOrDefault(b);
|
||||
}
|
||||
|
||||
double mbps = total * 8 / (width * sampleMs / 1000.0) / 1_000_000;
|
||||
sink.Emit(view.ProbeToUtcMs(bin * binUs), mbps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectOutcome
|
||||
{
|
||||
Ok = 0,
|
||||
Timeout = 1,
|
||||
Reset = 2,
|
||||
TlsAlert = 3,
|
||||
Other = 4,
|
||||
}
|
||||
|
||||
/// <summary>连接结果:作为比率(分子是命中的次数)或计数。</summary>
|
||||
internal sealed class OutcomeOp : MetricOp
|
||||
{
|
||||
private readonly ConnectOutcome _code;
|
||||
private readonly bool _asRatio;
|
||||
|
||||
public OutcomeOp(ConnectOutcome code, bool asRatio)
|
||||
{
|
||||
_code = code;
|
||||
_asRatio = asRatio;
|
||||
}
|
||||
|
||||
public override string Signature => $"outcome/{_code}/{(_asRatio ? "ratio" : "count")}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? outcome = stream.Find("outcome");
|
||||
if (ts is null || outcome is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
bool hit = outcome.Values[i] == (long)_code;
|
||||
long at = view.ColumnToUtcMs(ts, ts.Values[i]);
|
||||
if (_asRatio)
|
||||
{
|
||||
sink.Emit(at, hit ? 1 : 0, 1);
|
||||
}
|
||||
else if (hit)
|
||||
{
|
||||
sink.Emit(at, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>阶段耗时(TCP 建连、TLS 握手):只统计该阶段有值的尝试。</summary>
|
||||
internal sealed class PhaseMsOp : MetricOp
|
||||
{
|
||||
private readonly string _column;
|
||||
|
||||
public PhaseMsOp(string column)
|
||||
{
|
||||
_column = column;
|
||||
}
|
||||
|
||||
public override string Signature => $"phase_ms/{_column}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? column = stream.Find(_column);
|
||||
if (ts is null || column is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (column.IsValid(i))
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), column.Values[i] / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum HoldEvent
|
||||
{
|
||||
Open = 0,
|
||||
Heartbeat = 1,
|
||||
Reset = 2,
|
||||
Close = 3,
|
||||
}
|
||||
|
||||
internal sealed class HoldEventOp : MetricOp
|
||||
{
|
||||
private readonly HoldEvent _event;
|
||||
|
||||
public HoldEventOp(HoldEvent holdEvent)
|
||||
{
|
||||
_event = holdEvent;
|
||||
}
|
||||
|
||||
public override string Signature => $"hold_event/{_event}/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? ev = stream.Find("event");
|
||||
if (ts is null || ev is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (ev.Values[i] == (long)_event)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>连接存活时长:在重置或正常关闭时记录。</summary>
|
||||
internal sealed class HoldLifetimeOp : MetricOp
|
||||
{
|
||||
public override string Signature => "hold_lifetime/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? ts = stream.Find("ts");
|
||||
RawColumn? ev = stream.Find("event");
|
||||
RawColumn? lifetime = stream.Find("lifetime_ms");
|
||||
if (ts is null || ev is null || lifetime is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (ev.Values[i] is (long)HoldEvent.Reset or (long)HoldEvent.Close)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), lifetime.Values[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>单向 UDP 流(udp_train)的丢包:接收端没有记录到 t_recv 即为丢失。</summary>
|
||||
internal sealed class TrainLossOp : MetricOp
|
||||
{
|
||||
public override string Signature => "train_loss/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? send = stream.Find("t_send");
|
||||
RawColumn? recv = stream.Find("t_recv");
|
||||
if (send is null || recv is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
sink.Emit(view.ColumnToUtcMs(send, send.Values[i]), recv.IsValid(i) ? 0 : 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>单向时延变化:t_recv(Probe) − t_send(Agent),去除偏移和漂移。</summary>
|
||||
internal sealed class TrainJitterOp : MetricOp
|
||||
{
|
||||
public override string Signature => "train_jitter/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? send = stream.Find("t_send");
|
||||
RawColumn? recv = stream.Find("t_recv");
|
||||
if (send is null || recv is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var times = new List<double>(stream.Count);
|
||||
var delays = new List<double>(stream.Count);
|
||||
var utc = new List<long>(stream.Count);
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (!recv.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
times.Add(send.Values[i]);
|
||||
delays.Add(recv.Values[i] - send.Values[i]);
|
||||
utc.Add(view.ColumnToUtcMs(send, send.Values[i]));
|
||||
}
|
||||
|
||||
JitterEmitter.Emit(times, delays, utc, sink);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每秒实际收到的速率(Mbit/s)。</summary>
|
||||
internal sealed class TrainRateOp : MetricOp
|
||||
{
|
||||
public override string Signature => "train_rate/1";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
RawColumn? recv = stream.Find("t_recv");
|
||||
RawColumn? size = stream.Find("size");
|
||||
if (recv is null || size is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bins = new SortedDictionary<long, long>();
|
||||
for (int i = 0; i < stream.Count; i++)
|
||||
{
|
||||
if (!recv.IsValid(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long second = view.ColumnToUtcMs(recv, recv.Values[i]) / 1000;
|
||||
bins[second] = bins.GetValueOrDefault(second) + size.Values[i];
|
||||
}
|
||||
|
||||
foreach ((long second, long bytes) in bins)
|
||||
{
|
||||
sink.Emit(second * 1000, bytes * 8 / 1_000_000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user