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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +308 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/solid_ops/application.css +1 -0
- data/app/controllers/solid_ops/application_controller.rb +127 -0
- data/app/controllers/solid_ops/cache_entries_controller.rb +38 -0
- data/app/controllers/solid_ops/channels_controller.rb +30 -0
- data/app/controllers/solid_ops/dashboard_controller.rb +80 -0
- data/app/controllers/solid_ops/events_controller.rb +37 -0
- data/app/controllers/solid_ops/jobs_controller.rb +64 -0
- data/app/controllers/solid_ops/processes_controller.rb +11 -0
- data/app/controllers/solid_ops/queues_controller.rb +75 -0
- data/app/controllers/solid_ops/recurring_tasks_controller.rb +11 -0
- data/app/helpers/solid_ops/application_helper.rb +112 -0
- data/app/jobs/solid_ops/purge_job.rb +16 -0
- data/app/models/solid_ops/event.rb +34 -0
- data/app/views/layouts/solid_ops/application.html.erb +118 -0
- data/app/views/solid_ops/cache_entries/index.html.erb +86 -0
- data/app/views/solid_ops/cache_entries/show.html.erb +153 -0
- data/app/views/solid_ops/channels/index.html.erb +81 -0
- data/app/views/solid_ops/channels/show.html.erb +66 -0
- data/app/views/solid_ops/dashboard/cable.html.erb +98 -0
- data/app/views/solid_ops/dashboard/cache.html.erb +104 -0
- data/app/views/solid_ops/dashboard/index.html.erb +169 -0
- data/app/views/solid_ops/dashboard/jobs.html.erb +108 -0
- data/app/views/solid_ops/events/index.html.erb +98 -0
- data/app/views/solid_ops/events/show.html.erb +108 -0
- data/app/views/solid_ops/jobs/failed.html.erb +89 -0
- data/app/views/solid_ops/jobs/running.html.erb +134 -0
- data/app/views/solid_ops/jobs/show.html.erb +116 -0
- data/app/views/solid_ops/processes/index.html.erb +69 -0
- data/app/views/solid_ops/queues/index.html.erb +182 -0
- data/app/views/solid_ops/queues/show.html.erb +121 -0
- data/app/views/solid_ops/recurring_tasks/index.html.erb +64 -0
- data/app/views/solid_ops/shared/_nav.html.erb +50 -0
- data/app/views/solid_ops/shared/_pagination.html.erb +31 -0
- data/app/views/solid_ops/shared/_time_window.html.erb +10 -0
- data/app/views/solid_ops/shared/component_unavailable.html.erb +63 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20260224000100_create_solid_ops_events.rb +31 -0
- data/lib/generators/solid_ops/install/install_generator.rb +348 -0
- data/lib/generators/solid_ops/install/templates/create_solid_ops_events.rb +31 -0
- data/lib/generators/solid_ops/install/templates/solid_ops_initializer.rb +31 -0
- data/lib/solid_ops/configuration.rb +28 -0
- data/lib/solid_ops/context.rb +34 -0
- data/lib/solid_ops/current.rb +10 -0
- data/lib/solid_ops/engine.rb +60 -0
- data/lib/solid_ops/job_extension.rb +50 -0
- data/lib/solid_ops/middleware.rb +52 -0
- data/lib/solid_ops/subscribers.rb +215 -0
- data/lib/solid_ops/version.rb +5 -0
- data/lib/solid_ops.rb +25 -0
- data/lib/tasks/solid_ops.rake +32 -0
- data/log/test.log +2 -0
- data/sig/solid_ops.rbs +4 -0
- 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,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
|