Skip to main content

Ruby

Install the latest version​

Github | Ruby Gems

Quonfig.init

context = {
user: {
key: 123,
email: "alice@example.com"
},
team: {
key: 456,
name: "AliceCorp"
}
}

result = Quonfig.enabled? "my-first-feature-flag", context

puts "my-first-feature-flag is: #{result}"

Initialize Client​

If you set QUONFIG_BACKEND_SDK_KEY as an environment variable, initializing the client is as easy as

Quonfig.init # reads QUONFIG_BACKEND_SDK_KEY env var by default

API URLs​

By default the SDK derives all of its hostnames from QUONFIG_DOMAIN (default quonfig.com): it fetches config from https://primary.quonfig.com, streams live updates from https://stream.primary.quonfig.com, and automatically fails over to https://secondary.quonfig.com (on separate infrastructure). Failover and hedging are on by default — see Reliability for the full model.

Override the list with the api_urls option (or point QUONFIG_DOMAIN at a different domain to re-derive both legs). Pass both a primary and a secondary URL to keep automatic failover; a single URL disables it (the SDK logs a warning at init):

Quonfig.init(
Quonfig::Options.new(
api_urls: [
"https://primary.quonfig.com",
"https://secondary.quonfig.com",
],
)
)

Rails Applications​

Initializing Quonfig in your application.rb will allow you to reference dynamic configuration in your environment (e.g. staging.rb) and initializers. This is useful for setting environment-specific config like your redis connection URL.

#application.rb
module MyApplication
class Application < Rails::Application
#...

Quonfig.init
end
end

Forking servers and forking jobs​

Ruby threads do not survive fork(2), so a forked child needs its own Quonfig client. On Ruby 3.1+ the SDK handles this for you. It installs a Process._fork hook at load time, covering Puma clustered mode, Unicorn workers, Spring, Resque, the parallel gem, and a plain fork { ... } inside a Sidekiq job. No wiring is required.

After a fork, the child re-initializes on its first use of the client, exactly like a newly constructed client — including its on_init_failure policy: it fetches its own config and starts its own threads. It does not evaluate from the parent's snapshot. The hook itself does no I/O. So the first call in a forked child pays one fetch, and a child that never uses the client costs nothing — no fetch, no stream, no thread.

That first fetch blocks, and it blocks every other thread that reaches the client while it is in flight — they wait for it and then see the fetched config. One fetch, one stream dial, and one telemetry reporter per child, however many threads race the first request.

The default is on_init_failure: :raise, so if the child's fetch fails against every api_urls leg (primary and secondary both unreachable) the failure raises out of that first lookup, exactly as Quonfig::Client.new would at boot, and later lookups keep raising — without re-fetching — until the update channel lands an envelope. On 1.3.0 and earlier a child in that situation silently served the parent's snapshot. If you would rather a forked child serve defaults through an outage, set on_init_failure: :return: a failed first fetch then logs one line and the child serves defaults until its stream or poller lands an envelope.

connection_state never triggers the re-initialization: a diagnostic must not open a socket. A child that has not used the client yet reports :initializing, and flips to :connected on first use.

Per-job forking pays per job. A Resque-style worker that forks a child per job (or Parallel.map with one row per process) pays, in each child that touches the client, one config fetch, one SSE dial, and — when the child exits normally — one telemetry POST at exit. That is the price of the child holding its own current config and its own telemetry window, and it is deliberate — the delivery service counts each of those connections as a real client. A child that never uses the client pays none of it.

The at-exit drain depends on the child running at_exit handlers at all. Parallel children do. Resque children call exit! by default, which skips every at_exit handler — so there is no drain and no telemetry POST unless you set RUN_AT_EXIT_HOOKS=1. Nothing else about the child changes; it just never flushes the evaluations it collected.

A fork never touches the process that forked. The parent keeps its stream, its poller, and its live config straight through any number of forks — so a long-lived process that forks workers and keeps evaluating stays current. (Before 1.4.0 the SDK tore the parent's stream down before the fork syscall, and the parent stopped receiving updates permanently. If you fork from a process that keeps evaluating, upgrade to 1.4.0 or later.)

Upgrading from 1.3.0 or earlier

If you added a manual Quonfig.instance.after_fork_in_child call in the parent as a workaround for the parent going dark, remove it. As of 1.4.0 that call is a no-op in the process that owns the client — the SDK decides that by comparing the current pid against the one it stamped when the client was built, so it is exact whether or not the parent has any threads running. It will not hurt you, but it is no longer doing anything, and the parent needs no call.

Sidekiq OSS itself does not fork — it runs jobs on threads in one process — so Quonfig.init in your initializer is all it needs.

If you use SemanticLogger, you still need to reopen the logger in each fork.

On Ruby 3.1+, no Quonfig wiring is needed. If you use SemanticLogger, reopen it in the worker — Quonfig.fork is not needed in that block. The SDK has already handled the fork by the time on_worker_boot runs, and since 1.4.0 a Quonfig.fork call in a child the hook has already prepared simply returns the same client, so a leftover call from older docs is harmless:

# puma.rb (Ruby 3.1+)
on_worker_boot do
SemanticLogger.reopen # only if you are using SemanticLogger
end

On Ruby 3.0 (which has no Process._fork hook), rebuild the client yourself:

# puma.rb (Ruby 3.0 only)
on_worker_boot do
Quonfig.fork # rebuild a fresh client per worker
SemanticLogger.reopen # if you are using SemanticLogger
end

Do not add a before_fork { Quonfig.instance.stop } — the master does not need to be torn down for the workers to be healthy, and stopping it means the master stops receiving config.

Feature Flags​

For boolean flags, you can use the enabled? convenience method:

if Quonfig.enabled?("my-first-feature-flag")
# ...
else
# ...
end

Feature flags don't have to return just true or false.

You can get other data types using get:

Quonfig.get("ff-with-string")
Quonfig.get("ff-with-int")

Context​

Feature flags become more powerful when we give the flag evaluation rules more information to work with. We do this by providing context of the current user (and/or team, request, etc.)

Global Context​

When initializing the client, you can set a global context that will be used for all evaluations.

Quonfig.init(
global_context: {
application: {key: "my.corp.web"},
cpu: {count: 4},
clock: {timezone: "UTC"}
}
)

Global context is the least specific context and will be overridden by more specific context passed in at the time of evaluation.

Thread-local (Request-scoped)​

To make the best use of Quonfig, we recommend setting context in an around_action in your ApplicationController. Setting this context for the life-cycle of the request means the Quonfig logger can be aware of your user/etc and you won't have to explicitly pass context into your .enabled? and .get calls.

# application_controller.rb
class ApplicationController < ActionController::Base
around_action do |_, block|
Quonfig.with_context(quonfig_context, &block)
end

def quonfig_context
{
device: {
mobile: mobile?
# ...
},
}.merge(quonfig_user_context)
end

def quonfig_user_context
return {} unless current_user

{
user: {
key: current_user.tracking_id,
id: current_user.id,
email: current_user.email,
country: current_user.country,
# ...
},
}
end
end

Just-in-time Context

You can also pass context when evaluating individual flags or config values.

context = {
user: {
id: 123,
key: 'user-123',
subscription_level: 'pro',
email: "alice@example.com"
},
team: {
id: 432,
key: 'team-abc',
},
device: {
key: "abcdef",
mobile: true,
}
}
result = Quonfig.enabled?("my-first-feature-flag", context)

puts "my-first-feature-flag is: #{result} for #{context.inspect}"

Dynamic Config​

Config values are accessed the same way as feature flag values. You can use enabled? as a convenience for boolean values, and get works for all data types

config_key = "my-first-int-config"
puts "#{config_key} is: #{Quonfig.get(config_key)}"

Default Values for Configs​

Here we ask for the value of a config named max-jobs-per-second, and we specify 10 as a default value if no value is available.

Quonfig.get("max-jobs-per-second", 10) # => returns `10` if no value is available

If we don't provide a default and no value is available, a Quonfig::Errors::MissingDefaultError error will be raised.

Quonfig.get("max-jobs-per-second") # => raises if no value is available
note

You can modify this behavior by setting the option on_no_default to Quonfig::Options::ON_NO_DEFAULT::RETURN_NIL

Developer overrides (qfg override)​

The qfg override CLI flips a flag for your developer machine without affecting anyone else. It does this by writing a top-priority rule on the flag keyed on the property quonfig-user.email. The SDK injects that property automatically whenever the qfg login token file is present, so the rule is dead code in production by construction (a server that never ran qfg login has no quonfig-user.email on its eval context, and the rule cannot fire).

Injection is on by default — when ~/.quonfig/tokens.json (written by qfg login) exists, the SDK reads it on init and merges { 'quonfig-user' => { 'email' => <user_email> } } into the global context. Customer-supplied quonfig-user keys (set via global_context:) win on collision. If the file is missing or unparseable the SDK is a no-op — init still succeeds.

To opt out (e.g. on a shared box where qfg login has run):

Quonfig.init(
Quonfig::Options.new(
enable_quonfig_user_context: false, # opt out
)
)

Or set QUONFIG_DEV_CONTEXT=false in the environment. Precedence: the explicit option wins, then QUONFIG_DEV_CONTEXT, then the default (true).

Telemetry note: quonfig-user.email flows through telemetry like any other context attribute. It only appears in dev-machine telemetry because production never injects it.

Dynamic Log Levels​

Log levels in Quonfig are stored as a log_level config (e.g. log-level.my-app). The SDK consults that config on every log call, so changes made in Quonfig take effect live via SSE without redeploying.

Concept​

  • One log_level config per app, keyed like log-level.my-app. Value is one of TRACE, DEBUG, INFO, WARN, ERROR, FATAL.
  • Tell the client which config to consult via Quonfig::Options.new(logger_key: ...).
  • should_log?(logger_path:, desired_level:) pushes logger_path into the evaluation context as quonfig-sdk-logging.key (verbatim — no normalization) so a single config can drive per-class rules.
  • Logger names flowing through quonfig-sdk-logging.key are auto-captured by example-context telemetry, so the dashboard can auto-suggest rule targets.

Basic usage​

require "quonfig"

options = Quonfig::Options.new(
sdk_key: ENV.fetch("QUONFIG_BACKEND_SDK_KEY"),
logger_key: "log-level.my-app",
)
Quonfig.init(options)

if Quonfig.instance.should_log?(
logger_path: "MyApp::Services::Auth",
desired_level: :debug,
)
# ...
end

Rule example​

Create a log_level config with key log-level.my-app and target individual loggers via quonfig-sdk-logging.key:

# Default to INFO for every logger in this app
default: INFO

rules:
# Bump one namespace to DEBUG
- criteria:
quonfig-sdk-logging.key:
starts-with: "MyApp::Services::Auth"
value: DEBUG

# Silence a chatty gem
- criteria:
quonfig-sdk-logging.key:
starts-with: "SomeGem"
value: ERROR

# Turn DEBUG on for one developer, everywhere
- criteria:
user.email: "developer@example.com"
value: DEBUG

Because the evaluator sees your full context — global context, per-request thread-local context, and quonfig-sdk-logging.key — you can combine logger rules with user, environment, or request context for targeted debugging.

SemanticLogger integration​

SemanticLogger is a popular structured logging framework. Attach a filter built by semantic_logger_filter(config_key:) and SemanticLogger will gate each record through Quonfig:

# Gemfile
gem "semantic_logger"
require "semantic_logger"
require "quonfig"

Quonfig.init(Quonfig::Options.new(logger_key: "log-level.my-app"))

SemanticLogger.sync!
SemanticLogger.default_level = :trace # let Quonfig do the filtering
SemanticLogger.add_appender(
io: $stdout,
formatter: :json,
filter: Quonfig.instance.semantic_logger_filter(config_key: "log-level.my-app"),
)

The filter uses the SemanticLogger log's name as the quonfig-sdk-logging.key context value, so the rule examples above (starts-with: "MyApp::Services::Auth") work out of the box.

Stdlib Logger integration​

If you use Ruby's standard library Logger, attach a Quonfig::Client#stdlib_formatter:

require "logger"
require "quonfig"

Quonfig.init(Quonfig::Options.new(logger_key: "log-level.my-app"))

logger = Logger.new($stdout)
logger.level = Logger::DEBUG # let Quonfig do the filtering
logger.formatter = Quonfig.instance.stdlib_formatter(logger_name: "MyApp::Services::Auth")

logger.debug "filtered by Quonfig"
logger.info "filtered by Quonfig"

The formatter asks Quonfig should_log?(logger_path:, desired_level:) before each line. logger_name: is passed verbatim as quonfig-sdk-logging.key. If you omit logger_name: the formatter falls back to the logger's progname.

Telemetry​

By default, Quonfig uploads telemetry that enables a number of useful features. You can alter or disable this behavior using the following options:

NameDescriptionDefault
collect_evaluation_summariesSend counts of config/flag evaluation results back to Quonfig to view in web apptrue
context_upload_modeUpload either context "shapes" (the names and data types your app uses in Quonfig contexts) or periodically send full example contexts:periodic_example

Logger names flowing through quonfig-sdk-logging.key are picked up by the normal example-context telemetry, so no separate logger-counts toggle is needed — the dashboard sees candidate logger names via the same path.

If you want to change any of these options, you can pass an options object when initializing the Quonfig client.

#application.rb
module MyApplication
class Application < Rails::Application
#...

options = Quonfig::Options.new(
collect_evaluation_summaries: true,
context_upload_mode: :periodic_example,
)

Quonfig.init(options)
end
end

Delivery options​

How the SDK delivers telemetry, and what it does when the endpoint is slow or down, is the same in every SDK and is explained once on the Telemetry page. The Ruby option names and defaults (quonfig gem 1.5.0+), all passed to Quonfig::Options.new:

OptionDefault
collect_sync_interval60 (seconds)
telemetry_timeout_ms15_000 (15s)
telemetry_connect_timeout_ms5_000 (5s)
telemetry_max_retained_batches5
telemetry_max_retained_bytes2_097_152 (2MB)
telemetry_max_retained_age_ms300_000 (5 min)
collect_max_evaluation_summaries10_000
context_max_size10_000 (context-shape fields, and separately example contexts)

For the five telemetry_* options, an invalid value (non-numeric, or <= 0) falls back to the default. collect_sync_interval falls back to 60 only when it is nil. collect_max_evaluation_summaries and context_max_size are not validated: set either to 0 to turn off that aggregator. The SDK's default logger prints warnings and errors only; pass logger: to see the debug and info lines. stop and the reporter's at_exit hook send the current window once with a 5s deadline.

Keeping batches small. A single batch larger than telemetry_max_retained_bytes is sent once and, if that POST fails, dropped rather than kept. Large batches come from example contexts. If you see telemetry drop warnings with large batches, use context_upload_mode: :shapes_only or a lower context_max_size.

Changes in 1.5.0: the per-window caps dropped from 100,000 to 10,000; the flush interval is a fixed 60s (before, it started at 8s and grew to 600s); a telemetry POST now has its own 15s timeout (before, Faraday's 60s connect plus 60s read); a failed POST logs at debug instead of warning every time.

Debugging​

In the rare case that you are trying to debug issues that occur within the library, set env var

QUONFIG_LOG_CLIENT_BOOTSTRAP_LOG_LEVEL=debug

Asset Precompilation in Rails​

Developers trying to run rake assets:precompile or rails assets:precompile in CI/CD know the pain of missing environment variables. Quonfig can help with this, but you don't want to hardcode your Quonfig SDK key in your Dockerfile. What should you do instead?

We recommend running in datadir mode for assets:precompile. Pull your workspace config files locally with the Quonfig CLI:

qfg pull --dir ./config
# clones your workspace config files to ./config

Check the resulting files into your repo (or into your Docker build context) for use in CI/CD and automated testing.

Now point the SDK at that directory for assets:precompile — the client boots entirely from the local files and never contacts the Quonfig servers:

QUONFIG_DIR=./config QUONFIG_ENVIRONMENT=test bundle exec rake assets:precompile

Set QUONFIG_ENVIRONMENT to whichever environment you want to evaluate (test, production, etc.). Re-run qfg pull to refresh the local files.

Testing​

Test Setup​

You can use a datafile for consistency, reproducibility, and offline testing. See Testing with DataFiles.

If you need to test multiple scenarios that depend on a single config or feature key, you can change the Quonfig value using a mock or stub.

Example Test​

Imagine we want to test a batches method on our Job class. batches depends on job.batch.size and the value for job.batch.size in our default config file is 3.

We can test how batches performs with different values for job.batch.size by mocking the return value of Quonfig.get.

class Job < Array
def batches
slice_size = Quonfig.get('job.batch.size')
each_slice(slice_size)
end
end

RSpec.describe Job do
describe '#batches' do
it 'returns batches of jobs' do
jobs = Job.new([1, 2, 3, 4, 5])

expect(jobs.batches.map(&:size)).to eq([3, 2])

allow(Quonfig).to receive(:get).with('job.batch.size').and_return(2)
expect(jobs.batches.map(&:size)).to eq([2, 2, 1])
end
end
end

Reference​

Client Initialization Options​

For more control, you can initialize your client with options. Here are the defaults with explanations.

options = Quonfig::Options.new(
sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
api_urls: ['https://primary.quonfig.com', 'https://secondary.quonfig.com'], # primary + secondary (failover on by default). Derived from ENV['QUONFIG_DOMAIN'] when omitted; SSE URL is derived by prepending 'stream.'
on_no_default: Quonfig::Options::ON_NO_DEFAULT::RAISE, # ::RAISE (:raise) or ::RETURN_NIL (:return_nil)
init_timeout_ms: 10_000, # how long to wait before on_init_failure (the old initialization_timeout_sec, in seconds, is a deprecated alias)
on_init_failure: Quonfig::Options::ON_INITIALIZATION_FAILURE::RAISE, # choose to crash or continue with local data only if unable to fetch config data from Quonfig at startup
datadir: ENV['QUONFIG_DIR'], # local workspace dir for offline/datadir mode
logger_key: nil, # the `log_level` config key consulted by `should_log?(logger_path:, ...)`, e.g. "log-level.my-app"
enable_quonfig_user_context: nil, # inject quonfig-user.email from ~/.quonfig/tokens.json (qfg login). Pairs with `qfg override`. Default on, gated on the token file's presence (inert in prod). Set false or QUONFIG_DEV_CONTEXT=false to opt out.
collect_max_paths: Quonfig::Options::DEFAULT_MAX_PATHS,
collect_sync_interval: 60, # seconds between telemetry flushes (nil also means 60)
context_upload_mode: :periodic_example, # :periodic_example, :shapes_only, :none
context_max_size: Quonfig::Options::DEFAULT_MAX_EVAL_SUMMARIES,
collect_evaluation_summaries: true, # send counts of config/flag evaluation results back to Quonfig to view in web app
collect_max_evaluation_summaries: Quonfig::Options::DEFAULT_MAX_EVAL_SUMMARIES,
global_context: {}
)

Quonfig.init(options)