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:
- Recommended: initialize lazily — wrap
Quonfig.initso it only runs the first timeQuonfig.instanceis called from a non-preloader process. - Or: call
Quonfig.forkfrom aSpring.after_forkhook.
# 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