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
|
@@ -17,32 +17,59 @@ module RailsPulse
|
|
|
17
17
|
job = nil
|
|
18
18
|
job_run = nil
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
# Pulse DB writes before yield must not prevent the job from running.
|
|
21
|
+
begin
|
|
22
|
+
with_recording_suppressed do
|
|
23
|
+
job = find_or_create_job(active_job)
|
|
24
|
+
job_run = create_job_run(job, active_job, adapter, occurred_at)
|
|
25
|
+
end
|
|
26
|
+
rescue => e
|
|
27
|
+
RailsPulse.logger.error "Failed to initialize job tracking: #{e.class} - #{e.message}"
|
|
28
|
+
job_run = nil
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
RequestStore.store[:rails_pulse_request_id] = nil
|
|
26
|
-
RequestStore.store[:rails_pulse_job_run_id] = job_run
|
|
32
|
+
RequestStore.store[:rails_pulse_job_run_id] = job_run&.id
|
|
27
33
|
RequestStore.store[:rails_pulse_operations] = []
|
|
28
34
|
|
|
29
35
|
yield
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
job_run
|
|
37
|
+
# Record success — must not re-raise if the job already completed.
|
|
38
|
+
begin
|
|
39
|
+
if job_run
|
|
40
|
+
duration = elapsed_time_ms(start_time)
|
|
41
|
+
with_recording_suppressed do
|
|
42
|
+
job_run.update!(status: "success", duration: duration)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
rescue => e
|
|
46
|
+
RailsPulse.logger.error "Failed to record job success: #{e.class} - #{e.message}"
|
|
34
47
|
end
|
|
35
48
|
rescue => error
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
job_run
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
# Record failure — must always re-raise the ORIGINAL error.
|
|
50
|
+
begin
|
|
51
|
+
if job_run
|
|
52
|
+
duration = elapsed_time_ms(start_time)
|
|
53
|
+
with_recording_suppressed do
|
|
54
|
+
job_run.update!(
|
|
55
|
+
status: failure_status_for(error),
|
|
56
|
+
duration: duration,
|
|
57
|
+
error_class: error.class.name,
|
|
58
|
+
# Same redaction as exception occurrences — a job failure
|
|
59
|
+
# message can carry the same literal PII a request one can.
|
|
60
|
+
error_message: RailsPulse::ExceptionMessageSanitizer.for_exception(error)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
# Capture outside with_recording_suppressed — the capture service
|
|
65
|
+
# checks skip_recording_rails_pulse_activity and would bail out.
|
|
66
|
+
# The service's own SQL is already filtered by the rails_pulse_ prefix.
|
|
67
|
+
RailsPulse::ExceptionCaptureService.capture(error, environment: Rails.env.to_s)
|
|
68
|
+
RequestStore.store[:rails_pulse_captured_exception] = error
|
|
69
|
+
rescue => e
|
|
70
|
+
RailsPulse.logger.error "Failed to record job failure: #{e.class} - #{e.message}"
|
|
44
71
|
end
|
|
45
|
-
raise
|
|
72
|
+
raise error
|
|
46
73
|
ensure
|
|
47
74
|
begin
|
|
48
75
|
save_operations(job_run)
|
|
@@ -11,6 +11,10 @@ module RailsPulse
|
|
|
11
11
|
".svg" => "image/svg+xml"
|
|
12
12
|
}.freeze
|
|
13
13
|
|
|
14
|
+
# "/rails-pulse-assets/0.3.3/rails-pulse.css" → lookup at
|
|
15
|
+
# public/rails-pulse-assets/rails-pulse.css. Unversioned paths still work.
|
|
16
|
+
VERSIONED_ASSET_PATH = %r{\A(/rails-pulse-assets/)(?:\d+\.\d+[\w.\-]*/)}.freeze
|
|
17
|
+
|
|
14
18
|
def initialize(app, root, options = {})
|
|
15
19
|
# Rack::Static expects (app, options) where options[:root] is the root path
|
|
16
20
|
options = options.merge(root: root) if root.is_a?(String) || root.is_a?(Pathname)
|
|
@@ -23,6 +27,9 @@ module RailsPulse
|
|
|
23
27
|
return @app.call(env)
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
env = env.dup
|
|
31
|
+
env["PATH_INFO"] = strip_asset_version(env["PATH_INFO"])
|
|
32
|
+
|
|
26
33
|
# Log asset requests for debugging
|
|
27
34
|
RailsPulse.logger.debug "Asset request: #{env['PATH_INFO']}"
|
|
28
35
|
|
|
@@ -51,6 +58,10 @@ module RailsPulse
|
|
|
51
58
|
env["PATH_INFO"]&.start_with?("/rails-pulse-assets/")
|
|
52
59
|
end
|
|
53
60
|
|
|
61
|
+
def strip_asset_version(path)
|
|
62
|
+
path.to_s.sub(VERSIONED_ASSET_PATH, '\1')
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def cache_headers
|
|
55
66
|
if defined?(Rails) && Rails.env.development?
|
|
56
67
|
{ "Cache-Control" => "no-cache, no-store, must-revalidate", "Pragma" => "no-cache" }
|
|
@@ -40,10 +40,11 @@ module RailsPulse
|
|
|
40
40
|
# Deep copy operations array to prevent race condition in async mode
|
|
41
41
|
operations = RequestStore.store[:rails_pulse_operations] || []
|
|
42
42
|
detect_n_plus_one(operations)
|
|
43
|
-
|
|
43
|
+
path_params = env["action_dispatch.request.path_parameters"] || {}
|
|
44
|
+
controller_action = [ path_params[:controller], path_params[:action] ].compact.join("#").presence
|
|
44
45
|
tracking_data = {
|
|
45
46
|
method: req.request_method,
|
|
46
|
-
path: req.path,
|
|
47
|
+
path: RailsPulse::RoutePathNormalizer.normalize(req.path, path_params),
|
|
47
48
|
duration: duration,
|
|
48
49
|
status: status,
|
|
49
50
|
is_error: status.to_i >= 500,
|
|
@@ -54,8 +55,12 @@ module RailsPulse
|
|
|
54
55
|
operations: operations.map(&:dup)
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
# Send to tracker
|
|
58
|
-
|
|
58
|
+
# Send to tracker — rescue ensures Pulse never 500s a host request.
|
|
59
|
+
begin
|
|
60
|
+
RailsPulse::Tracker.track_request(tracking_data)
|
|
61
|
+
rescue => e
|
|
62
|
+
RailsPulse.logger.error("RailsPulse tracking failed: #{e.class} - #{e.message}")
|
|
63
|
+
end
|
|
59
64
|
|
|
60
65
|
[ status, headers, response ]
|
|
61
66
|
ensure
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module RailsPulse
|
|
8
|
+
# Copies gem-built dashboard files into the host's public/assets with a
|
|
9
|
+
# content digest, then records them in a small manifest. This is hooked to
|
|
10
|
+
# assets:precompile so CDNs (asset_sync, CloudFront on /assets) and
|
|
11
|
+
# CDN-only CSP policies see the same URLs as the rest of the host app —
|
|
12
|
+
# without running the host js_compressor over the already-minified bundle.
|
|
13
|
+
class PackagedAssets
|
|
14
|
+
LOGICAL_NAMES = %w[
|
|
15
|
+
rails-pulse.css
|
|
16
|
+
rails-pulse.js
|
|
17
|
+
rails-pulse-icons.js
|
|
18
|
+
rails-pulse-logo.png
|
|
19
|
+
search.svg
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
MANIFEST_FILENAME = ".rails-pulse-manifest.json"
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def install!(destination: default_destination)
|
|
26
|
+
FileUtils.mkdir_p(destination)
|
|
27
|
+
entries = {}
|
|
28
|
+
|
|
29
|
+
LOGICAL_NAMES.each do |logical_name|
|
|
30
|
+
source = source_path(logical_name)
|
|
31
|
+
next unless source.exist?
|
|
32
|
+
|
|
33
|
+
digested_name = digested_filename(logical_name, source)
|
|
34
|
+
FileUtils.cp(source, destination.join(digested_name))
|
|
35
|
+
copy_adjacent_source_map(source, destination)
|
|
36
|
+
entries[logical_name] = digested_name
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
File.write(destination.join(MANIFEST_FILENAME), JSON.pretty_generate(entries))
|
|
40
|
+
patch_host_manifests(destination, entries)
|
|
41
|
+
reset!
|
|
42
|
+
entries
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def url_path(logical_name)
|
|
46
|
+
digested = manifest[logical_name.to_s]
|
|
47
|
+
return if digested.blank?
|
|
48
|
+
|
|
49
|
+
"/assets/#{digested}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def manifest
|
|
53
|
+
@manifest ||= load_manifest
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reset!
|
|
57
|
+
@manifest = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def uninstall!(destination: default_destination)
|
|
61
|
+
path = destination.join(MANIFEST_FILENAME)
|
|
62
|
+
if path.exist?
|
|
63
|
+
JSON.parse(path.read).each_value do |filename|
|
|
64
|
+
FileUtils.rm_f(destination.join(filename))
|
|
65
|
+
end
|
|
66
|
+
FileUtils.rm_f(path)
|
|
67
|
+
end
|
|
68
|
+
%w[rails-pulse.js.map rails-pulse.css.map rails-pulse-icons.js.map].each do |map|
|
|
69
|
+
FileUtils.rm_f(destination.join(map))
|
|
70
|
+
end
|
|
71
|
+
reset!
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def default_destination
|
|
77
|
+
Rails.public_path.join("assets")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def source_path(logical_name)
|
|
81
|
+
Engine.root.join("public", "rails-pulse-assets", logical_name)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def digested_filename(logical_name, source)
|
|
85
|
+
ext = File.extname(logical_name)
|
|
86
|
+
base = File.basename(logical_name, ext)
|
|
87
|
+
digest = Digest::SHA256.file(source).hexdigest
|
|
88
|
+
"#{base}-#{digest}#{ext}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def copy_adjacent_source_map(source, destination)
|
|
92
|
+
map = Pathname.new("#{source}.map")
|
|
93
|
+
FileUtils.cp(map, destination.join(map.basename)) if map.exist?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def load_manifest
|
|
97
|
+
path = Rails.public_path.join("assets", MANIFEST_FILENAME)
|
|
98
|
+
return {} unless path.exist?
|
|
99
|
+
|
|
100
|
+
JSON.parse(path.read)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
{}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def patch_host_manifests(destination, entries)
|
|
106
|
+
patch_propshaft_manifest(destination, entries)
|
|
107
|
+
patch_sprockets_manifests(destination, entries)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def patch_propshaft_manifest(destination, entries)
|
|
111
|
+
path = destination.join(".manifest.json")
|
|
112
|
+
return unless path.exist?
|
|
113
|
+
|
|
114
|
+
data = JSON.parse(path.read)
|
|
115
|
+
File.write(path, JSON.pretty_generate(data.merge(entries)))
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def patch_sprockets_manifests(destination, entries)
|
|
121
|
+
Dir.glob(destination.join(".sprockets-manifest-*.json")).each do |path|
|
|
122
|
+
data = JSON.parse(File.read(path))
|
|
123
|
+
data["assets"] ||= {}
|
|
124
|
+
data["files"] ||= {}
|
|
125
|
+
entries.each do |logical_name, digested_name|
|
|
126
|
+
data["assets"][logical_name] = digested_name
|
|
127
|
+
file = destination.join(digested_name)
|
|
128
|
+
data["files"][digested_name] = {
|
|
129
|
+
"logical_path" => logical_name,
|
|
130
|
+
"mtime" => file.mtime.iso8601,
|
|
131
|
+
"size" => file.size,
|
|
132
|
+
"digest" => digested_name[/-([a-f0-9]{32,})\./, 1],
|
|
133
|
+
"integrity" => integrity_for(file)
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
File.write(path, JSON.pretty_generate(data))
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def integrity_for(file)
|
|
143
|
+
"sha256-#{Digest::SHA256.base64digest(file.binread)}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsPulse
|
|
4
|
+
# Adapter-specific unique index that groups unrecognized routes (404s, middleware
|
|
5
|
+
# short-circuits) by path. PostgreSQL and SQLite use a partial unique index;
|
|
6
|
+
# MySQL uses a functional unique index because it has no partial indexes.
|
|
7
|
+
#
|
|
8
|
+
# Must not be added while upgrade rows still have a null controller_action on
|
|
9
|
+
# different-verb REST siblings (GET /users vs POST /users). Call this after
|
|
10
|
+
# RouteControllerActionBackfiller has assigned actions.
|
|
11
|
+
#
|
|
12
|
+
# PostgreSQL and SQLite go through `add_index(..., where:)` so `schema.rb`
|
|
13
|
+
# round-trips the predicate. Raw multiline `CREATE UNIQUE INDEX ... WHERE`
|
|
14
|
+
# is stored with a trailing newline that SQLite's schema dumper does not
|
|
15
|
+
# parse, so `db:schema:load` (and parallel test workers) recreate a unique
|
|
16
|
+
# index on every path. MySQL still uses SQL: `add_index(..., where:)` is
|
|
17
|
+
# silently ignored and would unique-index every path.
|
|
18
|
+
module RouteIndexes
|
|
19
|
+
NULL_ACTION_INDEX = "index_rails_pulse_routes_on_path_without_action"
|
|
20
|
+
|
|
21
|
+
def self.ensure_null_action_uniqueness!(connection)
|
|
22
|
+
return if exists?(connection)
|
|
23
|
+
|
|
24
|
+
adapter = connection.adapter_name.downcase
|
|
25
|
+
|
|
26
|
+
if adapter.include?("mysql")
|
|
27
|
+
version_string = connection.select_value("SELECT VERSION()")
|
|
28
|
+
if version_string.to_s.include?("MariaDB")
|
|
29
|
+
raise "Rails Pulse requires MySQL >= 8.0.13 for functional indexes. " \
|
|
30
|
+
"MariaDB is not supported — it does not implement the required " \
|
|
31
|
+
"CREATE INDEX ... ((expression)) syntax."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
mysql_version = version_string.to_s.scan(/\A(\d+\.\d+\.\d+)/).flatten.first
|
|
35
|
+
if mysql_version && Gem::Version.new(mysql_version) < Gem::Version.new("8.0.13")
|
|
36
|
+
raise "Rails Pulse requires MySQL >= 8.0.13 for functional indexes " \
|
|
37
|
+
"(found #{mysql_version}). Upgrade MySQL or use PostgreSQL/SQLite."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
table = connection.quote_table_name(:rails_pulse_routes)
|
|
41
|
+
connection.execute(<<~SQL.strip)
|
|
42
|
+
CREATE UNIQUE INDEX #{NULL_ACTION_INDEX}
|
|
43
|
+
ON #{table} ((CASE WHEN controller_action IS NULL THEN path ELSE NULL END))
|
|
44
|
+
SQL
|
|
45
|
+
else
|
|
46
|
+
connection.add_index(
|
|
47
|
+
:rails_pulse_routes,
|
|
48
|
+
:path,
|
|
49
|
+
unique: true,
|
|
50
|
+
where: "controller_action IS NULL",
|
|
51
|
+
name: NULL_ACTION_INDEX
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.remove_null_action_uniqueness!(connection)
|
|
57
|
+
return unless exists?(connection)
|
|
58
|
+
|
|
59
|
+
connection.remove_index(:rails_pulse_routes, name: NULL_ACTION_INDEX)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.exists?(connection)
|
|
63
|
+
connection.indexes(:rails_pulse_routes).any? { |idx| idx.name == NULL_ACTION_INDEX }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Subscribers
|
|
3
|
+
class ExceptionSubscriber
|
|
4
|
+
def self.subscribe!
|
|
5
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
6
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
7
|
+
new(event).process
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(event)
|
|
12
|
+
@event = event
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def process
|
|
16
|
+
return unless RailsPulse.configuration.enabled
|
|
17
|
+
return unless RailsPulse.configuration.track_exceptions
|
|
18
|
+
return if RequestStore.store[:skip_recording_rails_pulse_activity]
|
|
19
|
+
|
|
20
|
+
exception = @event.payload[:exception_object]
|
|
21
|
+
return unless exception
|
|
22
|
+
return if RequestStore.store[:rails_pulse_captured_exception].equal?(exception)
|
|
23
|
+
|
|
24
|
+
RailsPulse::ExceptionCaptureService.capture(
|
|
25
|
+
exception,
|
|
26
|
+
request_url: @event.payload[:path],
|
|
27
|
+
request_method: @event.payload[:method],
|
|
28
|
+
request_params: @event.payload[:params],
|
|
29
|
+
environment: Rails.env.to_s
|
|
30
|
+
)
|
|
31
|
+
rescue => e
|
|
32
|
+
Rails.logger.error("[RailsPulse] ExceptionSubscriber error: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/rails_pulse/tracker.rb
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require "async"
|
|
2
|
-
|
|
3
1
|
module RailsPulse
|
|
4
2
|
module Tracker
|
|
5
3
|
# PG::Connection::PQTRANS_INERROR (libpq enum value 3) — the connection's transaction
|
|
@@ -13,8 +11,12 @@ module RailsPulse
|
|
|
13
11
|
def track_request(data)
|
|
14
12
|
return if RequestStore.store[:skip_recording_rails_pulse_activity]
|
|
15
13
|
|
|
16
|
-
if RailsPulse.configuration.async
|
|
17
|
-
|
|
14
|
+
if RailsPulse.configuration.async && !connection_shared_across_threads?
|
|
15
|
+
Thread.new do
|
|
16
|
+
perform_tracking(data)
|
|
17
|
+
rescue => e
|
|
18
|
+
log_error(e)
|
|
19
|
+
end
|
|
18
20
|
else
|
|
19
21
|
perform_tracking(data)
|
|
20
22
|
end
|
|
@@ -34,10 +36,11 @@ module RailsPulse
|
|
|
34
36
|
clear_aborted_transaction(conn)
|
|
35
37
|
RequestStore.store[:skip_recording_rails_pulse_activity] = true
|
|
36
38
|
|
|
37
|
-
route = RailsPulse::Route.
|
|
39
|
+
route = RailsPulse::Route.find_or_create_for_request(data[:method], data[:path], controller_action: data[:controller_action])
|
|
38
40
|
|
|
39
41
|
request = RailsPulse::Request.create!(
|
|
40
42
|
route: route,
|
|
43
|
+
method: data[:method],
|
|
41
44
|
duration: data[:duration],
|
|
42
45
|
status: data[:status],
|
|
43
46
|
is_error: data[:is_error],
|
|
@@ -51,12 +54,28 @@ module RailsPulse
|
|
|
51
54
|
RailsPulse::Operation.persist_bulk(ops, request_id: request.id)
|
|
52
55
|
|
|
53
56
|
request
|
|
54
|
-
rescue => e
|
|
55
|
-
log_error(e)
|
|
56
|
-
nil
|
|
57
|
-
ensure
|
|
58
|
-
RequestStore.store[:skip_recording_rails_pulse_activity] = false
|
|
59
57
|
end
|
|
58
|
+
rescue => e
|
|
59
|
+
log_error(e)
|
|
60
|
+
nil
|
|
61
|
+
ensure
|
|
62
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Transactional tests pin one connection per pool and hand that same
|
|
66
|
+
# connection to every thread that asks for one. A background writer would
|
|
67
|
+
# then interleave its statements with the test's on a single socket, which
|
|
68
|
+
# PostgreSQL reports as "message type 0x5a arrived from server while idle".
|
|
69
|
+
# Rails 7.2+ records the pin in the pool's @pinned_connection; 7.1 sets a
|
|
70
|
+
# @lock_thread on the pool instead. Neither is public API, so read both
|
|
71
|
+
# defensively and treat any failure as "not shared".
|
|
72
|
+
def connection_shared_across_threads?
|
|
73
|
+
pool = RailsPulse::ApplicationRecord.connection_pool
|
|
74
|
+
return true if pool.instance_variable_get(:@pinned_connection)
|
|
75
|
+
|
|
76
|
+
pool.instance_variable_get(:@lock_thread) ? true : false
|
|
77
|
+
rescue StandardError
|
|
78
|
+
false
|
|
60
79
|
end
|
|
61
80
|
|
|
62
81
|
def clear_aborted_transaction(conn)
|
data/lib/rails_pulse/version.rb
CHANGED
data/lib/rails_pulse.rb
CHANGED
data/lib/rails_pulse_server.ru
CHANGED
|
@@ -1,58 +1,22 @@
|
|
|
1
|
-
# Load Rails environment
|
|
1
|
+
# Load the host Rails environment. The dashboard is a mounted engine and needs
|
|
2
|
+
# the host's models, routes and initializer (authentication, database), so run
|
|
3
|
+
# this from the Rails application's root — or from the gem root, which loads
|
|
4
|
+
# the dummy app for development.
|
|
2
5
|
if File.exist?("test/dummy/config/environment.rb")
|
|
3
|
-
# Running from Rails Pulse gem root (for development/testing)
|
|
4
6
|
require_relative "../test/dummy/config/environment"
|
|
5
7
|
elsif File.exist?("config/environment.rb")
|
|
6
|
-
# Running from a Rails app that has Rails Pulse installed
|
|
7
8
|
require File.expand_path("config/environment", Dir.pwd)
|
|
8
9
|
else
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
puts "RailsPulse Dashboard (Standalone Mode)"
|
|
12
|
-
puts "=" * 80
|
|
10
|
+
abort <<~MESSAGE
|
|
11
|
+
RailsPulse standalone dashboard: config/environment.rb not found in #{Dir.pwd}.
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
require "active_support/all"
|
|
16
|
-
require "active_record"
|
|
13
|
+
Run from your Rails application's root directory:
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
cd /path/to/your/app
|
|
16
|
+
RAILS_ENV=production bundle exec rackup $(bundle show rails_pulse)/lib/rails_pulse_server.ru -p 3001
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{ url: ENV["DATABASE_URL"] }
|
|
24
|
-
elsif File.exist?("config/database.yml")
|
|
25
|
-
require "yaml"
|
|
26
|
-
db_yml = YAML.load_file("config/database.yml", aliases: true)
|
|
27
|
-
rails_env = ENV.fetch("RAILS_ENV", "production")
|
|
28
|
-
|
|
29
|
-
rails_pulse_config = db_yml.dig(rails_env, "rails_pulse")
|
|
30
|
-
if rails_pulse_config
|
|
31
|
-
rails_pulse_config
|
|
32
|
-
else
|
|
33
|
-
puts "WARNING: No 'rails_pulse' database found in config/database.yml, using primary connection"
|
|
34
|
-
db_yml[rails_env]
|
|
35
|
-
end
|
|
36
|
-
else
|
|
37
|
-
raise "Database configuration not found. Set DATABASE_URL or provide config/database.yml"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
puts "Connecting to database: #{db_config[:database] || db_config[:url]&.split('@')&.last}"
|
|
41
|
-
|
|
42
|
-
# Configure RailsPulse for dashboard-only mode
|
|
43
|
-
RailsPulse.configure do |config|
|
|
44
|
-
# CRITICAL: Disable tracking in dashboard process
|
|
45
|
-
config.enabled = false
|
|
46
|
-
|
|
47
|
-
# Configure database connection
|
|
48
|
-
config.connects_to = { database: db_config }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Establish database connection
|
|
52
|
-
RailsPulse::ApplicationRecord.establish_connection(db_config)
|
|
53
|
-
|
|
54
|
-
puts "Dashboard ready on port #{ENV.fetch('PORT', 3001)}"
|
|
55
|
-
puts "=" * 80
|
|
18
|
+
RAILS_ENV selects the database and enables dashboard authentication.
|
|
19
|
+
MESSAGE
|
|
56
20
|
end
|
|
57
21
|
|
|
58
22
|
# Disable output buffering so logs appear immediately
|
|
@@ -99,10 +63,15 @@ secret_key = ENV.fetch("SECRET_KEY_BASE") do
|
|
|
99
63
|
raise "SECRET_KEY_BASE environment variable must be set for standalone dashboard"
|
|
100
64
|
end
|
|
101
65
|
|
|
66
|
+
# `secure` keeps the session cookie off plain HTTP in production. Set
|
|
67
|
+
# RAILS_PULSE_INSECURE_SESSION=1 only for a deliberately non-TLS deployment
|
|
68
|
+
# (a private network with no reverse proxy in front).
|
|
102
69
|
use Rack::Session::Cookie,
|
|
103
70
|
key: "rails_pulse_session",
|
|
104
71
|
secret: secret_key,
|
|
105
72
|
same_site: :lax,
|
|
73
|
+
httponly: true,
|
|
74
|
+
secure: Rails.env.production? && ENV["RAILS_PULSE_INSECURE_SESSION"].blank?,
|
|
106
75
|
max_age: 86400 # 1 day
|
|
107
76
|
|
|
108
77
|
run DashboardApp.new
|
data/lib/tasks/rails_pulse.rake
CHANGED
|
@@ -9,6 +9,10 @@ namespace :db do
|
|
|
9
9
|
if schema_file.exist?
|
|
10
10
|
load schema_file
|
|
11
11
|
puts "Rails Pulse schema loaded successfully"
|
|
12
|
+
# Record any copied migrations as already applied so they don't show as
|
|
13
|
+
# pending after a fresh schema load. The schema file creates all tables and
|
|
14
|
+
# columns directly, so the incremental migrations are logically already done.
|
|
15
|
+
record_rails_pulse_migrations_applied
|
|
12
16
|
else
|
|
13
17
|
puts "Rails Pulse schema file not found. Run: rails generate rails_pulse:install --database=separate"
|
|
14
18
|
end
|
|
@@ -28,12 +32,87 @@ namespace :db do
|
|
|
28
32
|
end
|
|
29
33
|
end
|
|
30
34
|
|
|
35
|
+
# Hook into test database preparation so the separate Rails Pulse DB is populated
|
|
36
|
+
# for test runs, not just for development/production db:prepare.
|
|
37
|
+
# The task may be namespaced as "app:db:schema:load_rails_pulse" in engine context
|
|
38
|
+
# vs "db:schema:load_rails_pulse" in a host app — try both.
|
|
39
|
+
Rake::Task["db:test:prepare"].enhance do
|
|
40
|
+
if separate_database_setup?
|
|
41
|
+
task_name = %w[db:schema:load_rails_pulse app:db:schema:load_rails_pulse]
|
|
42
|
+
.find { |name| Rake::Task.task_defined?(name) }
|
|
43
|
+
if task_name
|
|
44
|
+
Rake::Task[task_name].reenable
|
|
45
|
+
Rake::Task[task_name].invoke
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end if Rake::Task.task_defined?("db:test:prepare")
|
|
49
|
+
|
|
31
50
|
# Helper function to detect database setup type
|
|
32
51
|
def separate_database_setup?
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
# A .keep file alone (created by install_generator for all modes) does not
|
|
53
|
+
# indicate separate-DB. Require actual migration files or a database.yml entry.
|
|
54
|
+
migrate_dir = Rails.root.join("db/rails_pulse_migrate")
|
|
55
|
+
has_migration_files = migrate_dir.exist? && Dir.glob("#{migrate_dir}/*.rb").any?
|
|
56
|
+
has_db_config = Rails.application.config.database_configuration.dig(Rails.env, "rails_pulse").present?
|
|
57
|
+
has_migration_files || has_db_config
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# After a schema load, insert version numbers for any migration files already in
|
|
61
|
+
# db/rails_pulse_migrate/ into schema_migrations so Rails does not report them as
|
|
62
|
+
# pending. The schema already includes every column those migrations would add, so
|
|
63
|
+
# running them would be a no-op — but Rails still needs the version recorded.
|
|
64
|
+
def record_rails_pulse_migrations_applied
|
|
65
|
+
return unless defined?(RailsPulse::ApplicationRecord)
|
|
66
|
+
|
|
67
|
+
migrations_path = Rails.root.join("db/rails_pulse_migrate")
|
|
68
|
+
return unless migrations_path.exist?
|
|
69
|
+
|
|
70
|
+
connection = RailsPulse::ApplicationRecord.connection
|
|
71
|
+
|
|
72
|
+
# Only record versions if the schema load actually created/updated tables.
|
|
73
|
+
# On an existing database, the schema file is a no-op, so marking migrations
|
|
74
|
+
# as applied would hide genuinely pending migrations.
|
|
75
|
+
unless connection.table_exists?(:rails_pulse_routes)
|
|
76
|
+
puts "[RailsPulse] Schema load did not create tables — skipping migration recording"
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Create schema_migrations if this is a brand-new database that has not yet
|
|
81
|
+
# had any migrations run against it (e.g. just after db:create).
|
|
82
|
+
unless connection.table_exists?(:schema_migrations)
|
|
83
|
+
connection.create_table :schema_migrations, id: false do |t|
|
|
84
|
+
t.string :version, null: false
|
|
85
|
+
end
|
|
86
|
+
connection.add_index :schema_migrations, :version,
|
|
87
|
+
unique: true, name: "unique_schema_migrations"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Dir.glob("#{migrations_path}/*.rb").sort.each do |file|
|
|
91
|
+
version = File.basename(file, ".rb").split("_").first
|
|
92
|
+
migration_name = File.basename(file, ".rb").sub(/\A\d+_/, "")
|
|
93
|
+
|
|
94
|
+
# For the 0.4.0 route-identity migrations, only record them as applied if
|
|
95
|
+
# the schema actually has the columns they would add. This prevents marking
|
|
96
|
+
# them applied on an existing DB where the schema load was a no-op.
|
|
97
|
+
if migration_name.include?("change_rails_pulse_routes") || migration_name.include?("null_action_unique_index")
|
|
98
|
+
unless connection.column_exists?(:rails_pulse_routes, :controller_action) &&
|
|
99
|
+
connection.column_exists?(:rails_pulse_routes, :http_methods)
|
|
100
|
+
puts "[RailsPulse] Skipping #{version} — route columns not present yet"
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
next if connection.select_values(
|
|
106
|
+
"SELECT version FROM schema_migrations WHERE version = #{connection.quote(version)}"
|
|
107
|
+
).any?
|
|
108
|
+
|
|
109
|
+
connection.execute(
|
|
110
|
+
"INSERT INTO schema_migrations (version) VALUES (#{connection.quote(version)})"
|
|
111
|
+
)
|
|
112
|
+
puts "[RailsPulse] Marked migration #{version} as applied (schema already up to date)"
|
|
113
|
+
end
|
|
114
|
+
rescue => e
|
|
115
|
+
warn "[RailsPulse] Could not mark migrations as applied: #{e.message}"
|
|
37
116
|
end
|
|
38
117
|
|
|
39
118
|
namespace :rails_pulse do
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
namespace :rails_pulse do
|
|
2
|
+
desc "Copy pre-built dashboard assets into public/assets without running the host JS compressor"
|
|
3
|
+
task install_assets: :environment do
|
|
4
|
+
entries = RailsPulse::PackagedAssets.install!
|
|
5
|
+
puts "[RailsPulse] Installed #{entries.size} dashboard assets into public/assets"
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Copy after the host pipeline finishes so asset_sync / CDN uploads include
|
|
10
|
+
# the files, but never register them for Sprockets compression (#190).
|
|
11
|
+
if Rake::Task.task_defined?("assets:precompile")
|
|
12
|
+
Rake::Task["assets:precompile"].enhance do
|
|
13
|
+
Rake::Task["rails_pulse:install_assets"].invoke
|
|
14
|
+
end
|
|
15
|
+
end
|