quonfig

Ruby SDK for Quonfig — Feature Flags, Live Config, and Dynamic Log Levels.

Note: This SDK is pre-1.0 and the API is not yet stable.

Installation

Add the gem to your Gemfile:

gem 'quonfig'

Or install directly:

gem install quonfig

Quickstart

require 'quonfig'

client = Quonfig::Client.new(sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'])

# Feature flags
if client.enabled?('new-dashboard')
  # show new dashboard
end

# Typed config values
limit   = client.get_int('rate-limit')
name    = client.get_string('app.display-name')
regions = client.get_string_list('allowed-regions')

# Context-aware evaluation — pass a context hash as the last argument
value = client.get_string('homepage-hero', user: { key: 'user-123', country: 'US' })

Context

Contexts are hashes grouped by scope (user, team, device, etc.). You can attach a context in three ways:

1. Per-call context

client.get_bool('beta-feature', user: { key: 'user-123', plan: 'pro' })

2. in_context block

Everything evaluated inside the block sees the supplied context. The block's return value is returned from in_context.

result = client.in_context(user: { key: 'user-123', plan: 'pro' }) do |bound|
  {
    hero:   bound.get_string('homepage-hero'),
    limit:  bound.get_int('rate-limit'),
    beta?:  bound.enabled?('beta-feature')
  }
end

3. with_context — BoundClient for repeated lookups

with_context returns an immutable BoundClient that carries the context on every call. Useful when you want to pass a context-bound handle down the stack.

bound = client.with_context(user: { key: 'user-123', plan: 'pro' })

bound.get_string('homepage-hero')
bound.enabled?('beta-feature')
bound.get_int('rate-limit')

Datadir / offline mode

For tests, CI, or air-gapped environments, point the client at a local workspace directory instead of the Quonfig API. In datadir mode the SDK loads JSON config files from disk and performs no network I/O.

client = Quonfig::Client.new(
  datadir:     '/path/to/workspace',
  environment: 'production'
)

client.get_bool('feature-x')

You can also set QUONFIG_DIR in the environment and omit the datadir: option; when QUONFIG_DIR is set the SDK switches to datadir mode automatically. environment is required in datadir mode — it can be provided via the option or via QUONFIG_ENVIRONMENT.

export QUONFIG_DIR=/path/to/workspace
export QUONFIG_ENVIRONMENT=production
client = Quonfig::Client.new  # reads QUONFIG_DIR + QUONFIG_ENVIRONMENT

Environment variables

Variable Purpose
QUONFIG_BACKEND_SDK_KEY SDK key used to authenticate against the Quonfig API. Used when sdk_key: is omitted.
QUONFIG_DIR Path to a workspace directory. When set, the SDK runs in datadir/offline mode.
QUONFIG_ENVIRONMENT Environment name (production, staging, development) evaluated in datadir mode.
QUONFIG_DOMAIN Base domain used to derive api, sse, and telemetry URLs. Defaults to quonfig.com. Set to quonfig-staging.com to point at staging. Explicit api_urls: / telemetry_url: kwargs override this.

Constructor options

Quonfig::Client.new(
  sdk_key:         '...',                          # required unless QUONFIG_BACKEND_SDK_KEY is set
  api_urls:        ['https://primary.quonfig.com', 'https://secondary.quonfig.com'],
  telemetry_url:   'https://telemetry.quonfig.com',
  enable_sse:      true,
  enable_polling:  false,
  poll_interval:   60,
  init_timeout:    10,
  on_no_default:   :error,
  global_context:  {},
  datadir:         '/path/to/workspace',
  environment:     'production'
)
Option Type Default Description
sdk_key String ENV['QUONFIG_BACKEND_SDK_KEY'] SDK key for API authentication.
api_urls Array<String> ["https://primary.${QUONFIG_DOMAIN}", "https://secondary.${QUONFIG_DOMAIN}"] Ordered list of API base URLs to try. SSE stream URLs are derived by prepending stream. to each hostname. Defaults derive from QUONFIG_DOMAIN (default quonfig.com).
telemetry_url String https://telemetry.${QUONFIG_DOMAIN} Base URL for the telemetry service. Default derives from QUONFIG_DOMAIN.
enable_sse Boolean true Receive real-time updates over Server-Sent Events.
enable_polling Boolean false Poll the API on an interval as a fallback.
poll_interval Integer (seconds) 60 Polling interval when enable_polling is true.
init_timeout Integer (seconds) 10 Maximum time to wait for the initial config load.
on_no_default Symbol :error Behavior when a key has no value and no default: :error, :warn, or :ignore.
global_context Hash {} Context applied to every evaluation.
datadir String ENV['QUONFIG_DIR'] Path to a local workspace. When set, the SDK runs offline from disk.
environment String ENV['QUONFIG_ENVIRONMENT'] Environment to evaluate in datadir mode. Required when datadir is set.
logger Logger-like object nil Optional host-app logger (e.g. Rails.logger). Must respond to debug/info/warn/error. When set, all SDK warnings/errors flow through this logger instead of the default stderr / SemanticLogger backend.

Typed getters

Each typed getter takes a config key and an optional context hash. If the key is missing or the stored value does not match the requested type, the getter returns nil.

Method Returns
get_string(key, contexts = nil) String or nil
get_int(key, contexts = nil) Integer or nil
get_float(key, contexts = nil) Float or nil
get_bool(key, contexts = nil) true, false, or nil
get_string_list(key, contexts = nil) Array<String> or nil
get_duration(key, contexts = nil) Float (seconds) or nil
get_json(key, contexts = nil) Hash, Array, or nil
enabled?(feature_name, contexts = nil) true or false

Example:

client.get_string('app.display-name')
client.get_int('rate-limit', user: { key: 'user-123' })
client.get_float('pricing.multiplier')
client.get_bool('flags.new-checkout')
client.get_string_list('allowed-regions')
client.get_duration('request-timeout')
client.get_json('homepage.layout')
client.enabled?('beta-feature', user: { key: 'user-123' })

Dynamic log levels (SemanticLogger)

Quonfig can drive per-class log levels at runtime. Set config keys like log-levels.my_app.foo.bar to one of trace, debug, info, warn, error, fatal and wire the filter into SemanticLogger:

require 'quonfig'
require 'semantic_logger'

client = Quonfig::Client.new(sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'])
SemanticLogger.add_appender(io: $stdout, filter: client.semantic_logger_filter)

Lookup is exact-match only: logger name MyApp::Foo::Bar normalizes to log-levels.my_app.foo.bar. If no key is set the log is allowed through and SemanticLogger's static level decides. There is no hierarchy walk — a value on log-levels.my_app does not affect log-levels.my_app.foo.bar.

Pass key_prefix: to use a prefix other than log-levels.:

client.semantic_logger_filter(key_prefix: 'debug.')

Dynamic log levels with stdlib Logger

If you use Ruby's built-in ::Logger instead of SemanticLogger, wire the formatter returned by client.stdlib_formatter into your logger:

require 'quonfig'
require 'logger'

client = Quonfig::Client.new(
  sdk_key:    ENV['QUONFIG_BACKEND_SDK_KEY'],
  logger_key: 'log-level.my-app'
)

logger = ::Logger.new($stdout)
logger.level = ::Logger::DEBUG
logger.formatter = client.stdlib_formatter(logger_name: 'MyApp::Services::Auth')

The formatter asks the client should_log?(logger_path:, desired_level:) for every call; lines below the configured level return an empty string (which ::Logger writes as zero bytes, suppressing the line). logger_name is passed to Quonfig verbatim under quonfig-sdk-logging.key so a single log-level.my-app config can drive per-class overrides via rules like PROP_STARTS_WITH_ONE_OF "MyApp::Services::".

Omit logger_name: to have the formatter fall through to the Logger's progname at call time:

logger.formatter = client.stdlib_formatter
logger.progname  = 'MyApp::Services::Auth'

If both are supplied, the explicit logger_name: wins.

Rails integration

The SDK runs a background SSE thread (and optional polling thread) that you do not want to inherit across a fork(2). Forked threads in the child process are dead — the SSE socket is held open by a thread that no longer exists, and the child silently stops receiving live updates.

Use Quonfig::Client#fork (or Quonfig.fork if you use the module-level singleton) in any process that fork-spawns workers. It returns a fresh client configured for the child: a new ConfigStore, a new SSE subscription, and suppressed telemetry double-counting (Options#is_fork is set to true).

Puma (clustered mode)

# config/puma.rb
before_fork do
  Quonfig.instance.stop          # close the master's SSE before forking
end

on_worker_boot do
  Quonfig.fork                   # rebuild a fresh client per worker
end

If you initialize Quonfig lazily (in a Rails initializer) and run Puma in single mode (no clustering), no fork hook is needed.

Sidekiq

Sidekiq's parent process forks workers. Wire the same lifecycle:

# config/initializers/quonfig.rb
Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.on(:startup)  { Quonfig.fork if Process.ppid != 1 }
  config.on(:shutdown) { Quonfig.instance.stop rescue nil }
end

For Sidekiq web/CLI processes that don't fork (default concurrency: 1), Quonfig.init in the initializer is sufficient.

Spring / Bootsnap preloaders

Spring forks the preloader for each command. If your initializer creates a Quonfig client at boot, the SSE thread will be inherited dead in every child. Two options:

  1. Recommended: initialize lazily — wrap Quonfig.init so it only runs the first time Quonfig.instance is called from a non-preloader process.
  2. Or: call Quonfig.fork from a Spring.after_fork hook.
# config/spring.rb
Spring.after_fork do
  Quonfig.fork if defined?(Quonfig) && Quonfig.instance_variable_get(:@singleton)
end

Code reloading (Zeitwerk, development mode)

Quonfig::Client is a long-lived object — keep it out of app/ (where Zeitwerk reloads classes on every request) and pin it to a constant set in a Rails initializer. The client itself is reload-safe because it does not reference any application classes; the failure mode to avoid is creating a new client per request, which leaks SSE threads and quickly exhausts file descriptors.

# config/initializers/quonfig.rb
# Quonfig.init is idempotent — a second call warns and returns the existing
# singleton — so it's safe to wrap in to_prepare for reload-friendliness.
Rails.application.config.to_prepare do
  Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))
end

Thread safety

Quonfig::Client is safe to share across threads. Reads (get, enabled?, get_*) and SSE-driven writes to the underlying ConfigStore use Concurrent::Map for per-key atomicity. Eventual consistency across an envelope is intentional: a reader concurrent with envelope application may observe the new value for some keys and the old value for others, then converge once the envelope finishes applying.

Quonfig.fork is the only safe way to "carry" a client across Process.fork — do not reuse the parent's client in a child process.

Documentation

Full documentation, including SPEC, SDK reference, and operational guides, is available at https://quonfig.com/docs.

License

MIT