diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index b80a8fddf23..ec4900fcdbf 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,4 +1,5 @@ require "sidekiq/web" +require Rails.root.join("lib/settings_log_dump") if Rails.env.production? Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| @@ -14,3 +15,7 @@ # 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs config.reschedule_grace_period = 600 end + +Sidekiq.configure_server do |_config| + SettingsLogDump.install_usr1_trap(process_label: "Sidekiq worker") +end diff --git a/config/initializers/signal_handlers.rb b/config/initializers/signal_handlers.rb new file mode 100644 index 00000000000..681c1334e16 --- /dev/null +++ b/config/initializers/signal_handlers.rb @@ -0,0 +1,81 @@ +# Signal handlers for worker processes (Sidekiq) +# +# SIGUSR1: Dump current settings array values (masked) to Rails.log +# +# Note: For web processes (Puma), the signal handler is configured in config/puma.rb +# using on_worker_boot to avoid conflicts with Puma's master process signal handling +Rails.application.config.after_initialize do + # Only set up signal handler for Sidekiq worker processes + # Puma workers get their handler set up in config/puma.rb + if defined?(Sidekiq) && Sidekiq.server? + Signal.trap("USR1") do + Thread.new do + begin + Rails.logger.info "=" * 80 + Rails.logger.info "SIGUSR1 received in Sidekiq worker - Dumping application settings" + Rails.logger.info "Process: #{$PROGRAM_NAME} (PID: #{Process.pid})" + Rails.logger.info "=" * 80 + + # Get all declared fields from Setting model + declared_fields = Setting.singleton_class.instance_methods(false) + .map(&:to_s) + .reject { |m| m.end_with?("=") || m.start_with?("raw_") || %w[[] []= key? delete dynamic_keys validate_onboarding_state! validate_openai_config!].include?(m) } + .sort + + # Get all dynamic fields + dynamic_fields = Setting.dynamic_keys.sort + + # Helper to mask sensitive values + mask_value = lambda do |field_name, value| + return nil if value.nil? + + sensitive = [ /key/i, /token/i, /secret/i, /password/i, /api/i, /credentials?/i, /auth/i ] + is_sensitive = sensitive.any? { |pattern| field_name.match?(pattern) } + + if is_sensitive && value.present? + case value + when String + value.length <= 4 ? "[MASKED]" : "#{value[0..3]}#{'*' * [ value.length - 4, 8 ].min}" + when TrueClass, FalseClass + value + else + "[MASKED]" + end + else + value + end + end + + # Dump declared fields + unless declared_fields.empty? + Rails.logger.info "\n--- Declared Settings ---" + declared_fields.each do |field| + value = Setting.public_send(field) + masked_value = mask_value.call(field, value) + Rails.logger.info " #{field}: #{masked_value.inspect}" + end + end + + # Dump dynamic fields + unless dynamic_fields.empty? + Rails.logger.info "\n--- Dynamic Settings ---" + dynamic_fields.each do |field| + value = Setting[field] + masked_value = mask_value.call(field, value) + Rails.logger.info " #{field}: #{masked_value.inspect}" + end + end + + Rails.logger.info "\n" + "=" * 80 + Rails.logger.info "Settings dump complete (#{declared_fields.size} declared, #{dynamic_fields.size} dynamic)" + Rails.logger.info "=" * 80 + rescue => e + Rails.logger.error "Error dumping settings: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + end + end + end + + Rails.logger.info "Signal handlers initialized for Sidekiq worker (SIGUSR1 -> dump settings)" + end +end diff --git a/config/puma.rb b/config/puma.rb index 47a2362e275..b2bd19f0ca6 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -2,6 +2,8 @@ # are invoked here are part of Puma's configuration DSL. For more information # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +require_relative "../lib/settings_log_dump" + rails_env = ENV.fetch("RAILS_ENV", "development") # Puma starts a configurable number of processes (workers) and each process @@ -54,3 +56,10 @@ # isn't killed by Puma when suspended by a debugger. worker_timeout 3600 end + +# Set up SIGUSR1 handler in worker processes to dump settings +# This runs after Puma sets up its worker, so it only affects individual workers +# The master process can still use SIGUSR1 for phased restarts +on_worker_boot do + SettingsLogDump.install_usr1_trap(process_label: "Puma worker") +end diff --git a/lib/settings_log_dump.rb b/lib/settings_log_dump.rb new file mode 100644 index 00000000000..e16a9cd5694 --- /dev/null +++ b/lib/settings_log_dump.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "timeout" + +module SettingsSignalDumper + SENSITIVE_PATTERNS = [ + /key/i, + /token/i, + /secret/i, + /password/i, + /api/i, + /credentials?/i, + /auth/i + ].freeze + + SETTINGS_QUERY_TIMEOUT = 5 + + def self.install_usr1_trap(process_label:, logger: nil) + logger ||= Rails.logger + + Signal.trap("USR1") do + Thread.new do + dump_settings(process_label: process_label, logger: logger) + end + end + + logger.info "Signal handler initialized for #{process_label} (SIGUSR1 -> dump settings)" + end + + def self.dump_settings(process_label:, logger: nil) + logger ||= Rails.logger + + declared_fields = declared_setting_fields + dynamic_fields = dynamic_setting_fields(logger: logger) + + logger.info "=" * 80 + logger.info "SIGUSR1 received in #{process_label} - Dumping application settings" + logger.info "Process: #{$PROGRAM_NAME} (PID: #{Process.pid})" + logger.info "=" * 80 + + unless declared_fields.empty? + logger.info "\n--- Declared Settings ---" + declared_fields.each do |field| + value = Setting.public_send(field) + masked_value = mask_value(field, value) + logger.info " #{field}: #{masked_value.inspect}" + end + end + + unless dynamic_fields.empty? + logger.info "\n--- Dynamic Settings ---" + dynamic_fields.each do |field| + value = Setting[field] + masked_value = mask_value(field, value) + logger.info " #{field}: #{masked_value.inspect}" + end + end + + logger.info "\n" + "=" * 80 + logger.info "Settings dump complete (#{declared_fields.size} declared, #{dynamic_fields.size} dynamic)" + logger.info "=" * 80 + rescue => e + logger.error "Error dumping settings: #{e.class} - #{e.message}" + logger.error e.backtrace.join("\n") + end + + def self.declared_setting_fields + Setting.singleton_class.instance_methods(false) + .map(&:to_s) + .reject do |method_name| + method_name.end_with?("=") || + method_name.start_with?("raw_") || + %w[[] []= key? delete dynamic_keys validate_onboarding_state! validate_openai_config!].include?(method_name) + end + .sort + end + + def self.dynamic_setting_fields(logger: nil) + logger ||= Rails.logger + + Timeout.timeout(SETTINGS_QUERY_TIMEOUT) do + Setting.dynamic_keys.sort + end + rescue Timeout::Error + logger.error "Timed out fetching dynamic settings after #{SETTINGS_QUERY_TIMEOUT} seconds" + [] + end + + def self.mask_value(field_name, value) + return nil if value.nil? + + is_sensitive = SENSITIVE_PATTERNS.any? { |pattern| field_name.match?(pattern) } + + if is_sensitive + case value + when String + if value.empty? + "[EMPTY]" + elsif value.length <= 4 + "[MASKED]" + else + "#{value[0..3]}#{'*' * [ value.length - 4, 8 ].min}" + end + when TrueClass, FalseClass + value + else + "[MASKED]" + end + else + value + end + end +end