From 486ebb01e63a3f00d7018f411f40cd798c8b91a2 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 22 Jul 2026 16:22:01 +0200 Subject: [PATCH 1/2] feat: add structured logging with secret redaction --- CHANGELOG.md | 2 + README.md | 23 +++++ lib/getstream_ruby.rb | 1 + lib/getstream_ruby/client.rb | 47 ++++++--- lib/getstream_ruby/configuration.rb | 53 +++++------ lib/getstream_ruby/log_redaction.rb | 58 ++++++++++++ lib/getstream_ruby/request_logging.rb | 79 ++++++++++++++++ spec/connection_pooling_spec.rb | 31 +++--- spec/logging_spec.rb | 131 ++++++++++++++++++++++++++ 9 files changed, 362 insertions(+), 63 deletions(-) create mode 100644 lib/getstream_ruby/log_redaction.rb create mode 100644 lib/getstream_ruby/request_logging.rb create mode 100644 spec/logging_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f8382ea..6765301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - `Client#post` (and the multipart upload path) now deserialize the full canonical `APIError` envelope (`code`, `message`, `exception_fields`, `more_info`, `StatusCode`, `details`, `unrecoverable`, `duration`) and populate the new `ApiError` attributes. - Regenerated from the latest chat OpenAPI spec. New endpoints: `Moderation#analyze`, `Moderation#bulk_action_appeals`, `Moderation#get_setup_session`, `Moderation#upsert_setup_session`; `Feeds#get_or_create_follow`, `Feeds#get_or_create_unfollow`, `Feeds#get_user_interests`; `Chat#create_segment`, `Chat#update_segment`, `Chat#add_segment_targets`; `Common#cancel_import_v2_task`; `Video#report_client_call_event`, together with the request and response models backing them. - New webhook event types `moderation.image_analysis.complete` and `moderation.text_analysis.complete`, parsed into `ModerationImageAnalysisCompleteEvent` and `ModerationTextAnalysisCompleteEvent`. +- Structured logging (CHA-2957): the existing `logger:` kwarg on `GetStreamRuby::Client.new`/`Configuration` now drives 4 events: `client.initialized` (INFO, once at construction), `http.request.sent` and `http.response.received` (DEBUG, the latter fires for every response including 4xx/5xx), and `http.request.failed` (ERROR, transport failures only, no HTTP response received). Query values and top-level JSON body keys for `api_key`/`api_secret`/`token`/`password` are always redacted to ``; no headers are ever logged. New `log_bodies:` option (default `false`) opts into logging request/response bodies (still key-redacted) and emits one WARN at construction. New `GetStreamRuby::LogRedaction` module (`redact_query`, `redact_json_body`, `redact_message`). ### Changed @@ -21,6 +22,7 @@ - `Models::FlagResponse` now represents the full flag record (`created_at`, `updated_at`, `target_message`, `target_user`, `user`, `reason`, `details`, `custom`, and related fields). The moderation flag-action acknowledgement, which carries `item_id` and `duration`, moved to the new `Models::FlagItemResponse`; `Moderation#flag` now returns `FlagItemResponse`. The wire response of `/api/v2/moderation/flag` is unchanged, only the model name changed, so code reading `item_id`/`duration` off the parsed response is unaffected. Code referencing the `FlagResponse` model class for those two fields should switch to `FlagItemResponse`. - `ChannelInput#config_overrides` and `ChannelDataUpdate#config_overrides` are now typed as `ChannelConfigOverrides` (the override-specific field set) instead of the full `ChannelConfig`. - `LLMRule#description` and `TargetResolution#bitrate` are now optional. +- The former "connection pool" INFO line (CHA-2956) is now `client.initialized` and carries the structured-logging field set above (adapter identity is no longer part of it; a silent adapter fallback still always WARNs, see `warn_pool_fallback`). Its old `$stdout` fallback is removed: with no `logger:` configured, the SDK now produces zero log output. ### Webhook helpers diff --git a/README.md b/README.md index a7aeb8a..176f263 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,29 @@ rescue GetStreamRuby::APIError => e end ``` +## Logging + +Pass a stdlib `Logger` via `logger:` to get structured, single-line log events. With no `logger:`, the SDK produces zero output; the SDK never sets the logger's level either, that's the caller's call. + +```ruby +require 'logger' + +client = GetStreamRuby.manual( + api_key: "your_api_key", + api_secret: "your_api_secret", + logger: Logger.new($stdout) +) +``` + +Four events are emitted: + +- `client.initialized` (INFO, once at construction): SDK version and the effective client config (pool size, timeouts, gzip, whether a custom `http_client`/`log_bodies` is set). +- `http.request.sent` (DEBUG, before each request). +- `http.response.received` (DEBUG, after any response including 4xx/5xx — those are just data, not a failure). +- `http.request.failed` (ERROR, transport failure only: no HTTP response was received at all, e.g. connection reset, timeout, DNS failure, TLS handshake failure). + +Query values for `api_key`/`api_secret`/`token` are always redacted to ``, and the same keys are redacted (shallowly, top-level only) in JSON body logging. No headers are ever logged. Request/response bodies are not logged by default; pass `log_bodies: true` to opt in (values for the keys above are still redacted). Enabling it emits one WARN at construction as a reminder that your logs will now contain body content. + ## Development ### Quick Start diff --git a/lib/getstream_ruby.rb b/lib/getstream_ruby.rb index 29e4f25..a17fa35 100644 --- a/lib/getstream_ruby.rb +++ b/lib/getstream_ruby.rb @@ -2,6 +2,7 @@ # Only load dotenv for .env method, not for system env method require 'getstream_ruby/version' +require 'getstream_ruby/log_redaction' require 'getstream_ruby/client' require 'getstream_ruby/configuration' require 'getstream_ruby/errors' diff --git a/lib/getstream_ruby/client.rb b/lib/getstream_ruby/client.rb index 26dd349..88d7894 100644 --- a/lib/getstream_ruby/client.rb +++ b/lib/getstream_ruby/client.rb @@ -20,11 +20,15 @@ require_relative 'generated/models/api_error' require_relative 'stream_response' require_relative 'error_mapping' +require_relative 'log_redaction' +require_relative 'request_logging' module GetStreamRuby class Client + include RequestLogging + # Backdate the JWT `iat` claim by this many seconds. # # JWT timestamps are whole-second (RFC 7519 NumericDate), so `Time.now.to_i` @@ -38,20 +42,23 @@ class Client attr_reader :configuration def initialize(config = nil, api_key: nil, api_secret: nil, **options) - @configuration = config || GetStreamRuby.configuration - - # Create new configuration with overrides if any parameters provided - if api_key || api_secret || !options.empty? - @configuration = Configuration.with_overrides( - api_key: api_key, - api_secret: api_secret, - **options, - ) - end + # Overrides win over an explicit config, matching prior behavior. Only + # fall back to GetStreamRuby.configuration when neither is given, so + # that path (currently unimplemented) isn't evaluated needlessly. + @configuration = if api_key || api_secret || !options.empty? + Configuration.with_overrides( + api_key: api_key, + api_secret: api_secret, + **options, + ) + else + config || GetStreamRuby.configuration + end @configuration.validate! @connection = build_connection @configuration.log_pool_config_to(@configuration.logger) + warn_log_bodies_enabled end def feed_resource @@ -209,6 +216,10 @@ def request(method, path, data = {}, request_timeout: nil) # Check if this is a file upload request that needs multipart return make_multipart_request(method, path, query_params, data) if multipart_request?(data) + started = monotonic_now + body_json = data.to_json + log_request_sent(method, path, query_params, body_json) + response = @connection.send(method) do |req| req.url path, query_params @@ -216,13 +227,15 @@ def request(method, path, data = {}, request_timeout: nil) req.headers['Content-Type'] = 'application/json' req.headers['stream-auth-type'] = 'jwt' req.headers['X-Stream-Client'] = user_agent - req.body = data.to_json + req.body = body_json req.options.timeout = request_timeout if request_timeout end + log_response_received(response, started) handle_response(response) rescue Faraday::Error => e + log_request_failed(method, path, e, started) raise TransportError.new("Request failed: #{e.message}", error_type: ErrorMapping.classify_faraday_error(e)) end @@ -240,7 +253,10 @@ def build_connection interval_randomness: 0.5, backoff_factor: 2, } - conn.response :json, content_type: /\bjson$/ + # preserve_raw: true keeps the wire string in env[:raw_body] alongside + # the parsed body, so response logging can report size/content + # without re-serializing an already-parsed Hash. + conn.response :json, content_type: /\bjson$/, preserve_raw: true # :gzip must come after :json (Faraday runs response middleware in reverse). conn.request :gzip configure_adapter(conn) @@ -277,8 +293,6 @@ def configure_adapter(connection) # A fallback silently disables pooling, so always WARN (never swallow). @configuration.warn_pool_fallback(Faraday.default_adapter, e) connection.adapter Faraday.default_adapter - # Record the adapter actually built so the INFO log reports it accurately. - @configuration.effective_adapter = Faraday.default_adapter.to_s end def generate_auth_header @@ -346,6 +360,9 @@ def make_multipart_request(method, path, query_params, data) payload[:upload_sizes] = upload_sizes_json end + started = monotonic_now + log_request_sent(method, path, query_params) + response = @connection.send(method) do |req| req.url path, query_params @@ -356,8 +373,10 @@ def make_multipart_request(method, path, query_params, data) end + log_response_received(response, started) handle_response(response) rescue Faraday::Error => e + log_request_failed(method, path, e, started) raise TransportError.new("Request failed: #{e.message}", error_type: ErrorMapping.classify_faraday_error(e)) end diff --git a/lib/getstream_ruby/configuration.rb b/lib/getstream_ruby/configuration.rb index 52f8816..425bb63 100644 --- a/lib/getstream_ruby/configuration.rb +++ b/lib/getstream_ruby/configuration.rb @@ -8,7 +8,7 @@ class Configuration attr_accessor :api_key, :api_secret, :base_url, :timeout, :logger, :faraday_adapter, :faraday_adapter_options, :connection_keep_alive, :max_conns_per_host, :idle_timeout, :connect_timeout, - :request_timeout, :http_client, :effective_adapter + :request_timeout, :http_client, :log_bodies def initialize(api_key: nil, api_secret: nil, use_env: true, **options) http_options = options[:http_options] || {} @@ -22,6 +22,7 @@ def initialize(api_key: nil, api_secret: nil, use_env: true, **options) @timeout = @request_timeout @http_client = options[:http_client] @logger = options[:logger] + @log_bodies = options[:log_bodies] || false end def valid? @@ -48,41 +49,35 @@ def dup faraday_adapter_options: @faraday_adapter_options.dup, connection_keep_alive: @connection_keep_alive, logger: @logger, + log_bodies: @log_bodies, ) end - # Emit a single INFO line listing the 5 effective pool knobs plus the active escape hatch (CHA-2956). - # If no logger is supplied, a default $stdout INFO logger is used. - # The faraday_adapter label reflects the adapter actually built - # (effective_adapter, set by Client#configure_adapter) so a silent fallback - # to the default adapter is never misreported as the requested adapter. + # Emit the `client.initialized` INFO event once at construction (structured + # logging spec §6.1). A no-op when no logger is configured: unlike + # `warn_pool_fallback` below, a quiet start-up here is expected, not a + # silently-swallowed problem, so there is no $stdout fallback. def log_pool_config_to(logger) - logger ||= Logger.new($stdout).tap { |l| l.level = Logger::INFO } - flag = @http_client ? 'user_http_client=true' : 'user_http_client=false' - adapter_label = if @http_client - 'user-supplied' - elsif @effective_adapter - @effective_adapter - elsif @faraday_adapter - @faraday_adapter.to_s - else - 'default' - end - fmt = 'connection pool: max_conns_per_host=%d idle_timeout=%d ' \ - 'connect_timeout=%d request_timeout=%d %s faraday_adapter=%s' - logger.info( - format( - fmt, - m: @max_conns_per_host, i: @idle_timeout, c: @connect_timeout, - r: @request_timeout, flag: flag, a: adapter_label - ), - ) + return if logger.nil? + + fields = { + 'stream.sdk.name' => 'getstream-ruby', + 'stream.sdk.version' => VERSION, + 'stream.client.max_conns_per_host' => @max_conns_per_host, + 'stream.client.idle_timeout_seconds' => @idle_timeout, + 'stream.client.connect_timeout_seconds' => @connect_timeout, + 'stream.client.request_timeout_seconds' => @request_timeout, + 'stream.client.gzip_enabled' => true, + 'stream.client.user_http_client' => !@http_client.nil?, + 'stream.client.log_bodies' => @log_bodies, + } + logger.info { "client.initialized #{fields.map { |k, v| "#{k}=#{v}" }.join(' ')}" } end # Emit a WARNING that the requested adapter could not be built and pooling - # is disabled (CHA-2956). A fallback must never be silent, so when no logger - # is configured this uses a default $stdout logger, exactly like - # log_pool_config_to. + # is disabled (CHA-2956). A fallback must never be silent, so unlike the + # structured-logging events below, this uses a default $stdout logger when + # none is configured. def warn_pool_fallback(fallback_adapter, error) warn_logger = @logger || Logger.new($stdout).tap { |l| l.level = Logger::WARN } warn_logger.warn( diff --git a/lib/getstream_ruby/log_redaction.rb b/lib/getstream_ruby/log_redaction.rb new file mode 100644 index 0000000..119206a --- /dev/null +++ b/lib/getstream_ruby/log_redaction.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'json' + +module GetStreamRuby + + # Redaction helpers for the SDK's structured log events. Shallow by design. + module LogRedaction + + REDACTED = '' + QUERY_PARAMS = %w[api_key api_secret token].freeze + BODY_KEYS = %w[api_secret token password].freeze + # Matches `key=value` for the secret query params wherever they appear in a + # free-form string (e.g. a transport error message that embeds the request + # URL), value runs up to the next `&`, whitespace, or end of string. + MESSAGE_SECRET_PATTERN = /\b(#{QUERY_PARAMS.join('|')})=[^&\s]*/i.freeze + + module_function + + def redact_query(params) + return params if params.nil? || params.empty? + + params.to_h { |k, v| [k, QUERY_PARAMS.include?(k.to_s.downcase) ? REDACTED : v] } + end + + def redact_json_body(body) + return body if body.nil? || body.empty? + + data = JSON.parse(body) + return body unless data.is_a?(Hash) + + changed = false + BODY_KEYS.each do |key| + + if data.key?(key) + data[key] = REDACTED + changed = true + end + + end + changed ? JSON.generate(data) : body + rescue JSON::ParserError + body + end + + # Redacts secret query-parameter values wherever they appear in a free + # string (e.g. `error.message` from a transport exception, which may embed + # the full request URL). Case-insensitive on the key; the value is + # replaced regardless of its own case. + def redact_message(string) + return string if string.nil? || string.empty? + + string.gsub(MESSAGE_SECRET_PATTERN) { "#{Regexp.last_match(1)}=#{REDACTED}" } + end + + end + +end diff --git a/lib/getstream_ruby/request_logging.rb b/lib/getstream_ruby/request_logging.rb new file mode 100644 index 0000000..4a3f8ad --- /dev/null +++ b/lib/getstream_ruby/request_logging.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module GetStreamRuby + + # Structured-logging event emission mixed into Client (kept in its own file + # to stay under Metrics/ClassLength; these methods rely on Client's + # @configuration/monotonic_now same as if they lived on the class directly). + module RequestLogging + + private + + # One-shot WARN at construction so a log_bodies: true deployment can't miss + # that bodies (secrets key-redacted, but otherwise verbatim) are now being + # written to its logs. + def warn_log_bodies_enabled + return unless @configuration.log_bodies && @configuration.logger + + @configuration.logger.warn do + + 'log_bodies is enabled: request and response bodies will be logged ' \ + '(secrets are key-redacted, but treat log storage as sensitive).' + + end + end + + def log_request_sent(method, path, query_params, body_json = nil) + logger = @configuration.logger + return unless logger + + query = LogRedaction.redact_query(query_params).map { |k, v| "#{k}=#{v}" }.join('&') + line = +"http.request.sent http.request.method=#{method} url.path=#{path} url.query=#{query}" + line << " http.request.body=#{LogRedaction.redact_json_body(body_json)}" if @configuration.log_bodies && body_json + logger.debug { line } + end + + def log_response_received(response, started) + logger = @configuration.logger + return unless logger + + raw_body = raw_response_body(response) + line = +"http.response.received http.response.status_code=#{response.status} " \ + "http.response.body.size=#{raw_body.bytesize} duration_ms=#{elapsed_ms(started)}" + line << " http.response.body=#{LogRedaction.redact_json_body(raw_body)}" if @configuration.log_bodies + logger.debug { line } + end + + def log_request_failed(method, path, error, started) + logger = @configuration.logger + return unless logger + + error_type = ErrorMapping.classify_faraday_error(error) + message = LogRedaction.redact_message(error.message) + logger.error do + + "http.request.failed http.request.method=#{method} url.path=#{path} " \ + "error.type=#{error_type} error.message=#{message} duration_ms=#{elapsed_ms(started)}" + + end + end + + def elapsed_ms(started) + ((monotonic_now - started) * 1000).round + end + + # The `:json` response middleware parses env[:body] in place once its + # content type matches, so the raw wire string only survives via + # `preserve_raw:` (see Client#build_connection). Falls back to + # `response.body` itself when it was never parsed (non-JSON content type, + # or no body). + def raw_response_body(response) + raw = response.env[:raw_body] + return raw unless raw.nil? + + response.body.to_s + end + + end + +end diff --git a/spec/connection_pooling_spec.rb b/spec/connection_pooling_spec.rb index 9b9e4ab..9a6202f 100644 --- a/spec/connection_pooling_spec.rb +++ b/spec/connection_pooling_spec.rb @@ -204,15 +204,14 @@ def capture_adapter_call end - it 'reports the EFFECTIVE adapter in the INFO log, not the requested one' do + it 'still emits exactly one client.initialized INFO line (adapter identity lives in the WARN above, not here)' do log_io = StringIO.new logger = Logger.new(log_io).tap { |l| l.level = Logger::INFO } GetStreamRuby.manual(api_key: 'k', api_secret: 's', faraday_adapter: bogus, logger: logger) info_lines = log_io.string.lines.select { |l| l.include?('INFO') } expect(info_lines.size).to eq(1) - expect(info_lines.first).to include("faraday_adapter=#{Faraday.default_adapter}") - expect(info_lines.first).not_to include("faraday_adapter=#{bogus}") + expect(info_lines.first).to include('client.initialized') end @@ -223,34 +222,26 @@ def capture_adapter_call let(:log_io) { StringIO.new } let(:logger) { Logger.new(log_io).tap { |l| l.level = Logger::INFO } } - it 'emits exactly one INFO line listing the 5 effective values' do + it 'emits exactly one client.initialized INFO line with the 5 effective pool values' do GetStreamRuby.manual(api_key: 'k', api_secret: 's', logger: logger) info_lines = log_io.string.lines.select { |l| l.include?('INFO') } expect(info_lines.size).to eq(1) line = info_lines.first - expect(line).to include('connection pool') - expect(line).to include('max_conns_per_host=5') - expect(line).to include('idle_timeout=55') - expect(line).to include('connect_timeout=10') - expect(line).to include('request_timeout=30') - expect(line).to include('user_http_client=false') - expect(line).to include('faraday_adapter=default') + expect(line).to include('client.initialized') + expect(line).to include('stream.client.max_conns_per_host=5') + expect(line).to include('stream.client.idle_timeout_seconds=55') + expect(line).to include('stream.client.connect_timeout_seconds=10') + expect(line).to include('stream.client.request_timeout_seconds=30') + expect(line).to include('stream.client.user_http_client=false') end - it 'reports user_http_client=true when http_client is supplied' do + it 'reports stream.client.user_http_client=true when http_client is supplied' do custom = Faraday.new(url: 'https://example.invalid') { |c| c.adapter :test } GetStreamRuby.manual(api_key: 'k', api_secret: 's', logger: logger, http_client: custom) - expect(log_io.string).to include('user_http_client=true') - - end - - it 'reports the adapter symbol when faraday_adapter is supplied' do - - GetStreamRuby.manual(api_key: 'k', api_secret: 's', logger: logger, faraday_adapter: :net_http) - expect(log_io.string).to include('faraday_adapter=net_http') + expect(log_io.string).to include('stream.client.user_http_client=true') end diff --git a/spec/logging_spec.rb b/spec/logging_spec.rb new file mode 100644 index 0000000..7ccc9e6 --- /dev/null +++ b/spec/logging_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'stringio' + +RSpec.describe 'structured logging' do + + def build(stubs, logger: nil, log_bodies: false) + conn = Faraday.new { |b| b.adapter :test, stubs } + options = { base_url: 'http://localhost', http_client: conn } + options[:logger] = logger if logger + options[:log_bodies] = log_bodies if log_bodies + GetStreamRuby::Client.new(api_key: 'key', api_secret: 'sekret', **options) + end + + def recorder + io = StringIO.new + logger = Logger.new(io) + logger.level = Logger::DEBUG + [logger, io] + end + + it 'emits client.initialized once with the schema' do + + logger, io = recorder + build(Faraday::Adapter::Test::Stubs.new, logger: logger) + lines = io.string.lines.select { |l| l.include?('client.initialized') } + expect(lines.size).to eq(1) + expect(lines.first).to include('stream.sdk.name=getstream-ruby').and include('stream.client.max_conns_per_host=') + + end + + it 'emits sent and received on success' do + + stubs = Faraday::Adapter::Test::Stubs.new { |s| s.get(%r{/x}) { [200, { 'Content-Type' => 'application/json' }, '{"ok":true}'] } } + logger, io = recorder + build(stubs, logger: logger).make_request(:get, '/x') + expect(io.string).to include('http.request.sent') + expect(io.string).to include('http.response.received') + expect(io.string).to include('http.response.status_code=200') + + end + + it 'routes 5xx through received, not failed' do + + stubs = Faraday::Adapter::Test::Stubs.new { |s| s.get(%r{/x}) { [500, {}, '{"code":1,"message":"boom"}'] } } + logger, io = recorder + client = build(stubs, logger: logger) + expect { client.make_request(:get, '/x') }.to raise_error(GetStreamRuby::ApiError) + expect(io.string).to include('http.response.status_code=500') + expect(io.string).not_to include('http.request.failed') + + end + + it 'emits failed on transport error' do + + stubs = Faraday::Adapter::Test::Stubs.new { |s| s.get(%r{/x}) { raise Faraday::TimeoutError } } + logger, io = recorder + client = build(stubs, logger: logger) + expect { client.make_request(:get, '/x') }.to raise_error(GetStreamRuby::TransportError) + expect(io.string).to include('http.request.failed').and include('error.type=timeout') + + end + + it 'produces zero output without a logger' do + + stubs = Faraday::Adapter::Test::Stubs.new { |s| s.get(%r{/x}) { [200, {}, '{}'] } } + expect { build(stubs).make_request(:get, '/x') }.not_to output.to_stdout + + end + + it 'redacts api_key in url.query' do + + stubs = Faraday::Adapter::Test::Stubs.new { |s| s.get(%r{/x}) { [200, {}, '{}'] } } + logger, io = recorder + build(stubs, logger: logger).make_request(:get, '/x') + expect(io.string).not_to include('api_key=key') + expect(io.string).to include('api_key=') + + end + + it 'log_bodies opt-in adds redacted bodies and warns once' do + + stubs = Faraday::Adapter::Test::Stubs.new { |s| s.get(%r{/x}) { [200, { 'Content-Type' => 'application/json' }, '{"token":"supersecretvalue","keep":"v"}'] } } + logger, io = recorder + build(stubs, logger: logger, log_bodies: true).make_request(:get, '/x') + expect(io.string.scan('bodies will be logged').size).to eq(1) + expect(io.string).to include('http.response.body=') + expect(io.string).not_to include('supersecretvalue') + + end + + it 'redaction helpers' do + + expect(GetStreamRuby::LogRedaction.redact_query({ api_key: 'k', x: 1 })).to eq({ api_key: '', x: 1 }) + out = GetStreamRuby::LogRedaction.redact_json_body('{"api_secret":"s","password":"p","keep":"v"}') + expect(out).not_to include('"s"') + expect(out).to include('"keep":"v"') + expect(GetStreamRuby::LogRedaction.redact_json_body('not json')).to eq('not json') + + end + + # PROACTIVE SECRET-LEAK GUARD: a Faraday transport error's #message may embed + # the full request URL (with api_key/api_secret/token query values) verbatim. + # error.message must be scrubbed before it reaches the logger. + it 'redacts secrets embedded in the transport error message' do + + stubs = Faraday::Adapter::Test::Stubs.new do |s| + + s.get(%r{/x}) { raise Faraday::ConnectionFailed, 'execution expired for http://x/api/v2/app?api_key=SUPERSECRETKEY&user_id=123' } + + end + logger, io = recorder + client = build(stubs, logger: logger) + expect { client.make_request(:get, '/x') }.to raise_error(GetStreamRuby::TransportError) + failed_line = io.string.lines.find { |l| l.include?('http.request.failed') } + expect(failed_line).to include('api_key=') + expect(failed_line).not_to include('SUPERSECRETKEY') + + end + + it 'LogRedaction.redact_message redacts secret query values in a free string' do + + msg = 'GET failed for http://x/api/v2/app?api_key=SUPERSECRETKEY&user_id=123' + out = GetStreamRuby::LogRedaction.redact_message(msg) + expect(out).to include('api_key=') + expect(out).not_to include('SUPERSECRETKEY') + + end + +end From d924a2faa6913e165219672e1658bfef813b4507 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 22 Jul 2026 16:54:19 +0200 Subject: [PATCH 2/2] style: align log_bodies warning to canonical cross-SDK wording --- lib/getstream_ruby/request_logging.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/getstream_ruby/request_logging.rb b/lib/getstream_ruby/request_logging.rb index 4a3f8ad..a0ea7c0 100644 --- a/lib/getstream_ruby/request_logging.rb +++ b/lib/getstream_ruby/request_logging.rb @@ -17,8 +17,9 @@ def warn_log_bodies_enabled @configuration.logger.warn do - 'log_bodies is enabled: request and response bodies will be logged ' \ - '(secrets are key-redacted, but treat log storage as sensitive).' + 'HTTP request/response bodies will be logged. Auth headers and ' \ + 'known-secret fields are still redacted, but other sensitive ' \ + 'data (messages, PII) may appear in logs. Disable for production.' end end