devformance 0.1.0
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 +7 -0
- data/README.md +205 -0
- data/app/assets/builds/tailwind.css +2 -0
- data/app/assets/images/icon.png +0 -0
- data/app/assets/images/icon.svg +68 -0
- data/app/assets/stylesheets/devmetrics/dashboard.css +476 -0
- data/app/assets/stylesheets/devmetrics_live/application.css +10 -0
- data/app/assets/tailwind/application.css +1 -0
- data/app/channels/application_cable/channel.rb +4 -0
- data/app/channels/application_cable/connection.rb +4 -0
- data/app/channels/devformance/metrics_channel.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/controllers/devformance/application_controller.rb +19 -0
- data/app/controllers/devformance/icons_controller.rb +21 -0
- data/app/controllers/devformance/metrics_controller.rb +41 -0
- data/app/controllers/devformance/playground_controller.rb +89 -0
- data/app/helpers/application_helper.rb +9 -0
- data/app/helpers/metrics_helper.rb +2 -0
- data/app/helpers/playground_helper.rb +2 -0
- data/app/javascript/devformance/channels/consumer.js +2 -0
- data/app/javascript/devformance/channels/index.js +1 -0
- data/app/javascript/devformance/controllers/application.js +9 -0
- data/app/javascript/devformance/controllers/hello_controller.js +7 -0
- data/app/javascript/devformance/controllers/index.js +14 -0
- data/app/javascript/devformance/controllers/metrics_controller.js +364 -0
- data/app/javascript/devformance/controllers/playground_controller.js +33 -0
- data/app/javascript/devmetrics.js +4 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/devformance/file_runner_job.rb +318 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/devformance/file_result.rb +14 -0
- data/app/models/devformance/run.rb +19 -0
- data/app/models/devformance/slow_query.rb +5 -0
- data/app/views/devformance/metrics/index.html.erb +79 -0
- data/app/views/devformance/playground/run.html.erb +63 -0
- data/app/views/layouts/devformance/application.html.erb +856 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/metrics/index.html.erb +334 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/config/BUSINESS_LOGIC_PLAN.md +1244 -0
- data/config/application.rb +31 -0
- data/config/boot.rb +4 -0
- data/config/cable.yml +17 -0
- data/config/cache.yml +16 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +98 -0
- data/config/deploy.yml +116 -0
- data/config/engine_routes.rb +13 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +84 -0
- data/config/environments/production.rb +90 -0
- data/config/environments/test.rb +59 -0
- data/config/importmap.rb +11 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/content_security_policy.rb +25 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/locales/en.yml +31 -0
- data/config/master.key +1 -0
- data/config/puma.rb +41 -0
- data/config/queue.yml +22 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +20 -0
- data/config/storage.yml +34 -0
- data/db/migrate/20260317144616_create_slow_queries.rb +13 -0
- data/db/migrate/20260317175630_create_performance_runs.rb +14 -0
- data/db/migrate/20260317195043_add_run_id_to_slow_queries.rb +10 -0
- data/db/migrate/20260319000001_create_devformance_runs.rb +20 -0
- data/db/migrate/20260319000002_create_devformance_file_results.rb +29 -0
- data/db/migrate/20260319000003_add_columns_to_slow_queries.rb +7 -0
- data/lib/devformance/bullet_log_parser.rb +47 -0
- data/lib/devformance/compatibility.rb +12 -0
- data/lib/devformance/coverage_setup.rb +33 -0
- data/lib/devformance/engine.rb +80 -0
- data/lib/devformance/log_writer.rb +29 -0
- data/lib/devformance/run_orchestrator.rb +58 -0
- data/lib/devformance/sql_instrumentor.rb +29 -0
- data/lib/devformance/test_framework/base.rb +43 -0
- data/lib/devformance/test_framework/coverage_helper.rb +76 -0
- data/lib/devformance/test_framework/detector.rb +26 -0
- data/lib/devformance/test_framework/minitest.rb +71 -0
- data/lib/devformance/test_framework/registry.rb +24 -0
- data/lib/devformance/test_framework/rspec.rb +60 -0
- data/lib/devformance/test_helper.rb +42 -0
- data/lib/devformance/version.rb +3 -0
- data/lib/devformance.rb +196 -0
- data/lib/generators/devformance/install/install_generator.rb +73 -0
- data/lib/generators/devformance/install/templates/add_columns_to_slow_queries.rb.erb +7 -0
- data/lib/generators/devformance/install/templates/add_run_id_to_slow_queries.rb.erb +10 -0
- data/lib/generators/devformance/install/templates/create_devformance_file_results.rb.erb +29 -0
- data/lib/generators/devformance/install/templates/create_devformance_runs.rb.erb +20 -0
- data/lib/generators/devformance/install/templates/create_performance_runs.rb.erb +14 -0
- data/lib/generators/devformance/install/templates/create_slow_queries.rb.erb +13 -0
- data/lib/generators/devformance/install/templates/initializer.rb +23 -0
- data/lib/tasks/devformance.rake +45 -0
- data/spec/fixtures/devformance/devformance_run.rb +27 -0
- data/spec/fixtures/devformance/file_result.rb +34 -0
- data/spec/fixtures/devformance/slow_query.rb +11 -0
- data/spec/lib/devmetrics/log_writer_spec.rb +81 -0
- data/spec/lib/devmetrics/run_orchestrator_spec.rb +102 -0
- data/spec/lib/devmetrics/sql_instrumentor_spec.rb +115 -0
- data/spec/models/devmetrics/file_result_spec.rb +87 -0
- data/spec/models/devmetrics/run_spec.rb +66 -0
- data/spec/models/query_log_spec.rb +21 -0
- data/spec/rails_helper.rb +20 -0
- data/spec/requests/devmetrics/metrics_controller_spec.rb +149 -0
- data/spec/requests/devmetrics_pages_spec.rb +12 -0
- data/spec/requests/performance_spec.rb +17 -0
- data/spec/requests/slow_perf_spec.rb +9 -0
- data/spec/spec_helper.rb +114 -0
- data/spec/support/devmetrics_formatter.rb +106 -0
- data/spec/support/devmetrics_metrics.rb +37 -0
- data/spec/support/factory_bot.rb +3 -0
- metadata +200 -0
data/config/puma.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# This configuration file will be evaluated by Puma. The top-level methods that
|
|
2
|
+
# are invoked here are part of Puma's configuration DSL. For more information
|
|
3
|
+
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
|
|
4
|
+
#
|
|
5
|
+
# Puma starts a configurable number of processes (workers) and each process
|
|
6
|
+
# serves each request in a thread from an internal thread pool.
|
|
7
|
+
#
|
|
8
|
+
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
|
|
9
|
+
# should only set this value when you want to run 2 or more workers. The
|
|
10
|
+
# default is already 1.
|
|
11
|
+
#
|
|
12
|
+
# The ideal number of threads per worker depends both on how much time the
|
|
13
|
+
# application spends waiting for IO operations and on how much you wish to
|
|
14
|
+
# prioritize throughput over latency.
|
|
15
|
+
#
|
|
16
|
+
# As a rule of thumb, increasing the number of threads will increase how much
|
|
17
|
+
# traffic a given process can handle (throughput), but due to CRuby's
|
|
18
|
+
# Global VM Lock (GVL) it has diminishing returns and will degrade the
|
|
19
|
+
# response time (latency) of the application.
|
|
20
|
+
#
|
|
21
|
+
# The default is set to 3 threads as it's deemed a decent compromise between
|
|
22
|
+
# throughput and latency for the average Rails application.
|
|
23
|
+
#
|
|
24
|
+
# Any libraries that use a connection pool or another resource pool should
|
|
25
|
+
# be configured to provide at least as many connections as the number of
|
|
26
|
+
# threads. This includes Active Record's `pool` parameter in `database.yml`.
|
|
27
|
+
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
|
|
28
|
+
threads threads_count, threads_count
|
|
29
|
+
|
|
30
|
+
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
|
31
|
+
port ENV.fetch("PORT", 3000)
|
|
32
|
+
|
|
33
|
+
# Allow puma to be restarted by `bin/rails restart` command.
|
|
34
|
+
plugin :tmp_restart
|
|
35
|
+
|
|
36
|
+
# Run the Solid Queue supervisor inside of Puma for single-server deployments
|
|
37
|
+
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
|
38
|
+
|
|
39
|
+
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
|
40
|
+
# In other environments, only set the PID file if requested.
|
|
41
|
+
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
|
data/config/queue.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
default: &default
|
|
2
|
+
dispatchers:
|
|
3
|
+
- polling_interval: 1
|
|
4
|
+
batch_size: 500
|
|
5
|
+
workers:
|
|
6
|
+
- queues: [devformance]
|
|
7
|
+
threads: 10
|
|
8
|
+
processes: 1
|
|
9
|
+
polling_interval: 0.1
|
|
10
|
+
- queues: "*"
|
|
11
|
+
threads: 3
|
|
12
|
+
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
|
|
13
|
+
polling_interval: 0.1
|
|
14
|
+
|
|
15
|
+
development:
|
|
16
|
+
<<: *default
|
|
17
|
+
|
|
18
|
+
test:
|
|
19
|
+
<<: *default
|
|
20
|
+
|
|
21
|
+
production:
|
|
22
|
+
<<: *default
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# examples:
|
|
2
|
+
# periodic_cleanup:
|
|
3
|
+
# class: CleanSoftDeletedRecordsJob
|
|
4
|
+
# queue: background
|
|
5
|
+
# args: [ 1000, { batch_size: 500 } ]
|
|
6
|
+
# schedule: every hour
|
|
7
|
+
# periodic_cleanup_with_command:
|
|
8
|
+
# command: "SoftDeletedRecord.due.delete_all"
|
|
9
|
+
# priority: 2
|
|
10
|
+
# schedule: at 5am every day
|
|
11
|
+
|
|
12
|
+
production:
|
|
13
|
+
clear_solid_queue_finished_jobs:
|
|
14
|
+
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
15
|
+
schedule: every hour at minute 12
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Rails.application.routes.draw do
|
|
2
|
+
scope "/devformance", module: "devformance", as: "devformance" do
|
|
3
|
+
root "metrics#index"
|
|
4
|
+
|
|
5
|
+
post "run_tests", to: "metrics#run_tests"
|
|
6
|
+
get "runs/:run_id/status", to: "metrics#run_status"
|
|
7
|
+
get "runs/:run_id/logs/:file_key/download", to: "metrics#download_log"
|
|
8
|
+
|
|
9
|
+
get "playground", to: "playground#run"
|
|
10
|
+
post "playground/run", to: "playground#run"
|
|
11
|
+
|
|
12
|
+
get "icon.svg", to: "icons#svg"
|
|
13
|
+
get "icon.png", to: "icons#png"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
get "/", to: redirect("/devformance")
|
|
17
|
+
get "/up", to: "rails/health#show"
|
|
18
|
+
|
|
19
|
+
mount ActionCable.server => "/devformance/cable"
|
|
20
|
+
end
|
data/config/storage.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
test:
|
|
2
|
+
service: Disk
|
|
3
|
+
root: <%= Rails.root.join("tmp/storage") %>
|
|
4
|
+
|
|
5
|
+
local:
|
|
6
|
+
service: Disk
|
|
7
|
+
root: <%= Rails.root.join("storage") %>
|
|
8
|
+
|
|
9
|
+
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
|
10
|
+
# amazon:
|
|
11
|
+
# service: S3
|
|
12
|
+
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
|
|
13
|
+
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
|
|
14
|
+
# region: us-east-1
|
|
15
|
+
# bucket: your_own_bucket-<%= Rails.env %>
|
|
16
|
+
|
|
17
|
+
# Remember not to checkin your GCS keyfile to a repository
|
|
18
|
+
# google:
|
|
19
|
+
# service: GCS
|
|
20
|
+
# project: your_project
|
|
21
|
+
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
|
|
22
|
+
# bucket: your_own_bucket-<%= Rails.env %>
|
|
23
|
+
|
|
24
|
+
# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
|
|
25
|
+
# microsoft:
|
|
26
|
+
# service: AzureStorage
|
|
27
|
+
# storage_account_name: your_account_name
|
|
28
|
+
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
|
|
29
|
+
# container: your_container_name-<%= Rails.env %>
|
|
30
|
+
|
|
31
|
+
# mirror:
|
|
32
|
+
# service: Mirror
|
|
33
|
+
# primary: local
|
|
34
|
+
# mirrors: [ amazon, google, microsoft ]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateSlowQueries < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
unless table_exists?(:slow_queries)
|
|
4
|
+
create_table :slow_queries do |t|
|
|
5
|
+
t.string :model_class
|
|
6
|
+
t.integer :line_number
|
|
7
|
+
t.text :fix_suggestion
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreatePerformanceRuns < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
unless table_exists?(:performance_runs)
|
|
4
|
+
create_table :performance_runs do |t|
|
|
5
|
+
t.string :run_id
|
|
6
|
+
t.integer :total_files
|
|
7
|
+
t.integer :completed_files
|
|
8
|
+
t.string :status
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class AddRunIdToSlowQueries < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
unless column_exists?(:slow_queries, :run_id)
|
|
4
|
+
add_column :slow_queries, :run_id, :string
|
|
5
|
+
end
|
|
6
|
+
unless index_exists?(:slow_queries, :run_id)
|
|
7
|
+
add_index :slow_queries, :run_id
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreateDevformanceRuns < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
unless table_exists?(:devformance_runs)
|
|
4
|
+
create_table :devformance_runs do |t|
|
|
5
|
+
t.string :run_id, null: false
|
|
6
|
+
t.integer :status, default: 0, null: false
|
|
7
|
+
t.datetime :started_at
|
|
8
|
+
t.datetime :finished_at
|
|
9
|
+
t.integer :total_files, default: 0
|
|
10
|
+
t.integer :completed_files, default: 0
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
unless index_exists?(:devformance_runs, :run_id)
|
|
16
|
+
add_index :devformance_runs, :run_id, unique: true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class CreateDevformanceFileResults < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
unless table_exists?(:devformance_file_results)
|
|
4
|
+
create_table :devformance_file_results do |t|
|
|
5
|
+
t.string :run_id, null: false
|
|
6
|
+
t.string :file_key, null: false
|
|
7
|
+
t.string :file_path
|
|
8
|
+
t.integer :status, default: 0, null: false
|
|
9
|
+
t.integer :total_tests, default: 0
|
|
10
|
+
t.integer :passed_tests, default: 0
|
|
11
|
+
t.integer :failed_tests, default: 0
|
|
12
|
+
t.integer :slow_query_count, default: 0
|
|
13
|
+
t.integer :n1_count, default: 0
|
|
14
|
+
t.float :coverage
|
|
15
|
+
t.integer :duration_ms
|
|
16
|
+
t.string :log_path
|
|
17
|
+
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
unless index_exists?(:devformance_file_results, :run_id)
|
|
22
|
+
add_index :devformance_file_results, :run_id
|
|
23
|
+
end
|
|
24
|
+
unless index_exists?(:devformance_file_results, [ :run_id, :file_key ])
|
|
25
|
+
add_index :devformance_file_results, [ :run_id, :file_key ], unique: true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddColumnsToSlowQueries < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
add_column :slow_queries, :sql, :text unless column_exists?(:slow_queries, :sql)
|
|
4
|
+
add_column :slow_queries, :duration_ms, :float unless column_exists?(:slow_queries, :duration_ms)
|
|
5
|
+
add_column :slow_queries, :file_key, :string unless column_exists?(:slow_queries, :file_key)
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
module BulletLogParser
|
|
3
|
+
Warning = Data.define(:type, :endpoint, :model_class, :associations, :fix_suggestion, :line_number, :call_stack)
|
|
4
|
+
|
|
5
|
+
BLOCK_START = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\[WARN\]/
|
|
6
|
+
|
|
7
|
+
def self.parse(content)
|
|
8
|
+
blocks = content.split(BLOCK_START).map(&:strip).reject(&:empty?)
|
|
9
|
+
blocks.filter_map { |block| parse_block(block) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.parse_block(block)
|
|
13
|
+
lines = block.lines.map(&:chomp)
|
|
14
|
+
|
|
15
|
+
endpoint = lines.find { |l| l.match?(/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+/) }&.strip
|
|
16
|
+
type_line = lines.find { |l| l.include?("eager loading detected") }
|
|
17
|
+
model_line = lines.find { |l| l.match?(/\w+ => \[/) }
|
|
18
|
+
fix_line = lines.find { |l| l.include?("Add to your query") || l.include?("Remove from your query") }
|
|
19
|
+
|
|
20
|
+
return nil unless type_line && model_line
|
|
21
|
+
|
|
22
|
+
type = type_line.strip.start_with?("USE") ? :add_eager_load : :remove_eager_load
|
|
23
|
+
|
|
24
|
+
model_class = model_line.match(/^\s*(\w+)\s*=>/)[1] rescue nil
|
|
25
|
+
associations = model_line.match(/=>\s*(\[.+\])/)[1] rescue nil
|
|
26
|
+
|
|
27
|
+
fix_suggestion = fix_line&.strip
|
|
28
|
+
|
|
29
|
+
call_stack_start = lines.index { |l| l.strip == "Call stack" }
|
|
30
|
+
call_stack = call_stack_start ? lines[(call_stack_start + 1)..].map(&:strip).reject(&:empty?) : []
|
|
31
|
+
|
|
32
|
+
app_line = call_stack.find { |l| !l.include?("/gems/") && !l.include?("/ruby/") && l.match?(/\.rb:\d+/) }
|
|
33
|
+
line_number = app_line&.match(/:(\d+):/)&.[](1)&.to_i
|
|
34
|
+
|
|
35
|
+
Warning.new(
|
|
36
|
+
type: type,
|
|
37
|
+
endpoint: endpoint,
|
|
38
|
+
model_class: model_class,
|
|
39
|
+
associations: associations,
|
|
40
|
+
fix_suggestion: fix_suggestion,
|
|
41
|
+
line_number: line_number,
|
|
42
|
+
call_stack: call_stack
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :parse_block
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
module Compatibility
|
|
3
|
+
def self.importmap? = defined?(::Importmap::Engine)
|
|
4
|
+
def self.turbo? = defined?(::Turbo::Engine)
|
|
5
|
+
def self.stimulus? = defined?(::Stimulus::Engine)
|
|
6
|
+
def self.propshaft? = defined?(::Propshaft::Engine)
|
|
7
|
+
def self.bullet? = defined?(::Bullet)
|
|
8
|
+
def self.solid_cable? = defined?(::SolidCable)
|
|
9
|
+
def self.hotwire? = turbo? && stimulus? && importmap?
|
|
10
|
+
def self.rails_version = Gem::Version.new(Rails.version)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "simplecov"
|
|
2
|
+
|
|
3
|
+
module Devformance
|
|
4
|
+
class CoverageSetup
|
|
5
|
+
def self.start
|
|
6
|
+
return unless ENV["COVERAGE"] == "true" || ENV["SIMPLECOV"] == "true"
|
|
7
|
+
|
|
8
|
+
SimpleCov.start do
|
|
9
|
+
add_filter "/vendor/"
|
|
10
|
+
add_filter "/spec/"
|
|
11
|
+
|
|
12
|
+
unless ENV["DEVMETRICS_INCLUDE_TESTS"] == "true"
|
|
13
|
+
add_filter "/test/"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_filter "/config/"
|
|
17
|
+
add_filter "/db/"
|
|
18
|
+
|
|
19
|
+
minimum_coverage Devformance.configuration.coverage_minimum_coverage || 80
|
|
20
|
+
|
|
21
|
+
coverage_dir Devformance.configuration.coverage_dir || "coverage"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.result
|
|
26
|
+
SimpleCov.result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.pct
|
|
30
|
+
SimpleCov.result&.covered_percent&.round(1)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace Devformance
|
|
4
|
+
|
|
5
|
+
config.static_assets = true
|
|
6
|
+
|
|
7
|
+
# Use a dedicated routes file so config/routes.rb can be the host-app routes
|
|
8
|
+
paths["config/routes.rb"] = "config/engine_routes.rb"
|
|
9
|
+
|
|
10
|
+
config.to_prepare do
|
|
11
|
+
Devformance::ApplicationController.layout "devformance/application"
|
|
12
|
+
|
|
13
|
+
require "devformance/log_writer"
|
|
14
|
+
require "devformance/sql_instrumentor"
|
|
15
|
+
require "devformance/run_orchestrator"
|
|
16
|
+
require "devformance/bullet_log_parser"
|
|
17
|
+
|
|
18
|
+
# Explicitly require and alias channel for ActionCable/Solid Cable
|
|
19
|
+
# ActionCable uses constantize which needs the class to be loadable
|
|
20
|
+
begin
|
|
21
|
+
require "devformance/metrics_channel"
|
|
22
|
+
rescue LoadError
|
|
23
|
+
# Already loaded via autoload
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless ::Object.const_defined?(:DevformanceChannel)
|
|
27
|
+
::Object.const_set(:DevformanceChannel, Devformance::MetricsChannel)
|
|
28
|
+
end
|
|
29
|
+
unless ::Object.const_defined?(:MetricsChannel)
|
|
30
|
+
::Object.const_set(:MetricsChannel, Devformance::MetricsChannel)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ── Asset & View configuration ───────────────────────────────────────────
|
|
35
|
+
initializer "devformance.importmap", after: "importmap" do |app|
|
|
36
|
+
if app.config.respond_to?(:importmap)
|
|
37
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
38
|
+
app.config.importmap.cache_sweepers << root.join("app/javascript")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ── Action View Helpers ──────────────────────────────────────────────────
|
|
43
|
+
initializer "devformance.helpers" do
|
|
44
|
+
ActiveSupport.on_load(:action_view) do
|
|
45
|
+
if defined?(::Importmap::ImportmapTagsHelper)
|
|
46
|
+
include ::Importmap::ImportmapTagsHelper
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if defined?(::Turbo::FramesHelper)
|
|
50
|
+
include ::Turbo::FramesHelper
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
initializer "devformance.assets" do |app|
|
|
56
|
+
if app.config.respond_to?(:assets)
|
|
57
|
+
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
58
|
+
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
59
|
+
app.config.assets.paths << root.join("app/assets/images")
|
|
60
|
+
app.config.assets.paths << root.join("app/javascript")
|
|
61
|
+
unless defined?(::Propshaft)
|
|
62
|
+
app.config.assets.precompile += %w[devformance/application.js devformance/dashboard.css icon.svg icon.png]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
initializer "devformance.sql_notifications" do
|
|
68
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
69
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
70
|
+
Devformance::SqlInstrumentor.record(event) if defined?(Devformance::SqlInstrumentor)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
config.after_initialize do
|
|
75
|
+
if ENV["DEVMETRICS_SKIP_DB_SETUP"] == "1"
|
|
76
|
+
Rails.logger.info "[Devformance] Database setup skipped for test runs"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
class LogWriter
|
|
3
|
+
LOG_BASE = -> { Rails.root.join("log", "devformance", "runs") }
|
|
4
|
+
|
|
5
|
+
def self.open(run_id, file_key)
|
|
6
|
+
dir = LOG_BASE.call.join(run_id.to_s)
|
|
7
|
+
FileUtils.mkdir_p(dir)
|
|
8
|
+
new(dir.join("#{file_key}.log"))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = path
|
|
13
|
+
@file = File.open(path, "w")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write(line)
|
|
17
|
+
@file.puts(line)
|
|
18
|
+
@file.flush
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def close
|
|
22
|
+
@file.close
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def path
|
|
26
|
+
@path.to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
class RunOrchestrator
|
|
3
|
+
def self.call(framework: nil)
|
|
4
|
+
new(framework: framework).call
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def initialize(framework: nil)
|
|
8
|
+
@framework = framework || detect_framework
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
file_paths = @framework.discover_files
|
|
13
|
+
if file_paths.empty?
|
|
14
|
+
return { error: "No test files found matching #{@framework.file_pattern}" }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
tagged = file_paths.select { |f| File.read(f).match?(/devformance/i) }
|
|
18
|
+
file_paths = tagged if tagged.any?
|
|
19
|
+
|
|
20
|
+
run = ::Devformance::Run.create_for_files(file_paths)
|
|
21
|
+
|
|
22
|
+
file_metas = file_paths.map do |path|
|
|
23
|
+
file_key = ::Devformance::FileResult.file_key_for(path)
|
|
24
|
+
::Devformance::FileResult.create!(
|
|
25
|
+
run_id: run.run_id,
|
|
26
|
+
file_key: file_key,
|
|
27
|
+
file_path: path,
|
|
28
|
+
status: :pending
|
|
29
|
+
)
|
|
30
|
+
{ file_key: file_key, file_path: path, display_name: path.sub(Rails.root.to_s + "/", "") }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
ActionCable.server.broadcast(
|
|
34
|
+
"devformance:run:#{run.run_id}",
|
|
35
|
+
{ type: "run_started", run_id: run.run_id, files: file_metas, framework: @framework.name }
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
file_metas.each do |meta|
|
|
39
|
+
::Devformance::FileRunnerJob.perform_later(
|
|
40
|
+
run_id: run.run_id,
|
|
41
|
+
file_path: meta[:file_path],
|
|
42
|
+
file_key: meta[:file_key],
|
|
43
|
+
framework: @framework.name
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{ run_id: run.run_id, file_count: file_metas.size, files: file_metas, framework: @framework.name }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def detect_framework
|
|
53
|
+
Devformance.configuration.framework_adapter ||
|
|
54
|
+
Devformance::TestFramework::Detector.detect(Rails.root) ||
|
|
55
|
+
Devformance::TestFramework::RSpec.new(Rails.root)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
class SqlInstrumentor
|
|
3
|
+
THREAD_KEY = :devformance_sql_collector
|
|
4
|
+
|
|
5
|
+
def self.around_run
|
|
6
|
+
Thread.current[THREAD_KEY] = { queries: [], start: Time.current }
|
|
7
|
+
yield
|
|
8
|
+
ensure
|
|
9
|
+
Thread.current[THREAD_KEY] = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.record(event)
|
|
13
|
+
collector = Thread.current[THREAD_KEY]
|
|
14
|
+
return unless collector
|
|
15
|
+
|
|
16
|
+
ms = event.duration.round(2)
|
|
17
|
+
sql = event.payload[:sql].to_s.strip
|
|
18
|
+
|
|
19
|
+
return if sql.match?(/\A(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i)
|
|
20
|
+
|
|
21
|
+
collector[:queries] << { sql: sql, ms: ms, at: Time.current.iso8601 }
|
|
22
|
+
collector[:queries].last
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.queries
|
|
26
|
+
Thread.current[THREAD_KEY]&.dig(:queries) || []
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
module TestFramework
|
|
3
|
+
class Base
|
|
4
|
+
attr_reader :root_path
|
|
5
|
+
|
|
6
|
+
def initialize(root_path)
|
|
7
|
+
@root_path = root_path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def discover_files
|
|
11
|
+
raise NotImplementedError, "#{self.class} must implement #discover_files"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run_command(file_path)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #run_command"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse_summary(output)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #parse_summary"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def classify_line(line)
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #classify_line"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def file_pattern
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #file_pattern"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def name
|
|
31
|
+
raise NotImplementedError, "#{self.class} must implement #name"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def available?
|
|
35
|
+
!discover_files.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def runner_command
|
|
39
|
+
raise NotImplementedError, "#{self.class} must implement #runner_command"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Devformance
|
|
2
|
+
module TestFramework
|
|
3
|
+
module CoverageHelper
|
|
4
|
+
def self.setup_simplecov
|
|
5
|
+
return unless defined?(SimpleCov)
|
|
6
|
+
|
|
7
|
+
SimpleCov.start do
|
|
8
|
+
add_filter "/vendor/"
|
|
9
|
+
add_filter "/spec/"
|
|
10
|
+
add_filter "/test/"
|
|
11
|
+
add_filter "/config/"
|
|
12
|
+
add_filter "/db/"
|
|
13
|
+
|
|
14
|
+
minimum_coverage Devformance.configuration.coverage_minimum_coverage || 80
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.run_with_coverage(file_path, framework_name)
|
|
19
|
+
if framework_name == "RSpec"
|
|
20
|
+
run_rspec_with_coverage(file_path)
|
|
21
|
+
elsif framework_name == "Minitest"
|
|
22
|
+
run_minitest_with_coverage(file_path)
|
|
23
|
+
else
|
|
24
|
+
raise "Unsupported framework: #{framework_name}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.run_rspec_with_coverage(file_path)
|
|
29
|
+
require "simplecov"
|
|
30
|
+
SimpleCov.start do
|
|
31
|
+
add_filter "/vendor/"
|
|
32
|
+
add_filter "/spec/"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
require "rspec/core"
|
|
36
|
+
exit_code = RSpec::Core::Runner.run([ file_path, "--format", "progress" ])
|
|
37
|
+
{ exit_code: exit_code, coverage: SimpleCov.result }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.run_minitest_with_coverage(file_path)
|
|
41
|
+
require "simplecov"
|
|
42
|
+
SimpleCov.start do
|
|
43
|
+
add_filter "/vendor/"
|
|
44
|
+
add_filter "/test/"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
require "rails/test_help"
|
|
48
|
+
require "minitest/autorun"
|
|
49
|
+
Minitest.run
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.parse_coverage_json
|
|
53
|
+
resultset_path = Rails.root.join(Devformance.configuration.coverage_dir || "coverage", ".resultset.json")
|
|
54
|
+
return nil unless File.exist?(resultset_path)
|
|
55
|
+
|
|
56
|
+
JSON.parse(File.read(resultset_path))
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.calculate_coverage_pct(resultset, file_path)
|
|
62
|
+
return nil unless resultset
|
|
63
|
+
|
|
64
|
+
rel_path = file_path.sub(Rails.root.to_s + "/", "")
|
|
65
|
+
all_lines = resultset.values.flat_map do |r|
|
|
66
|
+
r.dig("coverage", rel_path)&.compact || []
|
|
67
|
+
end.compact
|
|
68
|
+
|
|
69
|
+
return nil if all_lines.empty?
|
|
70
|
+
|
|
71
|
+
covered = all_lines.count { |v| v.to_i > 0 }
|
|
72
|
+
(covered.to_f / all_lines.size * 100).round(1)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require_relative "rspec"
|
|
2
|
+
require_relative "minitest"
|
|
3
|
+
|
|
4
|
+
module Devformance
|
|
5
|
+
module TestFramework
|
|
6
|
+
class Detector
|
|
7
|
+
FRAMEWORKS = [ RSpec, Minitest ].freeze
|
|
8
|
+
|
|
9
|
+
def self.detect(root_path)
|
|
10
|
+
FRAMEWORKS.each do |framework_class|
|
|
11
|
+
adapter = framework_class.new(root_path)
|
|
12
|
+
return adapter if adapter.available?
|
|
13
|
+
end
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.all
|
|
18
|
+
FRAMEWORKS
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.for_name(name)
|
|
22
|
+
FRAMEWORKS.find { |f| f.name == name }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|