Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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_signal_dumper")

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
Loading