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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
- data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
- data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
- data/app/jobs/rails_pulse/summary_job.rb +13 -0
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +5 -18
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
- data/app/models/rails_pulse/cleanup_run.rb +32 -0
- data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
- data/app/models/rails_pulse/cloud/installation.rb +55 -0
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
- data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
- data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +41 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
- data/app/models/rails_pulse/like_pattern.rb +35 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/time_window.rb +15 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/cloud/batch.rb +83 -0
- data/app/services/rails_pulse/cloud/client.rb +68 -0
- data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
- data/app/services/rails_pulse/cloud/health_item.rb +77 -0
- data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
- data/app/services/rails_pulse/cloud/preview.rb +83 -0
- data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
- data/app/services/rails_pulse/cloud/record_items.rb +60 -0
- data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
- data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
- data/app/services/rails_pulse/cloud/sync.rb +275 -0
- data/app/services/rails_pulse/config_recommendations.rb +132 -0
- data/app/services/rails_pulse/deployment_comparison.rb +177 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/change_point.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/period_insights.rb +131 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +39 -2
- data/config/routes.rb +23 -0
- data/db/rails_pulse_cloud_schema.rb +58 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +19 -0
- data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
- data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
- data/lib/rails_pulse/cleanup_service.rb +37 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +54 -0
- data/lib/rails_pulse/cli/exceptions.rb +116 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/insights.rb +70 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +76 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +67 -0
- data/lib/rails_pulse/cli/requests.rb +57 -0
- data/lib/rails_pulse/cli/routes.rb +73 -0
- data/lib/rails_pulse/configuration.rb +100 -2
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/installers/config_updater.rb +9 -2
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +78 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
- data/lib/rails_pulse/tracker.rb +76 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/lib/tasks/rails_pulse.rake +6 -0
- data/lib/tasks/rails_pulse_tasks.rake +7 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +123 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- data/app/controllers/concerns/zoom_range_concern.rb +0 -86
|
@@ -1,22 +1,54 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
class SqlQueryNormalizer
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
79
|
-
content.
|
|
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
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
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
|
|
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.
|
|
155
|
-
#
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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
|