rails_pulse 0.3.3 → 0.4.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 +299 -0
- data/README.md +44 -3
- data/app/assets/stylesheets/rails_pulse/application.css +21 -32
- data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
- data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
- data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
- data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
- data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
- data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
- data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
- data/app/controllers/concerns/chart_table_concern.rb +15 -3
- data/app/controllers/concerns/ransack_params_concern.rb +18 -0
- data/app/controllers/concerns/response_range_concern.rb +1 -1
- data/app/controllers/concerns/time_range_concern.rb +28 -14
- data/app/controllers/rails_pulse/application_controller.rb +104 -31
- data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
- data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
- data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
- data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
- data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/routes_controller.rb +7 -1
- data/app/controllers/rails_pulse/storage_controller.rb +7 -0
- data/app/controllers/rails_pulse/tags_controller.rb +2 -2
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
- data/app/helpers/rails_pulse/chart_helper.rb +0 -3
- data/app/helpers/rails_pulse/route_helper.rb +8 -16
- data/app/javascript/rails_pulse/application.js +29 -8
- data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
- data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
- data/app/models/concerns/rails_pulse/taggable.rb +11 -6
- data/app/models/rails_pulse/cards/base.rb +53 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
- data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
- data/app/models/rails_pulse/deployment.rb +47 -1
- data/app/models/rails_pulse/exception_group.rb +37 -0
- data/app/models/rails_pulse/exception_occurrence.rb +27 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
- data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
- data/app/models/rails_pulse/operation.rb +13 -4
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
- data/app/models/rails_pulse/request.rb +5 -1
- data/app/models/rails_pulse/route.rb +71 -21
- data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
- data/app/models/rails_pulse/routes/tables/index.rb +8 -6
- data/app/models/rails_pulse/summary.rb +21 -10
- data/app/models/rails_pulse/tables/base.rb +4 -2
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
- data/app/services/rails_pulse/exception_capture_service.rb +228 -0
- data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
- data/app/services/rails_pulse/operations/change_point.rb +144 -0
- data/app/services/rails_pulse/operations/compare.rb +92 -0
- data/app/services/rails_pulse/operations/comparison.rb +126 -0
- data/app/services/rails_pulse/operations/metric.rb +63 -0
- data/app/services/rails_pulse/operations/series.rb +84 -0
- data/app/services/rails_pulse/operations/subject.rb +77 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
- data/app/services/rails_pulse/route_merger.rb +99 -0
- data/app/services/rails_pulse/route_migrator.rb +139 -0
- data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
- data/app/services/rails_pulse/route_recognizer.rb +47 -0
- data/app/services/rails_pulse/summary_service.rb +45 -0
- data/app/services/rails_pulse/tag_filter_service.rb +5 -3
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
- data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +16 -6
- data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
- data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
- data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
- data/app/views/rails_pulse/deployments/index.html.erb +15 -0
- data/app/views/rails_pulse/deployments/show.html.erb +62 -0
- data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
- data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
- data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
- data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
- data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
- data/app/views/rails_pulse/requests/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
- data/app/views/rails_pulse/routes/_table.html.erb +22 -8
- data/app/views/rails_pulse/routes/index.html.erb +5 -1
- data/app/views/rails_pulse/routes/show.html.erb +5 -0
- data/app/views/rails_pulse/storage/show.html.erb +123 -0
- data/config/routes.rb +13 -4
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
- data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
- data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
- data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
- data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
- data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
- data/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/base_methods.rb +2 -0
- data/lib/generators/rails_pulse/install_generator.rb +5 -0
- data/lib/generators/rails_pulse/schema_parser.rb +22 -2
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
- data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
- data/lib/rails_pulse/cleanup_service.rb +110 -15
- data/lib/rails_pulse/configuration.rb +125 -6
- data/lib/rails_pulse/engine.rb +32 -17
- data/lib/rails_pulse/installers/config_updater.rb +202 -0
- data/lib/rails_pulse/job_run_collector.rb +43 -16
- data/lib/rails_pulse/middleware/asset_server.rb +11 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -4
- data/lib/rails_pulse/packaged_assets.rb +147 -0
- data/lib/rails_pulse/route_indexes.rb +66 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
- data/lib/rails_pulse/tracker.rb +29 -10
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +1 -0
- data/lib/rails_pulse_server.ru +16 -47
- data/lib/tasks/rails_pulse.rake +83 -4
- data/lib/tasks/rails_pulse_assets.rake +15 -0
- data/lib/tasks/rails_pulse_tasks.rake +29 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +18 -117
- metadata +78 -37
- data/Rakefile +0 -554
- data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
- data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
- data/app/javascript/rails_pulse/theme.js +0 -416
- data/config/brakeman.ignore +0 -190
- data/config/brakeman.yml +0 -68
- data/config/initializers/rails_pulse.rb +0 -287
- data/public/rails-pulse-assets/csp-test.js +0 -120
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
- data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
- data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
- data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
- data/vendor/assets/javascripts/rails-pulse.js +0 -157
- data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
- data/vendor/assets/stylesheets/rails-pulse.css +0 -1
- data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
|
@@ -41,12 +41,16 @@ module RailsPulse
|
|
|
41
41
|
RailsPulse.logger.info "Time-based cleanup: removing records older than #{cutoff_time}"
|
|
42
42
|
|
|
43
43
|
# Clean up in order that respects foreign key constraints
|
|
44
|
-
@stats[:time_based][:operations]
|
|
45
|
-
@stats[:time_based][:job_runs]
|
|
46
|
-
@stats[:time_based][:requests]
|
|
47
|
-
@stats[:time_based][:queries]
|
|
48
|
-
@stats[:time_based][:routes]
|
|
49
|
-
@stats[:time_based][:jobs]
|
|
44
|
+
@stats[:time_based][:operations] = cleanup_operations_by_time(cutoff_time)
|
|
45
|
+
@stats[:time_based][:job_runs] = cleanup_job_runs_by_time(cutoff_time)
|
|
46
|
+
@stats[:time_based][:requests] = cleanup_requests_by_time(cutoff_time)
|
|
47
|
+
@stats[:time_based][:queries] = cleanup_queries_by_time(cutoff_time)
|
|
48
|
+
@stats[:time_based][:routes] = cleanup_routes_by_time(cutoff_time)
|
|
49
|
+
@stats[:time_based][:jobs] = cleanup_jobs_by_time(cutoff_time)
|
|
50
|
+
if exception_tables_exist?
|
|
51
|
+
@stats[:time_based][:exception_occurrences] = cleanup_exception_occurrences_by_time(cutoff_time)
|
|
52
|
+
@stats[:time_based][:exception_groups] = cleanup_orphaned_exception_groups
|
|
53
|
+
end
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def perform_count_based_cleanup
|
|
@@ -63,12 +67,32 @@ module RailsPulse
|
|
|
63
67
|
ops_scope = cutoff ? RailsPulse::Operation.where("occurred_at < ?", cutoff) : RailsPulse::Operation.none
|
|
64
68
|
|
|
65
69
|
# Clean up in order that respects foreign key constraints
|
|
66
|
-
@stats[:count_based][:operations]
|
|
67
|
-
@stats[:count_based][:job_runs]
|
|
68
|
-
@stats[:count_based][:requests]
|
|
69
|
-
@stats[:count_based][:queries]
|
|
70
|
-
@stats[:count_based][:routes]
|
|
71
|
-
@stats[:count_based][:jobs]
|
|
70
|
+
@stats[:count_based][:operations] = cleanup_by_count(RailsPulse::Operation, :rails_pulse_operations, order_column: :occurred_at, scope: ops_scope)
|
|
71
|
+
@stats[:count_based][:job_runs] = cleanup_job_runs_by_count
|
|
72
|
+
@stats[:count_based][:requests] = cleanup_requests_by_count
|
|
73
|
+
@stats[:count_based][:queries] = cleanup_queries_by_count
|
|
74
|
+
@stats[:count_based][:routes] = cleanup_routes_by_count
|
|
75
|
+
@stats[:count_based][:jobs] = cleanup_jobs_by_count
|
|
76
|
+
if exception_tables_exist?
|
|
77
|
+
@stats[:count_based][:exception_occurrences] = cleanup_exception_occurrences_by_count
|
|
78
|
+
@stats[:count_based][:exception_groups] = cleanup_exception_groups_by_count
|
|
79
|
+
@stats[:count_based][:orphaned_exception_groups] = cleanup_orphaned_exception_groups
|
|
80
|
+
end
|
|
81
|
+
if deployments_table_exists?
|
|
82
|
+
@stats[:count_based][:deployments] = cleanup_deployments_by_count
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Deployments are markers, not measurements, so they are never pruned by
|
|
87
|
+
# age — a marker from before the retention window still explains a
|
|
88
|
+
# summary row. They are capped by count so the token-authenticated
|
|
89
|
+
# create endpoint cannot grow the table without bound.
|
|
90
|
+
def cleanup_deployments_by_count
|
|
91
|
+
cleanup_by_count(RailsPulse::Deployment, :rails_pulse_deployments, order_column: :started_at)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def deployments_table_exists?
|
|
95
|
+
RailsPulse::ApplicationRecord.connection.table_exists?(:rails_pulse_deployments)
|
|
72
96
|
end
|
|
73
97
|
|
|
74
98
|
# Shared helper: delete oldest records beyond a configured max count
|
|
@@ -188,10 +212,81 @@ module RailsPulse
|
|
|
188
212
|
cleanup_by_count(RailsPulse::Job, :rails_pulse_jobs, order_column: :created_at, scope: scope)
|
|
189
213
|
end
|
|
190
214
|
|
|
215
|
+
def exception_tables_exist?
|
|
216
|
+
connection = RailsPulse::ApplicationRecord.connection
|
|
217
|
+
connection.table_exists?(:rails_pulse_exception_groups) &&
|
|
218
|
+
connection.table_exists?(:rails_pulse_exception_occurrences)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def cleanup_exception_occurrences_by_time(cutoff_time)
|
|
222
|
+
exception_occurrences_cleanup_scope.where("occurred_at < ?", cutoff_time).delete_all
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def cleanup_exception_occurrences_by_count
|
|
226
|
+
cleanup_by_count(
|
|
227
|
+
RailsPulse::ExceptionOccurrence,
|
|
228
|
+
:rails_pulse_exception_occurrences,
|
|
229
|
+
order_column: :occurred_at,
|
|
230
|
+
scope: exception_occurrences_cleanup_scope
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Preserve exempts a group from all automatic cleanup, including its
|
|
235
|
+
# occurrence rows — otherwise a preserved group can lose its backtraces.
|
|
236
|
+
def exception_occurrences_cleanup_scope
|
|
237
|
+
preserved_ids = RailsPulse::ExceptionGroup.where(preserve: true).select(:id)
|
|
238
|
+
RailsPulse::ExceptionOccurrence.where.not(exception_group_id: preserved_ids)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Prune oldest non-preserved, non-ignored groups when over the configured cap.
|
|
242
|
+
# Child occurrences must be deleted first — there is no ON DELETE CASCADE on the FK.
|
|
243
|
+
def cleanup_exception_groups_by_count
|
|
244
|
+
max_records = @config.max_table_records[:rails_pulse_exception_groups]
|
|
245
|
+
return 0 unless max_records
|
|
246
|
+
|
|
247
|
+
current_count = RailsPulse::ExceptionGroup.count
|
|
248
|
+
return 0 if current_count <= max_records
|
|
249
|
+
|
|
250
|
+
overage = current_count - max_records
|
|
251
|
+
scope = RailsPulse::ExceptionGroup.where(preserve: false).where.not(status: "ignored")
|
|
252
|
+
# Only delete as many as the deletable population allows — if ignored/preserved
|
|
253
|
+
# groups dominate, we delete what we can and log when the cap cannot be met.
|
|
254
|
+
deletable_count = scope.count
|
|
255
|
+
records_to_delete = [ overage, deletable_count ].min
|
|
256
|
+
return 0 if records_to_delete <= 0
|
|
257
|
+
|
|
258
|
+
ids_to_delete = scope.order(last_seen_at: :asc).limit(records_to_delete).pluck(:id)
|
|
259
|
+
return 0 if ids_to_delete.empty?
|
|
260
|
+
|
|
261
|
+
RailsPulse::ExceptionOccurrence.where(exception_group_id: ids_to_delete).delete_all
|
|
262
|
+
RailsPulse::ExceptionGroup.where(id: ids_to_delete).delete_all
|
|
263
|
+
|
|
264
|
+
if overage > deletable_count
|
|
265
|
+
RailsPulse.logger.warn("[RailsPulse] Exception group cap #{max_records} cannot be met: " \
|
|
266
|
+
"#{current_count - ids_to_delete.size} remain (#{current_count - deletable_count} are preserved/ignored)")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
ids_to_delete.size
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Delete groups whose last occurrence was removed — uses an atomic subquery
|
|
273
|
+
# so no orphan can be deleted while a concurrent occurrence is being inserted.
|
|
274
|
+
# preserve: true and ignored groups are never deleted automatically.
|
|
275
|
+
def cleanup_orphaned_exception_groups
|
|
276
|
+
RailsPulse::ExceptionGroup
|
|
277
|
+
.where(preserve: false)
|
|
278
|
+
.where.not(status: "ignored")
|
|
279
|
+
.where("id NOT IN (SELECT DISTINCT exception_group_id FROM rails_pulse_exception_occurrences)")
|
|
280
|
+
.delete_all
|
|
281
|
+
end
|
|
282
|
+
|
|
191
283
|
def perform_summary_cleanup
|
|
192
|
-
# Hourly summaries
|
|
193
|
-
#
|
|
194
|
-
|
|
284
|
+
# Hourly summaries back the 1-day time range view and set how precisely
|
|
285
|
+
# Operations::ChangePoint can place a change. Beyond this cutoff the finest
|
|
286
|
+
# answer available is a day, so raising `hourly_summary_retention` is what
|
|
287
|
+
# buys hour-accurate change points further back — at the cost of summary
|
|
288
|
+
# table growth. Day, week and month summaries are never pruned.
|
|
289
|
+
cutoff = @config.hourly_summary_retention.ago
|
|
195
290
|
deleted = RailsPulse::Summary.where(period_type: "hour").where("period_start < ?", cutoff).delete_all
|
|
196
291
|
@stats[:time_based][:hourly_summaries] = deleted
|
|
197
292
|
end
|
|
@@ -9,14 +9,18 @@ module RailsPulse
|
|
|
9
9
|
:ignored_queues,
|
|
10
10
|
:track_assets,
|
|
11
11
|
:track_jobs,
|
|
12
|
+
:track_exceptions,
|
|
13
|
+
:capture_exception_params,
|
|
14
|
+
:exception_message_filter,
|
|
15
|
+
:capture_actual_sql,
|
|
12
16
|
:custom_asset_patterns,
|
|
13
17
|
:mount_path,
|
|
14
18
|
:full_retention_period,
|
|
15
19
|
:archiving_enabled,
|
|
16
|
-
:max_table_records,
|
|
17
20
|
:connects_to,
|
|
18
21
|
:authentication_enabled,
|
|
19
22
|
:authentication_method,
|
|
23
|
+
:authorize,
|
|
20
24
|
:authentication_redirect_path,
|
|
21
25
|
:tags,
|
|
22
26
|
:job_tracking_mode,
|
|
@@ -27,7 +31,10 @@ module RailsPulse
|
|
|
27
31
|
:async,
|
|
28
32
|
:service_level_objectives,
|
|
29
33
|
:query_service_level_objectives,
|
|
30
|
-
:warn_on_stale_summaries
|
|
34
|
+
:warn_on_stale_summaries,
|
|
35
|
+
:baseline_window,
|
|
36
|
+
:comparison_window,
|
|
37
|
+
:hourly_summary_retention
|
|
31
38
|
|
|
32
39
|
# Override the attr_accessor setter to track explicit assignment.
|
|
33
40
|
# The default (enabled in production) should not trigger a warning
|
|
@@ -42,7 +49,10 @@ module RailsPulse
|
|
|
42
49
|
attr_reader :route_thresholds,
|
|
43
50
|
:request_thresholds,
|
|
44
51
|
:query_thresholds,
|
|
45
|
-
:job_thresholds
|
|
52
|
+
:job_thresholds,
|
|
53
|
+
:exception_thresholds,
|
|
54
|
+
:max_table_records,
|
|
55
|
+
:regression_thresholds
|
|
46
56
|
|
|
47
57
|
def initialize
|
|
48
58
|
@enabled = true
|
|
@@ -50,6 +60,9 @@ module RailsPulse
|
|
|
50
60
|
@request_thresholds = { slow: 700, very_slow: 2000, critical: 4000 }
|
|
51
61
|
@query_thresholds = { slow: 100, very_slow: 500, critical: 1000 }
|
|
52
62
|
@job_thresholds = { slow: 5_000, very_slow: 30_000, critical: 60_000 }
|
|
63
|
+
# Exception thresholds are occurrence counts over the dashboard period,
|
|
64
|
+
# not durations — an exception has no duration.
|
|
65
|
+
@exception_thresholds = { warning: 10, critical: 100 }
|
|
53
66
|
@ignored_routes = []
|
|
54
67
|
@ignored_requests = []
|
|
55
68
|
@ignored_queries = []
|
|
@@ -57,6 +70,18 @@ module RailsPulse
|
|
|
57
70
|
@ignored_queues = []
|
|
58
71
|
@track_assets = false
|
|
59
72
|
@track_jobs = false
|
|
73
|
+
# Off unless the host initializer opts in. The install template sets true
|
|
74
|
+
# for new apps; existing 0.3.3 initializers omit this key, so a true
|
|
75
|
+
# default would start capturing exception messages (unfiltered) on upgrade.
|
|
76
|
+
@track_exceptions = false
|
|
77
|
+
@capture_exception_params = true
|
|
78
|
+
# Optional host hook applied to every captured exception message after
|
|
79
|
+
# the built-in filter_parameters redaction. nil means no extra filtering.
|
|
80
|
+
@exception_message_filter = nil
|
|
81
|
+
# Off by default — actual_sql contains unparameterized SQL on mysql2
|
|
82
|
+
# (prepared_statements: false) and PG behind PgBouncer transaction mode.
|
|
83
|
+
# Inline literals (emails, passwords) would be stored in plaintext.
|
|
84
|
+
@capture_actual_sql = false
|
|
60
85
|
@custom_asset_patterns = []
|
|
61
86
|
@mount_path = nil
|
|
62
87
|
@full_retention_period = 30.days
|
|
@@ -67,12 +92,18 @@ module RailsPulse
|
|
|
67
92
|
rails_pulse_job_runs: 50_000,
|
|
68
93
|
rails_pulse_queries: 10_000,
|
|
69
94
|
rails_pulse_routes: 1_000,
|
|
70
|
-
rails_pulse_jobs: 1_000
|
|
95
|
+
rails_pulse_jobs: 1_000,
|
|
96
|
+
rails_pulse_exception_occurrences: 50_000,
|
|
97
|
+
rails_pulse_exception_groups: 10_000,
|
|
98
|
+
rails_pulse_deployments: 1_000
|
|
71
99
|
}
|
|
72
100
|
@connects_to = nil
|
|
73
|
-
@authentication_enabled = Rails.env.
|
|
101
|
+
@authentication_enabled = !(Rails.env.development? || Rails.env.test?)
|
|
74
102
|
@authentication_enabled_explicitly_set = false
|
|
75
103
|
@authentication_method = nil
|
|
104
|
+
# Fail-closed predicate: ->(controller) { true to allow }. Anything
|
|
105
|
+
# falsy is a 403. Runs after authentication_method, if both are set.
|
|
106
|
+
@authorize = nil
|
|
76
107
|
@authentication_redirect_path = "/"
|
|
77
108
|
@tags = [ "ignored", "critical", "experimental" ]
|
|
78
109
|
@job_tracking_mode = :universal
|
|
@@ -99,6 +130,29 @@ module RailsPulse
|
|
|
99
130
|
# Show a warning banner when summaries haven't been generated recently
|
|
100
131
|
@warn_on_stale_summaries = true
|
|
101
132
|
|
|
133
|
+
# Historical comparison. The baseline is the traffic-weighted metric across
|
|
134
|
+
# `baseline_window` of day summaries; `comparison_window` is the recent
|
|
135
|
+
# slice measured against it.
|
|
136
|
+
@baseline_window = 28.days
|
|
137
|
+
@comparison_window = 1.day
|
|
138
|
+
|
|
139
|
+
# Hourly summaries are what give a change point its precision. They are
|
|
140
|
+
# pruned aggressively because the 1-day UI view is all that reads them, so
|
|
141
|
+
# raising this is the knob that buys hour-accurate change points further
|
|
142
|
+
# back, at the cost of summary-table growth.
|
|
143
|
+
@hourly_summary_retention = 2.days
|
|
144
|
+
|
|
145
|
+
# Deterministic regression rules. A change must clear both the ratio and
|
|
146
|
+
# the absolute floor: without the floor, 2ms to 4ms reports as a 100%
|
|
147
|
+
# regression. min_samples keeps low-traffic routes from generating noise.
|
|
148
|
+
@regression_thresholds = {
|
|
149
|
+
ratio: 1.5,
|
|
150
|
+
min_delta_ms: 50.0,
|
|
151
|
+
min_delta_rate: 1.0,
|
|
152
|
+
min_samples: 100,
|
|
153
|
+
min_baseline_periods: 3
|
|
154
|
+
}
|
|
155
|
+
|
|
102
156
|
# Validate defaults eagerly so that a misconfigured initializer raises at
|
|
103
157
|
# boot time rather than at the first request. Auth settings are not
|
|
104
158
|
# validated against defaults — only when the user explicitly enables
|
|
@@ -128,6 +182,15 @@ module RailsPulse
|
|
|
128
182
|
@job_thresholds = value
|
|
129
183
|
end
|
|
130
184
|
|
|
185
|
+
# Merge user-supplied caps into the defaults so tables added in newer
|
|
186
|
+
# versions still have a cap when the user carries an older explicit hash.
|
|
187
|
+
# A nil value explicitly disables count-based cleanup.
|
|
188
|
+
def max_table_records=(value)
|
|
189
|
+
return @max_table_records = nil if value.nil?
|
|
190
|
+
|
|
191
|
+
@max_table_records = (@max_table_records || {}).merge(value)
|
|
192
|
+
end
|
|
193
|
+
|
|
131
194
|
# Get all routes to ignore, including asset patterns if track_assets is false
|
|
132
195
|
def ignored_routes
|
|
133
196
|
routes = @ignored_routes.dup
|
|
@@ -151,8 +214,10 @@ module RailsPulse
|
|
|
151
214
|
validate_job_settings!
|
|
152
215
|
validate_dashboard_settings!
|
|
153
216
|
validate_tracking_settings!
|
|
217
|
+
validate_exception_settings!
|
|
154
218
|
validate_service_level_objectives_settings!
|
|
155
219
|
validate_query_service_level_objectives_settings!
|
|
220
|
+
validate_comparison_settings!
|
|
156
221
|
end
|
|
157
222
|
|
|
158
223
|
private
|
|
@@ -174,6 +239,7 @@ module RailsPulse
|
|
|
174
239
|
validate_threshold_hash!(@request_thresholds, "request_thresholds")
|
|
175
240
|
validate_threshold_hash!(@query_thresholds, "query_thresholds")
|
|
176
241
|
validate_threshold_hash!(@job_thresholds, "job_thresholds")
|
|
242
|
+
validate_threshold_hash!(@exception_thresholds, "exception_thresholds")
|
|
177
243
|
end
|
|
178
244
|
|
|
179
245
|
def validate_retention_settings!
|
|
@@ -188,6 +254,41 @@ module RailsPulse
|
|
|
188
254
|
end
|
|
189
255
|
end
|
|
190
256
|
|
|
257
|
+
def validate_comparison_settings!
|
|
258
|
+
{
|
|
259
|
+
baseline_window: @baseline_window,
|
|
260
|
+
comparison_window: @comparison_window,
|
|
261
|
+
hourly_summary_retention: @hourly_summary_retention
|
|
262
|
+
}.each do |name, value|
|
|
263
|
+
unless value.respond_to?(:seconds) && value.to_i > 0
|
|
264
|
+
raise ArgumentError, "#{name} must be a positive time duration (e.g., 7.days), got #{value.inspect}"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
if @baseline_window.to_i <= @comparison_window.to_i
|
|
269
|
+
raise ArgumentError,
|
|
270
|
+
"baseline_window (#{@baseline_window.inspect}) must be longer than " \
|
|
271
|
+
"comparison_window (#{@comparison_window.inspect})"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
unless @regression_thresholds.is_a?(Hash)
|
|
275
|
+
raise ArgumentError, "regression_thresholds must be a hash, got #{@regression_thresholds.class}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
%i[ratio min_delta_ms min_delta_rate min_samples min_baseline_periods].each do |key|
|
|
279
|
+
value = @regression_thresholds[key]
|
|
280
|
+
unless value.is_a?(Numeric) && value > 0
|
|
281
|
+
raise ArgumentError, "regression_thresholds[:#{key}] must be a positive number, got #{value.inspect}"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
if @regression_thresholds[:ratio] <= 1
|
|
286
|
+
raise ArgumentError,
|
|
287
|
+
"regression_thresholds[:ratio] must be greater than 1 — it is a multiple of the baseline, " \
|
|
288
|
+
"got #{@regression_thresholds[:ratio].inspect}"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
191
292
|
def validate_patterns!
|
|
192
293
|
[ @ignored_routes, @ignored_requests, @ignored_queries, @custom_asset_patterns ].each do |patterns|
|
|
193
294
|
patterns.each do |pattern|
|
|
@@ -214,13 +315,17 @@ module RailsPulse
|
|
|
214
315
|
end
|
|
215
316
|
|
|
216
317
|
def validate_authentication_settings!
|
|
217
|
-
if @authentication_enabled && @authentication_enabled_explicitly_set && @authentication_method.nil?
|
|
318
|
+
if @authentication_enabled && @authentication_enabled_explicitly_set && @authentication_method.nil? && @authorize.nil?
|
|
218
319
|
RailsPulse.logger.warn "Authentication is enabled but no authentication method is configured. This will deny all access."
|
|
219
320
|
end
|
|
220
321
|
|
|
221
322
|
if @authentication_method && ![ Proc, Symbol, String ].include?(@authentication_method.class)
|
|
222
323
|
raise ArgumentError, "authentication_method must be a Proc, Symbol, String, or nil, got #{@authentication_method.class}"
|
|
223
324
|
end
|
|
325
|
+
|
|
326
|
+
if @authorize && !@authorize.respond_to?(:call)
|
|
327
|
+
raise ArgumentError, "authorize must respond to #call (a proc or lambda receiving the controller and returning true to allow), got #{@authorize.class}"
|
|
328
|
+
end
|
|
224
329
|
end
|
|
225
330
|
|
|
226
331
|
def validate_tags!
|
|
@@ -273,6 +378,20 @@ module RailsPulse
|
|
|
273
378
|
end
|
|
274
379
|
end
|
|
275
380
|
|
|
381
|
+
def validate_exception_settings!
|
|
382
|
+
unless [ true, false ].include?(@track_exceptions)
|
|
383
|
+
raise ArgumentError, "track_exceptions must be a boolean"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
unless [ true, false ].include?(@capture_exception_params)
|
|
387
|
+
raise ArgumentError, "capture_exception_params must be a boolean"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
if @exception_message_filter && !@exception_message_filter.respond_to?(:call)
|
|
391
|
+
raise ArgumentError, "exception_message_filter must respond to #call (a proc or lambda taking the message and the exception), got #{@exception_message_filter.class}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
276
395
|
def validate_service_level_objectives_settings!
|
|
277
396
|
unless @service_level_objectives.is_a?(Array)
|
|
278
397
|
raise ArgumentError, "service_level_objectives must be an array, got #{@service_level_objectives.class}"
|
data/lib/rails_pulse/engine.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
require "rails_pulse/version"
|
|
2
2
|
require "rails_pulse/statistics"
|
|
3
|
+
require "rails_pulse/route_indexes"
|
|
3
4
|
require "rails_pulse/middleware/request_collector"
|
|
4
5
|
require "rails_pulse/middleware/asset_server"
|
|
5
6
|
require "rails_pulse/subscribers/operation_subscriber"
|
|
7
|
+
require "rails_pulse/subscribers/exception_subscriber"
|
|
6
8
|
require "rails_pulse/job_run_collector"
|
|
7
9
|
require "rails_pulse/active_job_extensions"
|
|
8
10
|
require "rails_pulse/extensions/active_record"
|
|
@@ -16,7 +18,27 @@ module RailsPulse
|
|
|
16
18
|
autoload :SummaryService, File.expand_path("../../app/services/rails_pulse/summary_service", __dir__)
|
|
17
19
|
autoload :QueryAnalysisService, File.expand_path("../../app/services/rails_pulse/query_analysis_service", __dir__)
|
|
18
20
|
autoload :TagFilterService, File.expand_path("../../app/services/rails_pulse/tag_filter_service", __dir__)
|
|
21
|
+
autoload :ExceptionCaptureService, File.expand_path("../../app/services/rails_pulse/exception_capture_service", __dir__)
|
|
22
|
+
autoload :ExceptionMessageSanitizer, File.expand_path("../../app/services/rails_pulse/exception_message_sanitizer", __dir__)
|
|
19
23
|
autoload :OperationSuggestions, File.expand_path("../../app/services/rails_pulse/operation_suggestions", __dir__)
|
|
24
|
+
autoload :RoutePathNormalizer, File.expand_path("../../app/services/rails_pulse/route_path_normalizer", __dir__)
|
|
25
|
+
autoload :RouteRecognizer, File.expand_path("../../app/services/rails_pulse/route_recognizer", __dir__)
|
|
26
|
+
autoload :RouteMerger, File.expand_path("../../app/services/rails_pulse/route_merger", __dir__)
|
|
27
|
+
autoload :RouteMigrator, File.expand_path("../../app/services/rails_pulse/route_migrator", __dir__)
|
|
28
|
+
autoload :RouteControllerActionBackfiller, File.expand_path("../../app/services/rails_pulse/route_controller_action_backfiller", __dir__)
|
|
29
|
+
|
|
30
|
+
# Historical comparison operations. This namespace is the read-only interface
|
|
31
|
+
# for "what changed?" — everything else in the codebase, and anything built on
|
|
32
|
+
# top of Rails Pulse later, should ask these rather than querying summaries
|
|
33
|
+
# directly.
|
|
34
|
+
module Operations
|
|
35
|
+
autoload :ChangePoint, File.expand_path("../../app/services/rails_pulse/operations/change_point", __dir__)
|
|
36
|
+
autoload :Compare, File.expand_path("../../app/services/rails_pulse/operations/compare", __dir__)
|
|
37
|
+
autoload :Comparison, File.expand_path("../../app/services/rails_pulse/operations/comparison", __dir__)
|
|
38
|
+
autoload :Metric, File.expand_path("../../app/services/rails_pulse/operations/metric", __dir__)
|
|
39
|
+
autoload :Series, File.expand_path("../../app/services/rails_pulse/operations/series", __dir__)
|
|
40
|
+
autoload :Subject, File.expand_path("../../app/services/rails_pulse/operations/subject", __dir__)
|
|
41
|
+
end
|
|
20
42
|
|
|
21
43
|
# Analysis services
|
|
22
44
|
module Analysis
|
|
@@ -33,6 +55,7 @@ module RailsPulse
|
|
|
33
55
|
module Installers
|
|
34
56
|
autoload :MigrationInstaller, File.expand_path("installers/migration_installer", __dir__)
|
|
35
57
|
autoload :ConfigInstaller, File.expand_path("installers/config_installer", __dir__)
|
|
58
|
+
autoload :ConfigUpdater, File.expand_path("installers/config_updater", __dir__)
|
|
36
59
|
end
|
|
37
60
|
|
|
38
61
|
# Stats reporting services
|
|
@@ -64,24 +87,12 @@ module RailsPulse
|
|
|
64
87
|
end
|
|
65
88
|
|
|
66
89
|
initializer "rails_pulse.assets" do |app|
|
|
67
|
-
|
|
68
|
-
if app.config.respond_to?(:assets)
|
|
69
|
-
# Add vendor assets to the asset pipeline
|
|
70
|
-
app.config.assets.paths << Engine.root.join("vendor", "assets", "stylesheets").to_s
|
|
71
|
-
app.config.assets.paths << Engine.root.join("vendor", "assets", "javascripts").to_s
|
|
72
|
-
|
|
73
|
-
# Register bundled assets for precompilation
|
|
74
|
-
if defined?(::Sprockets)
|
|
75
|
-
app.config.assets.precompile += %w[
|
|
76
|
-
rails-pulse.css
|
|
77
|
-
rails-pulse.js
|
|
78
|
-
rails-pulse-icons.js
|
|
79
|
-
]
|
|
80
|
-
end
|
|
81
|
-
end
|
|
90
|
+
next unless RailsPulse.configuration.mount_dashboard
|
|
82
91
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
92
|
+
# Dashboard assets are bundled at gem build time. They are not registered
|
|
93
|
+
# with Sprockets (re-minifying the 2 MB bundle OOMs small hosts — #190).
|
|
94
|
+
# assets:precompile copies them into public/assets for CDN/CSP; the
|
|
95
|
+
# middleware is the development / no-pipeline fallback.
|
|
85
96
|
assets_path = Engine.root.join("public")
|
|
86
97
|
app.middleware.insert_after Rack::Runtime, RailsPulse::Middleware::AssetServer,
|
|
87
98
|
assets_path.to_s,
|
|
@@ -99,6 +110,10 @@ module RailsPulse
|
|
|
99
110
|
RailsPulse::Subscribers::OperationSubscriber.subscribe!
|
|
100
111
|
end
|
|
101
112
|
|
|
113
|
+
initializer "rails_pulse.exception_notifications" do
|
|
114
|
+
RailsPulse::Subscribers::ExceptionSubscriber.subscribe!
|
|
115
|
+
end
|
|
116
|
+
|
|
102
117
|
initializer "rails_pulse.active_job" do
|
|
103
118
|
ActiveSupport.on_load(:active_job) do
|
|
104
119
|
include RailsPulse::ActiveJobExtensions
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Installers
|
|
3
|
+
# Inserts settings from the install template that the host initializer does
|
|
4
|
+
# not already mention. Existing lines are never rewritten, so `git diff` is
|
|
5
|
+
# the review: keep or discard hunks. Values that differ between a new
|
|
6
|
+
# install and an upgrade (today: track_exceptions) use the gem default.
|
|
7
|
+
class ConfigUpdater
|
|
8
|
+
ASSIGNMENT = /\A\s*(#\s*)?config\.(\w+)\s*=/.freeze
|
|
9
|
+
HASH_ENTRY = /\A\s*([a-z_][a-z0-9_]*):/.freeze
|
|
10
|
+
|
|
11
|
+
UPGRADE_VALUE_OVERRIDES = {
|
|
12
|
+
"track_exceptions" => "false"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
BANNER = <<~RUBY.gsub(/^/, " ")
|
|
16
|
+
# ====================================================================================================
|
|
17
|
+
# ADDED BY rails generate rails_pulse:upgrade
|
|
18
|
+
# ====================================================================================================
|
|
19
|
+
# New settings from this gem version. Existing values above were not changed.
|
|
20
|
+
# Review with git diff and keep or discard hunks as you like.
|
|
21
|
+
|
|
22
|
+
RUBY
|
|
23
|
+
|
|
24
|
+
attr_reader :output, :source, :destination
|
|
25
|
+
|
|
26
|
+
def self.update(destination:, source: nil, output: $stdout)
|
|
27
|
+
new(destination: destination, source: source, output: output).update
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.template_path
|
|
31
|
+
File.expand_path("../../../../lib/generators/rails_pulse/templates/rails_pulse.rb", __FILE__)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(destination:, source: nil, output: $stdout)
|
|
35
|
+
@destination = destination
|
|
36
|
+
@source = source || self.class.template_path
|
|
37
|
+
@output = output
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def update
|
|
41
|
+
return { status: :missing, keys: [], hash_keys: [] } unless File.exist?(destination)
|
|
42
|
+
|
|
43
|
+
host = File.read(destination)
|
|
44
|
+
missing_keys = template_settings.map { |setting| setting[:key] } - mentioned_keys(host)
|
|
45
|
+
hash_keys = missing_hash_entries(host, missing_keys)
|
|
46
|
+
|
|
47
|
+
return { status: :unchanged, keys: [], hash_keys: [] } if missing_keys.empty? && hash_keys.empty?
|
|
48
|
+
|
|
49
|
+
updated = host
|
|
50
|
+
updated = inject_hash_entries(updated, hash_keys) if hash_keys.any?
|
|
51
|
+
updated = append_settings(updated, missing_keys) if missing_keys.any?
|
|
52
|
+
File.write(destination, updated)
|
|
53
|
+
|
|
54
|
+
{ status: :updated, keys: missing_keys, hash_keys: hash_keys.map { |entry| entry[:name] } }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def template_settings
|
|
60
|
+
@template_settings ||= parse_settings(File.read(source))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_settings(content)
|
|
64
|
+
settings = []
|
|
65
|
+
comments = []
|
|
66
|
+
lines = content.lines
|
|
67
|
+
index = 0
|
|
68
|
+
|
|
69
|
+
while index < lines.size
|
|
70
|
+
line = lines[index]
|
|
71
|
+
|
|
72
|
+
if line.match?(ASSIGNMENT)
|
|
73
|
+
key = line[ASSIGNMENT, 2]
|
|
74
|
+
assignment, index = read_multiline(lines, index)
|
|
75
|
+
unless settings.any? { |setting| setting[:key] == key }
|
|
76
|
+
settings << { key: key, lines: comments + assignment }
|
|
77
|
+
end
|
|
78
|
+
comments = []
|
|
79
|
+
elsif preamble?(line)
|
|
80
|
+
comments = []
|
|
81
|
+
index += 1
|
|
82
|
+
else
|
|
83
|
+
comments << line
|
|
84
|
+
index += 1
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
settings
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def preamble?(line)
|
|
92
|
+
line.match?(/\A\s*(RailsPulse\.configure|end)\b/) || line.match?(/\A\s*do\s*\|config\|/)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def read_multiline(lines, index)
|
|
96
|
+
collected = [ lines[index] ]
|
|
97
|
+
depth = nest_delta(lines[index])
|
|
98
|
+
index += 1
|
|
99
|
+
|
|
100
|
+
while index < lines.size && depth.positive?
|
|
101
|
+
collected << lines[index]
|
|
102
|
+
depth += nest_delta(lines[index])
|
|
103
|
+
index += 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
[ collected, index ]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def nest_delta(line)
|
|
110
|
+
code = line.sub(/#.*\z/, "")
|
|
111
|
+
code.count("{") - code.count("}") + code.count("[") - code.count("]")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def mentioned_keys(content)
|
|
115
|
+
content.scan(/config\.(\w+)/).flatten.uniq
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def missing_hash_entries(host, missing_keys)
|
|
119
|
+
template_settings.filter_map do |setting|
|
|
120
|
+
next if missing_keys.include?(setting[:key])
|
|
121
|
+
next unless host.match?(/^\s*config\.#{Regexp.escape(setting[:key])}\s*=\s*\{/)
|
|
122
|
+
|
|
123
|
+
setting[:lines].filter_map do |line|
|
|
124
|
+
name = line[HASH_ENTRY, 1]
|
|
125
|
+
next unless name
|
|
126
|
+
next if host.match?(/^\s*#{Regexp.escape(name)}:/)
|
|
127
|
+
|
|
128
|
+
{ setting: setting[:key], name: name, line: line }
|
|
129
|
+
end
|
|
130
|
+
end.flatten
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def inject_hash_entries(content, entries)
|
|
134
|
+
entries.group_by { |entry| entry[:setting] }.reduce(content) do |text, (setting, grouped)|
|
|
135
|
+
pattern = /(^\s*config\.#{Regexp.escape(setting)}\s*=\s*\{)(.*?)(\n\s*\})/m
|
|
136
|
+
text.sub(pattern) do
|
|
137
|
+
opening, body, closing = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
|
|
138
|
+
"#{opening}#{body_with_comma(body)}\n#{grouped.map { |entry| entry[:line].rstrip }.join("\n")}#{closing}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def body_with_comma(body)
|
|
144
|
+
lines = body.lines
|
|
145
|
+
index = lines.rindex { |line| line.strip != "" }
|
|
146
|
+
return body unless index
|
|
147
|
+
|
|
148
|
+
working = lines[index].chomp
|
|
149
|
+
return body if working.match?(/,\s*(#.*)?\z/)
|
|
150
|
+
|
|
151
|
+
working = if working.include?("#")
|
|
152
|
+
working.sub(/\s+#/, ", #")
|
|
153
|
+
else
|
|
154
|
+
"#{working},"
|
|
155
|
+
end
|
|
156
|
+
lines[index] = "#{working}\n"
|
|
157
|
+
lines.join
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def append_settings(content, keys)
|
|
161
|
+
blocks = keys.filter_map do |key|
|
|
162
|
+
setting = template_settings.find { |candidate| candidate[:key] == key }
|
|
163
|
+
next unless setting
|
|
164
|
+
|
|
165
|
+
apply_overrides(setting[:lines], key).join
|
|
166
|
+
end
|
|
167
|
+
return content if blocks.empty?
|
|
168
|
+
|
|
169
|
+
insertion = BANNER + blocks.join("\n").gsub(/\n+\z/, "\n")
|
|
170
|
+
insert_before_configure_end(content, insertion)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def insert_before_configure_end(content, insertion)
|
|
174
|
+
lines = content.lines
|
|
175
|
+
start_index = lines.index { |line| line.match?(/^\s*RailsPulse\.configure\b/) }
|
|
176
|
+
|
|
177
|
+
unless start_index
|
|
178
|
+
return content + "\nRailsPulse.configure do |config|\n#{insertion}end\n"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
indent = lines[start_index][/\A\s*/]
|
|
182
|
+
end_index = ((start_index + 1)...lines.size).find do |index|
|
|
183
|
+
lines[index].match?(/\A#{Regexp.escape(indent)}end\b/)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
unless end_index
|
|
187
|
+
return content.sub(/\nend\s*\z/, "\n\n#{insertion}end\n")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
(lines[0...end_index] + [ "\n#{insertion}" ] + lines[end_index..]).join
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def apply_overrides(lines, key)
|
|
194
|
+
return lines unless UPGRADE_VALUE_OVERRIDES.key?(key)
|
|
195
|
+
|
|
196
|
+
lines.map do |line|
|
|
197
|
+
line.sub(/config\.#{Regexp.escape(key)}\s*=\s*.*/, "config.#{key} = #{UPGRADE_VALUE_OVERRIDES[key]}")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|