feat: complete system event payload fields (Gap 10.6)

Add EventBuilder static class to EventTypes.cs with helpers for constructing
fully-populated ConnectEventMsg, DisconnectEventMsg, AccountNumConns, and
ServerStatsMsg. Also add RemoteServerShutdownEvent, RemoteServerUpdateEvent,
LeafNodeConnectEvent, and LeafNodeDisconnectEvent advisory types.

Add FullEventPayloadTests.cs (10 tests) covering all builders, GenerateEventId
uniqueness, GetTimestamp ISO 8601 format, DataStats zero defaults, and
ConnectEventMsg JSON roundtrip.
This commit is contained in:
Joseph Doherty
2026-02-25 13:11:58 -05:00
parent 619acc3c08
commit a6e7778c6c
4 changed files with 862 additions and 0 deletions

View File

@@ -540,6 +540,143 @@ public sealed class OcspPeerRejectEventMsg
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Remote server shutdown advisory.
/// Go reference: events.go — remote server lifecycle.
/// </summary>
public sealed class RemoteServerShutdownEvent
{
public const string EventType = "io.nats.server.advisory.v1.remote_shutdown";
[JsonPropertyName("type")]
public string Type { get; init; } = EventType;
[JsonPropertyName("id")]
public string Id { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("time")]
public string Time { get; init; } = DateTime.UtcNow.ToString("O");
[JsonPropertyName("server")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventServerInfo? Server { get; set; }
[JsonPropertyName("remote_server_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RemoteServerId { get; set; }
[JsonPropertyName("remote_server_name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RemoteServerName { get; set; }
[JsonPropertyName("reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reason { get; set; }
}
/// <summary>
/// Remote server update advisory.
/// Go reference: events.go — remote server lifecycle.
/// </summary>
public sealed class RemoteServerUpdateEvent
{
public const string EventType = "io.nats.server.advisory.v1.remote_update";
[JsonPropertyName("type")]
public string Type { get; init; } = EventType;
[JsonPropertyName("id")]
public string Id { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("time")]
public string Time { get; init; } = DateTime.UtcNow.ToString("O");
[JsonPropertyName("server")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventServerInfo? Server { get; set; }
[JsonPropertyName("remote_server_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RemoteServerId { get; set; }
[JsonPropertyName("remote_server_name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RemoteServerName { get; set; }
/// <summary>Update category, e.g. "routes_changed", "config_updated".</summary>
[JsonPropertyName("update_type")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? UpdateType { get; set; }
}
/// <summary>
/// Leaf node connect advisory.
/// Go reference: events.go — leaf node lifecycle.
/// </summary>
public sealed class LeafNodeConnectEvent
{
public const string EventType = "io.nats.server.advisory.v1.leafnode_connect";
[JsonPropertyName("type")]
public string Type { get; init; } = EventType;
[JsonPropertyName("id")]
public string Id { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("time")]
public string Time { get; init; } = DateTime.UtcNow.ToString("O");
[JsonPropertyName("server")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventServerInfo? Server { get; set; }
[JsonPropertyName("leaf_node_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LeafNodeId { get; set; }
[JsonPropertyName("leaf_node_name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LeafNodeName { get; set; }
[JsonPropertyName("remote_url")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RemoteUrl { get; set; }
[JsonPropertyName("account")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Account { get; set; }
}
/// <summary>
/// Leaf node disconnect advisory.
/// Go reference: events.go — leaf node lifecycle.
/// </summary>
public sealed class LeafNodeDisconnectEvent
{
public const string EventType = "io.nats.server.advisory.v1.leafnode_disconnect";
[JsonPropertyName("type")]
public string Type { get; init; } = EventType;
[JsonPropertyName("id")]
public string Id { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("time")]
public string Time { get; init; } = DateTime.UtcNow.ToString("O");
[JsonPropertyName("server")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventServerInfo? Server { get; set; }
[JsonPropertyName("leaf_node_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LeafNodeId { get; set; }
[JsonPropertyName("reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reason { get; set; }
}
/// <summary>
/// Account numeric connections request.
/// Go reference: events.go:233-236 accNumConnsReq.
@@ -552,3 +689,148 @@ public sealed class AccNumConnsReq
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
}
/// <summary>
/// Factory helpers that construct fully-populated system event messages,
/// mirroring Go's inline struct initialization patterns in events.go.
/// Go reference: events.go sendConnectEventMsg, sendDisconnectEventMsg,
/// sendAccountNumConns, sendServerStats.
/// </summary>
public static class EventBuilder
{
/// <summary>
/// Build a ConnectEventMsg with all required fields.
/// Go reference: events.go sendConnectEventMsg (around line 636).
/// </summary>
public static ConnectEventMsg BuildConnectEvent(
string serverId, string serverName, string? cluster,
ulong clientId, string host, string? account, string? user, string? name,
string? lang, string? version) =>
new()
{
Id = GenerateEventId(),
Time = DateTime.UtcNow,
Server = new EventServerInfo
{
Id = serverId,
Name = serverName,
Cluster = cluster,
},
Client = new EventClientInfo
{
Id = clientId,
Host = host,
Account = account,
User = user,
Name = name,
Lang = lang,
Version = version,
Start = DateTime.UtcNow,
},
};
/// <summary>
/// Build a DisconnectEventMsg with all required fields.
/// Go reference: events.go sendDisconnectEventMsg (around line 668).
/// </summary>
public static DisconnectEventMsg BuildDisconnectEvent(
string serverId, string serverName, string? cluster,
ulong clientId, string host, string? account, string? user,
string reason, DataStats sent, DataStats received) =>
new()
{
Id = GenerateEventId(),
Time = DateTime.UtcNow,
Server = new EventServerInfo
{
Id = serverId,
Name = serverName,
Cluster = cluster,
},
Client = new EventClientInfo
{
Id = clientId,
Host = host,
Account = account,
User = user,
Stop = DateTime.UtcNow,
},
Reason = reason,
Sent = sent,
Received = received,
};
/// <summary>
/// Build an AccountNumConns event.
/// Go reference: events.go sendAccountNumConns (around line 714).
/// </summary>
public static AccountNumConns BuildAccountConnsEvent(
string serverId, string serverName,
string accountName, int connections, int leafNodes,
int totalConnections, int numSubscriptions,
DataStats sent, DataStats received, long slowConsumers) =>
new()
{
Id = GenerateEventId(),
Time = DateTime.UtcNow,
Server = new EventServerInfo
{
Id = serverId,
Name = serverName,
},
AccountName = accountName,
Connections = connections,
LeafNodes = leafNodes,
TotalConnections = totalConnections,
NumSubscriptions = (uint)numSubscriptions,
Sent = sent,
Received = received,
SlowConsumers = slowConsumers,
};
/// <summary>
/// Build a ServerStatsMsg.
/// Go reference: events.go sendStatsz (around line 742).
/// </summary>
public static ServerStatsMsg BuildServerStats(
string serverId, string serverName,
long mem, int cores, double cpu,
int connections, int totalConnections, int activeAccounts,
int subscriptions, DataStats sent, DataStats received) =>
new()
{
Server = new EventServerInfo
{
Id = serverId,
Name = serverName,
},
Stats = new ServerStatsData
{
Start = DateTime.UtcNow,
Mem = mem,
Cores = cores,
Cpu = cpu,
Connections = connections,
TotalConnections = totalConnections,
ActiveAccounts = activeAccounts,
Subscriptions = subscriptions,
Sent = sent,
Received = received,
InMsgs = received.Msgs,
OutMsgs = sent.Msgs,
InBytes = received.Bytes,
OutBytes = sent.Bytes,
},
};
/// <summary>
/// Generate a unique event ID. Go uses nuid for this.
/// Go reference: events.go — nuid.Next() for event IDs.
/// </summary>
public static string GenerateEventId() => Guid.NewGuid().ToString("N");
/// <summary>
/// Get an ISO 8601 timestamp string for embedding in events.
/// </summary>
public static string GetTimestamp() => DateTime.UtcNow.ToString("O");
}

View File

@@ -464,6 +464,126 @@ public static class ConnzSorter
}
}
// ---------------------------------------------------------------------------
// Task 89 — closed connection reason tracking (Gap 10.7)
// ---------------------------------------------------------------------------
/// <summary>
/// Public enum representing the reason a client connection was closed.
/// Used in <see cref="ClosedClient.Reason"/> for /connz monitoring output.
/// Go reference: server/monitor.go ClosedState.String() — the set of reasons
/// surfaced to monitoring consumers.
/// </summary>
public enum ClosedReason
{
Unknown,
ClientClosed,
ServerShutdown,
AuthTimeout,
AuthViolation,
MaxConnectionsExceeded,
SlowConsumer,
WriteError,
ReadError,
ParseError,
StaleConnection,
MaxPayloadExceeded,
MaxSubscriptionsExceeded,
DuplicateRoute,
AccountExpired,
Revoked,
ServerError,
KickedByOperator,
}
/// <summary>
/// Helpers for converting <see cref="ClosedReason"/> to and from the Go-compatible
/// reason strings emitted by the reference server's /connz endpoint.
/// Go reference: server/monitor.go ClosedState.String().
/// </summary>
public static class ClosedReasonHelper
{
/// <summary>
/// Returns the Go-compatible human-readable string for the given reason.
/// These strings match the Go server's monitor.go ClosedState.String() output
/// so that tooling consuming the /connz endpoint sees identical values.
/// </summary>
public static string ToReasonString(ClosedReason reason) => reason switch
{
ClosedReason.ClientClosed => "Client Closed",
ClosedReason.ServerShutdown => "Server Shutdown",
ClosedReason.AuthTimeout => "Authentication Timeout",
ClosedReason.AuthViolation => "Authentication Failure",
ClosedReason.MaxConnectionsExceeded => "Maximum Connections Exceeded",
ClosedReason.SlowConsumer => "Slow Consumer (Pending Bytes)",
ClosedReason.WriteError => "Write Error",
ClosedReason.ReadError => "Read Error",
ClosedReason.ParseError => "Parse Error",
ClosedReason.StaleConnection => "Stale Connection",
ClosedReason.MaxPayloadExceeded => "Maximum Message Payload Exceeded",
ClosedReason.MaxSubscriptionsExceeded => "Maximum Subscriptions Exceeded",
ClosedReason.DuplicateRoute => "Duplicate Route",
ClosedReason.AccountExpired => "Authentication Expired",
ClosedReason.Revoked => "Credentials Revoked",
ClosedReason.ServerError => "Internal Server Error",
ClosedReason.KickedByOperator => "Kicked",
_ => "Unknown",
};
/// <summary>
/// Parses a Go-compatible reason string back to a <see cref="ClosedReason"/>.
/// Returns <see cref="ClosedReason.Unknown"/> for null, empty, or unrecognised values.
/// </summary>
public static ClosedReason FromReasonString(string? reason) => reason switch
{
"Client Closed" => ClosedReason.ClientClosed,
"Server Shutdown" => ClosedReason.ServerShutdown,
"Authentication Timeout" => ClosedReason.AuthTimeout,
"Authentication Failure" => ClosedReason.AuthViolation,
"Maximum Connections Exceeded" => ClosedReason.MaxConnectionsExceeded,
"Slow Consumer (Pending Bytes)" => ClosedReason.SlowConsumer,
"Write Error" => ClosedReason.WriteError,
"Read Error" => ClosedReason.ReadError,
"Parse Error" => ClosedReason.ParseError,
"Stale Connection" => ClosedReason.StaleConnection,
"Maximum Message Payload Exceeded" => ClosedReason.MaxPayloadExceeded,
"Maximum Subscriptions Exceeded" => ClosedReason.MaxSubscriptionsExceeded,
"Duplicate Route" => ClosedReason.DuplicateRoute,
"Authentication Expired" => ClosedReason.AccountExpired,
"Credentials Revoked" => ClosedReason.Revoked,
"Internal Server Error" => ClosedReason.ServerError,
"Kicked" => ClosedReason.KickedByOperator,
_ => ClosedReason.Unknown,
};
/// <summary>
/// Returns true when the close was initiated by the client itself (not the server).
/// Go reference: server/client.go closeConnection — client-side disconnect path.
/// </summary>
public static bool IsClientInitiated(ClosedReason reason) =>
reason == ClosedReason.ClientClosed;
/// <summary>
/// Returns true when the close was caused by an authentication or authorisation failure.
/// Go reference: server/auth.go getAuthErrClosedState.
/// </summary>
public static bool IsAuthRelated(ClosedReason reason) =>
reason is ClosedReason.AuthTimeout
or ClosedReason.AuthViolation
or ClosedReason.AccountExpired
or ClosedReason.Revoked;
/// <summary>
/// Returns true when the close was caused by a resource limit being exceeded.
/// Go reference: server/client.go maxPayloadExceeded / maxSubscriptionsExceeded paths.
/// </summary>
public static bool IsResourceLimit(ClosedReason reason) =>
reason is ClosedReason.MaxConnectionsExceeded
or ClosedReason.MaxPayloadExceeded
or ClosedReason.MaxSubscriptionsExceeded
or ClosedReason.SlowConsumer;
}
// ---------------------------------------------------------------------------
// Internal types — used by ConnzHandler and existing sort logic
// ---------------------------------------------------------------------------