Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<redacted>`; 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

Expand All @@ -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

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<redacted>`, 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
Expand Down
1 change: 1 addition & 0 deletions lib/getstream_ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
47 changes: 33 additions & 14 deletions lib/getstream_ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -209,20 +216,26 @@ 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
req.headers['Authorization'] = generate_auth_header
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

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
53 changes: 24 additions & 29 deletions lib/getstream_ruby/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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] || {}
Expand All @@ -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?
Expand All @@ -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=%<m>d idle_timeout=%<i>d ' \
'connect_timeout=%<c>d request_timeout=%<r>d %<flag>s faraday_adapter=%<a>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(
Expand Down
58 changes: 58 additions & 0 deletions lib/getstream_ruby/log_redaction.rb
Original file line number Diff line number Diff line change
@@ -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 = '<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
80 changes: 80 additions & 0 deletions lib/getstream_ruby/request_logging.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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

'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

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
Loading
Loading