From 9bac53f7ad1f053b95c4dc89fc152cba597e0f17 Mon Sep 17 00:00:00 2001 From: Mridankan Mandal Date: Mon, 17 Aug 2026 01:12:07 +0000 Subject: [PATCH 1/2] [Logger] Add structured log context filtering Signed-off-by: Mridankan Mandal --- CHANGELOG.md | 1 + lib/rage/configuration.rb | 35 +++++++++++++++ lib/rage/logger/logger.rb | 69 ++++++++++++++++++++++++++++-- spec/configuration_spec.rb | 64 ++++++++++++++++++++++++++++ spec/logger_spec.rb | 87 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51363886..20ab2a66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Added - [OpenAPI] Add `openapi:validate` Rake task for OpenAPI tags validation (#163). +- [Logger] Add `config.filter_parameters` for filtering structured log context. ### Fixed diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb index c4d3e17e..3b9afcc6 100644 --- a/lib/rage/configuration.rb +++ b/lib/rage/configuration.rb @@ -256,6 +256,25 @@ def log_context def log_tags @log_tags ||= LogTags.new end + + # Allows configuring case-insensitive partial matches for filtering structured log context keys. + # Matching keys will have their values replaced with `"[FILTERED]"` before the log entry is written. + # @return [Rage::Configuration::FilterParameters] + # + # @example Filter common secrets from structured logs + # Rage.configure do + # config.filter_parameters = [:password, :token, :secret] + # end + def filter_parameters + @filter_parameters ||= FilterParameters.new + end + + # Replace the current list of filtered log parameter keys. + # @param parameters [String, Symbol, Array, nil] one or more parameter filters + def filter_parameters=(parameters) + @filter_parameters = FilterParameters.new + @filter_parameters << parameters if parameters + end # @!endgroup # @!group Telemetry Configuration @@ -411,6 +430,18 @@ def validate_input!(obj) end end + class FilterParameters < LogContext + private + + def validate_input!(obj) + if obj.is_a?(Array) + obj.each { |item| validate_input!(item) } + elsif !obj.is_a?(String) && !obj.is_a?(Symbol) + raise ArgumentError, "filter parameters have to be strings, symbols, or arrays of strings and symbols" + end + end + end + class ErrorReporters # @private def initialize @@ -1278,6 +1309,10 @@ def __finalize(before_boot = false) @logger.dynamic_tags = Rage.__log_processor.dynamic_tags end + if @filter_parameters + @logger.filter_parameters = @filter_parameters.objects + end + if before_boot && @blocking_operation_pool&.enabled if defined?(Rage::FiberScheduler::BlockingOperationWait) Iodine.on_state(:pre_start) { puts "INFO: Using blocking operation pool" } diff --git a/lib/rage/logger/logger.rb b/lib/rage/logger/logger.rb index 5f02e3b2..77502864 100644 --- a/lib/rage/logger/logger.rb +++ b/lib/rage/logger/logger.rb @@ -60,11 +60,13 @@ class Rage::Logger fatal: Logger::FATAL, unknown: Logger::UNKNOWN } + FILTERED = "[FILTERED]".freeze + private_constant :FILTERED attr_reader :level, :formatter # @private - attr_reader :dynamic_tags, :dynamic_context + attr_reader :dynamic_tags, :dynamic_context, :filter_parameters # @private attr_reader :external_logger @@ -132,6 +134,29 @@ def dynamic_context=(dynamic_context) rebuild! end + # @private + def filter_parameters=(filter_parameters) + if filter_parameters&.any? + normalized_filters = filter_parameters.filter_map do |filter| + filter = filter.to_s.downcase + filter unless filter.empty? + end.uniq + + if normalized_filters.any? + @filter_parameters = normalized_filters.freeze + @filter_parameters_matcher = Regexp.union(@filter_parameters) + else + @filter_parameters = nil + @filter_parameters_matcher = nil + end + else + @filter_parameters = nil + @filter_parameters_matcher = nil + end + + rebuild! + end + # Add custom keys to an entry. # # @param context [Hash] a hash of custom keys @@ -196,7 +221,7 @@ def #{level_name}(msg = nil, context = nil) RUBY elsif @external_logger.is_a?(External::Static) # an object that implements Ruby's Logger interface is used as a logger - write_call = <<~RUBY + write_call = build_filtered_context_call(<<~RUBY) @external_logger.wrapped.#{level_name}( #{build_formatter_call(level_name, level_val)} ) @@ -233,7 +258,7 @@ def #{level_name}(msg = nil, context = nil) request_info: "Fiber[:__rage_logger_final].freeze" }) - write_call = <<~RUBY + write_call = build_filtered_context_call(<<~RUBY) @external_logger.wrapped.call(#{parameters}) RUBY @@ -253,7 +278,7 @@ def #{level_name}(msg = nil, context = nil) end RUBY else - write_call = <<~RUBY + write_call = build_filtered_context_call(<<~RUBY) @logdev.write( #{build_formatter_call(level_name, level_val)} ) @@ -296,6 +321,42 @@ def build_formatter_call(level_name, level_val) end end + def build_filtered_context_call(write_call) + return write_call unless @filter_parameters + + <<~RUBY + with_filtered_context do + #{write_call} + end + RUBY + end + + def with_filtered_context + context = Fiber[:__rage_logger_context] + return yield if context.nil? || context.empty? + + Fiber[:__rage_logger_context] = filter_value(context) + yield + ensure + Fiber[:__rage_logger_context] = context + end + + def filter_value(value) + if value.is_a?(Hash) + value.each_with_object({}) do |(key, nested_value), filtered| + filtered[key] = if @filter_parameters_matcher.match?(key.to_s.downcase) + FILTERED + else + filter_value(nested_value) + end + end + elsif value.is_a?(Array) + value.map { |item| filter_value(item) } + else + value + end + end + def with_dynamic_tags_and_context do_calls, end_calls = [], [] diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 42354893..4325fd0c 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -341,6 +341,70 @@ def call end end + describe "#filter_parameters" do + subject { described_class.new.filter_parameters } + + describe "#push" do + it "returns empty array" do + expect(subject.objects).to eq([]) + end + + it "allows to add strings and symbols" do + subject << :password << "token" + expect(subject.objects).to eq([:password, "token"]) + end + + it "allows to add arrays" do + subject << [:password, "token"] + expect(subject.objects).to eq([:password, "token"]) + end + + it "removes duplicates" do + subject << :password << [:password, "token"] + expect(subject.objects).to eq([:password, "token"]) + end + + it "raises an error for invalid objects" do + expect { + subject << proc {} + }.to raise_error(ArgumentError) + end + end + + describe "#filter_parameters=" do + let(:config) { described_class.new } + + it "replaces current filters" do + config.filter_parameters << :password + config.filter_parameters = [:token] + + expect(config.filter_parameters.objects).to eq([:token]) + end + + it "accepts nil" do + config.filter_parameters = nil + expect(config.filter_parameters.objects).to eq([]) + end + end + + describe "#__finalize" do + let(:config) { described_class.new } + let(:logger) { Rage::Logger.new(nil) } + + before do + config.logger = logger + end + + it "passes filters to logger" do + config.filter_parameters = [:password, "token"] + + config.__finalize + + expect(logger.filter_parameters).to eq(["password", "token"]) + end + end + end + describe "#logger" do subject { described_class.new } diff --git a/spec/logger_spec.rb b/spec/logger_spec.rb index 3106cb5c..d2e62c25 100644 --- a/spec/logger_spec.rb +++ b/spec/logger_spec.rb @@ -228,6 +228,35 @@ end end + context "with filtered parameters" do + before do + subject.filter_parameters = [:password, :token] + end + + it "filters partial key matches case-insensitively" do + subject.info "passed", password_confirmation: "secret", "AUTH_TOKEN" => "abc", user_id: 12345 + + expect(io.tap(&:rewind).read).to eq("[my_test_tag] timestamp=very_accurate_timestamp pid=777 level=info password_confirmation=[FILTERED] AUTH_TOKEN=[FILTERED] user_id=12345 message=passed\n") + end + + it "filters request log context" do + Fiber[:__rage_logger_final] = { + env: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/test_path" }, + params: { controller: "rspec", action: "index" }, + response: [200, {}, []], + duration: 1.45 + } + + stub_const("RspecController", double(name: "RspecController")) + + subject.with_context(password: "secret") do + subject.info nil + end + + expect(io.tap(&:rewind).read).to eq("[my_test_tag] timestamp=very_accurate_timestamp pid=777 level=info method=GET path=/test_path controller=RspecController action=index password=[FILTERED] status=200 duration=1.45\n") + end + end + context "outside the request/response cycle" do before do Fiber[:__rage_logger_tags] = nil @@ -646,6 +675,64 @@ end end + context "with filter parameters" do + before do + subject.filter_parameters = [:password, :token] + end + + it "filters nested context recursively without mutating original values" do + inline_context = { + user: { email: "user@example.com", password_confirmation: "secret" }, + sessions: [{ "AUTH_TOKEN" => "abc123" }, { name: "browser" }] + } + + subject.dynamic_context = proc { { api_token: "global-secret" } } + + expect(external_logger).to receive(:call).with( + severity: :info, + tags: ["my_test_tag"], + context: { + api_token: "[FILTERED]", + user: { email: "user@example.com", password_confirmation: "[FILTERED]" }, + sessions: [{ "AUTH_TOKEN" => "[FILTERED]" }, { name: "browser" }] + }, + message: "test", + request_info: nil + ) + + subject.info "test", inline_context + + expect(inline_context).to eq({ + user: { email: "user@example.com", password_confirmation: "secret" }, + sessions: [{ "AUTH_TOKEN" => "abc123" }, { name: "browser" }] + }) + end + + it "leaves request_info untouched" do + Fiber[:__rage_logger_final] = { + env: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/test_path" }, + params: { password: "secret" }, + response: [200, {}, []], + duration: 1.45 + } + + expect(external_logger).to receive(:call).with( + severity: :info, + tags: ["my_test_tag"], + context: { api_token: "[FILTERED]" }, + message: nil, + request_info: { + env: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/test_path" }, + params: { password: "secret" }, + response: [200, {}, []], + duration: 1.45 + } + ) + + subject.info nil, api_token: "secret" + end + end + context "with external logger as an instance" do let(:external_logger_class) do Class.new do From 4ec0339a4caff3ae3fec0d932ad8bb0779e81d65 Mon Sep 17 00:00:00 2001 From: Mridankan Mandal Date: Mon, 17 Aug 2026 01:35:21 +0000 Subject: [PATCH 2/2] Fix RuboCop redundant freeze Signed-off-by: Mridankan Mandal --- lib/rage/logger/logger.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rage/logger/logger.rb b/lib/rage/logger/logger.rb index 77502864..134c0cdb 100644 --- a/lib/rage/logger/logger.rb +++ b/lib/rage/logger/logger.rb @@ -60,7 +60,7 @@ class Rage::Logger fatal: Logger::FATAL, unknown: Logger::UNKNOWN } - FILTERED = "[FILTERED]".freeze + FILTERED = "[FILTERED]" private_constant :FILTERED attr_reader :level, :formatter