rails_pulse 0.4.2 → 0.5.0.pre.1

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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -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 +130 -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 +24 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
  15. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  16. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  17. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
  18. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
  19. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
  20. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  21. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
  22. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  23. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  24. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  25. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  26. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  27. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  28. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  29. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  30. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  31. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  32. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  33. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  34. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  35. data/app/models/rails_pulse/cards/base.rb +20 -11
  36. data/app/models/rails_pulse/charts/base.rb +2 -6
  37. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
  38. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  39. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  40. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
  41. data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
  42. data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
  43. data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
  44. data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
  45. data/app/models/rails_pulse/deployment.rb +2 -7
  46. data/app/models/rails_pulse/event.rb +40 -0
  47. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  48. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  49. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  50. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  51. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
  52. data/app/models/rails_pulse/job_run.rb +1 -1
  53. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  54. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  55. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  56. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
  57. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  58. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
  59. data/app/models/rails_pulse/like_pattern.rb +29 -0
  60. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  61. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  62. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  63. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  64. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
  65. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/request.rb +1 -1
  67. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  68. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  69. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  70. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  71. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  72. data/app/models/rails_pulse/summary.rb +5 -0
  73. data/app/models/rails_pulse/tables/base.rb +5 -6
  74. data/app/models/rails_pulse/time_range.rb +309 -0
  75. data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
  76. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
  77. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  78. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  79. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  80. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  81. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  82. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
  83. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  84. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  85. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  86. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  87. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  88. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  89. data/app/services/rails_pulse/summary_service.rb +121 -133
  90. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  91. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  92. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  93. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  94. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  95. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  96. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  97. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  98. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  99. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  100. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  101. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  102. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  103. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  104. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  105. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  106. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  107. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  108. data/app/views/rails_pulse/storage/show.html.erb +37 -0
  109. data/config/routes.rb +19 -0
  110. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  111. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  112. data/db/rails_pulse_schema.rb +20 -2
  113. data/exe/rails-pulse +7 -0
  114. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  115. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  116. data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
  117. data/lib/rails_pulse/cleanup_service.rb +26 -20
  118. data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
  119. data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
  120. data/lib/rails_pulse/cli/base_command.rb +37 -0
  121. data/lib/rails_pulse/cli/client.rb +64 -0
  122. data/lib/rails_pulse/cli/config.rb +87 -0
  123. data/lib/rails_pulse/cli/configure.rb +75 -0
  124. data/lib/rails_pulse/cli/coverage.rb +91 -0
  125. data/lib/rails_pulse/cli/deployments.rb +40 -0
  126. data/lib/rails_pulse/cli/exceptions.rb +111 -0
  127. data/lib/rails_pulse/cli/formatter.rb +31 -0
  128. data/lib/rails_pulse/cli/install.rb +145 -0
  129. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  130. data/lib/rails_pulse/cli/jobs.rb +71 -0
  131. data/lib/rails_pulse/cli/main.rb +72 -0
  132. data/lib/rails_pulse/cli/mcp.rb +47 -0
  133. data/lib/rails_pulse/cli/queries.rb +62 -0
  134. data/lib/rails_pulse/cli/requests.rb +49 -0
  135. data/lib/rails_pulse/cli/routes.rb +66 -0
  136. data/lib/rails_pulse/configuration.rb +23 -1
  137. data/lib/rails_pulse/current.rb +11 -0
  138. data/lib/rails_pulse/engine.rb +12 -3
  139. data/lib/rails_pulse/job_run_collector.rb +15 -15
  140. data/lib/rails_pulse/mcp/server.rb +76 -0
  141. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  142. data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
  143. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  144. data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
  145. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  146. data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
  147. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  148. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  149. data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
  150. data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
  151. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  152. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  153. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  154. data/lib/rails_pulse/statistics.rb +27 -0
  155. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  156. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  157. data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
  158. data/lib/rails_pulse/tracker.rb +75 -14
  159. data/lib/rails_pulse/version.rb +1 -1
  160. data/lib/rails_pulse.rb +0 -4
  161. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  162. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  163. metadata +96 -24
  164. data/app/controllers/concerns/page_timings.rb +0 -10
  165. data/app/controllers/concerns/response_range_concern.rb +0 -45
  166. data/app/controllers/concerns/time_range_concern.rb +0 -152
  167. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -0,0 +1,64 @@
1
+ require "net/http"
2
+ require "openssl"
3
+ require "uri"
4
+ require "json"
5
+ require_relative "config"
6
+
7
+ module RailsPulse
8
+ module CLI
9
+ # HTTP client for the read-only JSON API under <mount>/api/v1.
10
+ class Client
11
+ class ApiError < StandardError; end
12
+
13
+ def initialize(config = nil)
14
+ @config = config || Config.load
15
+ end
16
+
17
+ def get(path, params = {})
18
+ uri = URI("#{@config.url}#{@config.mount_path}/api/v1#{path}")
19
+ uri.query = URI.encode_www_form(params) unless params.empty?
20
+
21
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
22
+ req = Net::HTTP::Get.new(uri)
23
+ req["X-Rails-Pulse-Token"] = @config.token
24
+ response = http.request(req)
25
+ raise_for(response) unless response.is_a?(Net::HTTPSuccess)
26
+ parse(response, uri)
27
+ end
28
+ rescue URI::InvalidURIError => e
29
+ raise ApiError, "the configured URL and mount path do not form a valid URL (#{e.message})"
30
+ rescue OpenSSL::SSL::SSLError => e
31
+ raise ApiError, "TLS failed talking to #{uri.host}:#{uri.port} (#{e.message})"
32
+ rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
33
+ raise ApiError, "could not connect to #{uri.host}:#{uri.port} (#{e.message})"
34
+ end
35
+
36
+ private
37
+
38
+ # A 200 that is not JSON is almost always the wrong URL or mount path
39
+ # answered by the host app itself: a login page or a catch-all route.
40
+ def parse(response, uri)
41
+ JSON.parse(response.body.to_s)
42
+ rescue JSON::ParserError
43
+ type = response["Content-Type"].to_s.split(";").first
44
+ raise ApiError, "#{uri} answered with #{type.to_s.empty? ? 'a non-JSON body' : type} rather than JSON. " \
45
+ "Check the URL and mount path ('rails-pulse configure' or RAILS_PULSE_MOUNT_PATH)"
46
+ end
47
+
48
+ def raise_for(response)
49
+ body = begin
50
+ JSON.parse(response.body.to_s)
51
+ rescue JSON::ParserError
52
+ {}
53
+ end
54
+ body = {} unless body.is_a?(Hash)
55
+
56
+ # The API explains a 400 or 401 in the body ("Invalid sort. Valid
57
+ # values: ..."); relay it rather than only the status line.
58
+ message = "#{response.code} #{response.message}".strip
59
+ message = "#{message}: #{body["error"]}" if body["error"].is_a?(String) && !body["error"].empty?
60
+ raise ApiError, message
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,87 @@
1
+ require "yaml"
2
+
3
+ module RailsPulse
4
+ module CLI
5
+ # Where the CLI and MCP server find the app to talk to. Environment
6
+ # variables win; otherwise ~/.rails-pulse (or RAILS_PULSE_CONFIG), written
7
+ # by `rails-pulse configure`.
8
+ class Config
9
+ class ConfigError < StandardError; end
10
+
11
+ DEFAULT_PATH = "~/.rails-pulse".freeze
12
+ DEFAULT_MOUNT_PATH = "/rails_pulse".freeze
13
+ URL_SCHEMES = %r{\Ahttps?://\S+\z}i
14
+
15
+ # The file holds the API token, so nobody else on the machine may read it.
16
+ FILE_MODE = 0o600
17
+
18
+ attr_reader :url, :token, :mount_path
19
+
20
+ def initialize(url:, token:, mount_path: DEFAULT_MOUNT_PATH)
21
+ @url = url.to_s.chomp("/")
22
+ @token = token.to_s
23
+ # A root mount is an empty prefix, not "/", or every path gains "//".
24
+ trimmed = mount_path.to_s.delete_prefix("/").chomp("/")
25
+ @mount_path = trimmed.empty? ? "" : "/#{trimmed}"
26
+
27
+ raise ConfigError, "RAILS_PULSE_URL must start with http:// or https:// (got #{@url.inspect})" unless @url.match?(URL_SCHEMES)
28
+ # The API path is appended to the URL, so anything after the host
29
+ # belongs in the mount path instead.
30
+ if @url.match?(/[?#]/)
31
+ raise ConfigError, "RAILS_PULSE_URL must not contain a query string or fragment (got #{@url.inspect})"
32
+ end
33
+ end
34
+
35
+ def self.path
36
+ File.expand_path(ENV.fetch("RAILS_PULSE_CONFIG", DEFAULT_PATH))
37
+ end
38
+
39
+ def self.load
40
+ url = ENV["RAILS_PULSE_URL"]
41
+ token = ENV["RAILS_PULSE_TOKEN"]
42
+ mount_path = ENV["RAILS_PULSE_MOUNT_PATH"]
43
+
44
+ # Complete credentials in the environment never touch the file, so a
45
+ # broken ~/.rails-pulse cannot block a setup that does not use it.
46
+ if url.nil? || token.nil?
47
+ data = read_file
48
+ url ||= data["url"]
49
+ token ||= data["token"]
50
+ mount_path ||= data["mount_path"]
51
+ end
52
+
53
+ url = url.to_s.chomp("/")
54
+
55
+ raise ConfigError, "RAILS_PULSE_URL is not set. Run 'rails-pulse configure'" if url.empty?
56
+ raise ConfigError, "RAILS_PULSE_TOKEN is not set. Run 'rails-pulse configure'" if token.to_s.empty?
57
+
58
+ new(url: url, token: token, mount_path: mount_path || DEFAULT_MOUNT_PATH)
59
+ end
60
+
61
+ def self.write!(url:, token:, mount_path: nil)
62
+ data = { "url" => url.to_s.chomp("/"), "token" => token.to_s }
63
+ data["mount_path"] = mount_path if mount_path && mount_path != DEFAULT_MOUNT_PATH
64
+ # perm: applies only when the file is created, so an existing file is
65
+ # narrowed before the token is written into it.
66
+ File.chmod(FILE_MODE, path) if File.exist?(path)
67
+ File.write(path, YAML.dump(data), perm: FILE_MODE)
68
+ File.chmod(FILE_MODE, path)
69
+ end
70
+
71
+ # The parsed config file, or an empty hash when there is none yet.
72
+ def self.read_file
73
+ data = YAML.safe_load_file(path)
74
+ raise ConfigError, "#{path} should be a YAML mapping with url and token keys" unless data.nil? || data.is_a?(Hash)
75
+
76
+ data || {}
77
+ rescue Errno::ENOENT
78
+ {}
79
+ rescue Errno::EACCES, Errno::EISDIR => e
80
+ raise ConfigError, "#{path} cannot be read (#{e.message}). Fix its permissions or run 'rails-pulse configure'"
81
+ rescue Psych::SyntaxError => e
82
+ raise ConfigError, "#{path} is not valid YAML (#{e.message}). Fix it or run 'rails-pulse configure'"
83
+ end
84
+ private_class_method :read_file
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,75 @@
1
+ require_relative "base_command"
2
+ require_relative "config"
3
+ require_relative "client"
4
+
5
+ module RailsPulse
6
+ module CLI
7
+ class Configure < BaseCommand
8
+ default_task :setup
9
+
10
+ desc "setup", "Prompt for URL and API token, test the connection, and write ~/.rails-pulse"
11
+ long_desc <<~DESC
12
+ Interactively configure credentials for the Rails Pulse JSON API.
13
+
14
+ Prompts for:
15
+ - Rails Pulse URL (e.g. http://localhost:3000)
16
+ - API token (matches config.api_token in the app's Rails Pulse initializer)
17
+ - Mount path (where the engine is mounted; default /rails_pulse)
18
+
19
+ Existing values are shown as defaults — press Enter to keep them.
20
+ The token is not echoed as you type it.
21
+
22
+ A test request is made before saving. If it fails, no file is written.
23
+ Credentials are saved to ~/.rails-pulse (never to the project directory),
24
+ readable only by you.
25
+
26
+ You can also skip this command and set environment variables instead:
27
+ RAILS_PULSE_URL, RAILS_PULSE_TOKEN, RAILS_PULSE_MOUNT_PATH (optional)
28
+ DESC
29
+ def setup
30
+ current = begin
31
+ Config.load
32
+ rescue Config::ConfigError
33
+ nil
34
+ end
35
+
36
+ url = ask_with_default("Rails Pulse URL", current&.url)
37
+ token = ask_with_default("API token", current&.token, echo: false, mask: true)
38
+ mount_path = ask_with_default("Mount path", current&.mount_path || Config::DEFAULT_MOUNT_PATH)
39
+
40
+ say "Testing connection..."
41
+ begin
42
+ test_config = Config.new(url: url, token: token, mount_path: mount_path)
43
+ Client.new(test_config).get("/routes", { limit: 1 })
44
+ rescue Config::ConfigError, Client::ApiError => e
45
+ say "Connection failed: #{e.message}", :red
46
+ exit 1
47
+ end
48
+
49
+ Config.write!(url: url, token: token, mount_path: test_config.mount_path)
50
+ say "Configuration saved to #{Config.path}", :green
51
+ end
52
+
53
+ private
54
+
55
+ # Prompts with the current value as the default; an empty answer keeps
56
+ # it. A masked default shows only that a value exists, never the token.
57
+ def ask_with_default(label, default, echo: true, mask: false)
58
+ prompt = if default.to_s.empty?
59
+ "#{label}:"
60
+ else
61
+ "#{label} [#{mask ? 'keep current' : default}]:"
62
+ end
63
+ # Hiding input needs a terminal; piped input has nothing to hide from.
64
+ hide = !echo && terminal?
65
+ answer = ask(prompt, echo: !hide)
66
+ say "" if hide
67
+ answer.to_s.empty? ? default.to_s : answer
68
+ end
69
+
70
+ def terminal?
71
+ $stdin.tty?
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,91 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class Coverage < BaseCommand
7
+ desc "show", "Report what has been recorded, how recently, and any gaps"
8
+ long_desc <<~DESC
9
+ Answers whether the data is there, rather than a question about the data: which
10
+ application and environment answered, the span of requests, job runs and exceptions
11
+ held, how far summaries have been generated, what retention keeps, and whether the
12
+ background writer has dropped anything.
13
+
14
+ Read this before concluding that nothing went wrong. An empty error list means
15
+ "nothing recorded", which is only the same as "nothing happened" when collection was
16
+ healthy over the window in question.
17
+
18
+ Use --json for the full structure.
19
+ DESC
20
+ option :json, type: :boolean, default: false, desc: "Output raw JSON"
21
+ def show
22
+ with_error_handling do
23
+ data = client.get("/coverage", {})
24
+ capabilities = client.get("/capabilities", {})
25
+ next say(JSON.pretty_generate(data.merge("capabilities" => capabilities))) if options[:json]
26
+
27
+ print_installation(capabilities)
28
+ print_telemetry(data["telemetry"] || {})
29
+ print_summaries(data["summaries"] || {})
30
+ print_retention(data["retention"] || {})
31
+ print_collection(data["collection"] || {})
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def print_installation(capabilities)
38
+ say "Installation"
39
+ say " application #{capabilities['application'] || '—'} (#{capabilities['environment']})"
40
+ say " version #{capabilities['rails_pulse_version']}"
41
+ say ""
42
+ end
43
+
44
+ def print_telemetry(telemetry)
45
+ say "Telemetry"
46
+ telemetry.each do |kind, info|
47
+ if info["tracked"] == false
48
+ say " #{kind.ljust(12)} not tracked — #{info['reason']}"
49
+ elsif info["newest"]
50
+ say " #{kind.ljust(12)} #{info['count']} rows, #{info['oldest']} → #{info['newest']}"
51
+ else
52
+ say " #{kind.ljust(12)} nothing recorded"
53
+ end
54
+ end
55
+ end
56
+
57
+ def print_summaries(summaries)
58
+ say ""
59
+ say "Summaries"
60
+ say " hourly #{summaries['hourly_from'] || '—'} → #{summaries['hourly_through'] || '—'}#{summaries['stale'] ? ' (stale)' : ''}"
61
+ say " #{summaries['note']}" if summaries["note"]
62
+ end
63
+
64
+ def print_retention(retention)
65
+ say ""
66
+ say "Retention"
67
+ say " raw records #{days(retention['raw_records'])}"
68
+ say " hourly #{days(retention['hourly_summaries'])}"
69
+ say " events #{days(retention['events'])}"
70
+ end
71
+
72
+ def print_collection(collection)
73
+ say ""
74
+ say "Collection"
75
+ if collection["known"] == false
76
+ say " unknown — #{collection['reason']}"
77
+ return
78
+ end
79
+
80
+ say " writers #{collection['live_writers']} live, queue #{collection['queue_depth']}/#{collection['queue_size']}"
81
+ say " dropped #{collection['dropped_last_hour']} in the last hour"
82
+ say " last beat #{collection['last_heartbeat_at'] || 'never'}"
83
+ say " #{collection['note']}" if collection["note"]
84
+ end
85
+
86
+ def days(value)
87
+ value ? "#{value['days']} days" : "—"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+
4
+ module RailsPulse
5
+ module CLI
6
+ class Deployments < BaseCommand
7
+ COLUMNS = [
8
+ [ "Revision", 14, :short_revision ],
9
+ [ "Started", 25, :started_at ],
10
+ [ "Finished", 25, :finished_at ]
11
+ ].freeze
12
+
13
+ desc "list", "List recorded deployments"
14
+ long_desc <<~DESC
15
+ Returns deployments ordered by most recent first. A deployment still in progress has
16
+ no finish time.
17
+
18
+ Filter by time window (ISO 8601):
19
+ --since 2026-06-01T00:00:00Z
20
+ --until 2026-06-01T23:59:59Z
21
+
22
+ Use --json to get the full revision, duration and deployment metadata.
23
+ DESC
24
+ option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
25
+ option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
26
+ option :since, type: :string, desc: "Return deployments started at or after this time (ISO 8601)"
27
+ option :until, type: :string, desc: "Return deployments started at or before this time (ISO 8601)"
28
+ option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope"
29
+ def list
30
+ with_error_handling do
31
+ params = { limit: options[:limit], offset: options[:offset] }
32
+ params[:since] = options[:since] if options[:since]
33
+ params[:until] = options[:until] if options[:until]
34
+ result = client.get("/deployments", params)
35
+ Formatter.render(result, json: options[:json], columns: COLUMNS)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,111 @@
1
+ require_relative "base_command"
2
+ require_relative "formatter"
3
+ require "json"
4
+
5
+ module RailsPulse
6
+ module CLI
7
+ class Exceptions < BaseCommand
8
+ COLUMNS = [
9
+ [ "ID", 6, :id ],
10
+ [ "Class", 36, :exception_class ],
11
+ [ "Location", 40, :location ],
12
+ [ "Status", 9, :status ],
13
+ [ "Count", 6, :occurrence_count ],
14
+ [ "Last seen", 25, :last_seen_at ]
15
+ ].freeze
16
+
17
+ desc "list", "List exception groups"
18
+ long_desc <<~DESC
19
+ Returns exception groups — one row per exception class and location — ordered by most
20
+ recently seen first. Each row carries the group's lifecycle status (open, resolved,
21
+ ignored), how many times it has occurred, and when it was first and last seen.
22
+
23
+ Filter by status:
24
+ --status open
25
+
26
+ Search the class name or location:
27
+ --search RecordNotFound
28
+
29
+ Filter by when the group was last seen (ISO 8601):
30
+ --since 2026-06-01T00:00:00Z
31
+ --until 2026-06-01T23:59:59Z
32
+
33
+ Sort by last_seen_at (default), first_seen_at, or occurrence_count:
34
+ --sort occurrence_count
35
+
36
+ Use --json to get the latest message, fingerprint, and the full response envelope.
37
+ DESC
38
+ option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
39
+ option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
40
+ option :since, type: :string, desc: "Return groups last seen at or after this time (ISO 8601)"
41
+ option :until, type: :string, desc: "Return groups last seen at or before this time (ISO 8601)"
42
+ option :status, type: :string, desc: "Filter by status (open, resolved, ignored)"
43
+ option :search, type: :string, desc: "Substring match on exception class or location"
44
+ option :sort, type: :string, desc: "Sort by last_seen_at, first_seen_at, or occurrence_count"
45
+ option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope"
46
+ def list
47
+ with_error_handling do
48
+ params = { limit: options[:limit], offset: options[:offset] }
49
+ params[:since] = options[:since] if options[:since]
50
+ params[:until] = options[:until] if options[:until]
51
+ params[:status] = options[:status] if options[:status]
52
+ params[:search] = options[:search] if options[:search]
53
+ params[:sort] = options[:sort] if options[:sort]
54
+ result = client.get("/exceptions", params)
55
+ Formatter.render(result, json: options[:json], columns: COLUMNS)
56
+ end
57
+ end
58
+
59
+ desc "show ID", "Show one exception group with its recent occurrences and backtraces"
60
+ long_desc <<~DESC
61
+ Prints the group's class, location, status and counts, then each of its most recent
62
+ occurrences with request, environment, deploy SHA, filtered params, and the backtrace.
63
+
64
+ rails-pulse exceptions show 42
65
+ rails-pulse exceptions show 42 --occurrences 10 --json
66
+ DESC
67
+ option :occurrences, type: :numeric, default: 3, desc: "Most recent occurrences to include (1–20)"
68
+ option :json, type: :boolean, default: false, desc: "Output raw JSON"
69
+ def show(id)
70
+ with_error_handling do
71
+ result = client.get("/exceptions/#{id.to_i}", { occurrences: options[:occurrences] })
72
+ if options[:json]
73
+ puts JSON.pretty_generate(result)
74
+ else
75
+ render_group(result["data"] || {})
76
+ end
77
+ end
78
+ end
79
+
80
+ no_commands do
81
+ def render_group(group)
82
+ say ""
83
+ say "#{group["exception_class"]} ##{group["id"]}", :bold
84
+ say "═" * 60
85
+ say " Location: #{group["location"] || "—"}"
86
+ say " Status: #{group["status"]}"
87
+ say " Count: #{group["occurrence_count"]}"
88
+ say " First seen: #{group["first_seen_at"]}"
89
+ say " Last seen: #{group["last_seen_at"]}"
90
+ say " Message: #{group["message"]}" if group["message"]
91
+
92
+ Array(group["occurrences"]).each_with_index do |occurrence, index|
93
+ say ""
94
+ say "Occurrence #{index + 1} — #{occurrence["occurred_at"]}", :bold
95
+ say " Request: #{occurrence["request_method"]} #{occurrence["request_url"]}" if occurrence["request_url"]
96
+ say " Env: #{occurrence["environment"]}" if occurrence["environment"]
97
+ say " Deploy: #{occurrence["deploy_sha"]}" if occurrence["deploy_sha"]
98
+ say " Params: #{occurrence["request_params"].to_json}" if occurrence["request_params"]
99
+ say " Message: #{occurrence["message"]}" if occurrence["message"] && occurrence["message"] != group["message"]
100
+ Array(occurrence["backtrace"]).each do |frame|
101
+ location = frame.is_a?(Hash) ? "#{frame["file"]}:#{frame["line"]}#{frame["method"] ? " in #{frame["method"]}" : ""}" : frame.to_s
102
+ app = location.start_with?("app/", "lib/", "config/")
103
+ say " #{app ? "▸" : " "} #{location}", app ? :green : nil
104
+ end
105
+ end
106
+ say ""
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,31 @@
1
+ require "json"
2
+
3
+ module RailsPulse
4
+ module CLI
5
+ class Formatter
6
+ def self.render(response, json: false, columns: [])
7
+ if json
8
+ puts JSON.pretty_generate(response)
9
+ else
10
+ render_table(response["data"], columns)
11
+ end
12
+ end
13
+
14
+ def self.render_table(rows, columns)
15
+ return puts "(no results)" if rows.empty?
16
+
17
+ header = columns.map { |header, width, _key| header.upcase.ljust(width) }.join(" ")
18
+ puts header
19
+ puts "-" * header.length
20
+
21
+ rows.each do |row|
22
+ line = columns.map do |_header, width, key|
23
+ val = row[key.to_s].to_s
24
+ val.length > width ? "#{val[0, width - 1]}…" : val.ljust(width)
25
+ end.join(" ")
26
+ puts line
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -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