rails_pulse 0.4.2 → 0.5.0.pre.2

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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +15 -4
  4. data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
  5. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
  6. data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
  7. data/app/controllers/concerns/chart_table_concern.rb +28 -69
  8. data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
  9. data/app/controllers/concerns/metric_card_concern.rb +6 -7
  10. data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
  11. data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
  12. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
  13. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
  15. data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
  16. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  17. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  18. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
  19. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
  20. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
  21. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  22. data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
  23. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  24. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  25. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  26. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  27. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  28. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  29. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  30. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  31. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  32. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  33. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  34. data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
  35. data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
  36. data/app/jobs/rails_pulse/summary_job.rb +13 -0
  37. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  38. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  39. data/app/models/rails_pulse/cards/base.rb +20 -11
  40. data/app/models/rails_pulse/charts/base.rb +5 -18
  41. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
  42. data/app/models/rails_pulse/cleanup_run.rb +32 -0
  43. data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
  44. data/app/models/rails_pulse/cloud/installation.rb +55 -0
  45. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  46. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  47. data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
  48. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
  49. data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
  50. data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
  51. data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
  52. data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
  53. data/app/models/rails_pulse/deployment.rb +2 -7
  54. data/app/models/rails_pulse/event.rb +41 -0
  55. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  56. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  57. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  58. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  59. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
  60. data/app/models/rails_pulse/job_run.rb +1 -1
  61. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  62. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  63. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  64. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
  65. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
  67. data/app/models/rails_pulse/like_pattern.rb +35 -0
  68. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  69. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  70. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  71. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  72. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
  73. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  74. data/app/models/rails_pulse/request.rb +1 -1
  75. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  76. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  77. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  78. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  79. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  80. data/app/models/rails_pulse/summary.rb +5 -0
  81. data/app/models/rails_pulse/tables/base.rb +5 -6
  82. data/app/models/rails_pulse/time_range.rb +309 -0
  83. data/app/models/rails_pulse/time_window.rb +15 -0
  84. data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
  85. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
  86. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  87. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  88. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  89. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  90. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  91. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
  92. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  93. data/app/services/rails_pulse/cloud/batch.rb +83 -0
  94. data/app/services/rails_pulse/cloud/client.rb +68 -0
  95. data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
  96. data/app/services/rails_pulse/cloud/health_item.rb +77 -0
  97. data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
  98. data/app/services/rails_pulse/cloud/preview.rb +83 -0
  99. data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
  100. data/app/services/rails_pulse/cloud/record_items.rb +60 -0
  101. data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
  102. data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
  103. data/app/services/rails_pulse/cloud/sync.rb +275 -0
  104. data/app/services/rails_pulse/config_recommendations.rb +132 -0
  105. data/app/services/rails_pulse/deployment_comparison.rb +177 -0
  106. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  107. data/app/services/rails_pulse/operations/change_point.rb +1 -1
  108. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  109. data/app/services/rails_pulse/period_insights.rb +131 -0
  110. data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
  111. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  112. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  113. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  114. data/app/services/rails_pulse/summary_service.rb +121 -133
  115. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  116. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  117. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  118. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  119. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  120. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  121. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  122. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
  123. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  124. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  125. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  126. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  127. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  128. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  129. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  130. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  131. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  132. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  133. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  134. data/app/views/rails_pulse/storage/show.html.erb +39 -2
  135. data/config/routes.rb +23 -0
  136. data/db/rails_pulse_cloud_schema.rb +58 -0
  137. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  138. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  139. data/db/rails_pulse_schema.rb +20 -2
  140. data/exe/rails-pulse +7 -0
  141. data/lib/generators/rails_pulse/base_methods.rb +19 -0
  142. data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
  143. data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
  144. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  145. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
  146. data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
  147. data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
  148. data/lib/rails_pulse/cleanup_service.rb +37 -20
  149. data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
  150. data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
  151. data/lib/rails_pulse/cli/base_command.rb +37 -0
  152. data/lib/rails_pulse/cli/client.rb +64 -0
  153. data/lib/rails_pulse/cli/config.rb +87 -0
  154. data/lib/rails_pulse/cli/configure.rb +75 -0
  155. data/lib/rails_pulse/cli/coverage.rb +91 -0
  156. data/lib/rails_pulse/cli/deployments.rb +54 -0
  157. data/lib/rails_pulse/cli/exceptions.rb +116 -0
  158. data/lib/rails_pulse/cli/formatter.rb +31 -0
  159. data/lib/rails_pulse/cli/insights.rb +70 -0
  160. data/lib/rails_pulse/cli/install.rb +145 -0
  161. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  162. data/lib/rails_pulse/cli/jobs.rb +71 -0
  163. data/lib/rails_pulse/cli/main.rb +76 -0
  164. data/lib/rails_pulse/cli/mcp.rb +47 -0
  165. data/lib/rails_pulse/cli/queries.rb +67 -0
  166. data/lib/rails_pulse/cli/requests.rb +57 -0
  167. data/lib/rails_pulse/cli/routes.rb +73 -0
  168. data/lib/rails_pulse/configuration.rb +100 -2
  169. data/lib/rails_pulse/current.rb +11 -0
  170. data/lib/rails_pulse/engine.rb +12 -3
  171. data/lib/rails_pulse/installers/config_updater.rb +9 -2
  172. data/lib/rails_pulse/job_run_collector.rb +15 -15
  173. data/lib/rails_pulse/mcp/server.rb +78 -0
  174. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  175. data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
  176. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  177. data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
  178. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  179. data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
  180. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  181. data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
  182. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  183. data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
  184. data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
  185. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  186. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  187. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  188. data/lib/rails_pulse/statistics.rb +27 -0
  189. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  190. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  191. data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
  192. data/lib/rails_pulse/tracker.rb +76 -14
  193. data/lib/rails_pulse/version.rb +1 -1
  194. data/lib/rails_pulse.rb +0 -4
  195. data/lib/tasks/rails_pulse.rake +6 -0
  196. data/lib/tasks/rails_pulse_tasks.rake +7 -0
  197. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  198. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  199. metadata +123 -24
  200. data/app/controllers/concerns/page_timings.rb +0 -10
  201. data/app/controllers/concerns/response_range_concern.rb +0 -45
  202. data/app/controllers/concerns/time_range_concern.rb +0 -152
  203. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -0,0 +1,145 @@
1
+ require "fileutils"
2
+ require_relative "base_command"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class Install < BaseCommand
7
+ AGENT_FILES_DIR = File.expand_path("agent_files", __dir__)
8
+
9
+ # Claude Code loads skills from ~/.claude/skills/<name>/SKILL.md; the
10
+ # file carries its own name and description in YAML front matter.
11
+ CLAUDE_SKILL_PATH = "~/.claude/skills/rails-pulse/SKILL.md".freeze
12
+
13
+ AGENTS_FILE = "agents.md".freeze
14
+
15
+ # Delimit the section so a re-run can replace exactly what a previous
16
+ # run wrote, leaving everything a project put around it untouched.
17
+ SECTION_START = "<!-- rails-pulse:start -->".freeze
18
+ SECTION_END = "<!-- rails-pulse:end -->".freeze
19
+ # Tolerates CRLF line endings and trailing spaces after a marker, which
20
+ # an editor or git's autocrlf can introduce between runs.
21
+ SECTION_PATTERN = /^#{Regexp.escape(SECTION_START)}[ \t]*\r?\n.*?^#{Regexp.escape(SECTION_END)}[ \t]*(?:\r?\n)?/m
22
+
23
+ INTEGRATIONS = {
24
+ "claude" => {
25
+ description: "Claude Code skill → #{CLAUDE_SKILL_PATH}",
26
+ source: "claude_skill.md"
27
+ },
28
+ "agents" => {
29
+ description: "Generic agent descriptor → ./#{AGENTS_FILE}",
30
+ source: "agents.md"
31
+ }
32
+ }.freeze
33
+
34
+ default_task :perform
35
+
36
+ desc "perform [INTEGRATION]", "Install an AI agent integration file"
37
+ long_desc <<~DESC
38
+ Copy a pre-built integration file to the correct location for the given agent framework.
39
+
40
+ Available integrations:
41
+ claude Installs a Claude Code skill to #{CLAUDE_SKILL_PATH}.
42
+ Claude Code then knows when and how to use the Rails Pulse MCP tools and CLI.
43
+ An earlier copy of the skill is replaced.
44
+ agents Installs a generic agent descriptor to ./#{AGENTS_FILE} in the current directory.
45
+ Compatible with other AI agent frameworks, including Codex. When an agents.md
46
+ or AGENTS.md already exists it is never overwritten: pass --append to add a
47
+ delimited Rails Pulse section to the end of it, which a later --append
48
+ replaces in place rather than duplicating.
49
+
50
+ Run --list to see all available integrations.
51
+ DESC
52
+ option :list, type: :boolean, desc: "List all available integrations and their destinations"
53
+ option :append, type: :boolean, default: false,
54
+ desc: "For 'agents': add a delimited Rails Pulse section to an existing AGENTS.md"
55
+ def perform(integration = nil)
56
+ if options[:list]
57
+ say "Available integrations:"
58
+ INTEGRATIONS.each { |name, meta| say " #{name.ljust(10)} #{meta[:description]}" }
59
+ return
60
+ end
61
+
62
+ case integration
63
+ when "claude"
64
+ install_claude
65
+ when "agents"
66
+ install_agents
67
+ else
68
+ say "Usage: rails-pulse install [claude|agents]", :yellow
69
+ say " rails-pulse install --list"
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def install_claude
76
+ dest = File.expand_path(CLAUDE_SKILL_PATH)
77
+ FileUtils.mkdir_p(File.dirname(dest))
78
+ FileUtils.cp(File.join(AGENT_FILES_DIR, "claude_skill.md"), dest)
79
+ say "Installed Claude Code skill to #{dest}", :green
80
+ end
81
+
82
+ # A project's own AGENTS.md is compared case-insensitively: on a
83
+ # case-insensitive filesystem (macOS by default) writing agents.md
84
+ # would silently replace it.
85
+ def install_agents
86
+ source = File.join(AGENT_FILES_DIR, "agents.md")
87
+ existing = Dir.children(Dir.pwd).find { |name| name.casecmp?(AGENTS_FILE) }
88
+
89
+ return append_to_agents(File.join(Dir.pwd, existing), source) if existing && options[:append]
90
+
91
+ if existing
92
+ say "#{File.join(Dir.pwd, existing)} already exists; not overwriting it.", :yellow
93
+ say "Run `rails-pulse install agents --append` to add a Rails Pulse section to it instead."
94
+ exit 1
95
+ end
96
+
97
+ # Written as a delimited section from the start, so a later --append
98
+ # recognises it and replaces it rather than adding a second copy.
99
+ dest = File.join(Dir.pwd, AGENTS_FILE)
100
+ File.write(dest, section(source, "\n"))
101
+ say "Installed agent descriptor to #{dest}", :green
102
+ end
103
+
104
+ # The project's own instructions are never rewritten: the Rails Pulse
105
+ # section is delimited, so a re-run replaces it where it sits and
106
+ # everything around it is left exactly as the project wrote it.
107
+ def append_to_agents(dest, source)
108
+ existing = File.read(dest)
109
+ newline = existing.include?("\r\n") ? "\r\n" : "\n"
110
+ section = section(source, newline)
111
+
112
+ if existing.match?(SECTION_PATTERN)
113
+ File.write(dest, existing.sub(SECTION_PATTERN) { section })
114
+ say "Updated the Rails Pulse section in #{dest}", :green
115
+ elsif existing.lstrip.start_with?(descriptor_title(source))
116
+ # A copy written before the section markers existed. Its end cannot
117
+ # be told from anything the project added after it, so it is left
118
+ # for a person to replace rather than guessed at or duplicated.
119
+ say "#{dest} starts with a Rails Pulse descriptor that has no section markers; not appending a second copy.", :yellow
120
+ say "Delete the Rails Pulse part of it (or the file, if nothing else is in it) and run this again."
121
+ exit 1
122
+ else
123
+ separator = if existing.empty? || existing.end_with?("#{newline}#{newline}")
124
+ ""
125
+ elsif existing.end_with?(newline)
126
+ newline
127
+ else
128
+ newline * 2
129
+ end
130
+ File.write(dest, "#{existing}#{separator}#{section}")
131
+ say "Appended a Rails Pulse section to #{dest}", :green
132
+ end
133
+ end
134
+
135
+ def section(source, newline)
136
+ body = File.read(source).strip.gsub(/\r?\n/, newline)
137
+ "#{SECTION_START}#{newline}#{body}#{newline}#{SECTION_END}#{newline}"
138
+ end
139
+
140
+ def descriptor_title(source)
141
+ File.foreach(source).first.to_s.strip
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,53 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class JobRuns < BaseCommand
7
+ COLUMNS = [
8
+ [ "ID", 8, :id ],
9
+ [ "Job", 35, :job_name ],
10
+ [ "Status", 10, :status ],
11
+ [ "Occurred", 25, :occurred_at ],
12
+ [ "Duration", 10, :duration ],
13
+ [ "Error", 35, :error_class ]
14
+ ].freeze
15
+
16
+ desc "list", "List individual background job runs"
17
+ long_desc <<~DESC
18
+ Returns job runs ordered by most recent first.
19
+
20
+ Filter by status:
21
+ --status failed failed and discarded runs
22
+ --status success exact status (enqueued, running, success, failed, discarded, retried)
23
+
24
+ Filter by job class:
25
+ --job GenerateReportJob
26
+
27
+ Filter by time window (ISO 8601):
28
+ --since 2026-06-01T00:00:00Z
29
+ --until 2026-06-01T23:59:59Z
30
+
31
+ Use --json to get error messages and the full response envelope.
32
+ DESC
33
+ option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
34
+ option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
35
+ option :since, type: :string, desc: "Return runs at or after this time (ISO 8601)"
36
+ option :until, type: :string, desc: "Return runs at or before this time (ISO 8601)"
37
+ option :status, type: :string, desc: "Filter by status (failed, success, discarded, ...)"
38
+ option :job, type: :string, desc: "Filter by job class name"
39
+ option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope"
40
+ def list
41
+ with_error_handling do
42
+ params = { limit: options[:limit], offset: options[:offset] }
43
+ params[:since] = options[:since] if options[:since]
44
+ params[:until] = options[:until] if options[:until]
45
+ params[:status] = options[:status] if options[:status]
46
+ params[:job] = options[:job] if options[:job]
47
+ result = client.get("/job_runs", params)
48
+ Formatter.render(result, json: options[:json], columns: COLUMNS)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,71 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class Jobs < BaseCommand
7
+ COLUMNS = [
8
+ [ "Name", 35, :name ],
9
+ [ "Queue", 15, :queue_name ],
10
+ [ "Runs", 8, :runs_count ],
11
+ [ "Failures", 8, :failures_count ],
12
+ [ "Fail %", 8, :failure_rate ],
13
+ [ "Avg (ms)", 10, :avg_duration ]
14
+ ].freeze
15
+
16
+ # With a window each row's figures cover it, read from summaries.
17
+ WINDOW_COLUMNS = [
18
+ [ "Name", 35, :name ],
19
+ [ "Queue", 15, :queue_name ],
20
+ [ "Runs", 8, :runs_count ],
21
+ [ "Failures", 8, :failures_count ],
22
+ [ "Fail %", 8, :failure_rate ],
23
+ [ "Avg (ms)", 10, :avg_duration ],
24
+ [ "Max (ms)", 10, :max_duration ]
25
+ ].freeze
26
+
27
+ desc "list", "List background jobs with run counts and duration stats"
28
+ long_desc <<~DESC
29
+ Returns all tracked background jobs ordered by name.
30
+
31
+ Without a window each row shows lifetime stats: total runs, failure count,
32
+ failure rate (%), and average duration in milliseconds.
33
+
34
+ With --since and/or --until (ISO 8601) each row covers that window instead,
35
+ and only jobs that ran in it are listed. p95 and p99 appear in --json output
36
+ when one summary period covers the window:
37
+ --since 2026-06-01T00:00:00Z --until 2026-06-02T00:00:00Z
38
+
39
+ Filter to jobs that have failed (inside the window, when one is given):
40
+ --status failed
41
+
42
+ Restrict to one job class:
43
+ --job GenerateReportJob
44
+
45
+ Use --json to also see p95 and p99 duration percentiles and meta.window.
46
+ DESC
47
+ option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
48
+ option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
49
+ option :since, type: :string, desc: "Window start (ISO 8601); rows then cover the window"
50
+ option :until, type: :string, desc: "Window end (ISO 8601)"
51
+ option :job, type: :string, desc: "Exact job class name"
52
+ option :status, type: :string, desc: "Filter by status — 'failed' returns only jobs with failures"
53
+ option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope and p95/p99 durations"
54
+ def list
55
+ with_error_handling do
56
+ params = { limit: options[:limit], offset: options[:offset] }
57
+ %i[status since until job].each { |key| params[key] = options[key] if options[key] }
58
+ result = client.get("/jobs", params)
59
+
60
+ # JSON keeps lifetime counters and the window's stats apart, as the
61
+ # API does; the table shows whichever the rows describe.
62
+ next Formatter.render(result, json: true) if options[:json]
63
+
64
+ windowed = result["data"].any? { |job| job["stats"] }
65
+ rows = result["data"].map { |job| job.merge(job["stats"] || {}) }
66
+ Formatter.render(result.merge("data" => rows), columns: windowed ? WINDOW_COLUMNS : COLUMNS)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,76 @@
1
+ require "thor"
2
+ require_relative "configure"
3
+ require_relative "install"
4
+ require_relative "routes"
5
+ require_relative "requests"
6
+ require_relative "queries"
7
+ require_relative "jobs"
8
+ require_relative "job_runs"
9
+ require_relative "exceptions"
10
+ require_relative "deployments"
11
+ require_relative "coverage"
12
+ require_relative "insights"
13
+ require_relative "mcp"
14
+
15
+ module RailsPulse
16
+ module CLI
17
+ class Main < Thor
18
+ def self.exit_on_failure?
19
+ true
20
+ end
21
+
22
+ register(Configure, "configure", "configure",
23
+ "Prompt for URL and API token, test the connection, and write ~/.rails-pulse")
24
+ register(Install, "install", "install [INTEGRATION]",
25
+ "Install AI agent integration files (claude, agents). Use --list to see options")
26
+ register(Routes, "routes", "routes SUBCOMMAND",
27
+ "List tracked HTTP routes and their tags")
28
+ register(Requests, "requests", "requests SUBCOMMAND",
29
+ "List recorded HTTP requests with optional status and time filters")
30
+ register(Queries, "queries", "queries SUBCOMMAND",
31
+ "List tracked SQL queries and their analysis results")
32
+ register(Jobs, "jobs", "jobs SUBCOMMAND",
33
+ "List background jobs with run counts, failure rates, and duration stats")
34
+ register(JobRuns, "job_runs", "job_runs SUBCOMMAND",
35
+ "List individual job runs with status, job, and time filters")
36
+ register(Exceptions, "exceptions", "exceptions SUBCOMMAND",
37
+ "List exception groups with status, search, and time filters")
38
+ register(Deployments, "deployments", "deployments SUBCOMMAND",
39
+ "List recorded deployments with optional time filters")
40
+ register(Coverage, "coverage", "coverage SUBCOMMAND",
41
+ "What has been recorded, how recently, and any collection gaps")
42
+ register(Insights, "insights", "insights SUBCOMMAND",
43
+ "What needs attention over one period, and whether the thresholds fit it")
44
+ register(Mcp, "mcp", "mcp",
45
+ "Start MCP server for AI coding agents (Claude Code, Codex, Cursor)")
46
+
47
+ no_commands do
48
+ def help(command = nil, subcommand = false)
49
+ super
50
+ return if command
51
+
52
+ say ""
53
+ say "Examples:"
54
+ say " rails-pulse configure # Set up credentials interactively"
55
+ say " rails-pulse routes list # List all tracked routes"
56
+ say " rails-pulse requests list --status 5xx --limit 10 # Last 10 server errors"
57
+ say " rails-pulse requests list --since 2026-06-01T00:00:00Z # Requests since a timestamp"
58
+ say " rails-pulse queries list --json # Queries with full analysis fields"
59
+ say " rails-pulse jobs list --status failed # Jobs with at least one failure"
60
+ say " rails-pulse jobs list --since 2026-06-01T00:00:00Z # Job stats for a window"
61
+ say " rails-pulse job_runs list --status failed --job ReportJob # Recent failed runs of one job"
62
+ say " rails-pulse exceptions list --status open # Open exception groups, most recent first"
63
+ say " rails-pulse exceptions show 42 # One group with backtraces and params"
64
+ say " rails-pulse deployments list # Recorded deploys, most recent first"
65
+ say " rails-pulse coverage show # What is recorded, how recently, any gaps"
66
+ say " rails-pulse insights show --period day # What needs attention yesterday, threshold fit"
67
+ say " rails-pulse install claude # Install Claude Code skill file"
68
+ say " rails-pulse mcp # Start MCP server for AI agents"
69
+ say ""
70
+ say "Credentials are read from RAILS_PULSE_URL / RAILS_PULSE_TOKEN env vars or ~/.rails-pulse."
71
+ say "Run 'rails-pulse configure' to set them up, or 'rails-pulse help COMMAND' for detailed flags."
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,47 @@
1
+ require_relative "base_command"
2
+
3
+ module RailsPulse
4
+ module CLI
5
+ class Mcp < BaseCommand
6
+ default_task :start
7
+
8
+ desc "start", "Start the Rails Pulse MCP server (stdio transport)"
9
+ long_desc <<~DESC
10
+ Starts a Model Context Protocol (MCP) server over stdio.
11
+
12
+ The MCP server exposes Rails Pulse performance data as structured tools
13
+ for AI coding agents (Claude Code, Codex, Cursor, etc.).
14
+
15
+ Configure your MCP client to run:
16
+ rails-pulse mcp
17
+
18
+ The server uses the same credentials as the CLI — set them with
19
+ `rails-pulse configure` or via RAILS_PULSE_URL / RAILS_PULSE_TOKEN
20
+ environment variables.
21
+
22
+ Available tools:
23
+ rails_pulse_routes — Discover endpoints with traffic and latency
24
+ rails_pulse_slow_requests — Slowest endpoints for a period
25
+ rails_pulse_errors — Recent errors grouped by endpoint
26
+ rails_pulse_endpoint — Deep profile of a single endpoint
27
+ rails_pulse_queries — Expensive and N+1 SQL queries
28
+ rails_pulse_jobs — Background job health and recent failures
29
+ rails_pulse_exceptions — Exception groups with status, count, and latest message
30
+ rails_pulse_exception — One group's recent occurrences with backtraces and params
31
+ rails_pulse_deployments — Recent deployments with revision, timing and metadata
32
+ rails_pulse_coverage — What has been recorded, how recently, and any collection gaps
33
+ DESC
34
+ def start
35
+ require_relative "../mcp/server"
36
+ RailsPulse::Mcp::Server.start!
37
+ rescue RailsPulse::CLI::Config::ConfigError => e
38
+ # Write errors to stderr — stdout is reserved for MCP JSON-RPC
39
+ $stderr.puts "Rails Pulse MCP error: #{e.message}"
40
+ exit 1
41
+ rescue RailsPulse::Mcp::Server::MissingDependencyError => e
42
+ $stderr.puts "Rails Pulse MCP error: #{e.message}"
43
+ exit 1
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,67 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class Queries < BaseCommand
7
+ COLUMNS = [
8
+ [ "ID", 10, :id ],
9
+ [ "SQL", 60, :normalized_sql ],
10
+ [ "Analyzed", 25, :analyzed_at ]
11
+ ].freeze
12
+
13
+ STATS_COLUMNS = [
14
+ [ "ID", 8, :id ],
15
+ [ "SQL", 50, :normalized_sql ],
16
+ [ "Execs", 7, :executions ],
17
+ [ "Avg ms", 8, :avg_duration_ms ],
18
+ [ "Max ms", 8, :max_duration_ms ],
19
+ [ "Total ms", 10, :total_duration_ms ],
20
+ [ "N+1", 5, :max_repetition_count ]
21
+ ].freeze
22
+
23
+ desc "list", "List tracked SQL queries"
24
+ long_desc <<~DESC
25
+ Returns normalized SQL queries Rails Pulse has observed, ordered by ID.
26
+
27
+ Filter by the time window in which the query was executed (ISO 8601).
28
+ The filter matches against operation timestamps, not the query creation time,
29
+ and adds per-query timing stats for the window:
30
+ --since 2026-06-01T00:00:00Z
31
+ --until 2026-06-01T23:59:59Z
32
+
33
+ Order by a stat (defaults the window to the last 24 hours when --since is omitted):
34
+ --sort total_duration | avg_duration | executions | max_duration
35
+
36
+ Look up one query by its hashed_sql, as listed with --json or by Rails Pulse Cloud:
37
+ --hashed-sql e4d909c2290d0fb1ca068ffaddf22cbd
38
+
39
+ Use --json to see analysis fields (issues, suggestions, n_plus_one) in full.
40
+ DESC
41
+ option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
42
+ option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
43
+ option :since, type: :string, desc: "Return queries executed at or after this time (ISO 8601)"
44
+ option :until, type: :string, desc: "Return queries executed at or before this time (ISO 8601)"
45
+ option :sort, type: :string, desc: "Order by total_duration, avg_duration, executions, or max_duration"
46
+ option :hashed_sql, type: :string, desc: "Exact hashed_sql of one query"
47
+ option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope and analysis fields"
48
+ def list
49
+ with_error_handling do
50
+ params = { limit: options[:limit], offset: options[:offset] }
51
+ params[:since] = options[:since] if options[:since]
52
+ params[:until] = options[:until] if options[:until]
53
+ params[:sort] = options[:sort] if options[:sort]
54
+ params[:hashed_sql] = options[:hashed_sql] if options[:hashed_sql]
55
+ result = client.get("/queries", params)
56
+
57
+ if result["data"].any? { |q| q["stats"] }
58
+ rows = result["data"].map { |q| q.merge(q["stats"] || {}) }
59
+ Formatter.render(result.merge("data" => rows), json: options[:json], columns: STATS_COLUMNS)
60
+ else
61
+ Formatter.render(result, json: options[:json], columns: COLUMNS)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,57 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class Requests < BaseCommand
7
+ COLUMNS = [
8
+ [ "ID", 10, :id ],
9
+ [ "Status", 6, :status ],
10
+ [ "Duration", 10, :duration ],
11
+ [ "Occurred", 25, :occurred_at ],
12
+ [ "Controller", 35, :controller_action ],
13
+ [ "Path", 35, :path ]
14
+ ].freeze
15
+
16
+ desc "list", "List recorded HTTP requests"
17
+ long_desc <<~DESC
18
+ Returns HTTP requests ordered by most recent first.
19
+
20
+ Filter by status code or class:
21
+ --status 500 exact code
22
+ --status 5xx any 5xx response
23
+ --status 4xx any 4xx response
24
+
25
+ Filter by the start of the path. A trailing * is ignored, so a Rails Pulse Cloud
26
+ path_prefix can be passed as it is; requests no route matched are stored under
27
+ their raw path:
28
+ --path-prefix /wp-admin/ --status 404
29
+
30
+ Filter by time window (ISO 8601):
31
+ --since 2026-06-01T00:00:00Z
32
+ --until 2026-06-01T23:59:59Z
33
+
34
+ Both --since and --until can be combined.
35
+ Use --json to get the full response envelope including meta.total.
36
+ DESC
37
+ option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
38
+ option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
39
+ option :since, type: :string, desc: "Return requests at or after this time (ISO 8601)"
40
+ option :until, type: :string, desc: "Return requests at or before this time (ISO 8601)"
41
+ option :status, type: :string, desc: "Filter by status code or class (e.g. 500, 5xx, 4xx)"
42
+ option :path_prefix, type: :string, desc: "Only paths starting with this (e.g. /wp-admin/)"
43
+ option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope"
44
+ def list
45
+ with_error_handling do
46
+ params = { limit: options[:limit], offset: options[:offset] }
47
+ params[:since] = options[:since] if options[:since]
48
+ params[:until] = options[:until] if options[:until]
49
+ params[:status] = options[:status] if options[:status]
50
+ params[:path_prefix] = options[:path_prefix] if options[:path_prefix]
51
+ result = client.get("/requests", params)
52
+ Formatter.render(result, json: options[:json], columns: COLUMNS)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,73 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class Routes < BaseCommand
7
+ COLUMNS = [
8
+ [ "Method", 8, :http_methods ],
9
+ [ "Path", 40, :path ],
10
+ [ "Action", 35, :controller_action ],
11
+ [ "Created", 25, :created_at ]
12
+ ].freeze
13
+
14
+ STATS_COLUMNS = [
15
+ [ "Method", 8, :http_methods ],
16
+ [ "Path", 40, :path ],
17
+ [ "Action", 35, :controller_action ],
18
+ [ "Requests", 8, :request_count ],
19
+ [ "Avg ms", 8, :avg_duration_ms ],
20
+ [ "Errors", 6, :error_count ]
21
+ ].freeze
22
+
23
+ desc "list", "List all tracked HTTP routes"
24
+ long_desc <<~DESC
25
+ Returns every route Rails Pulse has recorded, ordered by path.
26
+
27
+ Search by path or controller action (case-insensitive substring):
28
+ --search checkout
29
+
30
+ Match one route exactly by its path and controller action:
31
+ --path /orders/:id --controller-action orders#show
32
+
33
+ Add request stats for a time window (ISO 8601). Only routes with traffic
34
+ in the window are returned, ordered by request count:
35
+ --since 2026-06-01T00:00:00Z
36
+ --until 2026-06-01T23:59:59Z
37
+
38
+ Order stats by another column (defaults the window to the last 24 hours when --since is omitted):
39
+ --sort request_count | avg_duration | error_count
40
+
41
+ Use --limit and --offset to paginate through large route sets.
42
+ Use --json to get the full response envelope including meta.total.
43
+ DESC
44
+ option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
45
+ option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
46
+ option :since, type: :string, desc: "Include request stats at or after this time (ISO 8601)"
47
+ option :until, type: :string, desc: "Include request stats at or before this time (ISO 8601)"
48
+ option :search, type: :string, desc: "Filter by path or controller action substring"
49
+ option :path, type: :string, desc: "Exact route path"
50
+ option :controller_action, type: :string, desc: "Exact controller action"
51
+ option :sort, type: :string, desc: "Order stats by request_count, avg_duration, or error_count"
52
+ option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope"
53
+ def list
54
+ with_error_handling do
55
+ params = { limit: options[:limit], offset: options[:offset] }
56
+ params[:since] = options[:since] if options[:since]
57
+ params[:until] = options[:until] if options[:until]
58
+ params[:search] = options[:search] if options[:search]
59
+ params[:path] = options[:path] if options[:path]
60
+ params[:controller_action] = options[:controller_action] if options[:controller_action]
61
+ params[:sort] = options[:sort] if options[:sort]
62
+ result = client.get("/routes", params)
63
+
64
+ rows = result["data"].map do |route|
65
+ route.merge("http_methods" => Array(route["http_methods"]).join("|")).merge(route["stats"] || {})
66
+ end
67
+ columns = result["data"].any? { |r| r["stats"] } ? STATS_COLUMNS : COLUMNS
68
+ Formatter.render(result.merge("data" => rows), json: options[:json], columns: columns)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end