feat: add closed connection tracking, state filtering, ByStop/ByReason sorting
This commit is contained in:
25
src/NATS.Server/Monitoring/ClosedClient.cs
Normal file
25
src/NATS.Server/Monitoring/ClosedClient.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace NATS.Server.Monitoring;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of a closed client connection for /connz reporting.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ClosedClient
|
||||||
|
{
|
||||||
|
public required ulong Cid { get; init; }
|
||||||
|
public string Ip { get; init; } = "";
|
||||||
|
public int Port { get; init; }
|
||||||
|
public DateTime Start { get; init; }
|
||||||
|
public DateTime Stop { get; init; }
|
||||||
|
public string Reason { get; init; } = "";
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Lang { get; init; } = "";
|
||||||
|
public string Version { get; init; } = "";
|
||||||
|
public long InMsgs { get; init; }
|
||||||
|
public long OutMsgs { get; init; }
|
||||||
|
public long InBytes { get; init; }
|
||||||
|
public long OutBytes { get; init; }
|
||||||
|
public uint NumSubs { get; init; }
|
||||||
|
public TimeSpan Rtt { get; init; }
|
||||||
|
public string TlsVersion { get; init; } = "";
|
||||||
|
public string TlsCipherSuite { get; init; } = "";
|
||||||
|
}
|
||||||
@@ -169,6 +169,8 @@ public enum SortOpt
|
|||||||
ByIdle,
|
ByIdle,
|
||||||
ByUptime,
|
ByUptime,
|
||||||
ByRtt,
|
ByRtt,
|
||||||
|
ByStop,
|
||||||
|
ByReason,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -12,9 +12,25 @@ public sealed class ConnzHandler(NatsServer server)
|
|||||||
{
|
{
|
||||||
var opts = ParseQueryParams(ctx);
|
var opts = ParseQueryParams(ctx);
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var clients = server.GetClients().ToArray();
|
|
||||||
|
|
||||||
var connInfos = clients.Select(c => BuildConnInfo(c, now, opts)).ToList();
|
var connInfos = new List<ConnInfo>();
|
||||||
|
|
||||||
|
// Collect open connections
|
||||||
|
if (opts.State is ConnState.Open or ConnState.All)
|
||||||
|
{
|
||||||
|
var clients = server.GetClients().ToArray();
|
||||||
|
connInfos.AddRange(clients.Select(c => BuildConnInfo(c, now, opts)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect closed connections
|
||||||
|
if (opts.State is ConnState.Closed or ConnState.All)
|
||||||
|
{
|
||||||
|
connInfos.AddRange(server.GetClosedClients().Select(c => BuildClosedConnInfo(c, now, opts)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sort options that require closed state
|
||||||
|
if (opts.Sort is SortOpt.ByStop or SortOpt.ByReason && opts.State == ConnState.Open)
|
||||||
|
opts.Sort = SortOpt.ByCid; // Fallback
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
connInfos = opts.Sort switch
|
connInfos = opts.Sort switch
|
||||||
@@ -30,6 +46,8 @@ public sealed class ConnzHandler(NatsServer server)
|
|||||||
SortOpt.ByLast => connInfos.OrderByDescending(c => c.LastActivity).ToList(),
|
SortOpt.ByLast => connInfos.OrderByDescending(c => c.LastActivity).ToList(),
|
||||||
SortOpt.ByIdle => connInfos.OrderByDescending(c => now - c.LastActivity).ToList(),
|
SortOpt.ByIdle => connInfos.OrderByDescending(c => now - c.LastActivity).ToList(),
|
||||||
SortOpt.ByUptime => connInfos.OrderByDescending(c => now - c.Start).ToList(),
|
SortOpt.ByUptime => connInfos.OrderByDescending(c => now - c.Start).ToList(),
|
||||||
|
SortOpt.ByStop => connInfos.OrderByDescending(c => c.Stop ?? DateTime.MinValue).ToList(),
|
||||||
|
SortOpt.ByReason => connInfos.OrderBy(c => c.Reason).ToList(),
|
||||||
SortOpt.ByRtt => connInfos.OrderBy(c => c.Rtt).ToList(),
|
SortOpt.ByRtt => connInfos.OrderBy(c => c.Rtt).ToList(),
|
||||||
_ => connInfos.OrderBy(c => c.Cid).ToList(),
|
_ => connInfos.OrderBy(c => c.Cid).ToList(),
|
||||||
};
|
};
|
||||||
@@ -74,7 +92,7 @@ public sealed class ConnzHandler(NatsServer server)
|
|||||||
Reason = client.CloseReason.ToReasonString(),
|
Reason = client.CloseReason.ToReasonString(),
|
||||||
TlsVersion = client.TlsState?.TlsVersion ?? "",
|
TlsVersion = client.TlsState?.TlsVersion ?? "",
|
||||||
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
|
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
|
||||||
Rtt = client.Rtt == TimeSpan.Zero ? "" : $"{client.Rtt.TotalMilliseconds:F3}ms",
|
Rtt = FormatRtt(client.Rtt),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.Subscriptions)
|
if (opts.Subscriptions)
|
||||||
@@ -98,6 +116,35 @@ public sealed class ConnzHandler(NatsServer server)
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ConnInfo BuildClosedConnInfo(ClosedClient closed, DateTime now, ConnzOptions opts)
|
||||||
|
{
|
||||||
|
return new ConnInfo
|
||||||
|
{
|
||||||
|
Cid = closed.Cid,
|
||||||
|
Kind = "Client",
|
||||||
|
Type = "Client",
|
||||||
|
Ip = closed.Ip,
|
||||||
|
Port = closed.Port,
|
||||||
|
Start = closed.Start,
|
||||||
|
Stop = closed.Stop,
|
||||||
|
LastActivity = closed.Stop,
|
||||||
|
Uptime = FormatDuration(closed.Stop - closed.Start),
|
||||||
|
Idle = FormatDuration(now - closed.Stop),
|
||||||
|
InMsgs = closed.InMsgs,
|
||||||
|
OutMsgs = closed.OutMsgs,
|
||||||
|
InBytes = closed.InBytes,
|
||||||
|
OutBytes = closed.OutBytes,
|
||||||
|
NumSubs = closed.NumSubs,
|
||||||
|
Name = closed.Name,
|
||||||
|
Lang = closed.Lang,
|
||||||
|
Version = closed.Version,
|
||||||
|
Reason = closed.Reason,
|
||||||
|
Rtt = FormatRtt(closed.Rtt),
|
||||||
|
TlsVersion = closed.TlsVersion,
|
||||||
|
TlsCipherSuite = closed.TlsCipherSuite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static ConnzOptions ParseQueryParams(HttpContext ctx)
|
private static ConnzOptions ParseQueryParams(HttpContext ctx)
|
||||||
{
|
{
|
||||||
var q = ctx.Request.Query;
|
var q = ctx.Request.Query;
|
||||||
@@ -119,6 +166,8 @@ public sealed class ConnzHandler(NatsServer server)
|
|||||||
"idle" => SortOpt.ByIdle,
|
"idle" => SortOpt.ByIdle,
|
||||||
"uptime" => SortOpt.ByUptime,
|
"uptime" => SortOpt.ByUptime,
|
||||||
"rtt" => SortOpt.ByRtt,
|
"rtt" => SortOpt.ByRtt,
|
||||||
|
"stop" => SortOpt.ByStop,
|
||||||
|
"reason" => SortOpt.ByReason,
|
||||||
_ => SortOpt.ByCid,
|
_ => SortOpt.ByCid,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -131,6 +180,17 @@ public sealed class ConnzHandler(NatsServer server)
|
|||||||
opts.Subscriptions = true;
|
opts.Subscriptions = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (q.TryGetValue("state", out var state))
|
||||||
|
{
|
||||||
|
opts.State = state.ToString().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"open" => ConnState.Open,
|
||||||
|
"closed" => ConnState.Closed,
|
||||||
|
"all" => ConnState.All,
|
||||||
|
_ => ConnState.Open,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (q.TryGetValue("offset", out var offset) && int.TryParse(offset, out var o))
|
if (q.TryGetValue("offset", out var offset) && int.TryParse(offset, out var o))
|
||||||
opts.Offset = o;
|
opts.Offset = o;
|
||||||
|
|
||||||
@@ -140,6 +200,16 @@ public sealed class ConnzHandler(NatsServer server)
|
|||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatRtt(TimeSpan rtt)
|
||||||
|
{
|
||||||
|
if (rtt == TimeSpan.Zero) return "";
|
||||||
|
if (rtt.TotalMilliseconds < 1)
|
||||||
|
return $"{rtt.TotalMicroseconds:F3}\u00b5s";
|
||||||
|
if (rtt.TotalSeconds < 1)
|
||||||
|
return $"{rtt.TotalMilliseconds:F3}ms";
|
||||||
|
return $"{rtt.TotalSeconds:F3}s";
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatDuration(TimeSpan ts)
|
private static string FormatDuration(TimeSpan ts)
|
||||||
{
|
{
|
||||||
if (ts.TotalDays >= 1)
|
if (ts.TotalDays >= 1)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly NatsOptions _options;
|
private readonly NatsOptions _options;
|
||||||
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
|
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
|
||||||
|
private readonly ConcurrentQueue<ClosedClient> _closedClients = new();
|
||||||
|
private const int MaxClosedClients = 10_000;
|
||||||
private readonly ServerInfo _serverInfo;
|
private readonly ServerInfo _serverInfo;
|
||||||
private readonly ILogger<NatsServer> _logger;
|
private readonly ILogger<NatsServer> _logger;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
@@ -64,6 +66,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
public Action? ReOpenLogFile { get; set; }
|
public Action? ReOpenLogFile { get; set; }
|
||||||
public IEnumerable<NatsClient> GetClients() => _clients.Values;
|
public IEnumerable<NatsClient> GetClients() => _clients.Values;
|
||||||
|
|
||||||
|
public IEnumerable<ClosedClient> GetClosedClients() => _closedClients;
|
||||||
|
|
||||||
public IEnumerable<Auth.Account> GetAccounts() => _accounts.Values;
|
public IEnumerable<Auth.Account> GetAccounts() => _accounts.Values;
|
||||||
|
|
||||||
public Task WaitForReadyAsync() => _listeningStarted.Task;
|
public Task WaitForReadyAsync() => _listeningStarted.Task;
|
||||||
@@ -580,6 +584,33 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
{
|
{
|
||||||
_clients.TryRemove(client.Id, out _);
|
_clients.TryRemove(client.Id, out _);
|
||||||
_logger.LogDebug("Removed client {ClientId}", client.Id);
|
_logger.LogDebug("Removed client {ClientId}", client.Id);
|
||||||
|
|
||||||
|
// Snapshot for closed-connections tracking
|
||||||
|
_closedClients.Enqueue(new ClosedClient
|
||||||
|
{
|
||||||
|
Cid = client.Id,
|
||||||
|
Ip = client.RemoteIp ?? "",
|
||||||
|
Port = client.RemotePort,
|
||||||
|
Start = client.StartTime,
|
||||||
|
Stop = DateTime.UtcNow,
|
||||||
|
Reason = client.CloseReason.ToReasonString(),
|
||||||
|
Name = client.ClientOpts?.Name ?? "",
|
||||||
|
Lang = client.ClientOpts?.Lang ?? "",
|
||||||
|
Version = client.ClientOpts?.Version ?? "",
|
||||||
|
InMsgs = Interlocked.Read(ref client.InMsgs),
|
||||||
|
OutMsgs = Interlocked.Read(ref client.OutMsgs),
|
||||||
|
InBytes = Interlocked.Read(ref client.InBytes),
|
||||||
|
OutBytes = Interlocked.Read(ref client.OutBytes),
|
||||||
|
NumSubs = (uint)client.Subscriptions.Count,
|
||||||
|
Rtt = client.Rtt,
|
||||||
|
TlsVersion = client.TlsState?.TlsVersion ?? "",
|
||||||
|
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cap closed clients list
|
||||||
|
while (_closedClients.Count > MaxClosedClients)
|
||||||
|
_closedClients.TryDequeue(out _);
|
||||||
|
|
||||||
var subList = client.Account?.SubList ?? _globalAccount.SubList;
|
var subList = client.Account?.SubList ?? _globalAccount.SubList;
|
||||||
client.RemoveAllSubscriptions(subList);
|
client.RemoveAllSubscriptions(subList);
|
||||||
client.Account?.RemoveClient(client.Id);
|
client.Account?.RemoveClient(client.Id);
|
||||||
|
|||||||
@@ -179,6 +179,53 @@ public class MonitorTests : IAsyncLifetime
|
|||||||
conn.Subs.ShouldContain("bar");
|
conn.Subs.ShouldContain("bar");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connz_state_closed_returns_disconnected_clients()
|
||||||
|
{
|
||||||
|
// Connect then disconnect a client
|
||||||
|
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
|
||||||
|
using var stream = new NetworkStream(sock);
|
||||||
|
var buf = new byte[4096];
|
||||||
|
_ = await stream.ReadAsync(buf);
|
||||||
|
await stream.WriteAsync("CONNECT {\"name\":\"closing-client\"}\r\n"u8.ToArray());
|
||||||
|
await Task.Delay(200);
|
||||||
|
sock.Shutdown(SocketShutdown.Both);
|
||||||
|
sock.Dispose();
|
||||||
|
await Task.Delay(500);
|
||||||
|
|
||||||
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?state=closed");
|
||||||
|
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
||||||
|
connz.ShouldNotBeNull();
|
||||||
|
connz.Conns.ShouldContain(c => c.Name == "closing-client");
|
||||||
|
var closed = connz.Conns.First(c => c.Name == "closing-client");
|
||||||
|
closed.Stop.ShouldNotBeNull();
|
||||||
|
closed.Reason.ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connz_sort_by_stop_requires_closed_state()
|
||||||
|
{
|
||||||
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=stop&state=open");
|
||||||
|
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
||||||
|
connz.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connz_sort_by_reason()
|
||||||
|
{
|
||||||
|
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
|
||||||
|
var buf = new byte[4096];
|
||||||
|
_ = await sock.ReceiveAsync(buf);
|
||||||
|
sock.Shutdown(SocketShutdown.Both);
|
||||||
|
sock.Dispose();
|
||||||
|
await Task.Delay(500);
|
||||||
|
|
||||||
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=reason&state=closed");
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
private static int GetFreePort()
|
private static int GetFreePort()
|
||||||
{
|
{
|
||||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
|||||||
Reference in New Issue
Block a user