From e3615101009b9d0e8aef4b59ee33955cd1e8e403 Mon Sep 17 00:00:00 2001 From: SepComet <2428390463@qq.com> Date: Fri, 27 Mar 2026 17:39:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8D=8F=E8=AE=AE=E5=B1=82?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1=E7=BB=93=E6=9E=84=EF=BC=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=87=E4=BB=B6=E4=BF=9D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=20Logs=20=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KcpTransport.KcpSession.cs | 5 +- .../Network/NetworkTransport/KcpTransport.cs | 58 +- .../TransportMetricsModule.cs | 512 ++++++++++++++++++ .../TransportMetricsModule.cs.meta | 11 + .../EditMode/Network/KcpTransportTests.cs | 121 +++++ .../.openspec.yaml | 2 + .../design.md | 49 ++ .../proposal.md | 24 + .../specs/kcp-transport/spec.md | 14 + .../specs/transport-metrics-reporting/spec.md | 25 + .../tasks.md | 14 + openspec/specs/kcp-transport/spec.md | 14 +- .../specs/transport-metrics-reporting/spec.md | 29 + 13 files changed, 866 insertions(+), 12 deletions(-) create mode 100644 Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs create mode 100644 Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs.meta create mode 100644 openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/design.md create mode 100644 openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/proposal.md create mode 100644 openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/kcp-transport/spec.md create mode 100644 openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/transport-metrics-reporting/spec.md create mode 100644 openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/tasks.md create mode 100644 openspec/specs/transport-metrics-reporting/spec.md diff --git a/Assets/Scripts/Network/NetworkTransport/KcpTransport.KcpSession.cs b/Assets/Scripts/Network/NetworkTransport/KcpTransport.KcpSession.cs index eae5582..a7f5a61 100644 --- a/Assets/Scripts/Network/NetworkTransport/KcpTransport.KcpSession.cs +++ b/Assets/Scripts/Network/NetworkTransport/KcpTransport.KcpSession.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using System.Runtime.InteropServices; using kcp; @@ -61,6 +61,7 @@ namespace Network.NetworkTransport var result = KCP.ikcp_send(_kcp, buffer, payload.Length); 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}."); } } @@ -91,6 +92,7 @@ namespace Network.NetworkTransport var result = KCP.ikcp_input(_kcp, buffer, datagram.Length); if (result < 0) { + _owner.RecordTransportError("kcp-input", RemoteEndPoint, $"KCP input failed with error code {result}."); Console.WriteLine($"[KcpTransport] KCP input failed for {RemoteEndPoint}: {result}"); return; } @@ -125,6 +127,7 @@ namespace Network.NetworkTransport var result = KCP.ikcp_recv(_kcp, buffer, payload.Length); if (result < 0) { + _owner.RecordTransportError("kcp-recv", RemoteEndPoint, $"KCP recv failed with error code {result}."); payload = null; return false; } diff --git a/Assets/Scripts/Network/NetworkTransport/KcpTransport.cs b/Assets/Scripts/Network/NetworkTransport/KcpTransport.cs index 90ab9d7..a025106 100644 --- a/Assets/Scripts/Network/NetworkTransport/KcpTransport.cs +++ b/Assets/Scripts/Network/NetworkTransport/KcpTransport.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Linq; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; @@ -25,6 +26,7 @@ namespace Network.NetworkTransport private readonly bool _isServer; private readonly IPEndPoint _defaultRemoteEndPoint; private readonly uint _defaultConv; + private readonly ITransportMetricsModule _metricsModule; private readonly ConcurrentDictionary _sessions = new(); private readonly object _socketSendLock = new(); @@ -37,16 +39,17 @@ namespace Network.NetworkTransport 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); _isServer = true; _defaultConv = conv; + _metricsModule = metricsModule ?? new DefaultTransportMetricsModule(); 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)) { @@ -56,10 +59,16 @@ namespace Network.NetworkTransport _client = new UdpClient(0); _defaultRemoteEndPoint = new IPEndPoint(IPAddress.Parse(serverIp), serverPort); _defaultConv = conv; + _metricsModule = metricsModule ?? new DefaultTransportMetricsModule(); Console.WriteLine($"[KcpTransport] 客户端模式,目标: {_defaultRemoteEndPoint}, conv={conv}"); } + public TransportMetricsSnapshot GetMetricsSnapshot() + { + return _metricsModule.GetCurrentSnapshot(); + } + public Task StartAsync() { if (_isRunning) @@ -68,6 +77,7 @@ namespace Network.NetworkTransport } _sessions.Clear(); + _metricsModule.BeginRun(new TransportRunDescriptor(nameof(KcpTransport), _isServer, _defaultRemoteEndPoint)); _cancellationTokenSource = new CancellationTokenSource(); _isRunning = true; @@ -97,6 +107,7 @@ namespace Network.NetworkTransport DisposeAllSessions(); _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; + _metricsModule.CompleteRun(); Console.WriteLine("[KcpTransport] 传输层停止"); } @@ -133,6 +144,7 @@ namespace Network.NetworkTransport var session = GetOrCreateSession(target, _defaultConv); session.Send(data); + _metricsModule.RecordPayloadSent(session.RemoteEndPoint, data.Length); } public void SendToAll(byte[] data) @@ -152,6 +164,7 @@ namespace Network.NetworkTransport foreach (var session in _sessions.Values) { session.Send(data); + _metricsModule.RecordPayloadSent(session.RemoteEndPoint, data.Length); } } @@ -162,6 +175,7 @@ namespace Network.NetworkTransport try { var result = await _client.ReceiveAsync(); + _metricsModule.RecordDatagramReceived(result.RemoteEndPoint, result.Buffer.Length); var session = GetOrCreateSession(result.RemoteEndPoint, ResolveConv(result.Buffer)); session.Input(result.Buffer); DrainReceivedMessages(session); @@ -176,6 +190,7 @@ namespace Network.NetworkTransport } catch (Exception exception) { + _metricsModule.RecordError("socket-receive", null, exception.Message); Console.WriteLine($"[KcpTransport] 接收错误:{exception.Message}"); } } @@ -208,6 +223,7 @@ namespace Network.NetworkTransport { while (session.TryReceive(out var payload)) { + _metricsModule.RecordPayloadReceived(session.RemoteEndPoint, payload.Length); OnReceive?.Invoke(payload, session.RemoteEndPoint); } } @@ -217,7 +233,20 @@ namespace Network.NetworkTransport var normalizedEndPoint = NormalizeEndPoint(remoteEndPoint); 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) @@ -276,6 +305,7 @@ namespace Network.NetworkTransport { foreach (var innerException in exception.InnerExceptions) { + _metricsModule.RecordError("stop-wait", null, innerException.Message); Console.WriteLine($"[KcpTransport] 停止等待错误:{innerException.Message}"); } } @@ -283,12 +313,14 @@ namespace Network.NetworkTransport private void DisposeAllSessions() { - foreach (var pair in _sessions) - { - pair.Value.Dispose(); - } - + var sessions = _sessions.Values.ToArray(); _sessions.Clear(); + + foreach (var session in sessions) + { + session.Dispose(); + _metricsModule.RecordSessionClosed(session.RemoteEndPoint); + } } private unsafe int SendDatagram(byte* buffer, int length, IPEndPoint remoteEndPoint) @@ -305,7 +337,9 @@ namespace Network.NetworkTransport { 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) @@ -314,11 +348,15 @@ namespace Network.NetworkTransport } catch (SocketException exception) { + _metricsModule.RecordError("socket-send", remoteEndPoint, exception.Message); Console.WriteLine($"[KcpTransport] 发送错误:{exception.Message}"); return -1; } } - + private void RecordTransportError(string stage, IPEndPoint remoteEndPoint, string detail) + { + _metricsModule.RecordError(stage, remoteEndPoint, detail); + } } } \ No newline at end of file diff --git a/Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs b/Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs new file mode 100644 index 0000000..f1ec1da --- /dev/null +++ b/Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs @@ -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 ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal); + [DataMember(Order = 25)] public List 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 ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal); + } + + public sealed class DefaultTransportMetricsModule : ITransportMetricsModule + { + private readonly object gate = new(); + private readonly Func utcNowProvider; + private readonly TextWriter consoleWriter; + private readonly string reportDirectory; + private readonly Dictionary peers = new(StringComparer.Ordinal); + private readonly Dictionary 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 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 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 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 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) + }; + } + } + } +} diff --git a/Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs.meta b/Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs.meta new file mode 100644 index 0000000..c5a663a --- /dev/null +++ b/Assets/Scripts/Network/NetworkTransport/TransportMetricsModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3ae5cc796484e9698f058e34de23a3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/Tests/EditMode/Network/KcpTransportTests.cs b/Assets/Tests/EditMode/Network/KcpTransportTests.cs index f9a3b54..829afe9 100644 --- a/Assets/Tests/EditMode/Network/KcpTransportTests.cs +++ b/Assets/Tests/EditMode/Network/KcpTransportTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; @@ -180,6 +181,114 @@ namespace Tests.EditMode.Network 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(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 WaitFor(Task task, string failureMessage) { var completedTask = await Task.WhenAny(task, Task.Delay(DefaultTimeoutMs)); @@ -242,6 +351,18 @@ namespace Tests.EditMode.Network 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() { using var client = new UdpClient(0); diff --git a/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/.openspec.yaml b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/.openspec.yaml new file mode 100644 index 0000000..a61e7c1 --- /dev/null +++ b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-27 diff --git a/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/design.md b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/design.md new file mode 100644 index 0000000..87d9f5f --- /dev/null +++ b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/design.md @@ -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. \ No newline at end of file diff --git a/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/proposal.md b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/proposal.md new file mode 100644 index 0000000..e039088 --- /dev/null +++ b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/proposal.md @@ -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. \ No newline at end of file diff --git a/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/kcp-transport/spec.md b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/kcp-transport/spec.md new file mode 100644 index 0000000..d879f98 --- /dev/null +++ b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/kcp-transport/spec.md @@ -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 \ No newline at end of file diff --git a/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/transport-metrics-reporting/spec.md b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/transport-metrics-reporting/spec.md new file mode 100644 index 0000000..5df0c4f --- /dev/null +++ b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/specs/transport-metrics-reporting/spec.md @@ -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 \ No newline at end of file diff --git a/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/tasks.md b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/tasks.md new file mode 100644 index 0000000..a03dc95 --- /dev/null +++ b/openspec/changes/archive/2026-03-27-add-transport-metrics-reporting/tasks.md @@ -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. \ No newline at end of file diff --git a/openspec/specs/kcp-transport/spec.md b/openspec/specs/kcp-transport/spec.md index 82d3720..aecae60 100644 --- a/openspec/specs/kcp-transport/spec.md +++ b/openspec/specs/kcp-transport/spec.md @@ -1,4 +1,4 @@ -# kcp-transport Specification +# kcp-transport Specification ## Purpose 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 - **THEN** they do not find a usable `ReliableUdpTransport` class representing reliable delivery - **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 \ No newline at end of file diff --git a/openspec/specs/transport-metrics-reporting/spec.md b/openspec/specs/transport-metrics-reporting/spec.md new file mode 100644 index 0000000..dba8165 --- /dev/null +++ b/openspec/specs/transport-metrics-reporting/spec.md @@ -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 \ No newline at end of file