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
@@ -1,22 +1,54 @@
1
1
  module RailsPulse
2
2
  class SqlQueryNormalizer
3
- # Above this length, skip the regex-based literal scan in favor of a
4
- # slower-per-char but backtracking-free manual scan. Regex matching here
5
- # is linear (benchmarked at ~0.4s for an 8,000,000-char literal under a
6
- # 1s Regexp.timeout), but a host app can configure Regexp.timeout far
7
- # below that, and very long queries are rare enough that the fast path
8
- # isn't worth the risk. 100_000 chars leaves generous headroom below
9
- # where even an aggressive timeout config could plausibly trip — see
10
- # issue #286.
11
- LONG_QUERY_THRESHOLD = 100_000
3
+ # MySQL adapters. MySQL's quoting differs from PostgreSQL's and SQLite's
4
+ # in two ways the scanner must know about. Its drivers escape a quote
5
+ # inside a string with a backslash ('O\'Brien', "O\"Brien") rather than
6
+ # by doubling it, whereas on the other two a backslash in a plain string
7
+ # is an ordinary character ('C:\' is a complete string). And double
8
+ # quotes delimit a string, not an identifier (Rails quotes MySQL
9
+ # identifiers with backticks), so every double-quoted span is a value to
10
+ # redact, where on the other two it is an identifier when it looks like
11
+ # one (issue #313).
12
+ MYSQL_ADAPTERS = %w[mysql2 trilogy].freeze
13
+
14
+ # Opening delimiter of a PostgreSQL dollar-quoted string: $$ or $tag$.
15
+ # \G anchors the match at the scan position, so a failed attempt does
16
+ # not search ahead through the rest of the string.
17
+ DOLLAR_QUOTE_OPENER = /\G\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/
18
+
19
+ # String-literal prefixes: PostgreSQL escape strings (E'...'), bit,
20
+ # hex and national strings (B'...', X'...', N'...'), upper or lower case.
21
+ STRING_PREFIX_CHARS = "EeBbXxNn"
22
+
23
+ # A double-quoted token kept as an identifier on PostgreSQL and SQLite:
24
+ # a plain identifier or a dotted chain of them ("users.email").
25
+ # Anything else between double quotes — an email, a sentence, a number —
26
+ # can only be a value (SQLite accepts a double-quoted string in
27
+ # hand-written SQL), so it must not survive normalization.
28
+ QUOTED_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)*\z/
29
+
30
+ # Longest double-quoted span that is checked against QUOTED_IDENTIFIER.
31
+ # PostgreSQL caps an identifier at 63 bytes and MySQL at 64, so a dotted
32
+ # chain of three is well within this; anything longer is a value, and
33
+ # skipping the regex keeps a long literal from tripping a host's tight
34
+ # Regexp.timeout (issue #286).
35
+ MAX_QUOTED_IDENTIFIER_LENGTH = 256
12
36
 
13
37
  # Smart normalization: preserve table/column names, replace only literal values
14
- def self.normalize(query_string)
15
- new(query_string).normalize
38
+ def self.normalize(query_string, adapter: host_adapter)
39
+ new(query_string, adapter: adapter).normalize
16
40
  end
17
41
 
18
- def initialize(query_string)
42
+ # The host application's primary database adapter, which decides the
43
+ # string-escaping rules. A host that also queries a second database on a
44
+ # different adapter has those queries normalized with the primary's rules.
45
+ def self.host_adapter
46
+ @host_adapter ||= ActiveRecord::Base.connection_db_config.adapter.to_s
47
+ end
48
+
49
+ def initialize(query_string, adapter: self.class.host_adapter)
19
50
  @query_string = query_string
51
+ @mysql = MYSQL_ADAPTERS.include?(adapter.to_s.downcase)
20
52
  end
21
53
 
22
54
  def normalize
@@ -25,6 +57,12 @@ module RailsPulse
25
57
 
26
58
  normalized = @query_string.dup
27
59
 
60
+ # Step 0: Replace every string literal in one left-to-right pass, so a
61
+ # quote form occurring inside another (an apostrophe in a dollar-quoted
62
+ # string, $$ inside a plain string) cannot pair across two separate
63
+ # literals. Double-quoted spans are kept only when they are identifiers.
64
+ normalized = replace_string_literals(normalized)
65
+
28
66
  # Step 1: Temporarily protect quoted identifiers
29
67
  protected_identifiers = protect_identifiers(normalized)
30
68
  normalized = protected_identifiers[:normalized]
@@ -44,121 +82,148 @@ module RailsPulse
44
82
 
45
83
  private
46
84
 
85
+ # One linear scan over the query replacing every single-quoted string
86
+ # ('...', with '' doubling and, on MySQL, \'), prefixed string (E'...'
87
+ # and friends, with backslash escapes; U&'...') and dollar-quoted string
88
+ # ($$...$$, $tag$...$tag$) with "?". A double-quoted span is copied
89
+ # through when it is an identifier and replaced with "?" when it is a
90
+ # value; backticked spans are always identifiers and are copied through.
91
+ # Left to right with no backtracking, like the databases' own lexers,
92
+ # and no regex runs across literal content, so a host's tight
93
+ # Regexp.timeout cannot trip on a long literal (see issue #286).
94
+ def replace_string_literals(query)
95
+ result = +""
96
+ i = 0
97
+ len = query.length
98
+
99
+ while i < len
100
+ char = query[i]
101
+
102
+ case char
103
+ when "'"
104
+ close = find_closing_quote(query, i + 1, "'", backslash_escapes: @mysql)
105
+ if close
106
+ result << "?"
107
+ i = close + 1
108
+ else
109
+ result << query[i..]
110
+ break
111
+ end
112
+ when '"'
113
+ close = find_closing_quote(query, i + 1, '"', backslash_escapes: @mysql)
114
+ if close
115
+ result << (keep_double_quoted?(query[(i + 1)...close]) ? query[i..close] : "?")
116
+ i = close + 1
117
+ else
118
+ result << query[i..]
119
+ break
120
+ end
121
+ when "`"
122
+ close = find_closing_quote(query, i + 1, "`")
123
+ if close
124
+ result << query[i..close]
125
+ i = close + 1
126
+ else
127
+ result << query[i..]
128
+ break
129
+ end
130
+ when "$"
131
+ if (m = DOLLAR_QUOTE_OPENER.match(query, i))
132
+ delimiter = m[0]
133
+ close = query.index(delimiter, i + delimiter.length)
134
+ if close
135
+ result << "?"
136
+ i = close + delimiter.length
137
+ next
138
+ end
139
+ end
140
+ result << char
141
+ i += 1
142
+ else
143
+ if STRING_PREFIX_CHARS.include?(char) && query[i + 1] == "'" &&
144
+ (i == 0 || !query[i - 1].match?(/[A-Za-z0-9_$]/))
145
+ # Only E'...' takes backslash escapes outside MySQL: N'C:\' is a
146
+ # complete string on PostgreSQL and SQLite.
147
+ close = find_closing_quote(query, i + 2, "'", backslash_escapes: @mysql || char.casecmp?("e"))
148
+ if close
149
+ result << "?"
150
+ i = close + 1
151
+ next
152
+ end
153
+ elsif char.casecmp?("u") && query[i + 1] == "&" && query[i + 2] == "'" &&
154
+ (i == 0 || !query[i - 1].match?(/[A-Za-z0-9_$]/))
155
+ # PostgreSQL Unicode escape string (U&'...'). Its backslash
156
+ # introduces a code point, not a quote, so only '' escapes a quote.
157
+ close = find_closing_quote(query, i + 3, "'", backslash_escapes: @mysql)
158
+ if close
159
+ result << "?"
160
+ i = close + 1
161
+ next
162
+ end
163
+ end
164
+ result << char
165
+ i += 1
166
+ end
167
+ end
168
+
169
+ result
170
+ end
171
+
172
+ # Swaps every quoted identifier for a placeholder so the value
173
+ # replacement below cannot touch it: a column named "true" or `col2`
174
+ # must come back intact. A double-quoted span that is not an identifier
175
+ # can only remain after an unterminated literal was copied through
176
+ # verbatim; it is a value and is redacted here.
47
177
  def protect_identifiers(query)
48
178
  protected_identifiers = {}
49
179
  identifier_counter = 0
50
- normalized = query.dup
51
180
 
52
- # Protect backticked identifiers (MySQL style)
53
- normalized = normalized.gsub(/`([^`]+)`/) do |match|
181
+ normalized = query.gsub(/`[^`]+`|"([^"]+)"/) do |match|
182
+ next "?" if $1 && !keep_double_quoted?($1)
183
+
54
184
  placeholder = "__IDENTIFIER_#{identifier_counter}__"
55
185
  protected_identifiers[placeholder] = match
56
186
  identifier_counter += 1
57
187
  placeholder
58
188
  end
59
189
 
60
- # Protect double-quoted identifiers (PostgreSQL/SQL standard style)
61
- # Only protect if they appear in contexts where identifiers are expected
62
- normalized = normalized.gsub(/"([^"]+)"/) do |match|
63
- content = $1
64
- # Only protect if it looks like an identifier (no spaces, not a sentence)
65
- if looks_like_identifier?(content)
66
- placeholder = "__IDENTIFIER_#{identifier_counter}__"
67
- protected_identifiers[placeholder] = match
68
- identifier_counter += 1
69
- placeholder
70
- else
71
- match # Leave it as-is for now, will be replaced as string literal
72
- end
73
- end
74
-
75
190
  { normalized: normalized, mapping: protected_identifiers }
76
191
  end
77
192
 
78
- def looks_like_identifier?(content)
79
- content.match?(/^[a-zA-Z_][a-zA-Z0-9_]*$/) || content.include?(".")
193
+ def keep_double_quoted?(content)
194
+ !@mysql && content.length <= MAX_QUOTED_IDENTIFIER_LENGTH && content.match?(QUOTED_IDENTIFIER)
80
195
  end
81
196
 
82
197
  def replace_literal_values(query)
83
198
  normalized = query.dup
84
199
 
85
- # Replace string literals (single quotes) FIRST so a very long literal
86
- # collapses to a single "?" before any other regex in this method has
87
- # to scan across it — see LONG_QUERY_THRESHOLD and issue #286.
88
- normalized = replace_quoted_literals(normalized, "'")
89
-
90
- # Replace double-quoted string literals (not protected identifiers)
91
- normalized = replace_quoted_literals(normalized, '"')
92
-
93
- # Replace floating-point numbers FIRST (before integers) to avoid double replacement
200
+ # Replace numbers in exponent form first (1e5, 2.5E-3, 1.0e-05 as Ruby
201
+ # writes small floats), then decimals, then integers, so that no later
202
+ # pattern can match the remains of an earlier one. The lookarounds keep
203
+ # digits that are part of an identifier (users2, int4) in place.
204
+ normalized = normalized.gsub(/(?<![a-zA-Z_])\b\d+(?:\.\d+)?[eE][+-]?\d+\b(?![a-zA-Z_])/, "?")
94
205
  normalized = normalized.gsub(/(?<![a-zA-Z_])\b\d+\.\d+\b(?![a-zA-Z_])/, "?")
95
-
96
- # Replace integer literals with placeholders, but preserve identifiers containing numbers
97
- # Negative lookbehind/lookahead prevents replacing numbers in table/column names
98
206
  normalized = normalized.gsub(/(?<![a-zA-Z_])\b\d+\b(?![a-zA-Z_])/, "?")
99
207
 
100
208
  # Handle boolean literals
101
- normalized = normalized.gsub(/\b(true|false)\b/i, "?")
102
-
103
- normalized
104
- end
105
-
106
- # Replaces quote_char-delimited string literals with a single placeholder.
107
- # Uses a regex for the common case (fast — see benchmarks in
108
- # LONG_QUERY_THRESHOLD's comment) and falls back to a manual scan for
109
- # very long queries, where that regex risks Regexp::TimeoutError under
110
- # an aggressively configured Regexp.timeout — see issue #286.
111
- def replace_quoted_literals(query, quote_char)
112
- if query.length < LONG_QUERY_THRESHOLD
113
- pattern = quote_char == "'" ? /'(?:[^']|'')*'/ : /"(?:[^"]|"")*"/
114
- query.gsub(pattern, "?")
115
- else
116
- scan_quoted_literals(query, quote_char)
117
- end
118
- end
119
-
120
- # Linear, non-regex scan used above LONG_QUERY_THRESHOLD. A quote left
121
- # unterminated for the rest of the string is left untouched rather than
122
- # rescanned from every possible start position, which is what would make
123
- # a naive manual reimplementation quadratic.
124
- def scan_quoted_literals(query, quote_char)
125
- result = +""
126
- i = 0
127
- len = query.length
128
-
129
- while i < len
130
- char = query[i]
131
-
132
- unless char == quote_char
133
- result << char
134
- i += 1
135
- next
136
- end
137
-
138
- close_index = find_closing_quote(query, i + 1, quote_char)
139
-
140
- if close_index
141
- result << "?"
142
- i = close_index + 1
143
- else
144
- result << query[i..]
145
- break
146
- end
147
- end
148
-
149
- result
209
+ normalized.gsub(/\b(true|false)\b/i, "?")
150
210
  end
151
211
 
152
212
  # Returns the index of the unescaped closing quote_char starting the
153
213
  # search at `start`, treating a doubled quote_char (e.g. '') as an
154
- # escaped literal quote rather than a terminator. Returns nil if the
155
- # string is never closed.
156
- def find_closing_quote(query, start, quote_char)
214
+ # escaped literal quote rather than a terminator. With
215
+ # backslash_escapes, a backslash also escapes the character after it.
216
+ # Returns nil if the string is never closed.
217
+ def find_closing_quote(query, start, quote_char, backslash_escapes: false)
157
218
  j = start
158
219
  len = query.length
159
220
 
160
221
  while j < len
161
- if query[j] == quote_char
222
+ char = query[j]
223
+
224
+ if backslash_escapes && char == "\\"
225
+ j += 2
226
+ elsif char == quote_char
162
227
  return j unless query[j + 1] == quote_char
163
228
  j += 2
164
229
  else
@@ -171,42 +236,54 @@ module RailsPulse
171
236
 
172
237
  def handle_special_constructs(query)
173
238
  normalized = normalize_in_clauses(query)
239
+ normalized = normalize_values_rows(normalized)
174
240
  normalized.gsub(/\bBETWEEN\s+\?\s+AND\s+\?/i, "BETWEEN ? AND ?")
175
241
  end
176
242
 
177
- # Replaces IN clause contents with placeholders using paren-depth tracking
178
- # so that subqueries containing nested parens (e.g. HAVING (COUNT(*) > N))
179
- # are handled correctly. A flat [^)]+ regex stops at the first ) it finds,
180
- # which breaks when the subquery contains function calls or nested clauses.
243
+ # The number of values in an IN list is a property of one execution, not
244
+ # of the statement, so every literal list becomes IN (?): a preload that
245
+ # runs with 3 ids and then with 12 is one Query. A subquery is kept, with
246
+ # the IN clauses inside it normalized the same way, so `id IN (SELECT …)`
247
+ # stays a different statement from `id IN (?)`. Paren depth is tracked so
248
+ # a subquery containing nested parens (HAVING (COUNT(*) > ?)) ends where
249
+ # it should.
181
250
  def normalize_in_clauses(query)
182
251
  result = +""
183
252
  i = 0
184
253
  while i < query.length
185
- at_word_boundary = i == 0 || !query[i - 1].match?(/[a-zA-Z_0-9]/i)
186
- m = at_word_boundary && query[i..].match(/\AIN\s*\(\s*/i)
254
+ m = word_boundary?(query, i) && query[i..].match(/\AIN\s*\(/i)
187
255
 
188
256
  if m
189
- content_start = i + m[0].length
190
- depth = 1
191
- j = content_start
192
-
193
- while j < query.length && depth > 0
194
- case query[j]
195
- when "(" then depth += 1
196
- when ")" then depth -= 1
197
- end
198
- j += 1 if depth > 0
199
- end
257
+ open = i + m[0].length - 1
258
+ close = matching_paren(query, open)
259
+ content = query[(open + 1)...close]
260
+ result << (content.match?(/\bSELECT\b/i) ? "IN (#{normalize_in_clauses(content)})" : "IN (?)")
261
+ i = close + 1
262
+ else
263
+ result << query[i]
264
+ i += 1
265
+ end
266
+ end
267
+ result
268
+ end
269
+
270
+ # A multi-row insert (insert_all) lists one parenthesised group per row,
271
+ # and the row count is likewise a property of one execution: only the
272
+ # first row is kept, so VALUES (?, ?), (?, ?) becomes VALUES (?, ?).
273
+ def normalize_values_rows(query)
274
+ result = +""
275
+ i = 0
276
+ while i < query.length
277
+ m = word_boundary?(query, i) && query[i..].match(/\AVALUES\s*\(/i)
200
278
 
201
- content = query[content_start...j]
279
+ if m
280
+ close = matching_paren(query, i + m[0].length - 1)
281
+ result << query[i..close]
282
+ i = close + 1
202
283
 
203
- if content.strip.match?(/\ASELECT\s/i)
204
- result << "IN (?)"
205
- else
206
- value_count = [ content.split(",").length, 1 ].max
207
- result << "IN (#{Array.new(value_count, "?").join(", ")})"
284
+ while i < query.length && (row = query[i..].match(/\A\s*,\s*\(/))
285
+ i = matching_paren(query, i + row[0].length - 1) + 1
208
286
  end
209
- i = j + 1
210
287
  else
211
288
  result << query[i]
212
289
  i += 1
@@ -215,10 +292,33 @@ module RailsPulse
215
292
  result
216
293
  end
217
294
 
295
+ def word_boundary?(query, i)
296
+ i == 0 || !query[i - 1].match?(/[a-zA-Z_0-9]/)
297
+ end
298
+
299
+ # Index of the ")" matching the "(" at `open`, or the query's length
300
+ # when it is never closed.
301
+ def matching_paren(query, open)
302
+ depth = 0
303
+ j = open
304
+ while j < query.length
305
+ case query[j]
306
+ when "(" then depth += 1
307
+ when ")"
308
+ depth -= 1
309
+ return j if depth == 0
310
+ end
311
+ j += 1
312
+ end
313
+ query.length
314
+ end
315
+
218
316
  def restore_identifiers(query, identifier_mapping)
219
317
  normalized = query.dup
318
+ # Block form: a replacement string would interpret \` or \\ inside an
319
+ # identifier as a back-reference.
220
320
  identifier_mapping.each do |placeholder, original|
221
- normalized = normalized.gsub(placeholder, original)
321
+ normalized = normalized.gsub(placeholder) { original }
222
322
  end
223
323
  normalized
224
324
  end
@@ -0,0 +1,161 @@
1
+ module RailsPulse
2
+ class SummaryService
3
+ # Summary rows for a day, week or month, rolled up from the summaries of
4
+ # its child periods (hours for a day, days for a week or month) without
5
+ # reading raw rows.
6
+ #
7
+ # The database combines the children in one GROUP BY, so Ruby receives
8
+ # one row per summarizable however many child periods there are. Counts,
9
+ # totals, averages, min, max, status counts and the standard deviation
10
+ # combine exactly. P50/P95/P99 are the count-weighted average of the
11
+ # children's, the same weighting Tables::Base applies when a dashboard
12
+ # range spans several periods.
13
+ #
14
+ # Only plain SUM, MIN, MAX and arithmetic are used, so the query runs
15
+ # unchanged on SQLite (which has no STDDEV or, without its math
16
+ # extension, POWER), PostgreSQL and MySQL. Divisions happen in Ruby,
17
+ # where integer sums cannot truncate them.
18
+ #
19
+ # Rows carry the summarizable and its metrics; SummaryService adds the
20
+ # period columns.
21
+ class FromChildPeriods
22
+ TABLE = "rails_pulse_summaries".freeze
23
+ PERCENTILES = %i[p50_duration p95_duration p99_duration].freeze
24
+ STATUS_COLUMNS = %i[error_count success_count status_2xx status_3xx status_4xx status_5xx].freeze
25
+
26
+ AGGREGATES = {
27
+ count: "SUM(#{TABLE}.count)",
28
+ total_duration: "SUM(#{TABLE}.total_duration)",
29
+ min_duration: "MIN(#{TABLE}.min_duration)",
30
+ max_duration: "MAX(#{TABLE}.max_duration)",
31
+ **PERCENTILES.flat_map { |column|
32
+ [
33
+ [ :"#{column}_weighted", "SUM(#{TABLE}.#{column} * #{TABLE}.count)" ],
34
+ [ :"#{column}_weight", "SUM(CASE WHEN #{TABLE}.#{column} IS NOT NULL THEN #{TABLE}.count ELSE 0 END)" ]
35
+ ]
36
+ }.to_h,
37
+ # The three sums Statistics.pooled_stddev needs.
38
+ stddev_within_sum: "SUM((#{TABLE}.count - 1) * COALESCE(#{TABLE}.stddev_duration, 0) * COALESCE(#{TABLE}.stddev_duration, 0))",
39
+ weighted_mean_sum: "SUM(#{TABLE}.count * #{TABLE}.avg_duration)",
40
+ weighted_square_sum: "SUM(#{TABLE}.count * #{TABLE}.avg_duration * #{TABLE}.avg_duration)",
41
+ **STATUS_COLUMNS.to_h { |column| [ column, "SUM(#{TABLE}.#{column})" ] }
42
+ }.freeze
43
+
44
+ # `child_starts` are the boundaries the children must start on. Rows in
45
+ # the range that start elsewhere were written under a different
46
+ # aggregation time zone; they overlap these and would be counted twice.
47
+ def initialize(child_period_type, child_starts)
48
+ @child_period_type = child_period_type
49
+ @child_starts = child_starts
50
+ end
51
+
52
+ # Always exactly one row, even for an empty period (see SummaryService).
53
+ def request_rows
54
+ combined = combined_of("RailsPulse::Request")[0]
55
+
56
+ [ row("RailsPulse::Request", 0).merge(duration_metrics(combined), status_metrics(combined)) ]
57
+ end
58
+
59
+ def route_rows
60
+ combined_of("RailsPulse::Route").map do |route_id, combined|
61
+ row("RailsPulse::Route", route_id).merge(duration_metrics(combined), status_metrics(combined))
62
+ end
63
+ end
64
+
65
+ def query_rows
66
+ combined_of("RailsPulse::Query").map do |query_id, combined|
67
+ row("RailsPulse::Query", query_id).merge(duration_metrics(combined))
68
+ end
69
+ end
70
+
71
+ def job_rows
72
+ combined_by_job = combined_of("RailsPulse::Job")
73
+ return [] if combined_by_job.empty?
74
+
75
+ # A job whose row has gone (count-based cleanup) has nothing to
76
+ # summarize against.
77
+ known_job_ids = Job.where(id: combined_by_job.keys).pluck(:id).to_set
78
+
79
+ combined_by_job.filter_map do |job_id, combined|
80
+ next unless known_job_ids.include?(job_id)
81
+
82
+ row("RailsPulse::Job", job_id).merge(
83
+ duration_metrics(combined),
84
+ status_metrics(combined).slice(:error_count, :success_count)
85
+ )
86
+ end
87
+ end
88
+
89
+ # The children already carry the all-groups row (id 0), which sums like
90
+ # any other group.
91
+ def exception_rows
92
+ combined_of("RailsPulse::ExceptionGroup").map do |group_id, combined|
93
+ row("RailsPulse::ExceptionGroup", group_id).merge(count: combined[:count].to_i)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def row(summarizable_type, summarizable_id)
100
+ { summarizable_type: summarizable_type, summarizable_id: summarizable_id }
101
+ end
102
+
103
+ # { summarizable_type => { summarizable_id => { aggregate => value } } }.
104
+ # Empty children (an idle hour's count-0 heartbeat) are left out; they
105
+ # add nothing, and an idle period falls back to empty metrics.
106
+ def combined_children
107
+ @combined_children ||= Summary
108
+ .where(period_type: @child_period_type, period_start: @child_starts)
109
+ .where(count: 1..)
110
+ .group(:summarizable_type, :summarizable_id)
111
+ .pluck(:summarizable_type, :summarizable_id, *AGGREGATES.values.map { |sql| Arel.sql(sql) })
112
+ .each_with_object({}) do |(type, id, *values), by_type|
113
+ (by_type[type] ||= {})[id] = AGGREGATES.keys.zip(values).to_h
114
+ end
115
+ end
116
+
117
+ # { summarizable_id => combined } for one summarizable type.
118
+ def combined_of(summarizable_type)
119
+ combined_children.fetch(summarizable_type, {})
120
+ end
121
+
122
+ def duration_metrics(combined)
123
+ return Metrics.duration([]) unless combined
124
+
125
+ count = combined[:count].to_i
126
+ total = combined[:total_duration].to_f
127
+
128
+ {
129
+ count: count,
130
+ # Count-weighted mean of the children's averages: identical to
131
+ # total / count where every row has a duration (requests, routes,
132
+ # queries), and consistent with the children for jobs, whose count
133
+ # includes runs with no recorded duration (discarded runs) that
134
+ # total / count would dilute the average with.
135
+ avg_duration: combined[:weighted_mean_sum].to_f / count,
136
+ min_duration: combined[:min_duration]&.to_f,
137
+ max_duration: combined[:max_duration]&.to_f,
138
+ total_duration: total,
139
+ **PERCENTILES.to_h { |column| [ column, weighted_percentile(combined, column) ] },
140
+ stddev_duration: Statistics.pooled_stddev(
141
+ count: count,
142
+ within_sum: combined[:stddev_within_sum],
143
+ weighted_mean_sum: combined[:weighted_mean_sum],
144
+ weighted_square_sum: combined[:weighted_square_sum]
145
+ )
146
+ }
147
+ end
148
+
149
+ def weighted_percentile(combined, column)
150
+ weight = combined[:"#{column}_weight"].to_f
151
+ return nil if weight.zero?
152
+
153
+ combined[:"#{column}_weighted"].to_f / weight
154
+ end
155
+
156
+ def status_metrics(combined)
157
+ STATUS_COLUMNS.index_with { |column| combined ? combined[column].to_i : 0 }
158
+ end
159
+ end
160
+ end
161
+ end