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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Zeitwerk inflector that pins the engine's own file names and defers
|
|
3
|
+
# everything else to the host's inflector.
|
|
4
|
+
#
|
|
5
|
+
# Rails' autoloader inflector is one map for the whole application, keyed
|
|
6
|
+
# only on a file's basename. Registering "api" => "Api" there would also
|
|
7
|
+
# rename a host's app/controllers/api/ directory, and a host that declares
|
|
8
|
+
# `inflect.acronym "API"` would then fail to find API::V1::... Checking the
|
|
9
|
+
# absolute path keeps the overrides inside the engine.
|
|
10
|
+
class ScopedInflector
|
|
11
|
+
# @param fallback [#camelize] the inflector the autoloader had before
|
|
12
|
+
# @param root [String] absolute path whose files the overrides apply to
|
|
13
|
+
# @param overrides [Hash{String => String}] basename => constant name
|
|
14
|
+
def initialize(fallback, root:, overrides:)
|
|
15
|
+
@fallback = fallback
|
|
16
|
+
@root = File.join(root.to_s, "")
|
|
17
|
+
@overrides = overrides
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def camelize(basename, abspath)
|
|
21
|
+
if abspath.to_s.start_with?(@root) && @overrides.key?(basename)
|
|
22
|
+
@overrides[basename]
|
|
23
|
+
else
|
|
24
|
+
@fallback.camelize(basename, abspath)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Hosts register their own overrides with
|
|
29
|
+
# `Rails.autoloaders.main.inflector.inflect(...)`; those still reach the
|
|
30
|
+
# inflector they were written for.
|
|
31
|
+
def inflect(overrides)
|
|
32
|
+
@fallback.inflect(overrides)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -42,5 +42,32 @@ module RailsPulse
|
|
|
42
42
|
sum_of_squares = values.sum { |v| (v - mean) ** 2 }
|
|
43
43
|
Math.sqrt(sum_of_squares / (values.size - 1))
|
|
44
44
|
end
|
|
45
|
+
|
|
46
|
+
# Sample standard deviation of the union of several groups, from three
|
|
47
|
+
# sums over the groups that a database can compute in one GROUP BY, so
|
|
48
|
+
# the groups never have to be loaded. Exact: equal to calculate_stddev
|
|
49
|
+
# over all the groups' values together. For groups i with count n_i,
|
|
50
|
+
# mean m_i and sample standard deviation s_i (nil for a group of one):
|
|
51
|
+
#
|
|
52
|
+
# within_sum = sum of (n_i - 1) * s_i^2
|
|
53
|
+
# weighted_mean_sum = sum of n_i * m_i
|
|
54
|
+
# weighted_square_sum = sum of n_i * m_i^2
|
|
55
|
+
#
|
|
56
|
+
# @param count [Integer] Total number of values across the groups
|
|
57
|
+
# @return [Float, nil] The standard deviation or nil if fewer than two values
|
|
58
|
+
#
|
|
59
|
+
# @example Groups [10, 20, 30] (n 3, mean 20, s 10) and [100, 400] (n 2, mean 250, s 212.13)
|
|
60
|
+
# Statistics.pooled_stddev(count: 5, within_sum: 45_200, weighted_mean_sum: 560, weighted_square_sum: 126_200)
|
|
61
|
+
# # => 164.83 (approximately; calculate_stddev of [10, 20, 30, 100, 400])
|
|
62
|
+
def self.pooled_stddev(count:, within_sum:, weighted_mean_sum:, weighted_square_sum:)
|
|
63
|
+
return nil if count < 2
|
|
64
|
+
|
|
65
|
+
mean = weighted_mean_sum.to_f / count
|
|
66
|
+
# The between-group term, sum of n_i * (m_i - mean)^2, expanded so it
|
|
67
|
+
# needs only the two sums. Clamped because that expansion can come out
|
|
68
|
+
# a hair below zero in floating point when every value is equal.
|
|
69
|
+
sum_of_squares = within_sum.to_f + [ weighted_square_sum.to_f - (count * mean * mean), 0 ].max
|
|
70
|
+
Math.sqrt(sum_of_squares / (count - 1))
|
|
71
|
+
end
|
|
45
72
|
end
|
|
46
73
|
end
|
|
@@ -15,11 +15,11 @@ module RailsPulse
|
|
|
15
15
|
def process
|
|
16
16
|
return unless RailsPulse.configuration.enabled
|
|
17
17
|
return unless RailsPulse.configuration.track_exceptions
|
|
18
|
-
return if
|
|
18
|
+
return if RailsPulse::Current.skip_recording_rails_pulse_activity
|
|
19
19
|
|
|
20
20
|
exception = @event.payload[:exception_object]
|
|
21
21
|
return unless exception
|
|
22
|
-
return if
|
|
22
|
+
return if RailsPulse::Current.rails_pulse_captured_exception.equal?(exception)
|
|
23
23
|
|
|
24
24
|
RailsPulse::ExceptionCaptureService.capture(
|
|
25
25
|
exception,
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
module Subscribers
|
|
3
3
|
class OperationSubscriber
|
|
4
|
-
FRAME_SLICE = 40
|
|
5
|
-
|
|
6
4
|
class << self
|
|
7
5
|
def subscribe!
|
|
8
6
|
subscribe_sql_queries!
|
|
@@ -48,24 +46,12 @@ module RailsPulse
|
|
|
48
46
|
# not materialise the whole stack: a bare caller_locations on a
|
|
49
47
|
# typical 150-frame controller action costs ~30 µs per call, which is
|
|
50
48
|
# ~15 ms on a 500-query request before the response goes out.
|
|
51
|
-
# Thread.each_caller_location
|
|
52
|
-
#
|
|
49
|
+
# Thread.each_caller_location yields frames lazily and stops at the
|
|
50
|
+
# first match.
|
|
53
51
|
def find_app_frame
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return path if path&.start_with?(app_path)
|
|
58
|
-
end
|
|
59
|
-
return nil
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
offset = 2
|
|
63
|
-
while (frames = caller_locations(offset, FRAME_SLICE)) && !frames.empty?
|
|
64
|
-
frames.each do |loc|
|
|
65
|
-
path = loc.path
|
|
66
|
-
return path if path&.start_with?(app_path)
|
|
67
|
-
end
|
|
68
|
-
offset += FRAME_SLICE
|
|
52
|
+
Thread.each_caller_location do |loc|
|
|
53
|
+
path = loc.path
|
|
54
|
+
return path if path&.start_with?(app_path)
|
|
69
55
|
end
|
|
70
56
|
nil
|
|
71
57
|
end
|
|
@@ -100,10 +86,10 @@ module RailsPulse
|
|
|
100
86
|
|
|
101
87
|
def capture_operation(event_name, start, finish, payload, operation_type, label_key = nil, extra: {})
|
|
102
88
|
return unless RailsPulse.configuration.enabled
|
|
103
|
-
return if
|
|
89
|
+
return if RailsPulse::Current.skip_recording_rails_pulse_activity
|
|
104
90
|
|
|
105
|
-
request_id =
|
|
106
|
-
job_run_id =
|
|
91
|
+
request_id = RailsPulse::Current.rails_pulse_request_id
|
|
92
|
+
job_run_id = RailsPulse::Current.rails_pulse_job_run_id
|
|
107
93
|
return unless request_id || job_run_id
|
|
108
94
|
|
|
109
95
|
# Skip RailsPulse-related operations to prevent recursion
|
|
@@ -149,16 +135,16 @@ module RailsPulse
|
|
|
149
135
|
occurred_at: Time.zone.at(start)
|
|
150
136
|
}.merge(extra)
|
|
151
137
|
|
|
152
|
-
|
|
153
|
-
|
|
138
|
+
RailsPulse::Current.rails_pulse_operations ||= []
|
|
139
|
+
RailsPulse::Current.rails_pulse_operations << operation_data
|
|
154
140
|
end
|
|
155
141
|
|
|
156
142
|
def store_operation(label:, operation_type:, start:, finish:, codebase_location:)
|
|
157
143
|
return unless RailsPulse.configuration.enabled
|
|
158
|
-
return if
|
|
144
|
+
return if RailsPulse::Current.skip_recording_rails_pulse_activity
|
|
159
145
|
|
|
160
|
-
request_id =
|
|
161
|
-
job_run_id =
|
|
146
|
+
request_id = RailsPulse::Current.rails_pulse_request_id
|
|
147
|
+
job_run_id = RailsPulse::Current.rails_pulse_job_run_id
|
|
162
148
|
return unless request_id || job_run_id
|
|
163
149
|
|
|
164
150
|
operation_data = {
|
|
@@ -172,8 +158,8 @@ module RailsPulse
|
|
|
172
158
|
occurred_at: Time.zone.at(start)
|
|
173
159
|
}
|
|
174
160
|
|
|
175
|
-
|
|
176
|
-
|
|
161
|
+
RailsPulse::Current.rails_pulse_operations ||= []
|
|
162
|
+
RailsPulse::Current.rails_pulse_operations << operation_data
|
|
177
163
|
end
|
|
178
164
|
|
|
179
165
|
def subscribe_sql_queries!
|
|
@@ -11,7 +11,20 @@ module RailsPulse
|
|
|
11
11
|
# Anything that needs action is repeated in a closing "Action needed"
|
|
12
12
|
# list, and `report` returns false in that case so release scripts can
|
|
13
13
|
# stop on it.
|
|
14
|
+
#
|
|
15
|
+
# Below that, "Suggestions" covers how the install is run rather than
|
|
16
|
+
# whether it is current: SummaryJob or CleanupJob not running, hourly
|
|
17
|
+
# summaries kept too briefly, jobs not tracked. Development and test
|
|
18
|
+
# environments rarely schedule either job, so these never change the
|
|
19
|
+
# exit status.
|
|
14
20
|
class StatusReporter
|
|
21
|
+
# Hourly summaries back deployment comparisons and hour-precise change
|
|
22
|
+
# points; below a week, last week's deploys can no longer be compared.
|
|
23
|
+
RECOMMENDED_HOURLY_RETENTION = 7.days
|
|
24
|
+
|
|
25
|
+
# CleanupJob is meant to run daily.
|
|
26
|
+
CLEANUP_STALE_AFTER = 2.days
|
|
27
|
+
|
|
15
28
|
attr_reader :output, :config
|
|
16
29
|
|
|
17
30
|
def self.report(output: $stdout)
|
|
@@ -22,6 +35,7 @@ module RailsPulse
|
|
|
22
35
|
@output = output
|
|
23
36
|
@config = RailsPulse.configuration
|
|
24
37
|
@actions = []
|
|
38
|
+
@suggestions = []
|
|
25
39
|
end
|
|
26
40
|
|
|
27
41
|
# Returns true when nothing needs attention.
|
|
@@ -35,8 +49,14 @@ module RailsPulse
|
|
|
35
49
|
print_route_backfill
|
|
36
50
|
print_initializer
|
|
37
51
|
print_tracking
|
|
52
|
+
print_writer
|
|
38
53
|
print_summaries
|
|
54
|
+
print_retention
|
|
55
|
+
print_cleanup
|
|
56
|
+
print_jobs
|
|
57
|
+
print_cloud
|
|
39
58
|
print_actions
|
|
59
|
+
print_suggestions
|
|
40
60
|
|
|
41
61
|
@actions.empty?
|
|
42
62
|
end
|
|
@@ -47,6 +67,10 @@ module RailsPulse
|
|
|
47
67
|
@actions << text
|
|
48
68
|
end
|
|
49
69
|
|
|
70
|
+
def suggest(text)
|
|
71
|
+
@suggestions << text
|
|
72
|
+
end
|
|
73
|
+
|
|
50
74
|
# -- sections -----------------------------------------------------------
|
|
51
75
|
|
|
52
76
|
def print_database
|
|
@@ -149,21 +173,185 @@ module RailsPulse
|
|
|
149
173
|
output.puts "Tracking: enabled=#{config.enabled} requests=#{config.enabled} jobs=#{config.track_jobs} " \
|
|
150
174
|
"exceptions=#{config.track_exceptions} async=#{config.async}"
|
|
151
175
|
output.puts "Dashboard: mount_dashboard=#{config.mount_dashboard} authentication=#{auth}"
|
|
176
|
+
output.puts "API: api_token #{config.api_token.to_s.empty? ? 'NOT set (rails-pulse CLI and MCP server refuse every request)' : 'set'}, " \
|
|
177
|
+
"deployment_token #{config.deployment_token.to_s.empty? ? 'NOT set (POST deployments refuses every request; the rake tasks still work)' : 'set'}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# This process has no writer of its own; read every process's heartbeats
|
|
181
|
+
def print_writer
|
|
182
|
+
unless RailsPulse::Event.table_available?
|
|
183
|
+
output.puts "Writer: (skipped — events table missing, schema is behind)"
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
summary = RailsPulse::WriterHeartbeat.summary
|
|
188
|
+
if summary[:last_sampled_at].nil?
|
|
189
|
+
output.puts "Writer: no heartbeats yet (they start when a process with config.async tracks its first request)"
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
dropped = summary[:dropped].to_i
|
|
194
|
+
output.puts "Writer: #{summary[:processes]} live process(es), queue depth #{summary[:queue_depth]}/#{summary[:queue_size]}, " \
|
|
195
|
+
"#{dropped} request(s) dropped in the last hour, last heartbeat #{time_ago(summary[:last_sampled_at])}"
|
|
196
|
+
return if dropped.zero?
|
|
197
|
+
|
|
198
|
+
action "The writer dropped #{dropped} request(s) in the last hour. Raise config.async_queue_size or check database latency."
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
output.puts "Writer: could not check (#{e.class}: #{e.message})"
|
|
152
201
|
end
|
|
153
202
|
|
|
154
203
|
def print_summaries
|
|
155
204
|
last = RailsPulse::Summary.maximum(:updated_at)
|
|
156
205
|
if last.nil?
|
|
157
206
|
output.puts "Summaries: none generated yet (schedule RailsPulse::SummaryJob hourly; backfill with rails rails_pulse:backfill_summaries)"
|
|
207
|
+
suggest "Schedule RailsPulse::SummaryJob hourly (see the README); the dashboard, the API and cleanup all read its summaries."
|
|
158
208
|
elsif last < 2.hours.ago
|
|
159
209
|
output.puts "Summaries: last generated #{time_ago(last)} — stale (is RailsPulse::SummaryJob scheduled?)"
|
|
210
|
+
suggest "RailsPulse::SummaryJob last ran #{time_ago(last)}; check it is scheduled hourly and the worker is running."
|
|
160
211
|
else
|
|
161
212
|
output.puts "Summaries: last generated #{time_ago(last)}"
|
|
162
213
|
end
|
|
214
|
+
print_hourly_coverage
|
|
163
215
|
rescue StandardError => e
|
|
164
216
|
output.puts "Summaries: could not check (#{e.class}: #{e.message})"
|
|
165
217
|
end
|
|
166
218
|
|
|
219
|
+
# Hours SummaryJob skipped inside the hourly retention window. It writes
|
|
220
|
+
# the overall request row for every hour, even an empty one, so a gap
|
|
221
|
+
# between the oldest and newest row is an hour it did not run.
|
|
222
|
+
def print_hourly_coverage
|
|
223
|
+
hours = RailsPulse::Summary.overall_requests
|
|
224
|
+
.where(period_type: "hour")
|
|
225
|
+
.where(period_start: config.hourly_summary_retention.ago..)
|
|
226
|
+
.distinct
|
|
227
|
+
.pluck(:period_start)
|
|
228
|
+
return if hours.empty?
|
|
229
|
+
|
|
230
|
+
span = ((hours.max - hours.min) / 1.hour).round + 1
|
|
231
|
+
missing = span - hours.size
|
|
232
|
+
if missing.zero?
|
|
233
|
+
output.puts " hourly: #{hours.size} consecutive hour(s) summarized"
|
|
234
|
+
else
|
|
235
|
+
output.puts " hourly: #{hours.size} of the last #{span} hours summarized, #{missing} missing"
|
|
236
|
+
suggest "RailsPulse::SummaryJob skipped #{missing} hour(s) in the last #{span}. Check its schedule; " \
|
|
237
|
+
"rails rails_pulse:backfill_summaries fills the gaps."
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def print_retention
|
|
242
|
+
hourly = config.hourly_summary_retention
|
|
243
|
+
raw = config.full_retention_period ? days(config.full_retention_period) : "no age limit"
|
|
244
|
+
output.puts "Retention: raw data #{raw}, hourly summaries #{days(hourly)}"
|
|
245
|
+
return if hourly >= RECOMMENDED_HOURLY_RETENTION
|
|
246
|
+
|
|
247
|
+
suggest "Keep hourly summaries for #{days(RECOMMENDED_HOURLY_RETENTION)}: deployment comparisons and hour-precise " \
|
|
248
|
+
"change points read them, and at #{days(hourly)} a deploy from last week can no longer be compared. " \
|
|
249
|
+
"Add config.hourly_summary_retention = #{RECOMMENDED_HOURLY_RETENTION.in_days.to_i}.days to config/initializers/rails_pulse.rb."
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def print_cleanup
|
|
253
|
+
unless config.archiving_enabled
|
|
254
|
+
output.puts "Cleanup: off (config.archiving_enabled = false); nothing is pruned"
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
unless RailsPulse::Event.table_available?
|
|
258
|
+
output.puts "Cleanup: (skipped — events table missing, schema is behind)"
|
|
259
|
+
return
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
run = RailsPulse::CleanupRun.latest
|
|
263
|
+
if run.nil?
|
|
264
|
+
print_cleanup_never_run
|
|
265
|
+
elsif run.outcome == "failed"
|
|
266
|
+
stages = run.metadata_hash["failed_stages"].to_a.join(", ")
|
|
267
|
+
output.puts "Cleanup: last ran #{time_ago(run.occurred_at)} and failed (#{stages})"
|
|
268
|
+
suggest "The last cleanup failed in #{stages}; the log has the error. Run rails rails_pulse:cleanup to retry."
|
|
269
|
+
elsif run.occurred_at < CLEANUP_STALE_AFTER.ago
|
|
270
|
+
output.puts "Cleanup: last ran #{time_ago(run.occurred_at)} — stale (is RailsPulse::CleanupJob scheduled?)"
|
|
271
|
+
suggest "Cleanup last ran #{time_ago(run.occurred_at)}; schedule RailsPulse::CleanupJob daily so retention is enforced."
|
|
272
|
+
else
|
|
273
|
+
output.puts "Cleanup: last ran #{time_ago(run.occurred_at)}, #{run.value.to_i} row(s) deleted"
|
|
274
|
+
end
|
|
275
|
+
rescue StandardError => e
|
|
276
|
+
output.puts "Cleanup: could not check (#{e.class}: #{e.message})"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# No run recorded is only a problem once there is something to prune.
|
|
280
|
+
def print_cleanup_never_run
|
|
281
|
+
overdue = config.full_retention_period &&
|
|
282
|
+
RailsPulse::Request.where(occurred_at: ...(config.full_retention_period + 1.day).ago).exists?
|
|
283
|
+
|
|
284
|
+
if overdue
|
|
285
|
+
output.puts "Cleanup: no run recorded, and requests older than #{days(config.full_retention_period)} are still held"
|
|
286
|
+
suggest "Schedule RailsPulse::CleanupJob daily (see the README); data past retention is not being pruned."
|
|
287
|
+
else
|
|
288
|
+
output.puts "Cleanup: no run recorded yet (schedule RailsPulse::CleanupJob daily)"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def print_jobs
|
|
293
|
+
unless config.track_jobs
|
|
294
|
+
output.puts "Jobs: not tracked"
|
|
295
|
+
suggest "Track background jobs to see failures and slow jobs alongside requests: " \
|
|
296
|
+
"config.track_jobs = true in config/initializers/rails_pulse.rb."
|
|
297
|
+
return
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
unless RailsPulse::SchemaCheck.current?
|
|
301
|
+
output.puts "Jobs: tracked (count skipped — schema is behind)"
|
|
302
|
+
return
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
count = RailsPulse::Job.count
|
|
306
|
+
output.puts(count.zero? ? "Jobs: tracked, none recorded yet" : "Jobs: tracked, #{count} job class(es) recorded")
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
output.puts "Jobs: could not check (#{e.class}: #{e.message})"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Reported, never an action: a Cloud outage or a refused key must not
|
|
312
|
+
# fail a deploy that runs this task.
|
|
313
|
+
def print_cloud
|
|
314
|
+
cloud = config.cloud
|
|
315
|
+
unless cloud.enabled?
|
|
316
|
+
output.puts "Cloud: off (set config.cloud.api_key and config.cloud.application to send to Rails Pulse Cloud)"
|
|
317
|
+
return
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
unless RailsPulse::Cloud::Installation.table_exists? && RailsPulse::Cloud::BufferedBatch.table_exists?
|
|
321
|
+
output.puts "Cloud: configured, but its tables are not installed, so nothing is sent"
|
|
322
|
+
suggest "Install the Rails Pulse Cloud tables: rails generate rails_pulse:install_cloud, then #{migrate_command}."
|
|
323
|
+
return
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
installation = RailsPulse::Cloud::Installation.existing
|
|
327
|
+
buffered = RailsPulse::Cloud::BufferedBatch.count
|
|
328
|
+
buffer = "#{buffered} batch(es) buffered (#{(RailsPulse::Cloud::BufferedBatch.sum(:byte_size) / 1024.0).round(1)} KB)"
|
|
329
|
+
output.puts "Cloud: #{cloud.application} (#{cloud.environment}) to #{cloud.url}: #{cloud_state(installation)}; #{buffer}"
|
|
330
|
+
return suggest_cloud_health_job if installation.nil?
|
|
331
|
+
|
|
332
|
+
output.puts " last error #{time_ago(installation.last_error_at)}: #{installation.last_error}" if installation.last_error.present?
|
|
333
|
+
suggest installation.pause_reason if installation.paused?
|
|
334
|
+
if installation.contract_deprecated_on.present?
|
|
335
|
+
suggest "Rails Pulse Cloud stops accepting this gem's sync contract on #{installation.contract_deprecated_on}; upgrade rails_pulse before then."
|
|
336
|
+
end
|
|
337
|
+
suggest_cloud_health_job if installation.last_health_at.nil? || installation.last_health_at < 5.minutes.ago
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
output.puts "Cloud: could not check (#{e.class}: #{e.message})"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def cloud_state(installation)
|
|
343
|
+
return "not synced yet" if installation.nil?
|
|
344
|
+
return "paused until #{installation.paused_until.utc.iso8601}" if installation.paused?
|
|
345
|
+
return "connected, last accepted #{time_ago(installation.last_success_at)}" if installation.last_success_at
|
|
346
|
+
|
|
347
|
+
"nothing accepted yet"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def suggest_cloud_health_job
|
|
351
|
+
suggest "Schedule RailsPulse::CloudHealthJob every minute (see config/initializers/rails_pulse.rb). It sends health " \
|
|
352
|
+
"updates and retries buffered batches; the hourly sync follows RailsPulse::SummaryJob on its own."
|
|
353
|
+
end
|
|
354
|
+
|
|
167
355
|
def print_actions
|
|
168
356
|
output.puts
|
|
169
357
|
if @actions.empty?
|
|
@@ -174,6 +362,14 @@ module RailsPulse
|
|
|
174
362
|
end
|
|
175
363
|
end
|
|
176
364
|
|
|
365
|
+
def print_suggestions
|
|
366
|
+
return if @suggestions.empty?
|
|
367
|
+
|
|
368
|
+
output.puts
|
|
369
|
+
output.puts "Suggestions:"
|
|
370
|
+
@suggestions.each { |text| output.puts " - #{text}" }
|
|
371
|
+
end
|
|
372
|
+
|
|
177
373
|
# -- helpers ------------------------------------------------------------
|
|
178
374
|
|
|
179
375
|
def gem_migrations_not_in_host
|
|
@@ -204,6 +400,11 @@ module RailsPulse
|
|
|
204
400
|
config.connects_to.present? ? "rails db:migrate:rails_pulse" : "rails db:migrate"
|
|
205
401
|
end
|
|
206
402
|
|
|
403
|
+
def days(duration)
|
|
404
|
+
value = (duration / 1.day.to_f).round(1)
|
|
405
|
+
"#{value == value.to_i ? value.to_i : value} day#{'s' unless value == 1}"
|
|
406
|
+
end
|
|
407
|
+
|
|
207
408
|
def time_ago(time)
|
|
208
409
|
seconds = (Time.current - time).to_i
|
|
209
410
|
return "#{seconds}s ago" if seconds < 60
|
data/lib/rails_pulse/tracker.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
1
3
|
module RailsPulse
|
|
2
4
|
# Persists the data RequestCollector gathers for one request: the route,
|
|
3
5
|
# the request row and its operations.
|
|
@@ -12,6 +14,10 @@ module RailsPulse
|
|
|
12
14
|
#
|
|
13
15
|
# With `config.async = false`, or whenever the pool's connection is pinned
|
|
14
16
|
# to the current thread (transactional tests), writes happen inline.
|
|
17
|
+
#
|
|
18
|
+
# Once a minute the writer also records a WriterHeartbeat event (queue
|
|
19
|
+
# depth, drops since the last sample) so the dashboard and rails_pulse:status
|
|
20
|
+
# can see every process's writer, not just the one serving the page.
|
|
15
21
|
module Tracker
|
|
16
22
|
# PG::Connection::PQTRANS_INERROR (libpq enum value 3) — the connection's transaction
|
|
17
23
|
# was aborted by a DB-level error and no ROLLBACK has been issued yet. Defined as a
|
|
@@ -20,11 +26,16 @@ module RailsPulse
|
|
|
20
26
|
PG_TRANSACTION_INERROR = 3
|
|
21
27
|
private_constant :PG_TRANSACTION_INERROR
|
|
22
28
|
|
|
29
|
+
# How often record_heartbeat prunes stale rows, rather than every heartbeat
|
|
30
|
+
PRUNE_INTERVAL = 1.hour
|
|
31
|
+
private_constant :PRUNE_INTERVAL
|
|
32
|
+
|
|
23
33
|
# The background writer: one thread, one bounded queue, one connection.
|
|
24
34
|
class Writer
|
|
25
35
|
BATCH_SIZE = 50
|
|
26
36
|
DROP_LOG_INTERVAL = 60 # seconds between "queue full" log lines
|
|
27
37
|
SHUTDOWN_TIMEOUT = 5 # seconds to wait for the queue to drain at exit
|
|
38
|
+
HEARTBEAT_INTERVAL = 60 # seconds between WriterHeartbeat samples
|
|
28
39
|
|
|
29
40
|
attr_reader :queue_size, :dropped
|
|
30
41
|
|
|
@@ -38,6 +49,8 @@ module RailsPulse
|
|
|
38
49
|
@dropped = 0
|
|
39
50
|
@drop_window_count = 0
|
|
40
51
|
@drop_window_started = nil
|
|
52
|
+
@dropped_at_last_heartbeat = 0
|
|
53
|
+
@last_heartbeat_at = nil
|
|
41
54
|
@exit_hook_installed = false
|
|
42
55
|
end
|
|
43
56
|
|
|
@@ -59,6 +72,15 @@ module RailsPulse
|
|
|
59
72
|
@thread&.alive? || false
|
|
60
73
|
end
|
|
61
74
|
|
|
75
|
+
# Taking a sample resets the drop count for the next window
|
|
76
|
+
def take_heartbeat_sample
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
since_last = @dropped - @dropped_at_last_heartbeat
|
|
79
|
+
@dropped_at_last_heartbeat = @dropped
|
|
80
|
+
{ queue_size: @queue_size, queue_depth: @queue.size, dropped: since_last, dropped_total: @dropped }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
62
84
|
# Persist everything queued so far on the calling thread. Used by
|
|
63
85
|
# shutdown when the writer thread is gone, and by tests.
|
|
64
86
|
def drain
|
|
@@ -110,11 +132,17 @@ module RailsPulse
|
|
|
110
132
|
end
|
|
111
133
|
|
|
112
134
|
def run
|
|
113
|
-
# pop returns nil once the queue is closed and empty
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
135
|
+
# pop returns nil both on timeout and once the queue is closed and empty
|
|
136
|
+
loop do
|
|
137
|
+
first = @queue.pop(timeout: HEARTBEAT_INTERVAL)
|
|
138
|
+
if first.nil?
|
|
139
|
+
break if @queue.closed?
|
|
140
|
+
else
|
|
141
|
+
batch = [ first ]
|
|
142
|
+
batch << @queue.pop(true) while batch.size < BATCH_SIZE && !@queue.empty?
|
|
143
|
+
Tracker.perform_tracking_batch(batch)
|
|
144
|
+
end
|
|
145
|
+
heartbeat_if_due
|
|
118
146
|
end
|
|
119
147
|
rescue => e
|
|
120
148
|
# perform_tracking_batch rescues its own errors, so this is a bug in the
|
|
@@ -122,6 +150,14 @@ module RailsPulse
|
|
|
122
150
|
RailsPulse.logger.error("Rails Pulse writer thread stopped: #{e.class} - #{e.message}")
|
|
123
151
|
end
|
|
124
152
|
|
|
153
|
+
def heartbeat_if_due
|
|
154
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
|
+
return if @last_heartbeat_at && now - @last_heartbeat_at < HEARTBEAT_INTERVAL
|
|
156
|
+
|
|
157
|
+
@last_heartbeat_at = now
|
|
158
|
+
Tracker.record_heartbeat(take_heartbeat_sample)
|
|
159
|
+
end
|
|
160
|
+
|
|
125
161
|
def install_exit_hook
|
|
126
162
|
return if @exit_hook_installed
|
|
127
163
|
|
|
@@ -148,7 +184,7 @@ module RailsPulse
|
|
|
148
184
|
|
|
149
185
|
class << self
|
|
150
186
|
def track_request(data)
|
|
151
|
-
return if
|
|
187
|
+
return if RailsPulse::Current.skip_recording_rails_pulse_activity
|
|
152
188
|
return unless RailsPulse::SchemaCheck.tracking_allowed?
|
|
153
189
|
|
|
154
190
|
if RailsPulse.configuration.async && !connection_shared_across_threads?
|
|
@@ -175,6 +211,27 @@ module RailsPulse
|
|
|
175
211
|
# Discard the writer, queue contents included. For tests.
|
|
176
212
|
def reset_writer!
|
|
177
213
|
@writer_mutex.synchronize { @writer = nil }
|
|
214
|
+
@last_heartbeat_prune_at = nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# table_exists? has to run inside with_writer_connection too, or the bare
|
|
218
|
+
# call pins a connection on this thread that nothing would ever release
|
|
219
|
+
def record_heartbeat(sample)
|
|
220
|
+
with_writer_connection do
|
|
221
|
+
next false unless RailsPulse::Event.table_available?
|
|
222
|
+
|
|
223
|
+
RailsPulse::WriterHeartbeat.record!(hostname: hostname, pid: Process.pid,
|
|
224
|
+
host_label: RailsPulse.configuration.cloud.host_label, **sample)
|
|
225
|
+
prune_heartbeats_if_due
|
|
226
|
+
true
|
|
227
|
+
end
|
|
228
|
+
rescue => e
|
|
229
|
+
RailsPulse.logger.debug("Rails Pulse writer heartbeat failed: #{e.class} - #{e.message}")
|
|
230
|
+
false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def hostname
|
|
234
|
+
@hostname ||= Socket.gethostname
|
|
178
235
|
end
|
|
179
236
|
|
|
180
237
|
def healthy?
|
|
@@ -230,6 +287,14 @@ module RailsPulse
|
|
|
230
287
|
end
|
|
231
288
|
end
|
|
232
289
|
|
|
290
|
+
def prune_heartbeats_if_due
|
|
291
|
+
now = Time.current
|
|
292
|
+
return if @last_heartbeat_prune_at && now - @last_heartbeat_prune_at < PRUNE_INTERVAL
|
|
293
|
+
|
|
294
|
+
@last_heartbeat_prune_at = now
|
|
295
|
+
RailsPulse::WriterHeartbeat.prune!
|
|
296
|
+
end
|
|
297
|
+
|
|
233
298
|
def perform_tracking(data)
|
|
234
299
|
with_writer_connection do
|
|
235
300
|
request = persist_request(data)
|
|
@@ -245,11 +310,11 @@ module RailsPulse
|
|
|
245
310
|
def with_writer_connection
|
|
246
311
|
RailsPulse::ApplicationRecord.connection_pool.with_connection do |conn|
|
|
247
312
|
clear_aborted_transaction(conn)
|
|
248
|
-
|
|
313
|
+
RailsPulse::Current.skip_recording_rails_pulse_activity = true
|
|
249
314
|
yield
|
|
250
315
|
end
|
|
251
316
|
ensure
|
|
252
|
-
|
|
317
|
+
RailsPulse::Current.skip_recording_rails_pulse_activity = false
|
|
253
318
|
end
|
|
254
319
|
|
|
255
320
|
def persist_request(data)
|
|
@@ -280,14 +345,11 @@ module RailsPulse
|
|
|
280
345
|
# connection to every thread that asks for one. A background writer would
|
|
281
346
|
# then interleave its statements with the test's on a single socket, which
|
|
282
347
|
# PostgreSQL reports as "message type 0x5a arrived from server while idle".
|
|
283
|
-
# Rails
|
|
284
|
-
#
|
|
285
|
-
# defensively and treat any failure as "not shared".
|
|
348
|
+
# Rails records the pin in the pool's @pinned_connection. That is not
|
|
349
|
+
# public API, so read it defensively and treat any failure as "not shared".
|
|
286
350
|
def connection_shared_across_threads?
|
|
287
351
|
pool = RailsPulse::ApplicationRecord.connection_pool
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
pool.instance_variable_get(:@lock_thread) ? true : false
|
|
352
|
+
pool.instance_variable_get(:@pinned_connection) ? true : false
|
|
291
353
|
rescue StandardError
|
|
292
354
|
false
|
|
293
355
|
end
|
data/lib/rails_pulse/version.rb
CHANGED
data/lib/rails_pulse.rb
CHANGED
data/lib/tasks/rails_pulse.rake
CHANGED
|
@@ -11,6 +11,12 @@ namespace :db do
|
|
|
11
11
|
if schema_file.exist?
|
|
12
12
|
load schema_file
|
|
13
13
|
puts "Rails Pulse schema loaded successfully"
|
|
14
|
+
# Present only when the app ran rails_pulse:install_cloud.
|
|
15
|
+
cloud_schema_file = Rails.root.join("db/rails_pulse_cloud_schema.rb")
|
|
16
|
+
if cloud_schema_file.exist?
|
|
17
|
+
load cloud_schema_file
|
|
18
|
+
puts "Rails Pulse Cloud schema loaded successfully"
|
|
19
|
+
end
|
|
14
20
|
# Record any copied migrations as already applied so they don't show as
|
|
15
21
|
# pending after a fresh schema load. The schema file creates all tables and
|
|
16
22
|
# columns directly, so the incremental migrations are logically already done.
|
|
@@ -28,6 +28,13 @@ namespace :rails_pulse do
|
|
|
28
28
|
exit 1 unless RailsPulse::Tasks::StatusReporter.report
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
namespace :cloud do
|
|
32
|
+
desc "Prints exactly what the next Rails Pulse Cloud sync would send, without sending anything."
|
|
33
|
+
task preview: :environment do
|
|
34
|
+
print RailsPulse::Cloud::Preview.new.render
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
desc "Migrate existing routes: backfill controller actions, normalize paths, consolidate multi-verb routes."
|
|
32
39
|
task migrate_routes: :environment do
|
|
33
40
|
ca_results = RailsPulse::RouteControllerActionBackfiller.call
|