添加协议层数据统计结构,日志文件保存在 Logs 下
This commit is contained in:
parent
ca26ab8e38
commit
e361510100
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using kcp;
|
using kcp;
|
||||||
|
|
@ -61,6 +61,7 @@ namespace Network.NetworkTransport
|
||||||
var result = KCP.ikcp_send(_kcp, buffer, payload.Length);
|
var result = KCP.ikcp_send(_kcp, buffer, payload.Length);
|
||||||
if (result < 0)
|
if (result < 0)
|
||||||
{
|
{
|
||||||
|
_owner.RecordTransportError("kcp-send", RemoteEndPoint, $"KCP send failed with error code {result}.");
|
||||||
throw new InvalidOperationException($"KCP send failed with error code {result}.");
|
throw new InvalidOperationException($"KCP send failed with error code {result}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +92,7 @@ namespace Network.NetworkTransport
|
||||||
var result = KCP.ikcp_input(_kcp, buffer, datagram.Length);
|
var result = KCP.ikcp_input(_kcp, buffer, datagram.Length);
|
||||||
if (result < 0)
|
if (result < 0)
|
||||||
{
|
{
|
||||||
|
_owner.RecordTransportError("kcp-input", RemoteEndPoint, $"KCP input failed with error code {result}.");
|
||||||
Console.WriteLine($"[KcpTransport] KCP input failed for {RemoteEndPoint}: {result}");
|
Console.WriteLine($"[KcpTransport] KCP input failed for {RemoteEndPoint}: {result}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +127,7 @@ namespace Network.NetworkTransport
|
||||||
var result = KCP.ikcp_recv(_kcp, buffer, payload.Length);
|
var result = KCP.ikcp_recv(_kcp, buffer, payload.Length);
|
||||||
if (result < 0)
|
if (result < 0)
|
||||||
{
|
{
|
||||||
|
_owner.RecordTransportError("kcp-recv", RemoteEndPoint, $"KCP recv failed with error code {result}.");
|
||||||
payload = null;
|
payload = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
@ -25,6 +26,7 @@ namespace Network.NetworkTransport
|
||||||
private readonly bool _isServer;
|
private readonly bool _isServer;
|
||||||
private readonly IPEndPoint _defaultRemoteEndPoint;
|
private readonly IPEndPoint _defaultRemoteEndPoint;
|
||||||
private readonly uint _defaultConv;
|
private readonly uint _defaultConv;
|
||||||
|
private readonly ITransportMetricsModule _metricsModule;
|
||||||
private readonly ConcurrentDictionary<string, KcpSession> _sessions = new();
|
private readonly ConcurrentDictionary<string, KcpSession> _sessions = new();
|
||||||
private readonly object _socketSendLock = new();
|
private readonly object _socketSendLock = new();
|
||||||
|
|
||||||
|
|
@ -37,16 +39,17 @@ namespace Network.NetworkTransport
|
||||||
|
|
||||||
internal int ActiveSessionCount => _sessions.Count;
|
internal int ActiveSessionCount => _sessions.Count;
|
||||||
|
|
||||||
public KcpTransport(int listenPort, uint conv = DefaultConv)
|
public KcpTransport(int listenPort, uint conv = DefaultConv, ITransportMetricsModule metricsModule = null)
|
||||||
{
|
{
|
||||||
_client = new UdpClient(listenPort);
|
_client = new UdpClient(listenPort);
|
||||||
_isServer = true;
|
_isServer = true;
|
||||||
_defaultConv = conv;
|
_defaultConv = conv;
|
||||||
|
_metricsModule = metricsModule ?? new DefaultTransportMetricsModule();
|
||||||
|
|
||||||
Console.WriteLine($"[KcpTransport] 服务端模式,监听端口: {listenPort}");
|
Console.WriteLine($"[KcpTransport] 服务端模式,监听端口: {listenPort}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public KcpTransport(string serverIp, int serverPort, uint conv = DefaultConv)
|
public KcpTransport(string serverIp, int serverPort, uint conv = DefaultConv, ITransportMetricsModule metricsModule = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(serverIp))
|
if (string.IsNullOrWhiteSpace(serverIp))
|
||||||
{
|
{
|
||||||
|
|
@ -56,10 +59,16 @@ namespace Network.NetworkTransport
|
||||||
_client = new UdpClient(0);
|
_client = new UdpClient(0);
|
||||||
_defaultRemoteEndPoint = new IPEndPoint(IPAddress.Parse(serverIp), serverPort);
|
_defaultRemoteEndPoint = new IPEndPoint(IPAddress.Parse(serverIp), serverPort);
|
||||||
_defaultConv = conv;
|
_defaultConv = conv;
|
||||||
|
_metricsModule = metricsModule ?? new DefaultTransportMetricsModule();
|
||||||
|
|
||||||
Console.WriteLine($"[KcpTransport] 客户端模式,目标: {_defaultRemoteEndPoint}, conv={conv}");
|
Console.WriteLine($"[KcpTransport] 客户端模式,目标: {_defaultRemoteEndPoint}, conv={conv}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TransportMetricsSnapshot GetMetricsSnapshot()
|
||||||
|
{
|
||||||
|
return _metricsModule.GetCurrentSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
public Task StartAsync()
|
public Task StartAsync()
|
||||||
{
|
{
|
||||||
if (_isRunning)
|
if (_isRunning)
|
||||||
|
|
@ -68,6 +77,7 @@ namespace Network.NetworkTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
_sessions.Clear();
|
_sessions.Clear();
|
||||||
|
_metricsModule.BeginRun(new TransportRunDescriptor(nameof(KcpTransport), _isServer, _defaultRemoteEndPoint));
|
||||||
_cancellationTokenSource = new CancellationTokenSource();
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
|
|
||||||
|
|
@ -97,6 +107,7 @@ namespace Network.NetworkTransport
|
||||||
DisposeAllSessions();
|
DisposeAllSessions();
|
||||||
_cancellationTokenSource?.Dispose();
|
_cancellationTokenSource?.Dispose();
|
||||||
_cancellationTokenSource = null;
|
_cancellationTokenSource = null;
|
||||||
|
_metricsModule.CompleteRun();
|
||||||
|
|
||||||
Console.WriteLine("[KcpTransport] 传输层停止");
|
Console.WriteLine("[KcpTransport] 传输层停止");
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +144,7 @@ namespace Network.NetworkTransport
|
||||||
|
|
||||||
var session = GetOrCreateSession(target, _defaultConv);
|
var session = GetOrCreateSession(target, _defaultConv);
|
||||||
session.Send(data);
|
session.Send(data);
|
||||||
|
_metricsModule.RecordPayloadSent(session.RemoteEndPoint, data.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SendToAll(byte[] data)
|
public void SendToAll(byte[] data)
|
||||||
|
|
@ -152,6 +164,7 @@ namespace Network.NetworkTransport
|
||||||
foreach (var session in _sessions.Values)
|
foreach (var session in _sessions.Values)
|
||||||
{
|
{
|
||||||
session.Send(data);
|
session.Send(data);
|
||||||
|
_metricsModule.RecordPayloadSent(session.RemoteEndPoint, data.Length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +175,7 @@ namespace Network.NetworkTransport
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _client.ReceiveAsync();
|
var result = await _client.ReceiveAsync();
|
||||||
|
_metricsModule.RecordDatagramReceived(result.RemoteEndPoint, result.Buffer.Length);
|
||||||
var session = GetOrCreateSession(result.RemoteEndPoint, ResolveConv(result.Buffer));
|
var session = GetOrCreateSession(result.RemoteEndPoint, ResolveConv(result.Buffer));
|
||||||
session.Input(result.Buffer);
|
session.Input(result.Buffer);
|
||||||
DrainReceivedMessages(session);
|
DrainReceivedMessages(session);
|
||||||
|
|
@ -176,6 +190,7 @@ namespace Network.NetworkTransport
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_metricsModule.RecordError("socket-receive", null, exception.Message);
|
||||||
Console.WriteLine($"[KcpTransport] 接收错误:{exception.Message}");
|
Console.WriteLine($"[KcpTransport] 接收错误:{exception.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +223,7 @@ namespace Network.NetworkTransport
|
||||||
{
|
{
|
||||||
while (session.TryReceive(out var payload))
|
while (session.TryReceive(out var payload))
|
||||||
{
|
{
|
||||||
|
_metricsModule.RecordPayloadReceived(session.RemoteEndPoint, payload.Length);
|
||||||
OnReceive?.Invoke(payload, session.RemoteEndPoint);
|
OnReceive?.Invoke(payload, session.RemoteEndPoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,7 +233,20 @@ namespace Network.NetworkTransport
|
||||||
var normalizedEndPoint = NormalizeEndPoint(remoteEndPoint);
|
var normalizedEndPoint = NormalizeEndPoint(remoteEndPoint);
|
||||||
var key = normalizedEndPoint.ToString();
|
var key = normalizedEndPoint.ToString();
|
||||||
|
|
||||||
return _sessions.GetOrAdd(key, _ => new KcpSession(this, normalizedEndPoint, conv));
|
if (_sessions.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new KcpSession(this, normalizedEndPoint, conv);
|
||||||
|
if (_sessions.TryAdd(key, created))
|
||||||
|
{
|
||||||
|
_metricsModule.RecordSessionOpened(created.RemoteEndPoint);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
created.Dispose();
|
||||||
|
return _sessions[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
private IPEndPoint NormalizeEndPoint(IPEndPoint remoteEndPoint)
|
private IPEndPoint NormalizeEndPoint(IPEndPoint remoteEndPoint)
|
||||||
|
|
@ -276,6 +305,7 @@ namespace Network.NetworkTransport
|
||||||
{
|
{
|
||||||
foreach (var innerException in exception.InnerExceptions)
|
foreach (var innerException in exception.InnerExceptions)
|
||||||
{
|
{
|
||||||
|
_metricsModule.RecordError("stop-wait", null, innerException.Message);
|
||||||
Console.WriteLine($"[KcpTransport] 停止等待错误:{innerException.Message}");
|
Console.WriteLine($"[KcpTransport] 停止等待错误:{innerException.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -283,12 +313,14 @@ namespace Network.NetworkTransport
|
||||||
|
|
||||||
private void DisposeAllSessions()
|
private void DisposeAllSessions()
|
||||||
{
|
{
|
||||||
foreach (var pair in _sessions)
|
var sessions = _sessions.Values.ToArray();
|
||||||
{
|
|
||||||
pair.Value.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
_sessions.Clear();
|
_sessions.Clear();
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
session.Dispose();
|
||||||
|
_metricsModule.RecordSessionClosed(session.RemoteEndPoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsafe int SendDatagram(byte* buffer, int length, IPEndPoint remoteEndPoint)
|
private unsafe int SendDatagram(byte* buffer, int length, IPEndPoint remoteEndPoint)
|
||||||
|
|
@ -305,7 +337,9 @@ namespace Network.NetworkTransport
|
||||||
{
|
{
|
||||||
lock (_socketSendLock)
|
lock (_socketSendLock)
|
||||||
{
|
{
|
||||||
return _client.Send(datagram, datagram.Length, remoteEndPoint);
|
var bytes = _client.Send(datagram, datagram.Length, remoteEndPoint);
|
||||||
|
_metricsModule.RecordDatagramSent(remoteEndPoint, bytes);
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException) when (!_isRunning)
|
catch (ObjectDisposedException) when (!_isRunning)
|
||||||
|
|
@ -314,11 +348,15 @@ namespace Network.NetworkTransport
|
||||||
}
|
}
|
||||||
catch (SocketException exception)
|
catch (SocketException exception)
|
||||||
{
|
{
|
||||||
|
_metricsModule.RecordError("socket-send", remoteEndPoint, exception.Message);
|
||||||
Console.WriteLine($"[KcpTransport] 发送错误:{exception.Message}");
|
Console.WriteLine($"[KcpTransport] 发送错误:{exception.Message}");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RecordTransportError(string stage, IPEndPoint remoteEndPoint, string detail)
|
||||||
|
{
|
||||||
|
_metricsModule.RecordError(stage, remoteEndPoint, detail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,512 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using System.Runtime.Serialization.Json;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Network.NetworkTransport
|
||||||
|
{
|
||||||
|
public interface ITransportMetricsModule
|
||||||
|
{
|
||||||
|
void BeginRun(TransportRunDescriptor descriptor);
|
||||||
|
void RecordSessionOpened(IPEndPoint remoteEndPoint);
|
||||||
|
void RecordSessionClosed(IPEndPoint remoteEndPoint);
|
||||||
|
void RecordPayloadSent(IPEndPoint remoteEndPoint, int bytes);
|
||||||
|
void RecordPayloadReceived(IPEndPoint remoteEndPoint, int bytes);
|
||||||
|
void RecordDatagramSent(IPEndPoint remoteEndPoint, int bytes);
|
||||||
|
void RecordDatagramReceived(IPEndPoint remoteEndPoint, int bytes);
|
||||||
|
void RecordError(string stage, IPEndPoint remoteEndPoint, string detail = null);
|
||||||
|
TransportMetricsSnapshot GetCurrentSnapshot();
|
||||||
|
TransportMetricsSnapshot CompleteRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TransportRunDescriptor
|
||||||
|
{
|
||||||
|
public TransportRunDescriptor(string transportName, bool isServer, IPEndPoint defaultRemoteEndPoint = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(transportName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Transport name is required.", nameof(transportName));
|
||||||
|
}
|
||||||
|
|
||||||
|
TransportName = transportName;
|
||||||
|
IsServer = isServer;
|
||||||
|
DefaultRemoteEndPoint = defaultRemoteEndPoint == null
|
||||||
|
? null
|
||||||
|
: new IPEndPoint(defaultRemoteEndPoint.Address, defaultRemoteEndPoint.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TransportName { get; }
|
||||||
|
public bool IsServer { get; }
|
||||||
|
public IPEndPoint DefaultRemoteEndPoint { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataContract]
|
||||||
|
public sealed class TransportMetricsSnapshot
|
||||||
|
{
|
||||||
|
[DataMember(Order = 1)] public string RunId { get; set; }
|
||||||
|
[DataMember(Order = 2)] public string TransportName { get; set; }
|
||||||
|
[DataMember(Order = 3)] public string Mode { get; set; }
|
||||||
|
[DataMember(Order = 4)] public string DefaultRemoteEndPoint { get; set; }
|
||||||
|
[DataMember(Order = 5)] public DateTimeOffset? StartedAtUtc { get; set; }
|
||||||
|
[DataMember(Order = 6)] public DateTimeOffset? CompletedAtUtc { get; set; }
|
||||||
|
[DataMember(Order = 7)] public long DurationMs { get; set; }
|
||||||
|
[DataMember(Order = 8)] public string ReportPath { get; set; }
|
||||||
|
[DataMember(Order = 9)] public int ActiveSessions { get; set; }
|
||||||
|
[DataMember(Order = 10)] public int PeakActiveSessions { get; set; }
|
||||||
|
[DataMember(Order = 11)] public long SessionsCreated { get; set; }
|
||||||
|
[DataMember(Order = 12)] public long SessionsClosed { get; set; }
|
||||||
|
[DataMember(Order = 13)] public long PayloadMessagesSent { get; set; }
|
||||||
|
[DataMember(Order = 14)] public long PayloadBytesSent { get; set; }
|
||||||
|
[DataMember(Order = 15)] public long PayloadMessagesReceived { get; set; }
|
||||||
|
[DataMember(Order = 16)] public long PayloadBytesReceived { get; set; }
|
||||||
|
[DataMember(Order = 17)] public long DatagramsSent { get; set; }
|
||||||
|
[DataMember(Order = 18)] public long DatagramBytesSent { get; set; }
|
||||||
|
[DataMember(Order = 19)] public long DatagramsReceived { get; set; }
|
||||||
|
[DataMember(Order = 20)] public long DatagramBytesReceived { get; set; }
|
||||||
|
[DataMember(Order = 21)] public long SendErrors { get; set; }
|
||||||
|
[DataMember(Order = 22)] public long ReceiveErrors { get; set; }
|
||||||
|
[DataMember(Order = 23)] public long OtherErrors { get; set; }
|
||||||
|
[DataMember(Order = 24)] public Dictionary<string, long> ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
[DataMember(Order = 25)] public List<TransportPeerMetricsSnapshot> PeerSummaries { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataContract]
|
||||||
|
public sealed class TransportPeerMetricsSnapshot
|
||||||
|
{
|
||||||
|
[DataMember(Order = 1)] public string RemoteEndPoint { get; set; }
|
||||||
|
[DataMember(Order = 2)] public DateTimeOffset? FirstSeenUtc { get; set; }
|
||||||
|
[DataMember(Order = 3)] public DateTimeOffset? LastActivityUtc { get; set; }
|
||||||
|
[DataMember(Order = 4)] public long SessionOpens { get; set; }
|
||||||
|
[DataMember(Order = 5)] public long SessionCloses { get; set; }
|
||||||
|
[DataMember(Order = 6)] public long PayloadMessagesSent { get; set; }
|
||||||
|
[DataMember(Order = 7)] public long PayloadBytesSent { get; set; }
|
||||||
|
[DataMember(Order = 8)] public long PayloadMessagesReceived { get; set; }
|
||||||
|
[DataMember(Order = 9)] public long PayloadBytesReceived { get; set; }
|
||||||
|
[DataMember(Order = 10)] public long DatagramsSent { get; set; }
|
||||||
|
[DataMember(Order = 11)] public long DatagramBytesSent { get; set; }
|
||||||
|
[DataMember(Order = 12)] public long DatagramsReceived { get; set; }
|
||||||
|
[DataMember(Order = 13)] public long DatagramBytesReceived { get; set; }
|
||||||
|
[DataMember(Order = 14)] public Dictionary<string, long> ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DefaultTransportMetricsModule : ITransportMetricsModule
|
||||||
|
{
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly Func<DateTimeOffset> utcNowProvider;
|
||||||
|
private readonly TextWriter consoleWriter;
|
||||||
|
private readonly string reportDirectory;
|
||||||
|
private readonly Dictionary<string, PeerAccumulator> peers = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, long> errorCountsByStage = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private string runId;
|
||||||
|
private string transportName;
|
||||||
|
private string mode;
|
||||||
|
private string defaultRemoteEndPoint;
|
||||||
|
private DateTimeOffset? startedAtUtc;
|
||||||
|
private DateTimeOffset? completedAtUtc;
|
||||||
|
private string reportPath;
|
||||||
|
private bool hasRun;
|
||||||
|
private bool completed;
|
||||||
|
private long sessionsCreated;
|
||||||
|
private long sessionsClosed;
|
||||||
|
private int activeSessions;
|
||||||
|
private int peakActiveSessions;
|
||||||
|
private long payloadMessagesSent;
|
||||||
|
private long payloadBytesSent;
|
||||||
|
private long payloadMessagesReceived;
|
||||||
|
private long payloadBytesReceived;
|
||||||
|
private long datagramsSent;
|
||||||
|
private long datagramBytesSent;
|
||||||
|
private long datagramsReceived;
|
||||||
|
private long datagramBytesReceived;
|
||||||
|
private long sendErrors;
|
||||||
|
private long receiveErrors;
|
||||||
|
private long otherErrors;
|
||||||
|
private TransportMetricsSnapshot completedSnapshot;
|
||||||
|
|
||||||
|
public DefaultTransportMetricsModule(string reportDirectory = null, Func<DateTimeOffset> utcNowProvider = null, TextWriter consoleWriter = null)
|
||||||
|
{
|
||||||
|
this.reportDirectory = string.IsNullOrWhiteSpace(reportDirectory)
|
||||||
|
? Path.Combine(Directory.GetCurrentDirectory(), "Logs", "transport-metrics")
|
||||||
|
: reportDirectory;
|
||||||
|
this.utcNowProvider = utcNowProvider ?? (() => DateTimeOffset.UtcNow);
|
||||||
|
this.consoleWriter = consoleWriter ?? Console.Out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginRun(TransportRunDescriptor descriptor)
|
||||||
|
{
|
||||||
|
if (descriptor == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(descriptor));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
peers.Clear();
|
||||||
|
errorCountsByStage.Clear();
|
||||||
|
runId = Guid.NewGuid().ToString("N");
|
||||||
|
transportName = descriptor.TransportName;
|
||||||
|
mode = descriptor.IsServer ? "server" : "client";
|
||||||
|
defaultRemoteEndPoint = FormatEndPoint(descriptor.DefaultRemoteEndPoint);
|
||||||
|
startedAtUtc = utcNowProvider();
|
||||||
|
completedAtUtc = null;
|
||||||
|
reportPath = null;
|
||||||
|
hasRun = true;
|
||||||
|
completed = false;
|
||||||
|
sessionsCreated = 0;
|
||||||
|
sessionsClosed = 0;
|
||||||
|
activeSessions = 0;
|
||||||
|
peakActiveSessions = 0;
|
||||||
|
payloadMessagesSent = 0;
|
||||||
|
payloadBytesSent = 0;
|
||||||
|
payloadMessagesReceived = 0;
|
||||||
|
payloadBytesReceived = 0;
|
||||||
|
datagramsSent = 0;
|
||||||
|
datagramBytesSent = 0;
|
||||||
|
datagramsReceived = 0;
|
||||||
|
datagramBytesReceived = 0;
|
||||||
|
sendErrors = 0;
|
||||||
|
receiveErrors = 0;
|
||||||
|
otherErrors = 0;
|
||||||
|
completedSnapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordSessionOpened(IPEndPoint remoteEndPoint) => Update(remoteEndPoint, peer =>
|
||||||
|
{
|
||||||
|
sessionsCreated++;
|
||||||
|
activeSessions++;
|
||||||
|
peakActiveSessions = Math.Max(peakActiveSessions, activeSessions);
|
||||||
|
peer.SessionOpens++;
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordSessionClosed(IPEndPoint remoteEndPoint) => Update(remoteEndPoint, peer =>
|
||||||
|
{
|
||||||
|
sessionsClosed++;
|
||||||
|
activeSessions = Math.Max(0, activeSessions - 1);
|
||||||
|
peer.SessionCloses++;
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordPayloadSent(IPEndPoint remoteEndPoint, int bytes) => Update(remoteEndPoint, peer =>
|
||||||
|
{
|
||||||
|
payloadMessagesSent++;
|
||||||
|
payloadBytesSent += bytes;
|
||||||
|
peer.PayloadMessagesSent++;
|
||||||
|
peer.PayloadBytesSent += bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordPayloadReceived(IPEndPoint remoteEndPoint, int bytes) => Update(remoteEndPoint, peer =>
|
||||||
|
{
|
||||||
|
payloadMessagesReceived++;
|
||||||
|
payloadBytesReceived += bytes;
|
||||||
|
peer.PayloadMessagesReceived++;
|
||||||
|
peer.PayloadBytesReceived += bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordDatagramSent(IPEndPoint remoteEndPoint, int bytes) => Update(remoteEndPoint, peer =>
|
||||||
|
{
|
||||||
|
datagramsSent++;
|
||||||
|
datagramBytesSent += bytes;
|
||||||
|
peer.DatagramsSent++;
|
||||||
|
peer.DatagramBytesSent += bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordDatagramReceived(IPEndPoint remoteEndPoint, int bytes) => Update(remoteEndPoint, peer =>
|
||||||
|
{
|
||||||
|
datagramsReceived++;
|
||||||
|
datagramBytesReceived += bytes;
|
||||||
|
peer.DatagramsReceived++;
|
||||||
|
peer.DatagramBytesReceived += bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordError(string stage, IPEndPoint remoteEndPoint, string detail = null)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!hasRun)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Increment(errorCountsByStage, stage);
|
||||||
|
if (remoteEndPoint != null)
|
||||||
|
{
|
||||||
|
var peer = GetOrCreatePeer(remoteEndPoint);
|
||||||
|
Increment(peer.ErrorCountsByStage, stage);
|
||||||
|
peer.LastActivityUtc = utcNowProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(stage) && stage.IndexOf("send", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
sendErrors++;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(stage) && (stage.IndexOf("receive", StringComparison.OrdinalIgnoreCase) >= 0 || stage.IndexOf("recv", StringComparison.OrdinalIgnoreCase) >= 0 || stage.IndexOf("input", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||||
|
{
|
||||||
|
receiveErrors++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
otherErrors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(detail))
|
||||||
|
{
|
||||||
|
consoleWriter.WriteLine($"[TransportMetrics] {stage}: {detail}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransportMetricsSnapshot GetCurrentSnapshot()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return completed && completedSnapshot != null ? completedSnapshot : BuildSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransportMetricsSnapshot CompleteRun()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!hasRun)
|
||||||
|
{
|
||||||
|
return completedSnapshot ?? new TransportMetricsSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed && completedSnapshot != null)
|
||||||
|
{
|
||||||
|
return completedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
completedAtUtc = utcNowProvider();
|
||||||
|
completed = true;
|
||||||
|
completedSnapshot = BuildSnapshot();
|
||||||
|
completedSnapshot.ReportPath = WriteJsonReport(completedSnapshot);
|
||||||
|
reportPath = completedSnapshot.ReportPath;
|
||||||
|
consoleWriter.WriteLine($"[TransportMetrics] {completedSnapshot.TransportName} mode={completedSnapshot.Mode} run={completedSnapshot.RunId} durationMs={completedSnapshot.DurationMs} peak={completedSnapshot.PeakActiveSessions} payloadTx={completedSnapshot.PayloadMessagesSent}/{completedSnapshot.PayloadBytesSent}B payloadRx={completedSnapshot.PayloadMessagesReceived}/{completedSnapshot.PayloadBytesReceived}B datagramTx={completedSnapshot.DatagramsSent}/{completedSnapshot.DatagramBytesSent}B datagramRx={completedSnapshot.DatagramsReceived}/{completedSnapshot.DatagramBytesReceived}B errors={completedSnapshot.SendErrors + completedSnapshot.ReceiveErrors + completedSnapshot.OtherErrors} report={completedSnapshot.ReportPath ?? "none"}");
|
||||||
|
return completedSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update(IPEndPoint remoteEndPoint, Action<PeerAccumulator> update)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!hasRun)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var peer = GetOrCreatePeer(remoteEndPoint);
|
||||||
|
update(peer);
|
||||||
|
peer.LastActivityUtc = utcNowProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TransportMetricsSnapshot BuildSnapshot()
|
||||||
|
{
|
||||||
|
var end = completedAtUtc ?? utcNowProvider();
|
||||||
|
return new TransportMetricsSnapshot
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
TransportName = transportName,
|
||||||
|
Mode = mode,
|
||||||
|
DefaultRemoteEndPoint = defaultRemoteEndPoint,
|
||||||
|
StartedAtUtc = startedAtUtc,
|
||||||
|
CompletedAtUtc = completedAtUtc,
|
||||||
|
DurationMs = startedAtUtc.HasValue ? Math.Max(0L, (long)(end - startedAtUtc.Value).TotalMilliseconds) : 0L,
|
||||||
|
ReportPath = reportPath,
|
||||||
|
ActiveSessions = activeSessions,
|
||||||
|
PeakActiveSessions = peakActiveSessions,
|
||||||
|
SessionsCreated = sessionsCreated,
|
||||||
|
SessionsClosed = sessionsClosed,
|
||||||
|
PayloadMessagesSent = payloadMessagesSent,
|
||||||
|
PayloadBytesSent = payloadBytesSent,
|
||||||
|
PayloadMessagesReceived = payloadMessagesReceived,
|
||||||
|
PayloadBytesReceived = payloadBytesReceived,
|
||||||
|
DatagramsSent = datagramsSent,
|
||||||
|
DatagramBytesSent = datagramBytesSent,
|
||||||
|
DatagramsReceived = datagramsReceived,
|
||||||
|
DatagramBytesReceived = datagramBytesReceived,
|
||||||
|
SendErrors = sendErrors,
|
||||||
|
ReceiveErrors = receiveErrors,
|
||||||
|
OtherErrors = otherErrors,
|
||||||
|
ErrorCountsByStage = errorCountsByStage.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal),
|
||||||
|
PeerSummaries = peers.Values.Select(peer => peer.ToSnapshot()).OrderBy(peer => peer.RemoteEndPoint, StringComparer.Ordinal).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string WriteJsonReport(TransportMetricsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(reportDirectory);
|
||||||
|
var timestamp = (snapshot.CompletedAtUtc ?? utcNowProvider()).ToString("yyyyMMdd-HHmmssfff");
|
||||||
|
var filePath = Path.Combine(reportDirectory, $"{snapshot.Mode}-{snapshot.TransportName}-{timestamp}-{snapshot.RunId}.json");
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
var serializer = new DataContractJsonSerializer(typeof(TransportMetricsSnapshot));
|
||||||
|
serializer.WriteObject(stream, snapshot);
|
||||||
|
var json = Encoding.UTF8.GetString(stream.ToArray());
|
||||||
|
File.WriteAllText(filePath, FormatJson(json), new UTF8Encoding(false));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
consoleWriter.WriteLine($"[TransportMetrics] Failed to write report: {exception.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PeerAccumulator GetOrCreatePeer(IPEndPoint remoteEndPoint)
|
||||||
|
{
|
||||||
|
var key = FormatEndPoint(remoteEndPoint) ?? "unknown";
|
||||||
|
if (!peers.TryGetValue(key, out var peer))
|
||||||
|
{
|
||||||
|
peer = new PeerAccumulator(key, utcNowProvider());
|
||||||
|
peers.Add(key, peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatEndPoint(IPEndPoint remoteEndPoint)
|
||||||
|
{
|
||||||
|
return remoteEndPoint == null ? null : new IPEndPoint(remoteEndPoint.Address, remoteEndPoint.Port).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Increment(IDictionary<string, long> counts, string stage)
|
||||||
|
{
|
||||||
|
var key = string.IsNullOrWhiteSpace(stage) ? "unknown" : stage;
|
||||||
|
counts.TryGetValue(key, out var current);
|
||||||
|
counts[key] = current + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatJson(string json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder(json.Length + 128);
|
||||||
|
var indentation = 0;
|
||||||
|
var inString = false;
|
||||||
|
var isEscaped = false;
|
||||||
|
|
||||||
|
foreach (var character in json)
|
||||||
|
{
|
||||||
|
if (isEscaped)
|
||||||
|
{
|
||||||
|
builder.Append(character);
|
||||||
|
isEscaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == '\\' && inString)
|
||||||
|
{
|
||||||
|
builder.Append(character);
|
||||||
|
isEscaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == '"')
|
||||||
|
{
|
||||||
|
inString = !inString;
|
||||||
|
builder.Append(character);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inString)
|
||||||
|
{
|
||||||
|
builder.Append(character);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (character)
|
||||||
|
{
|
||||||
|
case '{':
|
||||||
|
case '[':
|
||||||
|
builder.Append(character);
|
||||||
|
builder.AppendLine();
|
||||||
|
indentation++;
|
||||||
|
builder.Append(new string(' ', indentation * 2));
|
||||||
|
break;
|
||||||
|
case '}':
|
||||||
|
case ']':
|
||||||
|
builder.AppendLine();
|
||||||
|
indentation = Math.Max(0, indentation - 1);
|
||||||
|
builder.Append(new string(' ', indentation * 2));
|
||||||
|
builder.Append(character);
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
builder.Append(character);
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.Append(new string(' ', indentation * 2));
|
||||||
|
break;
|
||||||
|
case ':':
|
||||||
|
builder.Append(": ");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (!char.IsWhiteSpace(character))
|
||||||
|
{
|
||||||
|
builder.Append(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PeerAccumulator
|
||||||
|
{
|
||||||
|
public PeerAccumulator(string remoteEndPoint, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
RemoteEndPoint = remoteEndPoint;
|
||||||
|
FirstSeenUtc = now;
|
||||||
|
LastActivityUtc = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RemoteEndPoint { get; }
|
||||||
|
public DateTimeOffset FirstSeenUtc { get; }
|
||||||
|
public DateTimeOffset LastActivityUtc { get; set; }
|
||||||
|
public long SessionOpens { get; set; }
|
||||||
|
public long SessionCloses { get; set; }
|
||||||
|
public long PayloadMessagesSent { get; set; }
|
||||||
|
public long PayloadBytesSent { get; set; }
|
||||||
|
public long PayloadMessagesReceived { get; set; }
|
||||||
|
public long PayloadBytesReceived { get; set; }
|
||||||
|
public long DatagramsSent { get; set; }
|
||||||
|
public long DatagramBytesSent { get; set; }
|
||||||
|
public long DatagramsReceived { get; set; }
|
||||||
|
public long DatagramBytesReceived { get; set; }
|
||||||
|
public Dictionary<string, long> ErrorCountsByStage { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public TransportPeerMetricsSnapshot ToSnapshot()
|
||||||
|
{
|
||||||
|
return new TransportPeerMetricsSnapshot
|
||||||
|
{
|
||||||
|
RemoteEndPoint = RemoteEndPoint,
|
||||||
|
FirstSeenUtc = FirstSeenUtc,
|
||||||
|
LastActivityUtc = LastActivityUtc,
|
||||||
|
SessionOpens = SessionOpens,
|
||||||
|
SessionCloses = SessionCloses,
|
||||||
|
PayloadMessagesSent = PayloadMessagesSent,
|
||||||
|
PayloadBytesSent = PayloadBytesSent,
|
||||||
|
PayloadMessagesReceived = PayloadMessagesReceived,
|
||||||
|
PayloadBytesReceived = PayloadBytesReceived,
|
||||||
|
DatagramsSent = DatagramsSent,
|
||||||
|
DatagramBytesSent = DatagramBytesSent,
|
||||||
|
DatagramsReceived = DatagramsReceived,
|
||||||
|
DatagramBytesReceived = DatagramBytesReceived,
|
||||||
|
ErrorCountsByStage = ErrorCountsByStage.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d3ae5cc796484e9698f058e34de23a3f
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -2,6 +2,7 @@ using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
|
@ -180,6 +181,114 @@ namespace Tests.EditMode.Network
|
||||||
Assert.DoesNotThrow(() => transport.Stop());
|
Assert.DoesNotThrow(() => transport.Stop());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void DefaultTransportMetricsModule_CompleteRun_IsIdempotentAndWritesSingleReport()
|
||||||
|
{
|
||||||
|
var reportDirectory = CreateReportDirectory();
|
||||||
|
var consoleWriter = new StringWriter();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var module = new DefaultTransportMetricsModule(reportDirectory, consoleWriter: consoleWriter);
|
||||||
|
var remote = new IPEndPoint(IPAddress.Loopback, 5001);
|
||||||
|
|
||||||
|
module.BeginRun(new TransportRunDescriptor(nameof(KcpTransport), isServer: false, defaultRemoteEndPoint: remote));
|
||||||
|
module.RecordSessionOpened(remote);
|
||||||
|
module.RecordPayloadSent(remote, 64);
|
||||||
|
module.RecordDatagramSent(remote, 96);
|
||||||
|
module.RecordError("socket-send", remote, "simulated");
|
||||||
|
module.RecordSessionClosed(remote);
|
||||||
|
|
||||||
|
var first = module.CompleteRun();
|
||||||
|
var second = module.CompleteRun();
|
||||||
|
var reportFiles = Directory.GetFiles(reportDirectory, "*.json");
|
||||||
|
var reportText = File.ReadAllText(reportFiles[0]);
|
||||||
|
|
||||||
|
Assert.That(reportFiles, Has.Length.EqualTo(1));
|
||||||
|
Assert.That(first.ReportPath, Is.EqualTo(reportFiles[0]));
|
||||||
|
Assert.That(second.ReportPath, Is.EqualTo(first.ReportPath));
|
||||||
|
Assert.That(first.SessionsCreated, Is.EqualTo(1));
|
||||||
|
Assert.That(first.SessionsClosed, Is.EqualTo(1));
|
||||||
|
Assert.That(first.ErrorCountsByStage["socket-send"], Is.EqualTo(1));
|
||||||
|
Assert.That(reportText, Does.Contain(Environment.NewLine));
|
||||||
|
Assert.That(reportText, Does.Contain(" \"RunId\""));
|
||||||
|
Assert.That(consoleWriter.ToString(), Does.Contain("[TransportMetrics] KcpTransport"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteDirectory(reportDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator MetricsSnapshot_TracksMultiplePeers_AndExportsOnceOnStop()
|
||||||
|
{
|
||||||
|
return RunAsync(MetricsSnapshot_TracksMultiplePeers_AndExportsOnceOnStopAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task MetricsSnapshot_TracksMultiplePeers_AndExportsOnceOnStopAsync()
|
||||||
|
{
|
||||||
|
var listenPort = GetAvailableUdpPort();
|
||||||
|
var serverReportDirectory = CreateReportDirectory();
|
||||||
|
var clientAReportDirectory = CreateReportDirectory();
|
||||||
|
var clientBReportDirectory = CreateReportDirectory();
|
||||||
|
var serverMetrics = new DefaultTransportMetricsModule(serverReportDirectory, consoleWriter: new StringWriter());
|
||||||
|
var clientAMetrics = new DefaultTransportMetricsModule(clientAReportDirectory, consoleWriter: new StringWriter());
|
||||||
|
var clientBMetrics = new DefaultTransportMetricsModule(clientBReportDirectory, consoleWriter: new StringWriter());
|
||||||
|
var server = new KcpTransport(listenPort, metricsModule: serverMetrics);
|
||||||
|
var clientA = new KcpTransport(IPAddress.Loopback.ToString(), listenPort, metricsModule: clientAMetrics);
|
||||||
|
var clientB = new KcpTransport(IPAddress.Loopback.ToString(), listenPort, metricsModule: clientBMetrics);
|
||||||
|
var received = new ConcurrentQueue<(byte[] Payload, IPEndPoint Sender)>();
|
||||||
|
var allMessagesTask = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
server.OnReceive += (data, sender) =>
|
||||||
|
{
|
||||||
|
received.Enqueue((data, sender));
|
||||||
|
if (received.Count >= 2)
|
||||||
|
{
|
||||||
|
allMessagesTask.TrySetResult(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await server.StartAsync();
|
||||||
|
await clientA.StartAsync();
|
||||||
|
await clientB.StartAsync();
|
||||||
|
|
||||||
|
clientA.Send(CreatePayload(128, seed: 91));
|
||||||
|
clientB.Send(CreatePayload(256, seed: 117));
|
||||||
|
|
||||||
|
await WaitFor(allMessagesTask.Task, "Timed out waiting for metrics traffic from both clients.");
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
var liveSnapshot = server.GetMetricsSnapshot();
|
||||||
|
Assert.That(liveSnapshot.PayloadMessagesReceived, Is.EqualTo(2));
|
||||||
|
Assert.That(liveSnapshot.PeakActiveSessions, Is.EqualTo(2));
|
||||||
|
Assert.That(liveSnapshot.PeerSummaries, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(liveSnapshot.PeerSummaries.Sum(peer => peer.PayloadMessagesReceived), Is.EqualTo(2));
|
||||||
|
Assert.That(liveSnapshot.PeerSummaries.Select(peer => peer.RemoteEndPoint).Distinct().Count(), Is.EqualTo(2));
|
||||||
|
|
||||||
|
server.Stop();
|
||||||
|
clientA.Stop();
|
||||||
|
clientB.Stop();
|
||||||
|
|
||||||
|
var completedSnapshot = server.GetMetricsSnapshot();
|
||||||
|
Assert.That(completedSnapshot.ReportPath, Is.Not.Null.And.Not.Empty);
|
||||||
|
Assert.That(Directory.GetFiles(serverReportDirectory, "*.json"), Has.Length.EqualTo(1));
|
||||||
|
Assert.That(completedSnapshot.ActiveSessions, Is.EqualTo(0));
|
||||||
|
Assert.That(completedSnapshot.SessionsClosed, Is.EqualTo(2));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
clientA.Stop();
|
||||||
|
clientB.Stop();
|
||||||
|
server.Stop();
|
||||||
|
DeleteDirectory(serverReportDirectory);
|
||||||
|
DeleteDirectory(clientAReportDirectory);
|
||||||
|
DeleteDirectory(clientBReportDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
private static async Task<T> WaitFor<T>(Task<T> task, string failureMessage)
|
private static async Task<T> WaitFor<T>(Task<T> task, string failureMessage)
|
||||||
{
|
{
|
||||||
var completedTask = await Task.WhenAny(task, Task.Delay(DefaultTimeoutMs));
|
var completedTask = await Task.WhenAny(task, Task.Delay(DefaultTimeoutMs));
|
||||||
|
|
@ -242,6 +351,18 @@ namespace Tests.EditMode.Network
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string CreateReportDirectory()
|
||||||
|
{
|
||||||
|
return Path.Combine(Directory.GetCurrentDirectory(), "Logs", "transport-metrics-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
private static int GetAvailableUdpPort()
|
private static int GetAvailableUdpPort()
|
||||||
{
|
{
|
||||||
using var client = new UdpClient(0);
|
using var client = new UdpClient(0);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-27
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`KcpTransport` currently exposes send/receive behavior and session isolation, but transport-level diagnostics are limited to ad-hoc console logging. Weak-network verification, multi-session troubleshooting, and resume-facing statistics all need structured counters and end-of-run summaries that do not depend on Unity and do not force changes onto `ITransport`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Add a transport-agnostic metrics module interface and snapshot model in shared networking code.
|
||||||
|
- Let `KcpTransport` publish lifecycle, session, payload, datagram, and error events into that module with minimal intrusion.
|
||||||
|
- Produce one JSON report plus one console summary when a transport run ends at `Stop()`.
|
||||||
|
- Allow tests and diagnostics code to query the current metrics snapshot without reading the exported file.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Add gameplay, UI, or session-state business metrics above the transport layer.
|
||||||
|
- Change `ITransport` or require all future transports to implement metrics immediately.
|
||||||
|
- Persist per-event trace logs or high-volume packet histories in v1.
|
||||||
|
- Add Unity-specific visualization or editor tooling for the exported metrics.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Use a standalone diagnostics interface instead of nesting metrics types under `KcpTransport`
|
||||||
|
The metrics contract will live in shared networking code as a transport-agnostic module interface with snapshot DTOs. `KcpTransport` will only hold a private reference and call interface methods at integration points. This keeps the module reusable and avoids making other callers depend on a concrete transport type.
|
||||||
|
|
||||||
|
### Keep `ITransport` unchanged and extend only `KcpTransport`
|
||||||
|
`ITransport` remains the transport contract for runtime networking. `KcpTransport` constructors gain an optional metrics-module parameter and a snapshot query method. This scopes the feature to the only reliable runtime transport without imposing a cross-cutting interface change.
|
||||||
|
|
||||||
|
### Treat each `StartAsync` to `Stop()` window as one metrics run
|
||||||
|
The module resets when the transport starts, accumulates counters for the active run, and finalizes exactly once at shutdown. Repeated `Stop()` calls must be idempotent so the same run does not emit duplicate reports.
|
||||||
|
|
||||||
|
### Export global and per-peer summaries, not event streams
|
||||||
|
The module aggregates totals for payloads, UDP datagrams, sessions, and errors globally and by remote endpoint. This is sufficient for Clumsy validation and multi-session diagnosis while avoiding heavy trace storage and output noise.
|
||||||
|
|
||||||
|
### Default reporting is JSON to `Logs/transport-metrics/` plus a compact console summary
|
||||||
|
The built-in module writes a timestamped JSON file on finalization and prints a one-line summary to the console. JSON preserves data for later scripting, while the console line gives an immediate close-out signal during local runs.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Extra synchronization overhead in hot transport paths] -> Mitigation: keep module callbacks coarse-grained, aggregate with counters/snapshots, and avoid per-packet file I/O.
|
||||||
|
- [Shutdown reporting can fail because of file-system issues] -> Mitigation: make file export best-effort, keep the in-memory snapshot available, and still print a console summary/error.
|
||||||
|
- [Transport metrics can be misread as business-layer truth] -> Mitigation: keep field names explicitly transport-scoped and exclude gameplay/session outcome claims from the module.
|
||||||
|
- [Dirty worktree around `KcpTransport` can cause merge pressure] -> Mitigation: constrain edits to additive hooks, new diagnostics types, and focused tests without rewriting existing KCP logic.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Add the diagnostics types, wire `KcpTransport`, update transport tests, and keep default behavior backward compatible by making metrics injection optional. No data migration or host adapter changes are required.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None for v1; output mode and aggregation scope are fixed to JSON plus console and global plus per-peer summaries.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The transport layer can already move payloads and manage KCP sessions, but it does not expose structured runtime metrics for weak-network verification or resume-ready project data. We need a transport-agnostic metrics module now so each run can produce a durable summary without introducing Unity dependencies or bloating `ITransport`.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add an independent transport metrics module interface and snapshot model that aggregate transport-level statistics without depending on Unity or a concrete transport implementation.
|
||||||
|
- Wire `KcpTransport` to emit transport lifecycle, session, logical payload, UDP datagram, and error events into the metrics module through that interface.
|
||||||
|
- Add final-run reporting so `KcpTransport.Stop()` writes one JSON summary per run and prints a compact console summary.
|
||||||
|
- Expose a runtime snapshot query API on `KcpTransport` for tests and diagnostic tooling without changing `ITransport`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `transport-metrics-reporting`: Structured transport metrics aggregation, peer-level summaries, and final report export for a single transport run.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `kcp-transport`: KCP transport instances can publish lifecycle, traffic, session, and error statistics to an injected metrics module and emit a final report on shutdown.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected code: `Assets/Scripts/Network/NetworkTransport/` transport implementation and new diagnostics module types.
|
||||||
|
- Affected APIs: `KcpTransport` constructors gain an optional metrics-module dependency and expose a snapshot query method; `ITransport` remains unchanged.
|
||||||
|
- Affected tests: edit-mode transport tests gain coverage for metrics aggregation, multi-session peer summaries, and single-report shutdown behavior.
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: KCP transport can emit structured metrics through an optional module
|
||||||
|
`KcpTransport` SHALL allow callers to provide an optional transport metrics module without changing the shared `ITransport` contract. While running, `KcpTransport` MUST publish transport lifecycle, session creation and disposal, logical payload traffic, UDP datagram traffic, and transport-stage errors into that module, and it MUST expose a current metrics snapshot query for diagnostics and tests.
|
||||||
|
|
||||||
|
#### Scenario: Injected metrics module receives KCP traffic statistics
|
||||||
|
- **WHEN** a caller starts a `KcpTransport`, sends and receives payloads, and then stops the transport
|
||||||
|
- **THEN** the injected metrics module receives enough events to aggregate the run's payload, datagram, session, and error statistics
|
||||||
|
- **THEN** diagnostics code can query the current snapshot without reading the exported report file
|
||||||
|
|
||||||
|
#### Scenario: Default metrics module exports on KCP transport shutdown
|
||||||
|
- **WHEN** a caller uses `KcpTransport` without providing a custom metrics module and later calls `Stop()`
|
||||||
|
- **THEN** the transport uses its built-in metrics module to finalize the run summary during shutdown
|
||||||
|
- **THEN** the transport emits the final JSON report and compact console summary exactly once for that run
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Transport metrics module is transport-agnostic and host-agnostic
|
||||||
|
The project SHALL provide a transport metrics module contract and snapshot model that do not depend on Unity runtime types or a specific `ITransport` implementation. Transport implementations MUST be able to publish lifecycle, traffic, session, and error events through that contract without exposing Unity-specific dependencies.
|
||||||
|
|
||||||
|
#### Scenario: KCP transport can publish into a shared metrics contract
|
||||||
|
- **WHEN** `KcpTransport` is constructed with a metrics module implementation
|
||||||
|
- **THEN** it can report start, shutdown, payload, datagram, session, and error events through that contract
|
||||||
|
- **THEN** the metrics module remains reusable outside Unity-specific hosts
|
||||||
|
|
||||||
|
### Requirement: Metrics summaries include global and per-peer transport statistics
|
||||||
|
The metrics module SHALL aggregate one run summary from transport start to transport stop, including global totals and per-peer totals keyed by remote endpoint. The summary MUST include at least payload counts and bytes, datagram counts and bytes, session lifecycle totals, and error counts.
|
||||||
|
|
||||||
|
#### Scenario: Multi-session traffic is preserved per remote endpoint
|
||||||
|
- **WHEN** a server transport communicates with multiple remote endpoints during one run
|
||||||
|
- **THEN** the final summary contains transport totals for the whole run
|
||||||
|
- **THEN** it also contains separate per-peer summaries so one endpoint's traffic and errors do not overwrite another's
|
||||||
|
|
||||||
|
### Requirement: Metrics module can finalize and export one run summary
|
||||||
|
The metrics module SHALL support end-of-run finalization that produces one durable summary per run and MUST make repeated finalization idempotent. The default reporting path MUST write a JSON report and emit a compact console summary when finalization occurs.
|
||||||
|
|
||||||
|
#### Scenario: Transport stop exports a single final summary
|
||||||
|
- **WHEN** a transport run reaches shutdown and triggers metrics finalization
|
||||||
|
- **THEN** one JSON summary is written for that run and one compact console summary is printed
|
||||||
|
- **THEN** a repeated shutdown call does not create a duplicate report for the same run
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
## 1. Metrics module
|
||||||
|
|
||||||
|
- [x] 1.1 Add transport metrics contracts, snapshot models, and default JSON-plus-console reporting implementation in shared networking code.
|
||||||
|
- [x] 1.2 Ensure the metrics module aggregates one run of global and per-peer counters and finalizes idempotently.
|
||||||
|
|
||||||
|
## 2. KCP transport integration
|
||||||
|
|
||||||
|
- [x] 2.1 Extend `KcpTransport` with optional metrics-module injection and current-snapshot access without changing `ITransport`.
|
||||||
|
- [x] 2.2 Publish start, shutdown, session, payload, datagram, and error events from `KcpTransport` into the metrics module and finalize reports on `Stop()`.
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Add edit-mode tests for metrics aggregation, peer isolation, and single-report shutdown behavior.
|
||||||
|
- [x] 3.2 Run the relevant network edit-mode test suite and confirm the new metrics behavior passes.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# kcp-transport Specification
|
# kcp-transport Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
TBD - created by archiving change introduce-kcp-transport. Update Purpose after archive.
|
TBD - created by archiving change introduce-kcp-transport. Update Purpose after archive.
|
||||||
|
|
@ -61,3 +61,15 @@ The codebase SHALL NOT keep a directly instantiable `ReliableUdpTransport` entry
|
||||||
- **WHEN** developers inspect the transport implementations available to runtime code
|
- **WHEN** developers inspect the transport implementations available to runtime code
|
||||||
- **THEN** they do not find a usable `ReliableUdpTransport` class representing reliable delivery
|
- **THEN** they do not find a usable `ReliableUdpTransport` class representing reliable delivery
|
||||||
- **THEN** the remaining transport naming makes the reliable-versus-unreliable boundary explicit
|
- **THEN** the remaining transport naming makes the reliable-versus-unreliable boundary explicit
|
||||||
|
### Requirement: KCP transport can emit structured metrics through an optional module
|
||||||
|
`KcpTransport` SHALL allow callers to provide an optional transport metrics module without changing the shared `ITransport` contract. While running, `KcpTransport` MUST publish transport lifecycle, session creation and disposal, logical payload traffic, UDP datagram traffic, and transport-stage errors into that module, and it MUST expose a current metrics snapshot query for diagnostics and tests.
|
||||||
|
|
||||||
|
#### Scenario: Injected metrics module receives KCP traffic statistics
|
||||||
|
- **WHEN** a caller starts a `KcpTransport`, sends and receives payloads, and then stops the transport
|
||||||
|
- **THEN** the injected metrics module receives enough events to aggregate the run's payload, datagram, session, and error statistics
|
||||||
|
- **THEN** diagnostics code can query the current snapshot without reading the exported report file
|
||||||
|
|
||||||
|
#### Scenario: Default metrics module exports on KCP transport shutdown
|
||||||
|
- **WHEN** a caller uses `KcpTransport` without providing a custom metrics module and later calls `Stop()`
|
||||||
|
- **THEN** the transport uses its built-in metrics module to finalize the run summary during shutdown
|
||||||
|
- **THEN** the transport emits the final JSON report and compact console summary exactly once for that run
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# transport-metrics-reporting Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Define the shared transport metrics contract and final-run reporting behavior that transport implementations can use without depending on Unity or a concrete transport implementation.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Transport metrics module is transport-agnostic and host-agnostic
|
||||||
|
The project SHALL provide a transport metrics module contract and snapshot model that do not depend on Unity runtime types or a specific `ITransport` implementation. Transport implementations MUST be able to publish lifecycle, traffic, session, and error events through that contract without exposing Unity-specific dependencies.
|
||||||
|
|
||||||
|
#### Scenario: KCP transport can publish into a shared metrics contract
|
||||||
|
- **WHEN** `KcpTransport` is constructed with a metrics module implementation
|
||||||
|
- **THEN** it can report start, shutdown, payload, datagram, session, and error events through that contract
|
||||||
|
- **THEN** the metrics module remains reusable outside Unity-specific hosts
|
||||||
|
|
||||||
|
### Requirement: Metrics summaries include global and per-peer transport statistics
|
||||||
|
The metrics module SHALL aggregate one run summary from transport start to transport stop, including global totals and per-peer totals keyed by remote endpoint. The summary MUST include at least payload counts and bytes, datagram counts and bytes, session lifecycle totals, and error counts.
|
||||||
|
|
||||||
|
#### Scenario: Multi-session traffic is preserved per remote endpoint
|
||||||
|
- **WHEN** a server transport communicates with multiple remote endpoints during one run
|
||||||
|
- **THEN** the final summary contains transport totals for the whole run
|
||||||
|
- **THEN** it also contains separate per-peer summaries so one endpoint's traffic and errors do not overwrite another's
|
||||||
|
|
||||||
|
### Requirement: Metrics module can finalize and export one run summary
|
||||||
|
The metrics module SHALL support end-of-run finalization that produces one durable summary per run and MUST make repeated finalization idempotent. The default reporting path MUST write a JSON report and emit a compact console summary when finalization occurs.
|
||||||
|
|
||||||
|
#### Scenario: Transport stop exports a single final summary
|
||||||
|
- **WHEN** a transport run reaches shutdown and triggers metrics finalization
|
||||||
|
- **THEN** one JSON summary is written for that run and one compact console summary is printed
|
||||||
|
- **THEN** a repeated shutdown call does not create a duplicate report for the same run
|
||||||
Loading…
Reference in New Issue