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,83 @@
1
+ require "zlib"
2
+ require "securerandom"
3
+
4
+ module RailsPulse
5
+ module Cloud
6
+ # Items wrapped in the envelope the sync contract defines, split so no
7
+ # batch exceeds what Cloud accepts. Each batch gets its own `batch_id`
8
+ # when it is built; a retry resends the same one, which is how Cloud
9
+ # tells a resend from new data.
10
+ class Batch
11
+ CONTRACT = 1
12
+ MAX_ITEMS = 5_000
13
+ MAX_COMPRESSED_BYTES = 1_000_000
14
+
15
+ attr_reader :envelope, :items, :batch_id
16
+
17
+ # @param items [Array<Hash>]
18
+ # @param envelope [Hash] the fields every batch repeats (see .envelope)
19
+ # @return [Array<Batch>] one or more batches, none over the limits
20
+ def self.build(items, envelope:)
21
+ items.each_slice(MAX_ITEMS).flat_map { |slice| fitting(slice, envelope) }
22
+ end
23
+
24
+ # Halves a batch until each part compresses under the limit. A single
25
+ # item larger than the limit is left as a batch of one; Cloud refuses
26
+ # it, which is the right outcome for an item that cannot be sent.
27
+ def self.fitting(items, envelope)
28
+ batch = new(items, envelope: envelope)
29
+ return [ batch ] if items.size <= 1 || batch.compressed_bytesize <= MAX_COMPRESSED_BYTES
30
+
31
+ middle = items.size / 2
32
+ fitting(items.first(middle), envelope) + fitting(items.drop(middle), envelope)
33
+ end
34
+
35
+ # The envelope fields that identify where a batch came from.
36
+ def self.envelope(application:, environment:, installation_id:)
37
+ {
38
+ contract: CONTRACT,
39
+ application: application,
40
+ environment: environment,
41
+ installation_id: installation_id,
42
+ time_zone: Time.zone.tzinfo.name,
43
+ gem_version: RailsPulse::VERSION,
44
+ rails_version: Rails.version,
45
+ ruby_version: RUBY_VERSION,
46
+ database_adapter: RailsPulse::ApplicationRecord.connection_db_config.adapter.to_s
47
+ }
48
+ end
49
+
50
+ # A UUID version 7: a millisecond timestamp followed by random bits, so
51
+ # batch IDs sort by when they were built.
52
+ def self.uuid_v7(time = Time.current)
53
+ return SecureRandom.uuid_v7 if SecureRandom.respond_to?(:uuid_v7)
54
+
55
+ milliseconds = (time.to_r * 1000).to_i
56
+ bytes = [ milliseconds >> 16, milliseconds & 0xffff ].pack("Nn").bytes + SecureRandom.random_bytes(10).bytes
57
+ bytes[6] = (bytes[6] & 0x0f) | 0x70
58
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
59
+ hex = bytes.pack("C*").unpack1("H*")
60
+ [ hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12] ].join("-")
61
+ end
62
+
63
+ def initialize(items, envelope:, batch_id: self.class.uuid_v7, sent_at: Time.current)
64
+ @items = items
65
+ @envelope = envelope
66
+ @batch_id = batch_id
67
+ @sent_at = sent_at
68
+ end
69
+
70
+ def to_h
71
+ { contract: CONTRACT, batch_id: @batch_id, sent_at: @sent_at.utc.iso8601, **@envelope.except(:contract), items: @items }
72
+ end
73
+
74
+ def to_json(*)
75
+ to_h.to_json
76
+ end
77
+
78
+ def compressed_bytesize
79
+ Zlib.gzip(to_json).bytesize
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,68 @@
1
+ require "net/http"
2
+ require "zlib"
3
+ require "json"
4
+
5
+ module RailsPulse
6
+ module Cloud
7
+ # Posts one batch to Rails Pulse Cloud. Gzipped JSON, the contract
8
+ # version in a header, and short timeouts: it runs in a background job,
9
+ # but a hung connection would still hold a worker.
10
+ class Client
11
+ OPEN_TIMEOUT = 5
12
+ READ_TIMEOUT = 15
13
+ PATH = "/v1/batches".freeze
14
+
15
+ Response = Struct.new(:status, :body, :headers, keyword_init: true) do
16
+ # The JSON error body Cloud sends, or an empty hash.
17
+ def error
18
+ parsed = JSON.parse(body.to_s)
19
+ parsed.is_a?(Hash) ? parsed : {}
20
+ rescue JSON::ParserError
21
+ {}
22
+ end
23
+
24
+ def message
25
+ error["message"].presence || "HTTP #{status}"
26
+ end
27
+
28
+ def header(name)
29
+ headers[name.downcase]
30
+ end
31
+ end
32
+
33
+ # Raised when Cloud could not be reached or did not answer in time.
34
+ class Unavailable < StandardError; end
35
+
36
+ def initialize(settings = RailsPulse.configuration.cloud)
37
+ @settings = settings
38
+ end
39
+
40
+ # @param body [String] the batch as JSON
41
+ # @return [Response]
42
+ def post(body)
43
+ uri = URI.join(@settings.url.to_s.chomp("/") + "/", PATH.delete_prefix("/"))
44
+ request = Net::HTTP::Post.new(uri)
45
+ request["Authorization"] = "Bearer #{@settings.api_key}"
46
+ request["Content-Type"] = "application/json"
47
+ request["Content-Encoding"] = "gzip"
48
+ request["Rails-Pulse-Contract"] = Batch::CONTRACT.to_s
49
+ request["User-Agent"] = user_agent
50
+ request.body = Zlib.gzip(body)
51
+
52
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
53
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT, write_timeout: READ_TIMEOUT) do |http|
54
+ http.request(request)
55
+ end
56
+ Response.new(status: response.code.to_i, body: response.body, headers: response.each_header.to_h)
57
+ rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, EOFError => e
58
+ raise Unavailable, "#{e.class}: #{e.message}"
59
+ end
60
+
61
+ private
62
+
63
+ def user_agent
64
+ "rails_pulse/#{RailsPulse::VERSION} (ruby #{RUBY_VERSION}; rails #{Rails.version})"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,49 @@
1
+ module RailsPulse
2
+ module Cloud
3
+ # Several summary rows of one hour folded into one item's metrics: the
4
+ # unmatched paths under one prefix, the routes or queries past the
5
+ # per-hour cap, or two stored routes that share a pattern. Counts, totals
6
+ # and status counts add up exactly, as do min, max, the mean and the
7
+ # standard deviation; percentiles are the count-weighted average of the
8
+ # rows', as SummaryService does when it rolls hours up into a day.
9
+ module CombinedMetrics
10
+ PERCENTILES = %i[p50_duration p95_duration p99_duration].freeze
11
+ COUNTS = %i[error_count success_count status_2xx status_3xx status_4xx status_5xx].freeze
12
+
13
+ module_function
14
+
15
+ # @param rows [Array<#[]>] summary rows, or hashes with the same keys
16
+ # @return [Hash] the metric fields of a summary item
17
+ def of(rows, counts: COUNTS)
18
+ rows = rows.reject { |row| row[:count].to_i.zero? }
19
+ count = rows.sum { |row| row[:count].to_i }
20
+ return { count: 0 } if count.zero?
21
+
22
+ weighted_mean_sum = rows.sum { |row| row[:count].to_i * row[:avg_duration].to_f }
23
+ {
24
+ count: count,
25
+ avg_duration: weighted_mean_sum / count,
26
+ min_duration: rows.filter_map { |row| row[:min_duration]&.to_f }.min,
27
+ max_duration: rows.filter_map { |row| row[:max_duration]&.to_f }.max,
28
+ total_duration: rows.sum { |row| row[:total_duration].to_f },
29
+ **PERCENTILES.index_with { |column| weighted(rows, column) },
30
+ stddev_duration: Statistics.pooled_stddev(
31
+ count: count,
32
+ within_sum: rows.sum { |row| (row[:count].to_i - 1) * (row[:stddev_duration].to_f**2) },
33
+ weighted_mean_sum: weighted_mean_sum,
34
+ weighted_square_sum: rows.sum { |row| row[:count].to_i * (row[:avg_duration].to_f**2) }
35
+ ),
36
+ **counts.index_with { |column| rows.sum { |row| row[column].to_i } }
37
+ }
38
+ end
39
+
40
+ def weighted(rows, column)
41
+ present = rows.reject { |row| row[column].nil? }
42
+ weight = present.sum { |row| row[:count].to_i }
43
+ return nil if weight.zero?
44
+
45
+ present.sum { |row| row[column].to_f * row[:count].to_i } / weight
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,77 @@
1
+ module RailsPulse
2
+ module Cloud
3
+ # The `health` item for one minute: request volume, errors and response
4
+ # time across the application, exceptions raised, and each host's
5
+ # writers. It runs every minute, so every read is a range on an indexed
6
+ # occurred_at column over that minute alone.
7
+ class HealthItem
8
+ def self.for_minute_before(time = Time.current)
9
+ window_start = (time - 1.minute).beginning_of_minute
10
+ new(window_start).item
11
+ end
12
+
13
+ def initialize(window_start)
14
+ @window = window_start...(window_start + 1.minute)
15
+ end
16
+
17
+ def item
18
+ durations, statuses = request_rows
19
+ sorted = durations.compact.sort
20
+ {
21
+ type: "health",
22
+ window_start: @window.begin.utc.iso8601,
23
+ window_end: @window.end.utc.iso8601,
24
+ request_count: durations.size,
25
+ error_count: statuses.count { |status| status.to_i >= 500 },
26
+ avg_duration: sorted.any? ? sorted.sum.to_f / sorted.size : nil,
27
+ p95_duration: Statistics.calculate_percentile(sorted, 0.95)&.to_f,
28
+ exception_count: exception_count,
29
+ hosts: hosts
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def request_rows
36
+ rows = Request.where(occurred_at: @window).pluck(:duration, :status)
37
+ [ rows.map(&:first), rows.map(&:last) ]
38
+ end
39
+
40
+ def exception_count
41
+ return 0 unless ExceptionOccurrence.table_exists?
42
+
43
+ ExceptionOccurrence.where(occurred_at: @window).count
44
+ end
45
+
46
+ # One entry per host from the writer heartbeats: the processes that
47
+ # reported recently, their latest queue depth, and requests dropped
48
+ # inside the minute. Process IDs are not sent.
49
+ def hosts
50
+ return [] unless Event.table_available?
51
+
52
+ recent = WriterHeartbeat.events
53
+ .where(occurred_at: (@window.end - WriterHeartbeat::LIVE_WINDOW)...@window.end)
54
+ .recent
55
+ .to_a
56
+ latest_per_process = recent.uniq(&:subject)
57
+
58
+ latest_per_process.group_by { |event| host_of(event) }.map do |host, events|
59
+ subjects = events.map(&:subject)
60
+ {
61
+ host: host,
62
+ processes: events.size,
63
+ queue_depth: events.sum { |event| event.metadata_hash["queue_depth"].to_i },
64
+ dropped: recent.select { |event| subjects.include?(event.subject) && @window.cover?(event.occurred_at) }.sum { |event| event.value.to_i },
65
+ last_heartbeat_at: events.map(&:occurred_at).max.utc.iso8601
66
+ }
67
+ end.sort_by { |entry| entry[:host].to_s }
68
+ end
69
+
70
+ # config.cloud.host_label when the host set one, otherwise its hostname.
71
+ def host_of(event)
72
+ metadata = event.metadata_hash
73
+ metadata["host_label"].presence || metadata["hostname"].presence || event.subject.to_s.rpartition(":").first
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,50 @@
1
+ module RailsPulse
2
+ module Cloud
3
+ # The prefix a request the router did not recognise is sent to Cloud
4
+ # under. The stored path is the raw URL, which can carry tokens, IDs or
5
+ # email addresses, so only a first segment that looks like code
6
+ # (`/wp-admin`, `/.env`) survives, and everything after it becomes `/*`.
7
+ #
8
+ # PathPrefix.for("/wp-admin/setup.php") # => "/wp-admin/*"
9
+ # PathPrefix.for("/wp-login.php") # => "/wp-login.php"
10
+ # PathPrefix.for("/users/jane.doe@example.com") # => "/users/*"
11
+ # PathPrefix.for("/8f3a9c1e2b7d") # => "/*"
12
+ module PathPrefix
13
+ # Every prefix past the busiest in an hour is combined under this one.
14
+ OTHER = "*".freeze
15
+
16
+ SAFE_SEGMENT = /\A[A-Za-z0-9._-]{1,40}\z/
17
+ DIGIT_RUN = /\d{4}/
18
+ HEXADECIMAL = /\A\h{8,}\z/
19
+ UUID = /\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/
20
+ EMAIL_MARKERS = [ "@", "%40" ].freeze
21
+
22
+ module_function
23
+
24
+ def for(path)
25
+ path = path.to_s
26
+ return "/" if path == "/"
27
+
28
+ first, rest = path.delete_prefix("/").split("/", 2)
29
+ return "/*" unless safe_segment?(first)
30
+
31
+ rest.nil? ? "/#{first}" : "/#{first}/*"
32
+ end
33
+
34
+ # True for a path segment that reads as part of the application rather
35
+ # than something a user supplied.
36
+ def safe_segment?(segment)
37
+ return false unless segment.is_a?(String) && segment.match?(SAFE_SEGMENT)
38
+
39
+ !identifier?(segment)
40
+ end
41
+
42
+ def identifier?(segment)
43
+ segment.match?(DIGIT_RUN) ||
44
+ segment.match?(HEXADECIMAL) ||
45
+ segment.match?(UUID) ||
46
+ EMAIL_MARKERS.any? { |marker| segment.include?(marker) }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,83 @@
1
+ require "json"
2
+
3
+ module RailsPulse
4
+ module Cloud
5
+ # What `rails rails_pulse:cloud:preview` prints: the batches the next
6
+ # hourly sync would queue, and the health update for the minute just
7
+ # ended. It reads the same plan and builds items with the same code as
8
+ # Sync, so the preview cannot drift from what is sent, and it writes
9
+ # nothing: no installation row, no buffered batch, no request.
10
+ class Preview
11
+ METADATA_NOTICE = "Deployment metadata is sent as recorded: everything your deploy script puts in it goes to Rails Pulse Cloud.".freeze
12
+
13
+ def initialize(settings: RailsPulse.configuration.cloud, now: Time.current)
14
+ @settings = settings
15
+ @now = now
16
+ @installation = Installation.table_exists? ? Installation.existing : nil
17
+ rescue ActiveRecord::ActiveRecordError
18
+ @installation = nil
19
+ end
20
+
21
+ def plan
22
+ @plan ||= Sync.new(settings: @settings, now: @now).plan_for(@installation)
23
+ end
24
+
25
+ def batches
26
+ route_patterns = RoutePattern.new
27
+ items = plan.hours.flat_map do |hour|
28
+ SummaryItems.new(hour, adapter: envelope[:database_adapter], route_patterns: route_patterns).items
29
+ end
30
+ items += RecordItems.exception_groups_updated(plan.exception_groups)
31
+ items += RecordItems.deployments_updated(plan.deployments)
32
+ Batch.build(items, envelope: envelope)
33
+ end
34
+
35
+ def health_batch
36
+ Batch.new([ HealthItem.for_minute_before(@now) ], envelope: envelope, sent_at: @now)
37
+ end
38
+
39
+ def render
40
+ lines = [ "Rails Pulse Cloud preview", "" ]
41
+ lines << status_line
42
+ lines << "Installation ID: assigned on the first sync" if @installation.nil?
43
+ lines << ""
44
+
45
+ built = batches
46
+ if plan.hours.empty?
47
+ lines << "No newly summarized hour to send. Is SummaryJob scheduled?"
48
+ else
49
+ lines << "Next hourly sync: #{plan.hours.map { |hour| hour.utc.iso8601 }.join(', ')}."
50
+ end
51
+ lines << "#{pluralize(built.sum { |batch| batch.items.size }, 'item')} in #{pluralize(built.size, 'batch')}."
52
+ lines << METADATA_NOTICE
53
+ lines << ""
54
+ built.each { |batch| lines << JSON.pretty_generate(batch.to_h) }
55
+
56
+ lines << ""
57
+ lines << "Health update for the minute just ended, sent every minute and never buffered:"
58
+ lines << JSON.pretty_generate(health_batch.to_h)
59
+ lines.join("\n") + "\n"
60
+ end
61
+
62
+ private
63
+
64
+ def envelope
65
+ @envelope ||= Batch.envelope(application: @settings.application, environment: @settings.environment,
66
+ installation_id: @installation&.installation_id)
67
+ end
68
+
69
+ def status_line
70
+ if @settings.enabled?
71
+ "Sending to #{@settings.url} as #{@settings.application} (#{@settings.environment})."
72
+ else
73
+ "Not sending: set config.cloud.api_key and config.cloud.application to turn Rails Pulse Cloud on. " \
74
+ "Until then no network calls are made."
75
+ end
76
+ end
77
+
78
+ def pluralize(count, word)
79
+ "#{count} #{count == 1 ? word : word.pluralize}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,195 @@
1
+ module RailsPulse
2
+ module Cloud
3
+ # What Cloud receives for a query, rebuilt from the normalised SQL by
4
+ # allow-list. Normalisation replaces the literals it recognises with `?`,
5
+ # so a literal in a form it does not recognise stays in the stored SQL;
6
+ # here every token must be a keyword, an identifier, an operator, a
7
+ # number or a placeholder, and a query with anything else has no shape.
8
+ #
9
+ # shape = QueryShape.new('SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?')
10
+ # shape.sql_shape # => "SELECT users.* FROM users WHERE users.email = ? LIMIT ?"
11
+ # shape.label # => "SELECT users"
12
+ #
13
+ # QueryShape.new("SELECT * FROM notes WHERE body = $$jane@example.com$$").sql_shape # => nil
14
+ class QueryShape
15
+ MAX_LENGTH = 2_000
16
+ MAX_LABEL_TABLES = 5
17
+
18
+ IDENTIFIER = /[A-Za-z_][A-Za-z0-9_$]*/
19
+ BARE_IDENTIFIER = /\A#{IDENTIFIER}\z/
20
+ NUMBER = /\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/
21
+ OPERATOR = %r{[=<>!+\-*/%:|]+}
22
+ PUNCTUATION = /[.,();]/
23
+ PLACEHOLDER = /\?|\$\d+/
24
+ # Comments carry no shape, and query log tags put request IDs in them.
25
+ COMMENT = %r{/\*.*?(?:\*/|\z)|--[^\n]*}m
26
+
27
+ # Keywords after which the next name is a table.
28
+ TABLE_KEYWORDS = %w[FROM JOIN INTO UPDATE].freeze
29
+
30
+ # Keywords that take a parenthesised list, so `IN (?, ?)` is not
31
+ # rendered like a function call.
32
+ KEYWORDS_BEFORE_PARENTHESIS = %w[
33
+ ALL AND ANY AS EXISTS FROM IN INTO JOIN LATERAL NOT ON OR SELECT SET SOME THEN UNION USING VALUES WHEN WHERE
34
+ ].freeze
35
+
36
+ # MySQL quotes identifiers with backticks; a double-quoted token there
37
+ # is a string, whatever it contains.
38
+ def self.for_adapter(sql, adapter)
39
+ new(sql, double_quotes_are_strings: adapter.to_s.match?(/mysql|trilogy/i))
40
+ end
41
+
42
+ def initialize(sql, double_quotes_are_strings: false)
43
+ @sql = sql.to_s.gsub(COMMENT, " ")
44
+ @double_quotes_are_strings = double_quotes_are_strings
45
+ end
46
+
47
+ # The shape, or nil when any token is not on the allow-list.
48
+ def sql_shape
49
+ return @sql_shape if defined?(@sql_shape)
50
+
51
+ @sql_shape = tokens.any? { |kind, _| kind == :unsafe } ? nil : render.first(MAX_LENGTH)
52
+ end
53
+
54
+ # The statement type and up to five table names, always available
55
+ # because it is built only from tokens that pass.
56
+ def label
57
+ first_kind, first_text = tokens.first
58
+ statement = first_kind == :word ? first_text.upcase : "QUERY"
59
+ tables = table_names
60
+ tables.empty? ? statement : "#{statement} #{tables.join(', ')}"
61
+ end
62
+
63
+ private
64
+
65
+ # [[kind, text], ...] where kind is :word, :name (a quoted identifier,
66
+ # unquoted), :number, :placeholder, :operator or :unsafe.
67
+ def tokens
68
+ @tokens ||= begin
69
+ scanner = StringScanner.new(@sql)
70
+ result = []
71
+ until scanner.eos?
72
+ next if scanner.skip(/\s+/)
73
+
74
+ result << next_token(scanner)
75
+ end
76
+ result
77
+ end
78
+ end
79
+
80
+ def next_token(scanner)
81
+ if (text = scanner.scan(IDENTIFIER)) then [ :word, text ]
82
+ elsif scanner.scan(NUMBER) then [ :number, "?" ]
83
+ elsif (text = scanner.scan(PLACEHOLDER)) then [ :placeholder, "?" ]
84
+ elsif (text = scanner.scan(/"(?:[^"]|"")*"?/)) then quoted(text, '"')
85
+ elsif (text = scanner.scan(/`[^`]*`?/)) then quoted(text, "`")
86
+ elsif (text = scanner.scan(/'(?:[^']|'')*'?/)) then [ :unsafe, text ]
87
+ elsif (text = scanner.scan(PUNCTUATION)) then [ :operator, text ]
88
+ elsif (text = scanner.scan(OPERATOR)) then [ :operator, text ]
89
+ else [ :unsafe, scanner.getch ]
90
+ end
91
+ end
92
+
93
+ def quoted(text, quote)
94
+ return [ :unsafe, text ] if quote == '"' && @double_quotes_are_strings
95
+
96
+ content = text.delete_prefix(quote).delete_suffix(quote)
97
+ content.match?(BARE_IDENTIFIER) && text.end_with?(quote) && text.length > 1 ? [ :name, content ] : [ :unsafe, text ]
98
+ end
99
+
100
+ # Tokens rejoined with single spaces, except none around `.`, after `(`,
101
+ # before `)` and `,`, or between a function name and its `(`, with
102
+ # schema qualifiers dropped.
103
+ def render
104
+ output = +""
105
+ tokens = without_schemas
106
+ tokens.each_with_index do |token, index|
107
+ output << " " if index.positive? && space_between?(tokens[index - 1], token, index > 1 ? tokens[index - 2] : nil)
108
+ output << token.last
109
+ end
110
+ output
111
+ end
112
+
113
+ def space_between?(previous, token, earlier)
114
+ before = previous.last
115
+ text = token.last
116
+ return false if before == "." || text == "." || before == "(" || text == ")" || text == ","
117
+ return false if text == "(" && function_name?(previous, earlier)
118
+
119
+ true
120
+ end
121
+
122
+ # A word before `(` is a function call, unless it is a keyword or the
123
+ # table named after INTO (`INSERT INTO posts (title)`).
124
+ def function_name?(token, earlier)
125
+ kind, text = token
126
+ return false unless kind == :word && !KEYWORDS_BEFORE_PARENTHESIS.include?(text.upcase)
127
+
128
+ !(earlier && earlier.first == :word && TABLE_KEYWORDS.include?(earlier.last.upcase))
129
+ end
130
+
131
+ # A three-part name (schema.table.column) loses its schema anywhere; a
132
+ # two-part name right after FROM, JOIN, INTO or UPDATE is schema.table
133
+ # and loses its schema too. Apps with one schema per tenant name their
134
+ # customers there.
135
+ def without_schemas
136
+ list = tokens.dup
137
+ index = 0
138
+ result = []
139
+ while index < list.size
140
+ if qualified_name?(list, index, 3)
141
+ index += 2
142
+ elsif qualified_name?(list, index, 2) && table_position?(result)
143
+ index += 2
144
+ end
145
+ result << list[index]
146
+ index += 1
147
+ end
148
+ result
149
+ end
150
+
151
+ # A name of `parts` identifiers joined by dots starts at `index`.
152
+ def qualified_name?(list, index, parts)
153
+ (0...parts).all? do |part|
154
+ kind, text = list[index + (part * 2)]
155
+ dot = list[index + (part * 2) + 1]
156
+ name = %i[word name].include?(kind)
157
+ last = part == parts - 1
158
+ name && (last || dot&.last == ".") && (!last || text != "*")
159
+ end
160
+ end
161
+
162
+ def table_position?(result)
163
+ kind, text = result.last
164
+ kind == :word && TABLE_KEYWORDS.include?(text.upcase)
165
+ end
166
+
167
+ def table_names
168
+ names = []
169
+ tokens.each_with_index do |(kind, text), index|
170
+ next unless kind == :word && TABLE_KEYWORDS.include?(text.upcase)
171
+
172
+ name = table_after(index + 1)
173
+ names << name if name && !names.include?(name)
174
+ break if names.size >= MAX_LABEL_TABLES
175
+ end
176
+ names
177
+ end
178
+
179
+ # The table named after a keyword, with any schema dropped. Nil when
180
+ # what follows is not a plain name (a subquery, or anything unsafe).
181
+ def table_after(index)
182
+ name = nil
183
+ loop do
184
+ kind, text = tokens[index]
185
+ return name unless %i[word name].include?(kind)
186
+
187
+ name = text
188
+ return name unless tokens[index + 1]&.last == "."
189
+
190
+ index += 2
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,60 @@
1
+ module RailsPulse
2
+ module Cloud
3
+ # Items for exception groups and deployments, the two records Cloud
4
+ # keeps whole rather than as hourly numbers. Each is sent when it
5
+ # changes: a group when its status or counts move, a deployment when it
6
+ # is recorded or finished.
7
+ module RecordItems
8
+ # Larger metadata is replaced rather than cut, so Cloud never shows a
9
+ # half of what the deploy script recorded.
10
+ METADATA_LIMIT = 4.kilobytes
11
+ TRUNCATED = { "truncated" => true }.freeze
12
+
13
+ module_function
14
+
15
+ # Items for the groups whose rows changed inside `window`.
16
+ def exception_groups_updated(window)
17
+ ExceptionGroup.where(updated_at: window).order(:id).map { |record| exception_group(record) }
18
+ end
19
+
20
+ # Items for the deployments recorded or finished inside `window`.
21
+ def deployments_updated(window)
22
+ Deployment.where(updated_at: window).order(:started_at, :id).map { |record| deployment(record) }
23
+ end
24
+
25
+ # The message is never sent: it is built from runtime values and can
26
+ # carry anything a user typed.
27
+ def exception_group(group)
28
+ {
29
+ type: "exception_group",
30
+ fingerprint: group.fingerprint,
31
+ exception_class: group.exception_class,
32
+ location: group.location,
33
+ status: group.status,
34
+ first_seen_at: group.first_seen_at&.utc&.iso8601,
35
+ last_seen_at: group.last_seen_at&.utc&.iso8601,
36
+ resolved_at: group.resolved_at&.utc&.iso8601,
37
+ occurrence_count: group.occurrence_count.to_i
38
+ }
39
+ end
40
+
41
+ # Metadata is sent as recorded: the deploy script decides what goes in
42
+ # it, and the preview task says so.
43
+ def deployment(deployment)
44
+ {
45
+ type: "deployment",
46
+ revision: deployment.revision,
47
+ started_at: deployment.started_at.utc.iso8601,
48
+ finished_at: deployment.finished_at&.utc&.iso8601,
49
+ metadata: metadata(deployment.metadata_hash)
50
+ }
51
+ end
52
+
53
+ def metadata(value)
54
+ return {} unless value.is_a?(Hash)
55
+
56
+ value.to_json.bytesize > METADATA_LIMIT ? TRUNCATED : value
57
+ end
58
+ end
59
+ end
60
+ end