feat(monitoring+events): add connz filtering, event payloads, and message trace context (E12+E13+E14)

- Add ConnzHandler with sorting, filtering, pagination, CID lookup, and closed connection ring buffer
- Add full Go events.go parity types (ConnectEventMsg, DisconnectEventMsg, ServerStatsMsg, etc.)
- Add MessageTraceContext for per-message trace propagation with header parsing
- 74 new tests (17 ConnzFilter + 16 EventPayload + 41 MessageTraceContext)
This commit is contained in:
Joseph Doherty
2026-02-24 16:17:21 -05:00
parent 37d3cc29ea
commit 94878d3dcc
10 changed files with 2595 additions and 15 deletions

View File

@@ -0,0 +1,420 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Monitoring;
namespace NATS.Server.Tests.Monitoring;
/// <summary>
/// Tests for ConnzHandler filtering, sorting, pagination, and closed connection
/// ring buffer behavior.
/// Go reference: monitor_test.go — TestConnz, TestConnzSortedByCid, TestConnzSortedByBytesTo,
/// TestConnzFilter, TestConnzWithCID, TestConnzOffsetAndLimit.
/// </summary>
public class ConnzFilterTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly NatsOptions _opts;
private readonly CancellationTokenSource _cts = new();
private readonly List<Socket> _sockets = [];
public ConnzFilterTests()
{
_opts = new NatsOptions
{
Port = GetFreePort(),
MaxClosedClients = 100,
Users =
[
new User { Username = "alice", Password = "pw", Account = "acctA" },
new User { Username = "bob", Password = "pw", Account = "acctB" },
],
};
_server = new NatsServer(_opts, NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
foreach (var s in _sockets)
{
try { s.Shutdown(SocketShutdown.Both); } catch { }
s.Dispose();
}
await _cts.CancelAsync();
_server.Dispose();
}
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private async Task<Socket> ConnectAsync(string user, string? subjectToSubscribe = null)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_sockets.Add(sock);
await sock.ConnectAsync(IPAddress.Loopback, _opts.Port);
var buf = new byte[4096];
await sock.ReceiveAsync(buf, SocketFlags.None); // INFO
var connect = $"CONNECT {{\"user\":\"{user}\",\"pass\":\"pw\"}}\r\n";
await sock.SendAsync(Encoding.ASCII.GetBytes(connect));
if (subjectToSubscribe != null)
{
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB {subjectToSubscribe} sid1\r\n"));
}
await sock.SendAsync("PING\r\n"u8.ToArray());
await ReadUntilAsync(sock, "PONG");
return sock;
}
private Connz GetConnz(string queryString = "")
{
var ctx = new DefaultHttpContext();
ctx.Request.QueryString = new QueryString(queryString);
return new ConnzHandler(_server).HandleConnz(ctx);
}
// --- Sort tests ---
[Fact]
public async Task Sort_by_cid_returns_ascending_order()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?sort=cid");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].Cid.ShouldBeGreaterThan(connz.Conns[i - 1].Cid);
}
}
[Fact]
public async Task Sort_by_bytes_to_returns_descending_order()
{
var sock1 = await ConnectAsync("alice");
var sock2 = await ConnectAsync("bob");
await Task.Delay(50);
// Publish some data through sock1 to accumulate bytes
await sock1.SendAsync(Encoding.ASCII.GetBytes("SUB test 1\r\nPUB test 10\r\n1234567890\r\n"));
await Task.Delay(100);
var connz = GetConnz("?sort=bytes_to");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].OutBytes.ShouldBeLessThanOrEqualTo(connz.Conns[i - 1].OutBytes);
}
}
[Fact]
public async Task Sort_by_msgs_from_returns_descending_order()
{
var sock1 = await ConnectAsync("alice");
await Task.Delay(50);
// Send a PUB to increment InMsgs
await sock1.SendAsync(Encoding.ASCII.GetBytes("PUB test 3\r\nabc\r\n"));
await Task.Delay(100);
var connz = GetConnz("?sort=msgs_from");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].InMsgs.ShouldBeLessThanOrEqualTo(connz.Conns[i - 1].InMsgs);
}
}
[Fact]
public async Task Sort_by_subs_returns_descending_order()
{
// Alice has 2 subs, Bob has 1
var sock1 = await ConnectAsync("alice", "test.a");
await sock1.SendAsync(Encoding.ASCII.GetBytes("SUB test.b sid2\r\n"));
var sock2 = await ConnectAsync("bob", "test.c");
await Task.Delay(100);
var connz = GetConnz("?sort=subs");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].NumSubs.ShouldBeLessThanOrEqualTo(connz.Conns[i - 1].NumSubs);
}
}
[Fact]
public async Task Sort_by_start_returns_ascending_order()
{
await ConnectAsync("alice");
await Task.Delay(20);
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?sort=start");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].Start.ShouldBeGreaterThanOrEqualTo(connz.Conns[i - 1].Start);
}
}
// --- Filter tests ---
[Fact]
public async Task Filter_by_account_returns_only_matching_connections()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?acc=acctA");
connz.Conns.ShouldAllBe(c => c.Account == "acctA");
connz.Conns.ShouldNotBeEmpty();
}
[Fact]
public async Task Filter_by_user_returns_only_matching_connections()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?user=bob");
connz.Conns.ShouldAllBe(c => c.AuthorizedUser == "bob");
connz.Conns.ShouldNotBeEmpty();
}
[Fact]
public async Task Filter_by_subject_returns_matching_subscribers()
{
await ConnectAsync("alice", "orders.>");
await ConnectAsync("bob", "payments.>");
await Task.Delay(50);
var connz = GetConnz("?filter_subject=orders.new&subs=1");
connz.Conns.ShouldNotBeEmpty();
connz.Conns.ShouldAllBe(c => c.Subs.Any(s => s.Contains("orders")));
}
// --- Pagination tests ---
[Fact]
public async Task Offset_and_limit_paginates_results()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await ConnectAsync("alice");
await Task.Delay(50);
var page1 = GetConnz("?sort=cid&limit=2&offset=0");
page1.Conns.Length.ShouldBe(2);
page1.Total.ShouldBeGreaterThanOrEqualTo(3);
page1.Offset.ShouldBe(0);
page1.Limit.ShouldBe(2);
var page2 = GetConnz("?sort=cid&limit=2&offset=2");
page2.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
page2.Offset.ShouldBe(2);
// Ensure no overlap between pages
var page1Cids = page1.Conns.Select(c => c.Cid).ToHashSet();
var page2Cids = page2.Conns.Select(c => c.Cid).ToHashSet();
page1Cids.Overlaps(page2Cids).ShouldBeFalse();
}
// --- CID lookup test ---
[Fact]
public async Task Cid_lookup_returns_single_connection()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
// Get all connections to find a known CID
var all = GetConnz("?sort=cid");
all.Conns.ShouldNotBeEmpty();
var targetCid = all.Conns[0].Cid;
var single = GetConnz($"?cid={targetCid}");
single.Conns.Length.ShouldBe(1);
single.Conns[0].Cid.ShouldBe(targetCid);
}
[Fact]
public void Cid_lookup_nonexistent_returns_empty()
{
var result = GetConnz("?cid=99999999");
result.Conns.Length.ShouldBe(0);
result.Total.ShouldBe(0);
}
// --- Closed connection tests ---
[Fact]
public async Task Closed_state_shows_disconnected_clients()
{
var sock = await ConnectAsync("alice");
await Task.Delay(50);
// Close the connection
sock.Shutdown(SocketShutdown.Both);
sock.Close();
_sockets.Remove(sock);
await Task.Delay(200);
var connz = GetConnz("?state=closed");
connz.Conns.ShouldNotBeEmpty();
connz.Conns.ShouldAllBe(c => c.Stop != null);
connz.Conns.ShouldAllBe(c => !string.IsNullOrEmpty(c.Reason));
}
[Fact]
public async Task All_state_shows_both_open_and_closed()
{
var sock1 = await ConnectAsync("alice");
var sock2 = await ConnectAsync("bob");
await Task.Delay(50);
// Close one connection
sock1.Shutdown(SocketShutdown.Both);
sock1.Close();
_sockets.Remove(sock1);
await Task.Delay(200);
var connz = GetConnz("?state=all");
connz.Total.ShouldBeGreaterThanOrEqualTo(2);
// Should have at least one open (bob) and one closed (alice)
connz.Conns.Any(c => c.Stop == null).ShouldBeTrue("expected at least one open connection");
connz.Conns.Any(c => c.Stop != null).ShouldBeTrue("expected at least one closed connection");
}
[Fact]
public async Task Closed_ring_buffer_caps_at_max()
{
// MaxClosedClients is 100, create and close 5 connections
for (int i = 0; i < 5; i++)
{
var sock = await ConnectAsync("alice");
await Task.Delay(20);
sock.Shutdown(SocketShutdown.Both);
sock.Close();
_sockets.Remove(sock);
await Task.Delay(100);
}
var connz = GetConnz("?state=closed");
connz.Total.ShouldBeLessThanOrEqualTo(_opts.MaxClosedClients);
}
// --- Sort fallback tests ---
[Fact]
public async Task Sort_by_stop_with_open_state_falls_back_to_cid()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
// sort=stop with state=open should fall back to cid sorting
var connz = GetConnz("?sort=stop&state=open");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].Cid.ShouldBeGreaterThan(connz.Conns[i - 1].Cid);
}
}
// --- Combined filter + sort test ---
[Fact]
public async Task Account_filter_with_bytes_sort_and_limit()
{
// Connect multiple alice clients
for (int i = 0; i < 3; i++)
{
var sock = await ConnectAsync("alice");
// Send varying amounts of data
var data = new string('x', (i + 1) * 100);
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB test 1\r\nPUB test {data.Length}\r\n{data}\r\n"));
}
await ConnectAsync("bob");
await Task.Delay(100);
var connz = GetConnz("?acc=acctA&sort=bytes_to&limit=2");
connz.Conns.Length.ShouldBeLessThanOrEqualTo(2);
connz.Conns.ShouldAllBe(c => c.Account == "acctA");
}
[Fact]
public async Task Closed_cid_lookup_returns_from_ring_buffer()
{
var sock = await ConnectAsync("alice");
await Task.Delay(50);
// Get the CID before closing
var all = GetConnz("?sort=cid");
all.Conns.ShouldNotBeEmpty();
var targetCid = all.Conns.Last().Cid;
// Close the socket
sock.Shutdown(SocketShutdown.Both);
sock.Close();
_sockets.Remove(sock);
await Task.Delay(200);
// Look up closed connection by CID
var single = GetConnz($"?cid={targetCid}");
single.Conns.Length.ShouldBe(1);
single.Conns[0].Cid.ShouldBe(targetCid);
single.Conns[0].Stop.ShouldNotBeNull();
}
private static async Task ReadUntilAsync(Socket sock, string expected)
{
var buf = new byte[4096];
var all = new StringBuilder();
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
if (sock.Available > 0)
{
var n = await sock.ReceiveAsync(buf, SocketFlags.None);
all.Append(Encoding.ASCII.GetString(buf, 0, n));
if (all.ToString().Contains(expected))
return;
}
else
{
await Task.Delay(10);
}
}
throw new TimeoutException($"Did not receive '{expected}' within 5 seconds. Got: {all}");
}
}