Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/initializers/sidekiq.rb
Original file line number Diff line number Diff line change
@@ -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|
Expand All @@ -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
81 changes: 81 additions & 0 deletions config/initializers/signal_handlers.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions config/puma.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
113 changes: 113 additions & 0 deletions lib/settings_log_dump.rb
Original file line number Diff line number Diff line change
@@ -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