pgbus 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +115 -0
  3. data/Rakefile +56 -3
  4. data/app/controllers/pgbus/dead_letter_controller.rb +5 -1
  5. data/app/helpers/pgbus/application_helper.rb +12 -2
  6. data/app/views/pgbus/dashboard/_processes_table.html.erb +1 -1
  7. data/app/views/pgbus/dashboard/_recent_failures.html.erb +1 -1
  8. data/app/views/pgbus/dashboard/_stats_cards.html.erb +1 -0
  9. data/app/views/pgbus/dead_letter/_messages_table.html.erb +33 -1
  10. data/app/views/pgbus/insights/show.html.erb +1 -1
  11. data/app/views/pgbus/jobs/_failed_table.html.erb +1 -1
  12. data/app/views/pgbus/processes/_processes_table.html.erb +1 -1
  13. data/app/views/pgbus/queues/show.html.erb +3 -0
  14. data/config/locales/da.yml +8 -0
  15. data/config/locales/de.yml +8 -0
  16. data/config/locales/en.yml +8 -0
  17. data/config/locales/es.yml +8 -0
  18. data/config/locales/fi.yml +8 -0
  19. data/config/locales/fr.yml +8 -0
  20. data/config/locales/it.yml +8 -0
  21. data/config/locales/ja.yml +8 -0
  22. data/config/locales/nb.yml +8 -0
  23. data/config/locales/nl.yml +8 -0
  24. data/config/locales/pt.yml +8 -0
  25. data/config/locales/sv.yml +8 -0
  26. data/lib/pgbus/cli.rb +16 -0
  27. data/lib/pgbus/client.rb +34 -7
  28. data/lib/pgbus/configuration.rb +17 -1
  29. data/lib/pgbus/integrations/appsignal/dashboard.json +353 -0
  30. data/lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json +224 -85
  31. data/lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json +154 -63
  32. data/lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json +218 -79
  33. data/lib/pgbus/integrations/appsignal/probe.rb +17 -2
  34. data/lib/pgbus/integrations/appsignal.rb +17 -0
  35. data/lib/pgbus/mcp/base_tool.rb +79 -0
  36. data/lib/pgbus/mcp/health_analyzer.rb +159 -0
  37. data/lib/pgbus/mcp/rack_app.rb +97 -0
  38. data/lib/pgbus/mcp/redactor.rb +72 -0
  39. data/lib/pgbus/mcp/runner.rb +73 -0
  40. data/lib/pgbus/mcp/server.rb +62 -0
  41. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +72 -0
  42. data/lib/pgbus/mcp/tools/dlq_tool.rb +48 -0
  43. data/lib/pgbus/mcp/tools/health_tool.rb +33 -0
  44. data/lib/pgbus/mcp/tools/job_detail_tool.rb +42 -0
  45. data/lib/pgbus/mcp/tools/jobs_tool.rb +56 -0
  46. data/lib/pgbus/mcp/tools/locks_tool.rb +28 -0
  47. data/lib/pgbus/mcp/tools/processes_tool.rb +32 -0
  48. data/lib/pgbus/mcp/tools/queue_detail_tool.rb +46 -0
  49. data/lib/pgbus/mcp/tools/queues_tool.rb +28 -0
  50. data/lib/pgbus/mcp/tools/recurring_tool.rb +27 -0
  51. data/lib/pgbus/mcp/tools/stats_tool.rb +40 -0
  52. data/lib/pgbus/mcp/tools/throughput_tool.rb +35 -0
  53. data/lib/pgbus/mcp.rb +55 -0
  54. data/lib/pgbus/process/heartbeat.rb +8 -2
  55. data/lib/pgbus/process/supervisor.rb +44 -0
  56. data/lib/pgbus/process/worker.rb +12 -1
  57. data/lib/pgbus/version.rb +1 -1
  58. data/lib/pgbus/web/data_source.rb +40 -2
  59. data/lib/pgbus.rb +15 -1
  60. metadata +21 -1
@@ -1,65 +1,156 @@
1
1
  {
2
- "title": "Pgbus — Streams",
3
- "description": "Real-time SSE pub/sub. Broadcasts, fanout, active connections, and the outbox/recurring scheduler.",
4
- "graphs": [
5
- {
6
- "title": "Stream broadcasts per minute",
7
- "line_label": "%stream %deferred",
8
- "format": "number",
9
- "kind": "timeseries",
10
- "draw_null_as_zero": true,
11
- "metrics": [
12
- { "name": "pgbus_stream_broadcast_count", "fields": ["COUNTER"], "tags": [] }
13
- ]
14
- },
15
- {
16
- "title": "Active SSE connections",
17
- "description": "Estimated from connect/disconnect events in the last 60 minutes. Use as a rough capacity gauge — exact count requires the SSE process telemetry.",
18
- "format": "number",
19
- "kind": "timeseries",
20
- "metrics": [
21
- { "name": "pgbus_stream_active_connections", "fields": ["GAUGE"], "tags": [] }
22
- ]
23
- },
24
- {
25
- "title": "Average fanout per broadcast",
26
- "description": "Mean number of connections that received each broadcast over the last hour.",
27
- "format": "number",
28
- "kind": "timeseries",
29
- "metrics": [
30
- { "name": "pgbus_stream_avg_fanout", "fields": ["GAUGE"], "tags": [] }
31
- ]
32
- },
33
- {
34
- "title": "Broadcast payload size (bytes)",
35
- "description": "Distribution of payload bytes. Use to spot accidentally-streaming-an-entire-page bugs.",
36
- "line_label": "%stream",
37
- "format": "size",
38
- "format_input": "byte",
39
- "kind": "timeseries",
40
- "metrics": [
41
- { "name": "pgbus_stream_broadcast_bytes", "fields": ["MEAN", "P95"], "tags": [] }
42
- ]
43
- },
44
- {
45
- "title": "Outbox publishes per minute",
46
- "line_label": "%kind",
47
- "format": "number",
48
- "kind": "timeseries",
49
- "draw_null_as_zero": true,
50
- "metrics": [
51
- { "name": "pgbus_outbox_published", "fields": ["COUNTER"], "tags": [] }
52
- ]
53
- },
54
- {
55
- "title": "Recurring tasks enqueued per minute",
56
- "line_label": "%task",
57
- "format": "number",
58
- "kind": "timeseries",
59
- "draw_null_as_zero": true,
60
- "metrics": [
61
- { "name": "pgbus_recurring_enqueued", "fields": ["COUNTER"], "tags": [] }
62
- ]
63
- }
64
- ]
2
+ "metric_keys": [
3
+ "pgbus_stream_broadcast_count"
4
+ ],
5
+ "dashboard": {
6
+ "title": "Pgbus Streams",
7
+ "description": "Real-time SSE pub/sub. Broadcasts, fanout, active connections,\nand the outbox/recurring scheduler.\n",
8
+ "visuals": [
9
+ {
10
+ "type": "timeseries",
11
+ "display": "LINE",
12
+ "title": "Stream broadcasts per minute",
13
+ "line_label": "%stream% - %deferred%",
14
+ "format": "number",
15
+ "draw_null_as_zero": true,
16
+ "metrics": [
17
+ {
18
+ "name": "pgbus_stream_broadcast_count",
19
+ "fields": [
20
+ {
21
+ "field": "COUNTER"
22
+ }
23
+ ],
24
+ "tags": [
25
+ {
26
+ "key": "stream",
27
+ "value": "*"
28
+ },
29
+ {
30
+ "key": "deferred",
31
+ "value": "*"
32
+ }
33
+ ]
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "type": "timeseries",
39
+ "display": "LINE",
40
+ "title": "Active SSE connections",
41
+ "description": "Estimated from connect/disconnect events in the last 60 minutes.",
42
+ "line_label": "Active connections",
43
+ "format": "number",
44
+ "draw_null_as_zero": true,
45
+ "metrics": [
46
+ {
47
+ "name": "pgbus_stream_active_connections",
48
+ "fields": [
49
+ {
50
+ "field": "GAUGE"
51
+ }
52
+ ],
53
+ "tags": []
54
+ }
55
+ ]
56
+ },
57
+ {
58
+ "type": "timeseries",
59
+ "display": "LINE",
60
+ "title": "Average fanout per broadcast",
61
+ "description": "Mean number of connections that received each broadcast over the last hour.",
62
+ "line_label": "Avg fanout",
63
+ "format": "number",
64
+ "draw_null_as_zero": false,
65
+ "metrics": [
66
+ {
67
+ "name": "pgbus_stream_avg_fanout",
68
+ "fields": [
69
+ {
70
+ "field": "GAUGE"
71
+ }
72
+ ],
73
+ "tags": []
74
+ }
75
+ ]
76
+ },
77
+ {
78
+ "type": "timeseries",
79
+ "display": "LINE",
80
+ "title": "Broadcast payload size (bytes)",
81
+ "description": "Distribution of payload bytes. Use to spot accidentally-streaming-an-entire-page bugs.",
82
+ "line_label": "%stream%",
83
+ "format": "size",
84
+ "format_input": "byte",
85
+ "draw_null_as_zero": false,
86
+ "metrics": [
87
+ {
88
+ "name": "pgbus_stream_broadcast_bytes",
89
+ "fields": [
90
+ {
91
+ "field": "MEAN"
92
+ },
93
+ {
94
+ "field": "P95"
95
+ }
96
+ ],
97
+ "tags": [
98
+ {
99
+ "key": "stream",
100
+ "value": "*"
101
+ }
102
+ ]
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ "type": "timeseries",
108
+ "display": "LINE",
109
+ "title": "Outbox publishes per minute",
110
+ "line_label": "%kind%",
111
+ "format": "number",
112
+ "draw_null_as_zero": true,
113
+ "metrics": [
114
+ {
115
+ "name": "pgbus_outbox_published",
116
+ "fields": [
117
+ {
118
+ "field": "COUNTER"
119
+ }
120
+ ],
121
+ "tags": [
122
+ {
123
+ "key": "kind",
124
+ "value": "*"
125
+ }
126
+ ]
127
+ }
128
+ ]
129
+ },
130
+ {
131
+ "type": "timeseries",
132
+ "display": "LINE",
133
+ "title": "Recurring tasks enqueued per minute",
134
+ "line_label": "%task%",
135
+ "format": "number",
136
+ "draw_null_as_zero": true,
137
+ "metrics": [
138
+ {
139
+ "name": "pgbus_recurring_enqueued",
140
+ "fields": [
141
+ {
142
+ "field": "COUNTER"
143
+ }
144
+ ],
145
+ "tags": [
146
+ {
147
+ "key": "task",
148
+ "value": "*"
149
+ }
150
+ ]
151
+ }
152
+ ]
153
+ }
154
+ ]
155
+ }
65
156
  }
@@ -1,81 +1,220 @@
1
1
  {
2
- "title": "Pgbus — Throughput & Latency",
3
- "description": "Job and event throughput, perform-duration percentiles, and PGMQ send/read counts. Drives the most common 'is the worker keeping up?' question.",
4
- "graphs": [
5
- {
6
- "title": "Jobs processed per minute",
7
- "description": "Successful, failed, and dead-lettered jobs. A spike in failed without a spike in processed usually means a deploy regression.",
8
- "line_label": "%queue %job_class %status",
9
- "format": "number",
10
- "format_input": null,
11
- "kind": "timeseries",
12
- "draw_null_as_zero": true,
13
- "metrics": [
14
- { "name": "pgbus_queue_job_count", "fields": ["COUNTER"], "tags": [] }
15
- ]
16
- },
17
- {
18
- "title": "Job perform duration (ms)",
19
- "description": "Distribution of how long perform_now takes per job class. P95 and P99 are the lines to watch.",
20
- "line_label": "%job_class",
21
- "format": "duration",
22
- "format_input": "millisecond",
23
- "kind": "timeseries",
24
- "metrics": [
25
- { "name": "pgbus_job_duration_ms", "fields": ["MEAN", "P95", "P99"], "tags": [] }
26
- ]
27
- },
28
- {
29
- "title": "Events processed per minute",
30
- "description": "Event-bus handler invocations grouped by routing key.",
31
- "line_label": "%routing_key %handler %status",
32
- "format": "number",
33
- "kind": "timeseries",
34
- "draw_null_as_zero": true,
35
- "metrics": [
36
- { "name": "pgbus_event_count", "fields": ["COUNTER"], "tags": [] }
37
- ]
38
- },
39
- {
40
- "title": "Event handler duration (ms)",
41
- "line_label": "%handler",
42
- "format": "duration",
43
- "format_input": "millisecond",
44
- "kind": "timeseries",
45
- "metrics": [
46
- { "name": "pgbus_event_duration_ms", "fields": ["MEAN", "P95", "P99"], "tags": [] }
47
- ]
48
- },
49
- {
50
- "title": "PGMQ messages sent (per minute)",
51
- "line_label": "%queue",
52
- "format": "number",
53
- "kind": "timeseries",
54
- "draw_null_as_zero": true,
55
- "metrics": [
56
- { "name": "pgbus_messages_sent", "fields": ["COUNTER"], "tags": [] }
57
- ]
58
- },
59
- {
60
- "title": "Send duration (ms)",
61
- "line_label": "%queue",
62
- "format": "duration",
63
- "format_input": "millisecond",
64
- "kind": "timeseries",
65
- "metrics": [
66
- { "name": "pgbus_send_duration_ms", "fields": ["MEAN", "P95", "P99"], "tags": [] }
67
- ]
68
- },
69
- {
70
- "title": "PGMQ messages read (per minute)",
71
- "description": "Messages fetched from queues by workers. Compare against 'sent' to spot backlog growth.",
72
- "line_label": "%queue",
73
- "format": "number",
74
- "kind": "timeseries",
75
- "draw_null_as_zero": true,
76
- "metrics": [
77
- { "name": "pgbus_messages_read", "fields": ["COUNTER"], "tags": [] }
78
- ]
79
- }
80
- ]
2
+ "metric_keys": [
3
+ "pgbus_queue_job_count",
4
+ "pgbus_event_count",
5
+ "pgbus_messages_sent",
6
+ "pgbus_messages_read"
7
+ ],
8
+ "dashboard": {
9
+ "title": "Pgbus — Throughput & Latency",
10
+ "description": "Job and event throughput, perform-duration percentiles, and PGMQ send/read counts.\nDrives the most common 'is the worker keeping up?' question.\n",
11
+ "visuals": [
12
+ {
13
+ "type": "timeseries",
14
+ "display": "LINE",
15
+ "title": "Jobs processed per minute",
16
+ "description": "Successful, failed, and dead-lettered jobs. A spike in failed without a spike in processed usually means a deploy regression.",
17
+ "line_label": "%queue% - %job_class% - %status%",
18
+ "format": "number",
19
+ "draw_null_as_zero": true,
20
+ "metrics": [
21
+ {
22
+ "name": "pgbus_queue_job_count",
23
+ "fields": [
24
+ {
25
+ "field": "COUNTER"
26
+ }
27
+ ],
28
+ "tags": [
29
+ {
30
+ "key": "queue",
31
+ "value": "*"
32
+ },
33
+ {
34
+ "key": "job_class",
35
+ "value": "*"
36
+ },
37
+ {
38
+ "key": "status",
39
+ "value": "*"
40
+ }
41
+ ]
42
+ }
43
+ ]
44
+ },
45
+ {
46
+ "type": "timeseries",
47
+ "display": "LINE",
48
+ "title": "Job perform duration (ms)",
49
+ "description": "Distribution of how long perform_now takes per job class. P95 and P99 are the lines to watch.",
50
+ "line_label": "%job_class%",
51
+ "format": "duration",
52
+ "draw_null_as_zero": false,
53
+ "metrics": [
54
+ {
55
+ "name": "pgbus_job_duration_ms",
56
+ "fields": [
57
+ {
58
+ "field": "MEAN"
59
+ },
60
+ {
61
+ "field": "P95"
62
+ },
63
+ {
64
+ "field": "P99"
65
+ }
66
+ ],
67
+ "tags": [
68
+ {
69
+ "key": "job_class",
70
+ "value": "*"
71
+ }
72
+ ]
73
+ }
74
+ ]
75
+ },
76
+ {
77
+ "type": "timeseries",
78
+ "display": "LINE",
79
+ "title": "Events processed per minute",
80
+ "description": "Event-bus handler invocations grouped by routing key.",
81
+ "line_label": "%routing_key% - %handler% - %status%",
82
+ "format": "number",
83
+ "draw_null_as_zero": true,
84
+ "metrics": [
85
+ {
86
+ "name": "pgbus_event_count",
87
+ "fields": [
88
+ {
89
+ "field": "COUNTER"
90
+ }
91
+ ],
92
+ "tags": [
93
+ {
94
+ "key": "routing_key",
95
+ "value": "*"
96
+ },
97
+ {
98
+ "key": "handler",
99
+ "value": "*"
100
+ },
101
+ {
102
+ "key": "status",
103
+ "value": "*"
104
+ }
105
+ ]
106
+ }
107
+ ]
108
+ },
109
+ {
110
+ "type": "timeseries",
111
+ "display": "LINE",
112
+ "title": "Event handler duration (ms)",
113
+ "line_label": "%handler%",
114
+ "format": "duration",
115
+ "draw_null_as_zero": false,
116
+ "metrics": [
117
+ {
118
+ "name": "pgbus_event_duration_ms",
119
+ "fields": [
120
+ {
121
+ "field": "MEAN"
122
+ },
123
+ {
124
+ "field": "P95"
125
+ },
126
+ {
127
+ "field": "P99"
128
+ }
129
+ ],
130
+ "tags": [
131
+ {
132
+ "key": "handler",
133
+ "value": "*"
134
+ }
135
+ ]
136
+ }
137
+ ]
138
+ },
139
+ {
140
+ "type": "timeseries",
141
+ "display": "LINE",
142
+ "title": "PGMQ messages sent (per minute)",
143
+ "line_label": "%queue%",
144
+ "format": "number",
145
+ "draw_null_as_zero": true,
146
+ "metrics": [
147
+ {
148
+ "name": "pgbus_messages_sent",
149
+ "fields": [
150
+ {
151
+ "field": "COUNTER"
152
+ }
153
+ ],
154
+ "tags": [
155
+ {
156
+ "key": "queue",
157
+ "value": "*"
158
+ }
159
+ ]
160
+ }
161
+ ]
162
+ },
163
+ {
164
+ "type": "timeseries",
165
+ "display": "LINE",
166
+ "title": "Send duration (ms)",
167
+ "line_label": "%queue%",
168
+ "format": "duration",
169
+ "draw_null_as_zero": false,
170
+ "metrics": [
171
+ {
172
+ "name": "pgbus_send_duration_ms",
173
+ "fields": [
174
+ {
175
+ "field": "MEAN"
176
+ },
177
+ {
178
+ "field": "P95"
179
+ },
180
+ {
181
+ "field": "P99"
182
+ }
183
+ ],
184
+ "tags": [
185
+ {
186
+ "key": "queue",
187
+ "value": "*"
188
+ }
189
+ ]
190
+ }
191
+ ]
192
+ },
193
+ {
194
+ "type": "timeseries",
195
+ "display": "LINE",
196
+ "title": "PGMQ messages read (per minute)",
197
+ "description": "Messages fetched from queues by workers. Compare against 'sent' to spot backlog growth.",
198
+ "line_label": "%queue%",
199
+ "format": "number",
200
+ "draw_null_as_zero": true,
201
+ "metrics": [
202
+ {
203
+ "name": "pgbus_messages_read",
204
+ "fields": [
205
+ {
206
+ "field": "COUNTER"
207
+ }
208
+ ],
209
+ "tags": [
210
+ {
211
+ "key": "queue",
212
+ "value": "*"
213
+ }
214
+ ]
215
+ }
216
+ ]
217
+ }
218
+ ]
219
+ }
81
220
  }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "socket"
4
+
3
5
  module Pgbus
4
6
  module Integrations
5
7
  module Appsignal
@@ -10,6 +12,14 @@ module Pgbus
10
12
  # method rescues StandardError and returns a safe default — but we
11
13
  # still wrap each section in our own rescue so a probe iteration
12
14
  # never raises out into the AppSignal probe runner.
15
+ #
16
+ # Tagging policy: most pgbus metrics are cluster-wide (the queue
17
+ # depth in PostgreSQL is the same regardless of which host reads it),
18
+ # so cluster-wide gauges are emitted WITHOUT a hostname tag — every
19
+ # host sends the same value and AppSignal's last-write-wins
20
+ # semantics keep the dashboard correct. The only gauge that is
21
+ # genuinely per-host is `active_processes`, which the probe filters
22
+ # to this host before tagging.
13
23
  module Probe
14
24
  METRIC_PREFIX = "pgbus_"
15
25
  private_constant :METRIC_PREFIX
@@ -43,6 +53,7 @@ module Pgbus
43
53
  class Runner
44
54
  def initialize(data_source: nil)
45
55
  @data_source = data_source
56
+ @hostname = Socket.gethostname
46
57
  end
47
58
 
48
59
  def call
@@ -68,14 +79,18 @@ module Pgbus
68
79
  gauge "queue_visible_depth", q[:queue_visible_length], tags
69
80
  gauge "queue_paused", q[:paused] ? 1 : 0, tags
70
81
  age = q[:oldest_msg_age_sec]
71
- gauge "queue_oldest_message_age_seconds", age, tags if age
82
+ if age
83
+ gauge "queue_oldest_message_age_seconds", age, tags
84
+ gauge "queue_latency", age * 1_000, tags
85
+ end
72
86
  end
73
87
  rescue StandardError => e
74
88
  log_failure("queue metrics", e)
75
89
  end
76
90
 
77
91
  def track_processes
78
- gauge "active_processes", data_source.processes.count
92
+ local_count = data_source.processes.count { |p| p[:hostname] == @hostname }
93
+ gauge "active_processes", local_count, { hostname: @hostname }
79
94
  rescue StandardError => e
80
95
  log_failure("process metrics", e)
81
96
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "pgbus/integrations/appsignal/subscriber"
4
5
  require "pgbus/integrations/appsignal/probe"
5
6
 
@@ -24,6 +25,9 @@ module Pgbus
24
25
  # All metric names are prefixed `pgbus_` so they group cleanly in
25
26
  # AppSignal's custom-metrics view.
26
27
  module Appsignal
28
+ DASHBOARD_PATH = File.expand_path("appsignal/dashboard.json", __dir__).freeze
29
+ DASHBOARDS_DIR = File.expand_path("appsignal/dashboards", __dir__).freeze
30
+
27
31
  module_function
28
32
 
29
33
  def install! # rubocop:disable Naming/PredicateMethod
@@ -41,11 +45,24 @@ module Pgbus
41
45
  @installed == true
42
46
  end
43
47
 
48
+ def dashboard_definition
49
+ @dashboard_definition ||= JSON.parse(File.read(DASHBOARD_PATH))
50
+ end
51
+
52
+ def dashboard_definitions
53
+ @dashboard_definitions ||=
54
+ Dir[File.join(DASHBOARDS_DIR, "*.json")].map do |path|
55
+ JSON.parse(File.read(path))
56
+ end
57
+ end
58
+
44
59
  # Test hook: tear everything down so a fresh install! can run.
45
60
  def reset!
46
61
  Subscriber.reset! if defined?(Subscriber)
47
62
  Probe.reset! if defined?(Probe)
48
63
  @installed = false
64
+ @dashboard_definition = nil
65
+ @dashboard_definitions = nil
49
66
  end
50
67
  end
51
68
  end