solid_ops 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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +308 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/solid_ops/application.css +1 -0
  9. data/app/controllers/solid_ops/application_controller.rb +127 -0
  10. data/app/controllers/solid_ops/cache_entries_controller.rb +38 -0
  11. data/app/controllers/solid_ops/channels_controller.rb +30 -0
  12. data/app/controllers/solid_ops/dashboard_controller.rb +80 -0
  13. data/app/controllers/solid_ops/events_controller.rb +37 -0
  14. data/app/controllers/solid_ops/jobs_controller.rb +64 -0
  15. data/app/controllers/solid_ops/processes_controller.rb +11 -0
  16. data/app/controllers/solid_ops/queues_controller.rb +75 -0
  17. data/app/controllers/solid_ops/recurring_tasks_controller.rb +11 -0
  18. data/app/helpers/solid_ops/application_helper.rb +112 -0
  19. data/app/jobs/solid_ops/purge_job.rb +16 -0
  20. data/app/models/solid_ops/event.rb +34 -0
  21. data/app/views/layouts/solid_ops/application.html.erb +118 -0
  22. data/app/views/solid_ops/cache_entries/index.html.erb +86 -0
  23. data/app/views/solid_ops/cache_entries/show.html.erb +153 -0
  24. data/app/views/solid_ops/channels/index.html.erb +81 -0
  25. data/app/views/solid_ops/channels/show.html.erb +66 -0
  26. data/app/views/solid_ops/dashboard/cable.html.erb +98 -0
  27. data/app/views/solid_ops/dashboard/cache.html.erb +104 -0
  28. data/app/views/solid_ops/dashboard/index.html.erb +169 -0
  29. data/app/views/solid_ops/dashboard/jobs.html.erb +108 -0
  30. data/app/views/solid_ops/events/index.html.erb +98 -0
  31. data/app/views/solid_ops/events/show.html.erb +108 -0
  32. data/app/views/solid_ops/jobs/failed.html.erb +89 -0
  33. data/app/views/solid_ops/jobs/running.html.erb +134 -0
  34. data/app/views/solid_ops/jobs/show.html.erb +116 -0
  35. data/app/views/solid_ops/processes/index.html.erb +69 -0
  36. data/app/views/solid_ops/queues/index.html.erb +182 -0
  37. data/app/views/solid_ops/queues/show.html.erb +121 -0
  38. data/app/views/solid_ops/recurring_tasks/index.html.erb +64 -0
  39. data/app/views/solid_ops/shared/_nav.html.erb +50 -0
  40. data/app/views/solid_ops/shared/_pagination.html.erb +31 -0
  41. data/app/views/solid_ops/shared/_time_window.html.erb +10 -0
  42. data/app/views/solid_ops/shared/component_unavailable.html.erb +63 -0
  43. data/config/routes.rb +49 -0
  44. data/db/migrate/20260224000100_create_solid_ops_events.rb +31 -0
  45. data/lib/generators/solid_ops/install/install_generator.rb +348 -0
  46. data/lib/generators/solid_ops/install/templates/create_solid_ops_events.rb +31 -0
  47. data/lib/generators/solid_ops/install/templates/solid_ops_initializer.rb +31 -0
  48. data/lib/solid_ops/configuration.rb +28 -0
  49. data/lib/solid_ops/context.rb +34 -0
  50. data/lib/solid_ops/current.rb +10 -0
  51. data/lib/solid_ops/engine.rb +60 -0
  52. data/lib/solid_ops/job_extension.rb +50 -0
  53. data/lib/solid_ops/middleware.rb +52 -0
  54. data/lib/solid_ops/subscribers.rb +215 -0
  55. data/lib/solid_ops/version.rb +5 -0
  56. data/lib/solid_ops.rb +25 -0
  57. data/lib/tasks/solid_ops.rake +32 -0
  58. data/log/test.log +2 -0
  59. data/sig/solid_ops.rbs +4 -0
  60. metadata +119 -0
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module SolidOps
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ class_option :queue, type: :boolean, default: nil, desc: "Also install Solid Queue"
11
+ class_option :cache, type: :boolean, default: nil, desc: "Also install Solid Cache"
12
+ class_option :cable, type: :boolean, default: nil, desc: "Also install Solid Cable"
13
+ class_option :all, type: :boolean, default: false, desc: "Install all Solid components (Queue, Cache, Cable)"
14
+
15
+ desc "Install SolidOps: creates initializer, mounts routes, and optionally installs Solid Queue/Cache/Cable."
16
+
17
+ def create_initializer
18
+ template "solid_ops_initializer.rb", "config/initializers/solid_ops.rb"
19
+ end
20
+
21
+ def add_routes
22
+ routes_file = File.join(destination_root, "config", "routes.rb")
23
+ if File.exist?(routes_file) && File.read(routes_file).include?("SolidOps::Engine")
24
+ say_status :skip, "SolidOps route already mounted", :yellow
25
+ else
26
+ route 'mount SolidOps::Engine => "/solid_ops"'
27
+ end
28
+ end
29
+
30
+ def install_solid_components
31
+ # Detect what's already installed
32
+ detect_existing_components
33
+
34
+ install_all = options[:all]
35
+
36
+ # If no flags given, ask
37
+ if !install_all && !options[:queue] && !options[:cache] && !options[:cable]
38
+ install_all = yes?("\n Install all Solid components (Queue, Cache, Cable)? (y/n)")
39
+ end
40
+
41
+ # A component is "active" if the user selected it OR it already exists.
42
+ # This ensures we configure environments / cable.yml / print database
43
+ # instructions even when someone re-runs the installer after manually
44
+ # adding gems.
45
+ @install_queue = install_all || options[:queue] || gem_in_bundle?("solid_queue")
46
+ @install_cache = install_all || options[:cache] || gem_in_bundle?("solid_cache")
47
+ @install_cable = install_all || options[:cable] || gem_in_bundle?("solid_cable")
48
+
49
+ if !@install_queue && !@install_cache && !@install_cable
50
+ warn_no_components_selected
51
+ return
52
+ end
53
+
54
+ add_missing_gems
55
+ run_component_installers
56
+ install_solid_ops_migrations
57
+ configure_environment("development")
58
+ configure_environment("test")
59
+ configure_cable_yml if @install_cable
60
+ print_database_yml_instructions
61
+ end
62
+
63
+ private
64
+
65
+ SOLID_COMPONENTS = { # rubocop:disable Lint/UselessConstantScoping
66
+ "solid_queue" => { label: "Solid Queue", purpose: "background job processing" },
67
+ "solid_cache" => { label: "Solid Cache", purpose: "database-backed caching" },
68
+ "solid_cable" => { label: "Solid Cable", purpose: "database-backed Action Cable" }
69
+ }.freeze
70
+
71
+ def detect_existing_components
72
+ say ""
73
+ say " Detecting Solid components...", :cyan
74
+ say ""
75
+
76
+ found = []
77
+ missing = []
78
+
79
+ # Snapshot pre-install state so run_component_installers only runs
80
+ # installers for gems we're about to add, not ones already present.
81
+ @queue_was_present = gem_in_bundle?("solid_queue")
82
+ @cache_was_present = gem_in_bundle?("solid_cache")
83
+ @cable_was_present = gem_in_bundle?("solid_cable")
84
+
85
+ SOLID_COMPONENTS.each do |gem_name, info|
86
+ if gem_in_bundle?(gem_name)
87
+ say " ✓ #{info[:label]} detected (#{info[:purpose]})", :green
88
+ found << gem_name
89
+ else
90
+ say " ✗ #{info[:label]} not found (#{info[:purpose]})", :yellow
91
+ missing << gem_name
92
+ end
93
+ end
94
+
95
+ say ""
96
+
97
+ if found.empty?
98
+ say " ⚠ No Solid components detected. SolidOps requires at least one.", :yellow
99
+ say " The installer can add them for you — select which ones below.", :yellow
100
+ say ""
101
+ elsif missing.empty?
102
+ say " All Solid components are present!", :green
103
+ say ""
104
+ end
105
+ end
106
+
107
+ def warn_no_components_selected
108
+ has_any = SOLID_COMPONENTS.keys.any? { |g| gem_in_bundle?(g) }
109
+
110
+ say ""
111
+ if has_any
112
+ say " No additional components selected — using existing Solid gems."
113
+ say " SolidOps will work with whatever Solid components are available."
114
+ else
115
+ say " ⚠ No Solid components installed or selected.", :yellow
116
+ say " SolidOps requires at least one of: solid_queue, solid_cache, solid_cable.", :yellow
117
+ say ""
118
+ say " To install later, run:", :yellow
119
+ say " bin/rails generate solid_ops:install --all", :yellow
120
+ say " Or add individual gems:", :yellow
121
+ say ' gem "solid_queue" # then: bin/rails generate solid_queue:install', :yellow
122
+ say ' gem "solid_cache" # then: bin/rails generate solid_cache:install', :yellow
123
+ say ' gem "solid_cable" # then: bin/rails generate solid_cable:install', :yellow
124
+ end
125
+ say ""
126
+ end
127
+
128
+ def add_missing_gems
129
+ gems_to_add = []
130
+ gems_to_add << "solid_queue" if @install_queue && !gem_in_bundle?("solid_queue")
131
+ gems_to_add << "solid_cache" if @install_cache && !gem_in_bundle?("solid_cache")
132
+ gems_to_add << "solid_cable" if @install_cable && !gem_in_bundle?("solid_cable")
133
+
134
+ return if gems_to_add.empty?
135
+
136
+ say "\n Adding gems: #{gems_to_add.join(", ")}"
137
+ gems_to_add.each { |g| append_to_file "Gemfile", "\ngem \"#{g}\"\n" }
138
+ run "bundle install"
139
+ say " 💡 If anything looks wrong after install, try: bin/spring stop", :cyan if spring_loaded?
140
+ end
141
+
142
+ def spring_loaded?
143
+ return true if Gem.loaded_specs.key?("spring")
144
+
145
+ gemfile = File.join(destination_root, "Gemfile")
146
+ File.exist?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']spring["']/)
147
+ rescue StandardError
148
+ false
149
+ end
150
+
151
+ def run_component_installers
152
+ # Only run component installers for gems we just added (not pre-existing).
153
+ # If we added new gems, they won't be loadable in this process.
154
+ # Use `rails generate` as a shell command so it starts a fresh process
155
+ # with the updated Gemfile.lock.
156
+ if @install_queue && !@queue_was_present
157
+ say "\n Installing Solid Queue..."
158
+ run_generator_command "solid_queue:install"
159
+ end
160
+
161
+ if @install_cache && !@cache_was_present
162
+ say "\n Installing Solid Cache..."
163
+ run_generator_command "solid_cache:install"
164
+ end
165
+
166
+ return unless @install_cable && !@cable_was_present
167
+
168
+ say "\n Installing Solid Cable..."
169
+ run_generator_command "solid_cable:install"
170
+ end
171
+
172
+ def install_solid_ops_migrations
173
+ say "\n Installing SolidOps migrations..."
174
+ # Use the standard Rails engine migration copy task (unscoped so it
175
+ # reliably copies from all engines — Rails skips duplicates by
176
+ # timestamp/name automatically).
177
+ rake "railties:install:migrations"
178
+ say " ✓ Migrations copied. They will be applied when you run: bin/rails db:prepare", :green
179
+ rescue StandardError => e
180
+ # Fallback if the rake task fails (Spring, binstub issues, etc.)
181
+ say " \u26a0 Could not auto-install migrations: #{e.message}", :yellow
182
+ say " Run manually: bin/rails railties:install:migrations", :yellow
183
+ end
184
+
185
+ def print_database_yml_instructions
186
+ say ""
187
+ say " #{"=" * 64}"
188
+ say " IMPORTANT: Update config/database.yml", :yellow
189
+ say " #{"=" * 64}"
190
+ say ""
191
+ say " The Solid component installers only configure production."
192
+ say " You need to update development and test to use multi-database."
193
+ say ""
194
+ say " Replace your development: and test: sections with:"
195
+ say ""
196
+
197
+ adapter = detect_database_adapter
198
+
199
+ %w[development test].each do |env|
200
+ app_name = File.basename(destination_root).gsub(/[^a-zA-Z0-9_]/, "_")
201
+ say " #{env}:"
202
+ say " primary:"
203
+ say " <<: *default"
204
+ say " database: #{db_name_for(adapter, app_name, env, nil)}"
205
+ if @install_queue
206
+ say " queue:"
207
+ say " <<: *default"
208
+ say " database: #{db_name_for(adapter, app_name, env, "queue")}"
209
+ say " migrations_paths: db/queue_migrate"
210
+ end
211
+ if @install_cache
212
+ say " cache:"
213
+ say " <<: *default"
214
+ say " database: #{db_name_for(adapter, app_name, env, "cache")}"
215
+ say " migrations_paths: db/cache_migrate"
216
+ end
217
+ if @install_cable
218
+ say " cable:"
219
+ say " <<: *default"
220
+ say " database: #{db_name_for(adapter, app_name, env, "cable")}"
221
+ say " migrations_paths: db/cable_migrate"
222
+ end
223
+ say ""
224
+ end
225
+
226
+ say " Then run:"
227
+ say ""
228
+ say " bin/rails db:prepare"
229
+ say ""
230
+ say " #{"=" * 64}"
231
+ say ""
232
+ end
233
+
234
+ def detect_database_adapter
235
+ db_yml = File.join(destination_root, "config", "database.yml")
236
+ return :sqlite3 unless File.exist?(db_yml)
237
+
238
+ content = File.read(db_yml)
239
+ if content.match?(/adapter:\s+postgresql/)
240
+ :postgresql
241
+ elsif content.match?(/adapter:\s+mysql2?/)
242
+ :mysql
243
+ else
244
+ :sqlite3
245
+ end
246
+ end
247
+
248
+ def db_name_for(adapter, app_name, env, suffix)
249
+ name = suffix ? "#{app_name}_#{env}_#{suffix}" : "#{app_name}_#{env}"
250
+ case adapter
251
+ when :sqlite3
252
+ suffix ? "storage/#{env}_#{suffix}.sqlite3" : "storage/#{env}.sqlite3"
253
+ else
254
+ name
255
+ end
256
+ end
257
+
258
+ def configure_environment(env_name)
259
+ env_file = File.join(destination_root, "config", "environments", "#{env_name}.rb")
260
+ return unless File.exist?(env_file)
261
+
262
+ content = File.read(env_file)
263
+ configs = []
264
+
265
+ # Set Solid Queue as the Active Job backend
266
+ configs << " config.active_job.queue_adapter = :solid_queue" if @install_queue && !content.include?("queue_adapter")
267
+
268
+ # Set Solid Cache as the cache store
269
+ configs << " config.cache_store = :solid_cache_store" if @install_cache && !content.include?("solid_cache_store")
270
+
271
+ # Database connections for Solid Queue and Solid Cache
272
+ if @install_queue && !content.include?("solid_queue.connects_to")
273
+ configs << " config.solid_queue.connects_to = { database: { writing: :queue } }"
274
+ end
275
+ if @install_cache && !content.include?("solid_cache.connects_to")
276
+ configs << " config.solid_cache.connects_to = { database: { writing: :cache } }"
277
+ end
278
+ # Solid Cable configures its database via config/cable.yml, not via
279
+ # config.solid_cable.connects_to — so we skip it here.
280
+
281
+ return if configs.empty?
282
+
283
+ inject_into_file env_file, before: /^end\s*\z/ do
284
+ "\n # Solid component configuration (added by solid_ops:install)\n#{configs.join("\n")}\n"
285
+ end
286
+ say " Updated config/environments/#{env_name}.rb with Solid component configuration"
287
+ end
288
+
289
+ def configure_cable_yml
290
+ cable_yml = File.join(destination_root, "config", "cable.yml")
291
+ return unless File.exist?(cable_yml)
292
+
293
+ content = File.read(cable_yml)
294
+ modified = false
295
+
296
+ # Only replace the default adapters (async for development, test for test).
297
+ # If the user has configured a different adapter (redis, postgresql, etc.),
298
+ # leave it alone — they set it up intentionally.
299
+ safe_defaults = { "development" => "async", "test" => "test" }
300
+
301
+ safe_defaults.each do |env, default_adapter|
302
+ # Skip if already using solid_cable
303
+ next if content.match?(/^#{env}:\s*\n\s+adapter:\s+solid_cable/m)
304
+
305
+ # Only touch the config if it's still using the stock default adapter
306
+ default_pattern = /^#{env}:\s*\n\s+adapter:\s+#{default_adapter}\s*$/m
307
+ unless content.match?(default_pattern)
308
+ current_adapter = content.match(/^#{env}:\s*\n\s+adapter:\s+(\S+)/m)&.captures&.first
309
+ if current_adapter
310
+ say " ⚠ config/cable.yml '#{env}' uses adapter '#{current_adapter}' — skipping.", :yellow
311
+ say " To use SolidOps cable management, set adapter: solid_cable manually.", :yellow
312
+ end
313
+ next
314
+ end
315
+
316
+ replacement = "#{env}:\n adapter: solid_cable\n connects_to:\n database:\n writing: cable\n polling_interval: 0.1.seconds\n message_retention: 1.day"
317
+ content = content.sub(default_pattern, replacement)
318
+ modified = true
319
+ end
320
+
321
+ return unless modified
322
+
323
+ File.write(cable_yml, content)
324
+ say " Updated config/cable.yml to use solid_cable adapter in development and test"
325
+ end
326
+
327
+ def gem_in_bundle?(name)
328
+ # Check loaded specs first (authoritative if gem is actually installed).
329
+ # Falls back to reading Gemfile, which tells us it's *declared* but may
330
+ # not yet be installed — good enough for detection purposes.
331
+ return true if Gem.loaded_specs.key?(name)
332
+
333
+ gemfile = File.join(destination_root, "Gemfile")
334
+ File.exist?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']#{name}["']/)
335
+ rescue StandardError
336
+ false
337
+ end
338
+
339
+ def run_generator_command(generator_name)
340
+ # Run as a shell command so newly-added gems are loadable in a fresh process
341
+ result = run "bin/rails generate #{generator_name}"
342
+ return if result
343
+
344
+ say " ⚠ #{generator_name} may not have completed. Run manually: bin/rails generate #{generator_name}", :yellow
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidOpsEvents < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :solid_ops_events do |t|
6
+ t.string :event_type, null: false
7
+ t.string :name, null: false
8
+
9
+ t.string :correlation_id
10
+ t.string :request_id
11
+ t.string :tenant_id
12
+ t.string :actor_id
13
+
14
+ t.float :duration_ms
15
+ t.datetime :occurred_at, null: false
16
+
17
+ t.json :metadata, null: false, default: {}
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :solid_ops_events, :occurred_at
23
+ add_index :solid_ops_events, :event_type
24
+ add_index :solid_ops_events, :correlation_id
25
+ add_index :solid_ops_events, :request_id
26
+ add_index :solid_ops_events, :tenant_id
27
+ add_index :solid_ops_events, :actor_id
28
+ add_index :solid_ops_events, :name
29
+ add_index :solid_ops_events, %i[event_type occurred_at]
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ SolidOps.configure do |config|
4
+ # Enable or disable event capture entirely.
5
+ # config.enabled = true
6
+
7
+ # Maximum size (in bytes) for metadata payloads before truncation.
8
+ # config.max_payload_bytes = 10_000
9
+
10
+ # Sampling rate: 1.0 = capture everything, 0.1 = capture 10%, 0.0 = capture nothing.
11
+ # Useful for high-traffic apps where you don't need every single event.
12
+ # config.sample_rate = 1.0
13
+
14
+ # How long to keep events before automatic purge.
15
+ # Run `rake solid_ops:purge` via cron, or enqueue SolidOps::PurgeJob on a schedule.
16
+ # config.retention_period = 7.days
17
+
18
+ # Optional redactor proc — receives metadata hash, returns sanitised hash.
19
+ # Useful for stripping PII or secrets before storage.
20
+ # config.redactor = ->(meta) { meta.except(:password, :token) }
21
+
22
+ # Tenant resolver — called with the Rack request to extract tenant ID.
23
+ # config.tenant_resolver = ->(request) { request.subdomain }
24
+
25
+ # Actor resolver — called with the Rack request to extract actor/user ID.
26
+ # config.actor_resolver = ->(request) { request.env["warden"]&.user&.id }
27
+
28
+ # Authentication check — return true to allow access, false to deny.
29
+ # When nil (default), the dashboard is open to anyone who can reach the route.
30
+ # config.auth_check = ->(controller) { controller.current_user&.admin? }
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidOps
4
+ class Configuration
5
+ attr_accessor :enabled, :max_payload_bytes, :redactor,
6
+ :retention_period, :sample_rate,
7
+ :tenant_resolver, :actor_resolver,
8
+ :auth_check
9
+
10
+ def initialize
11
+ @enabled = true
12
+ @max_payload_bytes = 10_000
13
+ @redactor = nil
14
+ @retention_period = 7.days # Auto-purge events older than this
15
+ @sample_rate = 1.0 # 1.0 = capture everything, 0.1 = 10%
16
+ @tenant_resolver = nil # ->(request) { request.subdomain }
17
+ @actor_resolver = nil # ->(request) { request.env["warden"]&.user&.id }
18
+ @auth_check = nil # ->(controller) { controller.current_user&.admin? }
19
+ end
20
+
21
+ def sample?
22
+ return true if sample_rate >= 1.0
23
+ return false if sample_rate <= 0.0
24
+
25
+ rand < sample_rate
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SolidOps
6
+ module Context
7
+ class << self
8
+ def ensure_correlation_id!
9
+ SolidOps::Current.correlation_id ||= SecureRandom.uuid
10
+ end
11
+
12
+ def with(correlation_id: nil, request_id: nil, tenant_id: nil, actor_id: nil)
13
+ prev = {
14
+ correlation_id: SolidOps::Current.correlation_id,
15
+ request_id: SolidOps::Current.request_id,
16
+ tenant_id: SolidOps::Current.tenant_id,
17
+ actor_id: SolidOps::Current.actor_id
18
+ }
19
+
20
+ SolidOps::Current.correlation_id = correlation_id if correlation_id
21
+ SolidOps::Current.request_id = request_id if request_id
22
+ SolidOps::Current.tenant_id = tenant_id if tenant_id
23
+ SolidOps::Current.actor_id = actor_id if actor_id
24
+
25
+ yield
26
+ ensure
27
+ SolidOps::Current.correlation_id = prev[:correlation_id]
28
+ SolidOps::Current.request_id = prev[:request_id]
29
+ SolidOps::Current.tenant_id = prev[:tenant_id]
30
+ SolidOps::Current.actor_id = prev[:actor_id]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidOps
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :correlation_id
6
+ attribute :request_id
7
+ attribute :tenant_id
8
+ attribute :actor_id
9
+ end
10
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidOps
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace SolidOps
6
+
7
+ # Serve the engine's precompiled CSS via the asset pipeline
8
+ initializer "solid_ops.assets" do |app|
9
+ if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:paths)
10
+ app.config.assets.paths << root.join("app", "assets", "stylesheets")
11
+ app.config.assets.precompile += %w[solid_ops/application.css]
12
+ end
13
+ end
14
+
15
+ # Make engine migrations available to the host app automatically
16
+ initializer "solid_ops.migrations" do |app|
17
+ config.paths["db/migrate"].expanded.each do |expanded_path|
18
+ app.config.paths["db/migrate"] << expanded_path
19
+ ActiveRecord::Migrator.migrations_paths << expanded_path
20
+ end
21
+ end
22
+
23
+ # Load rake tasks
24
+ rake_tasks do
25
+ load File.expand_path("../tasks/solid_ops.rake", __dir__)
26
+ end
27
+
28
+ # Insert middleware early to assign correlation + request context
29
+ initializer "solid_ops.middleware" do |app|
30
+ app.middleware.insert_before(0, SolidOps::Middleware)
31
+ end
32
+
33
+ # Hook into ActiveJob once it's loaded
34
+ initializer "solid_ops.active_job" do
35
+ ActiveSupport.on_load(:active_job) do
36
+ include SolidOps::JobExtension
37
+ end
38
+ end
39
+
40
+ # Install instrumentation subscribers after Rails boots
41
+ initializer "solid_ops.subscribers" do
42
+ ActiveSupport.on_load(:after_initialize) do
43
+ SolidOps::Subscribers.install! if defined?(SolidOps::Subscribers)
44
+ end
45
+ end
46
+
47
+ # Warn loudly if no auth_check is configured (dashboard is wide-open)
48
+ initializer "solid_ops.auth_warning" do
49
+ config.after_initialize do
50
+ unless SolidOps.configuration.auth_check.respond_to?(:call)
51
+ Rails.logger.warn(
52
+ "[SolidOps] WARNING: No auth_check configured — the dashboard is publicly accessible. " \
53
+ "Set SolidOps.configure { |c| c.auth_check = ->(controller) { controller.current_user&.admin? } } " \
54
+ "in an initializer to restrict access."
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidOps
4
+ module JobExtension
5
+ extend ActiveSupport::Concern
6
+
7
+ SERIALIZATION_KEY = "solid_ops_meta"
8
+
9
+ included do
10
+ around_perform do |_job, block|
11
+ meta = @solid_ops_meta
12
+
13
+ if meta
14
+ SolidOps::Context.with(
15
+ correlation_id: meta["correlation_id"],
16
+ request_id: meta["request_id"],
17
+ tenant_id: meta["tenant_id"],
18
+ actor_id: meta["actor_id"]
19
+ ) do
20
+ block.call
21
+ end
22
+ else
23
+ block.call
24
+ end
25
+ ensure
26
+ SolidOps::Current.reset
27
+ end
28
+ end
29
+
30
+ # Inject current context into the serialized job payload (called during enqueue)
31
+ def serialize
32
+ SolidOps::Context.ensure_correlation_id!
33
+
34
+ super.merge(
35
+ SERIALIZATION_KEY => {
36
+ "correlation_id" => SolidOps::Current.correlation_id,
37
+ "request_id" => SolidOps::Current.request_id,
38
+ "tenant_id" => SolidOps::Current.tenant_id,
39
+ "actor_id" => SolidOps::Current.actor_id
40
+ }
41
+ )
42
+ end
43
+
44
+ # Restore context from the serialized job payload (called before perform)
45
+ def deserialize(job_data)
46
+ super
47
+ @solid_ops_meta = job_data[SERIALIZATION_KEY] if job_data.key?(SERIALIZATION_KEY)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SolidOps
6
+ class Middleware
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ SolidOps::Current.reset
13
+
14
+ SolidOps::Current.correlation_id =
15
+ env["HTTP_X_CORRELATION_ID"] || SecureRandom.uuid
16
+ SolidOps::Current.request_id =
17
+ env["HTTP_X_REQUEST_ID"] || env["action_dispatch.request_id"] || SecureRandom.uuid
18
+
19
+ request = ActionDispatch::Request.new(env) if resolve_tenant? || resolve_actor?
20
+
21
+ if resolve_tenant?
22
+ SolidOps::Current.tenant_id = begin
23
+ SolidOps.configuration.tenant_resolver.call(request).to_s
24
+ rescue StandardError
25
+ nil
26
+ end
27
+ end
28
+
29
+ if resolve_actor?
30
+ SolidOps::Current.actor_id = begin
31
+ SolidOps.configuration.actor_resolver.call(request).to_s
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ end
36
+
37
+ @app.call(env)
38
+ ensure
39
+ SolidOps::Current.reset
40
+ end
41
+
42
+ private
43
+
44
+ def resolve_tenant?
45
+ SolidOps.configuration.tenant_resolver.respond_to?(:call)
46
+ end
47
+
48
+ def resolve_actor?
49
+ SolidOps.configuration.actor_resolver.respond_to?(:call)
50
+ end
51
+ end
52
+ end