diff --git a/bot/bot.go b/bot/bot.go index c656661..16e1666 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -265,6 +265,7 @@ func (b *Bot) connect(ctx context.Context) error { client := irc.NewClient(conn, irc.ClientConfig{Nick: b.Config.Identity.Nick, User: b.Config.Identity.User, Name: b.Config.Identity.Realname, Handler: irc.HandlerFunc(func(c *irc.Client, m *irc.Message) { handleSASL(c, m, b.Config.Identity.SASLUser, b.Config.Identity.SASLPass, mechanism, capState, b.Log) b.logIRCEvent(m) + b.trackOwnChannelMembership(c.CurrentNick(), m) if m.Command == "INVITE" { b.handleInvite(m) } @@ -336,6 +337,9 @@ func (b *Bot) connect(ctx context.Context) error { conn.Close() <-errc b.Stats.setNetworkConnected(b.networkStats, false) + b.mu.Lock() + b.client = nil + b.mu.Unlock() return nil case err := <-errc: b.Stats.setNetworkConnected(b.networkStats, false) @@ -346,6 +350,30 @@ func (b *Bot) connect(ctx context.Context) error { } } +func (b *Bot) trackOwnChannelMembership(currentNick string, message *irc.Message) { + if b.networkStats == nil || message == nil || message.Prefix == nil { + return + } + currentNick = strings.TrimSpace(currentNick) + if currentNick == "" { + currentNick = b.Config.Identity.Nick + } + switch message.Command { + case "JOIN": + if strings.EqualFold(message.Prefix.Name, currentNick) && len(message.Params) > 0 { + b.networkStats.joinChannel(message.Params[0]) + } + case "PART": + if strings.EqualFold(message.Prefix.Name, currentNick) && len(message.Params) > 0 { + b.networkStats.leaveChannel(message.Params[0]) + } + case "KICK": + if len(message.Params) > 1 && strings.EqualFold(message.Params[1], currentNick) { + b.networkStats.leaveChannel(message.Params[0]) + } + } +} + func (b *Bot) handleInvite(m *irc.Message) { if !b.Config.Invites.Enabled || len(m.Params) < 2 { return diff --git a/bot/stats.go b/bot/stats.go index fbec85f..6cf914f 100644 --- a/bot/stats.go +++ b/bot/stats.go @@ -30,6 +30,8 @@ type networkStats struct { name string configuredChannels int queue *Queue + channelMu sync.RWMutex + joinedChannels map[string]string connected atomic.Uint64 received atomic.Uint64 sent atomic.Uint64 @@ -91,11 +93,56 @@ func (s *Stats) registerNetwork(name string, configuredChannels int, queue *Queu if existing := s.networks[name]; existing != nil { return existing } - network := &networkStats{name: name, configuredChannels: configuredChannels, queue: queue} + network := &networkStats{name: name, configuredChannels: configuredChannels, queue: queue, joinedChannels: make(map[string]string)} s.networks[name] = network return network } +func (network *networkStats) joinChannel(channel string) { + if network == nil { + return + } + channel = strings.TrimSpace(channel) + if channel == "" { + return + } + network.channelMu.Lock() + network.joinedChannels[strings.ToLower(channel)] = channel + network.channelMu.Unlock() +} + +func (network *networkStats) leaveChannel(channel string) { + if network == nil { + return + } + network.channelMu.Lock() + delete(network.joinedChannels, strings.ToLower(strings.TrimSpace(channel))) + network.channelMu.Unlock() +} + +func (network *networkStats) clearJoinedChannels() { + if network == nil { + return + } + network.channelMu.Lock() + clear(network.joinedChannels) + network.channelMu.Unlock() +} + +func (network *networkStats) sortedJoinedChannels() []string { + if network == nil { + return nil + } + network.channelMu.RLock() + channels := make([]string, 0, len(network.joinedChannels)) + for _, channel := range network.joinedChannels { + channels = append(channels, channel) + } + network.channelMu.RUnlock() + sort.Slice(channels, func(i, j int) bool { return strings.ToLower(channels[i]) < strings.ToLower(channels[j]) }) + return channels +} + func (s *Stats) setNetworkConnected(network *networkStats, value bool) { if network == nil { if value { @@ -105,6 +152,9 @@ func (s *Stats) setNetworkConnected(network *networkStats, value bool) { } return } + if !value { + network.clearJoinedChannels() + } desired := uint64(0) delta := int64(-1) if value { @@ -201,20 +251,23 @@ func (s *Stats) networkSnapshot() map[string]interface{} { networks := make(map[string]interface{}) for _, network := range s.sortedNetworks() { depth, capacity := 0, 0 + joinedChannels := network.sortedJoinedChannels() if network.queue != nil { depth = network.queue.Depth() capacity = network.queue.Capacity() } networks[network.name] = map[string]interface{}{ - "connected": network.connected.Load() == 1, - "reconnects": network.reconnects.Load(), - "messages_received": network.received.Load(), - "messages_sent": network.sent.Load(), - "messages_dropped": network.dropped.Load(), - "commands_handled": network.commands.Load(), - "configured_channels": network.configuredChannels, - "queue_depth": depth, - "queue_capacity": capacity, + "connected": network.connected.Load() == 1, + "reconnects": network.reconnects.Load(), + "messages_received": network.received.Load(), + "messages_sent": network.sent.Load(), + "messages_dropped": network.dropped.Load(), + "commands_handled": network.commands.Load(), + "configured_channels": network.configuredChannels, + "joined_channel_count": len(joinedChannels), + "joined_channels": joinedChannels, + "queue_depth": depth, + "queue_capacity": capacity, } } return networks @@ -254,6 +307,12 @@ func (s *Stats) PrometheusSnapshot() string { writer.metric("bot_network_commands_handled_total", "Commands handled during the current process lifetime.", "counter", labels, network.commands.Load()) writer.metric("bot_network_messages_dropped_total", "Messages dropped because the network outbound queue was full.", "counter", labels, network.dropped.Load()) writer.metric("bot_network_configured_channels", "Configured IRC channels for the network.", "gauge", labels, network.configuredChannels) + joinedChannels := network.sortedJoinedChannels() + writer.metric("bot_network_joined_channels", "IRC channels the bot is currently joined to on the network.", "gauge", labels, len(joinedChannels)) + for _, channel := range joinedChannels { + channelLabels := append(append([]metricLabel{}, labels...), metricLabel{name: "channel", value: channel}) + writer.metric("bot_network_channel_joined", "Current IRC channel membership for the bot.", "gauge", channelLabels, 1) + } depth, capacity := 0, 0 if network.queue != nil { depth = network.queue.Depth() diff --git a/bot/stats_test.go b/bot/stats_test.go index 322d583..12dac6d 100644 --- a/bot/stats_test.go +++ b/bot/stats_test.go @@ -7,6 +7,7 @@ import ( "github.com/variablenix/GoBot/storage" "go.uber.org/zap" + "gopkg.in/irc.v3" ) func TestStatsListenAddress(t *testing.T) { @@ -52,6 +53,7 @@ func TestExpandedPrometheusMetricsRemainBackwardCompatible(t *testing.T) { network.received.Store(7) network.sent.Store(5) network.reconnects.Store(2) + network.joinChannel("#GoBot") stats.recordCommand(network, "help") stats.recordPluginPanic(network, "weather", "message") if !queue.Enqueue(Outgoing{Target: "#test", Text: "queued"}) { @@ -71,6 +73,8 @@ func TestExpandedPrometheusMetricsRemainBackwardCompatible(t *testing.T) { `bot_network_messages_received_total{network="libera"} 7`, `bot_network_messages_sent_total{network="libera"} 5`, `bot_network_configured_channels{network="libera"} 3`, + `bot_network_joined_channels{network="libera"} 1`, + `bot_network_channel_joined{network="libera",channel="#GoBot"} 1`, `bot_outgoing_queue_depth{network="libera"} 1`, `bot_outgoing_queue_capacity{network="libera"} 40`, `bot_plugin_commands_handled_total{network="libera",plugin="help"} 1`, @@ -85,11 +89,47 @@ func TestExpandedPrometheusMetricsRemainBackwardCompatible(t *testing.T) { t.Fatal("Snapshot() networks has an unexpected type") } libera, ok := networks["libera"].(map[string]interface{}) - if !ok || libera["configured_channels"] != 3 || libera["queue_depth"] != 1 { + joinedChannels, channelsOK := libera["joined_channels"].([]string) + if !ok || libera["configured_channels"] != 3 || libera["joined_channel_count"] != 1 || !channelsOK || len(joinedChannels) != 1 || joinedChannels[0] != "#GoBot" || libera["queue_depth"] != 1 { t.Fatalf("Snapshot() network details = %#v", networks["libera"]) } } +func TestOwnChannelMembershipTracksJoinPartKickAndDisconnect(t *testing.T) { + stats := NewStats() + instance := NewWithStats(Config{NetworkName: "libera", Identity: IdentityConfig{Nick: "GoBot"}}, nil, nil, zap.NewNop(), stats) + defer instance.Queue.Drain(context.Background()) + + instance.trackOwnChannelMembership("GoBot", &irc.Message{Prefix: &irc.Prefix{Name: "GoBot"}, Command: "JOIN", Params: []string{"#One"}}) + instance.trackOwnChannelMembership("GoBot", &irc.Message{Prefix: &irc.Prefix{Name: "someone"}, Command: "JOIN", Params: []string{"#Ignored"}}) + instance.trackOwnChannelMembership("GoBot", &irc.Message{Prefix: &irc.Prefix{Name: "GoBot"}, Command: "JOIN", Params: []string{"#two"}}) + instance.trackOwnChannelMembership("GoBot", &irc.Message{Prefix: &irc.Prefix{Name: "GoBot"}, Command: "PART", Params: []string{"#ONE"}}) + instance.trackOwnChannelMembership("GoBot", &irc.Message{Prefix: &irc.Prefix{Name: "operator"}, Command: "KICK", Params: []string{"#two", "gobot", "reason"}}) + + if channels := instance.networkStats.sortedJoinedChannels(); len(channels) != 0 { + t.Fatalf("joined channels after PART and KICK = %v, want none", channels) + } + instance.trackOwnChannelMembership("GoBot", &irc.Message{Prefix: &irc.Prefix{Name: "GoBot"}, Command: "JOIN", Params: []string{"#rejoined"}}) + stats.setNetworkConnected(instance.networkStats, false) + if channels := instance.networkStats.sortedJoinedChannels(); len(channels) != 0 { + t.Fatalf("joined channels after disconnect clear = %v, want none", channels) + } +} + +func TestJoinedChannelMetricLabelsAreEscapedAndSorted(t *testing.T) { + stats := NewStats() + network := stats.registerNetwork("libera", 0, nil) + network.joinChannel("#z") + network.joinChannel("#a\\\"") + + metrics := stats.PrometheusSnapshot() + first := strings.Index(metrics, `channel="#a\\\""`) + second := strings.Index(metrics, `channel="#z"`) + if first < 0 || second < 0 || first >= second { + t.Fatalf("joined channel labels were not escaped and sorted:\n%s", metrics) + } +} + type panicMetricsPlugin struct{} func (*panicMetricsPlugin) Name() string { return "panic-test" } diff --git a/docs/monitoring.md b/docs/monitoring.md index 9e2cc39..ede370d 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -46,6 +46,8 @@ GoBot also exposes operational metrics for richer dashboards: | `bot_network_commands_handled_total{network}` | counter | Per-network handled commands since process start | | `bot_network_messages_dropped_total{network}` | counter | Per-network outbound messages dropped since process start | | `bot_network_configured_channels{network}` | gauge | Configured channels per network | +| `bot_network_joined_channels{network}` | gauge | Channels the bot is currently joined to per network | +| `bot_network_channel_joined{network,channel}` | gauge | Current channel membership; one series with value 1 per joined channel | | `bot_outgoing_queue_depth{network}` | gauge | Messages currently waiting to be sent | | `bot_outgoing_queue_capacity{network}` | gauge | Maximum outbound queue size | | `bot_plugin_commands_handled_total{network,plugin}` | counter | Handled commands grouped by plugin | @@ -53,13 +55,15 @@ GoBot also exposes operational metrics for richer dashboards: Per-network and per-plugin counters reset when the GoBot process restarts; Prometheus `rate()` and `increase()` account for counter resets. Labels are -limited to configured network names, built-in plugin names, and the bounded -handler type. Channel names, nicknames, accounts, and message contents are not -exported. +limited to network names, the bot's current joined channels, built-in plugin +names, and the bounded handler type. Nicknames, accounts, and message contents +are not exported. Because joined channel names are exposed as metric labels, +keep `/metrics` on a private monitoring network. The `/stats` JSON response includes a `networks` object with connection, -traffic, command, reconnect, queue, and configured-channel details for each -network. +traffic, command, reconnect, queue, configured-channel, and current joined- +channel details for each network. Membership is updated from the bot's own +JOIN, PART, and KICK events and cleared whenever that IRC connection ends. ## Prometheus scrape configuration diff --git a/grafana/README.md b/grafana/README.md index 22baf42..a2f6594 100644 --- a/grafana/README.md +++ b/grafana/README.md @@ -7,6 +7,8 @@ dashboard for GoBot's `/metrics` endpoint. It covers: - process uptime and reliability events - per-network incoming and outgoing message rates - handled-command rates grouped by plugin +- the ten most-used plugins during the current GoBot process lifetime +- current networks and channels GoBot has actually joined - outbound queue depth and capacity by network - filtering by Prometheus job, environment, hostname, instance, and IRC network @@ -24,7 +26,8 @@ curl http://10.69.0.22:8082/metrics Along with the original `bot_*` metrics, the response should include `bot_network_connected`, `bot_network_messages_received_total`, -`bot_plugin_commands_handled_total`, and `bot_outgoing_queue_depth`. +`bot_plugin_commands_handled_total`, `bot_network_channel_joined`, and +`bot_outgoing_queue_depth`. ## 2. Configure Prometheus @@ -76,8 +79,14 @@ The dashboard refreshes every 30 seconds, matching the Prometheus scrape interval. A newly started bot may need two scrapes (about one minute) before rate panels have enough samples to draw a line. +The **Most-used plugins** panel counts handled commands since the current +GoBot process started, so its ranking resets after a service restart. The +**Joined networks and channels** panel reflects live JOIN/PART/KICK state and +clears a network's channels when its IRC connection ends. + ## Security note GoBot's `/stats` and `/metrics` endpoints do not provide authentication. Bind the listener to a private address and allow access only from the Prometheus -host through the firewall. +host through the firewall. The membership metric includes joined channel +names as labels; it does not expose users, accounts, or message contents. diff --git a/grafana/dashboard_test.go b/grafana/dashboard_test.go index 43618b9..751a634 100644 --- a/grafana/dashboard_test.go +++ b/grafana/dashboard_test.go @@ -52,6 +52,8 @@ func TestDashboardJSONUsesExpandedMetrics(t *testing.T) { "Command throughput by plugin", "Outbound queue pressure", "Uptime", + "Most-used plugins", + "Joined networks and channels", } { if !panelTitles[title] { t.Errorf("dashboard is missing panel %q", title) @@ -79,6 +81,7 @@ func TestDashboardJSONUsesExpandedMetrics(t *testing.T) { "bot_plugin_panics_total", "bot_outgoing_queue_depth", "bot_outgoing_queue_capacity", + "bot_network_channel_joined", } { if !strings.Contains(text, metric) { t.Errorf("dashboard does not query %s", metric) diff --git a/grafana/gobot-dashboard.json b/grafana/gobot-dashboard.json index f1a566d..cf137b2 100644 --- a/grafana/gobot-dashboard.json +++ b/grafana/gobot-dashboard.json @@ -3,7 +3,7 @@ "kind": "Dashboard", "metadata": { "name": "gobot-operations", - "generation": 6, + "generation": 7, "creationTimestamp": "2026-08-01T07:11:36Z", "labels": {}, "annotations": {} @@ -788,6 +788,180 @@ } } } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Most-used plugins", + "description": "Top plugins by commands handled during the current GoBot process lifetime. Counts reset when GoBot restarts.", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "spec": { + "expr": "topk(10, sum by (plugin) (bot_plugin_commands_handled_total{job=~\"$job\", environment=~\"$environment\", hostname=~\"$hostname\", instance=~\"$instance\", network=~\"$network\"}))", + "legendFormat": "{{plugin}}" + }, + "labels": { + "grafana.app/export-label": "prometheus-1" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "13.0.2", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-9": { + "kind": "Panel", + "spec": { + "id": 9, + "title": "Joined networks and channels", + "description": "GoBot's current IRC channel memberships. Only the bot's own network and channel labels are exported.", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "spec": { + "expr": "max by (network, channel) (bot_network_channel_joined{job=~\"$job\", environment=~\"$environment\", hostname=~\"$hostname\", instance=~\"$instance\", network=~\"$network\"})", + "legendFormat": "{{network}} / {{channel}}" + }, + "labels": { + "grafana.app/export-label": "prometheus-1" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "13.0.2", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "mappings": [ + { + "type": "value", + "options": { + "1": { + "text": "Joined", + "color": "green" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "red" + }, + { + "value": 1, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -884,6 +1058,32 @@ "name": "panel-6" } } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 22, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 22, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-9" + } + } } ] }