Skip to content

Ruby SDK Reference

TL;DR: Run gem install featureflip, call client.bool_variation("flag-key", { user_id: "u-123" }, false), and you’re evaluating flags — zero runtime dependencies.

The featureflip Ruby gem provides a thread-safe client for evaluating feature flags in any Ruby 3.2+ application — Rails, Sinatra, Hanami, Sidekiq workers, Rake tasks, or plain scripts. The SDK has zero runtime gem dependencies; it uses the standard library’s net/http for HTTPS and SSE streaming, so it doesn’t fight with your existing HTTP gem stack and won’t pull in faraday or httparty transitively. Distribution is RubyGems under the featureflip name.

The client is intended as a long-lived process-level singleton. In Rails, instantiate it once in an initializer and store it on a class constant or Rails.configuration; in Sidekiq, share it across workers; in Puma, build it after fork. Variation methods read from a Concurrent::Hash and return synchronously, so you can call them from controllers, background jobs, mailers, or partials without thread-safety concerns. The streaming connection runs on a background Thread, which gracefully handles GVL contention because the hot path is simple I/O and a hash lookup.

Evaluation context is a plain Ruby Hash with a required user_id key — { user_id: "u-123", plan: "pro", country: "US" } is the entire API. The user_id anchors percentage rollouts so a given user lands in a stable bucket across processes, dynos, and pods. Custom keys feed targeting rules. Symbol and string keys both work — the SDK normalizes them internally — so you don’t have to fight the hash-keys-as-strings-or-symbols ambiguity that plagues most Ruby APIs.

View source on GitHub

Add to your Gemfile:

gem "featureflip"

Or install directly:

Terminal window
gem install featureflip

The gem has zero runtime dependencies — it uses only Ruby stdlib (net/http, digest/md5).

The Config class accepts the following options:

OptionTypeDefaultDescription
base_urlString"https://eval.featureflip.io"Base URL of the Evaluation API
connect_timeoutNumeric5Connection timeout in seconds
read_timeoutNumeric10Read timeout in seconds
wrapper_nameStringnilFor libraries that wrap the SDK: a name appended to the User-Agent after the SDK’s own token
wrapper_versionStringnilVersion sent with wrapper_name
streamingBooleantrueEnable SSE for real-time flag updates
poll_intervalNumeric30Polling interval in seconds (used when streaming is disabled)
send_eventsBooleantrueEnable analytics event tracking
flush_intervalNumeric30Interval in seconds between event flush batches
flush_batch_sizeInteger100Maximum number of events per flush batch
init_timeoutNumeric10Maximum time in seconds to wait for initialization. With snapshot_store, a failed fetch is followed by a store read capped at the same time, so the worst case is twice this value
snapshot_storestore or nilnilPersists the last flag configuration so a process that starts while the Evaluation API is unreachable can still serve real values. See Starting during an outage
max_snapshot_age_secondsNumeric86400Oldest snapshot, in seconds, the client will start from. Must be a positive, finite number. Only used with snapshot_store
max_stream_retriesInteger5SSE retries before falling back to polling
config = Featureflip::Config.new(
base_url: "https://eval.featureflip.io",
streaming: true,
poll_interval: 60,
flush_interval: 15,
init_timeout: 30,
)

All timeout and interval values must be positive. The constructor validates these constraints and raises Featureflip::ConfigurationError for invalid values.

Create a client by providing an SDK key and optional configuration:

client = Featureflip::Client.get(
"sdk-dev-abc123",
config: Featureflip::Config.new(base_url: "https://eval.featureflip.io"),
)

Client.get performs initialization synchronously: it fetches the initial flag configuration, starts streaming or polling, and begins the event processor. If initialization fails or times out, an InitializationError is raised. With a snapshot_store set, a failed start does not raise. The client starts from the stored snapshot, or serves your defaults when there is none, and keeps retrying in the background (see Starting during an outage).

For applications that use a single client instance, the Featureflip module provides a convenience wrapper:

Featureflip.configure do |c|
c.sdk_key = "sdk-dev-abc123"
c.base_url = "https://eval.featureflip.io"
c.streaming = true
end
enabled = Featureflip.bool_variation("dark-mode", { user_id: "u-123" }, false)
Featureflip.close

The singleton is thread-safe — configure and close are protected by a Mutex.

If sdk_key is not provided, the client falls back to the FEATUREFLIP_SDK_KEY environment variable. A ConfigurationError is raised if neither is available.

ENV["FEATUREFLIP_SDK_KEY"] = "sdk-dev-abc123"
# SDK key read from environment
client = Featureflip::Client.get
client.initialized? # => true

Returns true once the client has successfully loaded flag data, or has finished starting with a snapshot_store configured, and false after close. A closed handle serves the caller’s default from every accessor. Check startup_info to tell a live start from a snapshot start.

By default Client.get raises InitializationError when the Evaluation API is unreachable at startup. Set snapshot_store to let a new process start from the last configuration it saved instead. The option is off unless you set it. For how this fits with the rest of the failure behavior, see Reliability.

client = Featureflip::Client.get(
"sdk-dev-abc123",
config: Featureflip::Config.new(
snapshot_store: Featureflip::FileSnapshotStore.new("/var/lib/featureflip/snapshot.json"),
max_snapshot_age_seconds: 6 * 60 * 60,
),
)

How it behaves:

  • The snapshot is read only when the initial fetch fails or times out. A successful fetch always wins, and the snapshot is never consulted afterward.
  • When the fetch fails and no usable snapshot exists, Client.get returns a client that serves your defaults instead of raising. startup_info then reports source as none, with a reason when a stored snapshot was rejected (snapshot-expired or snapshot-invalid) and no reason when nothing was stored. If you relied on rescue Featureflip::InitializationError to fail fast, check client.startup_info.source instead, because with a snapshot store configured the call no longer raises.
  • After a start from a snapshot the client keeps retrying the live API and replaces the snapshot’s values as soon as it connects. Listeners already registered at that moment fire for what changed between the two. Client.get blocks, so a listener you add right after it returns is registered too late to see how the snapshot differed from the first live data, and it only hears changes made after that. After registering, check startup_info.source: snapshot means the client is still on the stored copy, and live means it has already moved to live data, so re-read any flag you cached from the snapshot.
  • The client writes the full configuration after each full update it applies (the first fetch, a poll, a stream resync). Once it is connected live, single-flag updates write it too. Writes run on a background thread and never block evaluation.
  • While the client stays connected and healthy, it refreshes the stored copy at most every 5 minutes even when nothing changed, so a quiet configuration does not age past max_snapshot_age_seconds. Changes are written right away.
  • The store read is bounded by init_timeout. If the fetch times out and then the store read hangs too, Client.get returns after twice init_timeout, serving defaults.
  • The option is read on the first client built for an SDK key. Later calls with the same key reuse the existing one and log a warning if they pass a different snapshot_store or an explicit different max_snapshot_age_seconds.

A snapshot contains your targeting rules, including any customer attribute values used in them. On POSIX systems FileSnapshotStore creates the file readable by its owner only (mode 0600). It does not call fsync before replacing the file, so a power loss can leave an empty file. The client reads that as snapshot-invalid, logs a warning and serves defaults until the next write.

Container disks are usually ephemeral, so a file written inside the image or container layer is gone after a redeploy. Point the path at a mounted volume:

docker-compose.yml
services:
api:
volumes:
- featureflip-snapshot:/var/lib/featureflip
volumes:
featureflip-snapshot:

On Kubernetes, mount a PersistentVolumeClaim, or an emptyDir if you only need the file to survive container restarts within a pod, at the same path.

FileSnapshotStore logs its own read and write warnings to standard error by default. To send them to your application logger, pass it in: Featureflip::FileSnapshotStore.new(path, logger: Rails.logger).

FileSnapshotStore won’t create the parent directory, so it must already exist. Check the owner too. Docker creates a new named volume owned by root unless the image already has that directory, so a process running as a non-root user fails every write and logs a warning each time. Create the directory in your Dockerfile and hand it to that user, for example RUN mkdir -p /var/lib/featureflip && chown app /var/lib/featureflip.

client.startup_info reports where the flag data came from. It returns a frozen Featureflip::StartupInfo struct:

FieldValuesMeaning
source"live", "snapshot", "none" (also Featureflip::Snapshot::SOURCE_LIVE, SOURCE_SNAPSHOT and SOURCE_NONE)live: the initial fetch succeeded, or the client has since connected after starting from snapshot or none. snapshot: the client started from the store. none: no data yet, so evaluations return your defaults
reason"snapshot", "snapshot-expired", "snapshot-invalid", or nilSet when a snapshot was considered, and cleared once the client is live. snapshot: it was used. snapshot-expired: it was older than max_snapshot_age_seconds. snapshot-invalid: the stored text could not be used. That covers a garbage, empty or cut-off file, a snapshot written for a different SDK key or base URL, and a timestamp more than 60 seconds in the future. An empty store sets no reason
snapshot_age_secondsInteger or nilAge of the snapshot that was used or rejected for age, in whole seconds
info = client.startup_info
if info.source != "live"
logger.warn("flags did not load from the API: #{info.source} #{info.reason} #{info.snapshot_age_seconds}")
end

To share one snapshot across processes, pass any object with two methods. get returns the string last stored, or nil when there is none, and put(text) replaces it. Both may be called from a background thread. This sketch uses Redis:

require "redis"
class RedisSnapshotStore
KEY = "featureflip:snapshot:production".freeze
def initialize(redis)
@redis = redis
end
def get
@redis.get(KEY)
end
def put(text)
@redis.set(KEY, text)
end
end
config = Featureflip::Config.new(snapshot_store: RedisSnapshotStore.new(Redis.new(url: ENV.fetch("REDIS_URL"))))

The store never needs to parse the text. The SDK wraps the configuration in its own envelope, with a timestamp and a hash of the SDK key and base URL, and checks all of it on read. Text written for a different environment is rejected as snapshot-invalid and never served, which is why one store instance can safely be shared by clients for several SDK keys. Still give each environment its own key or path. Two environments sharing one slot overwrite each other’s snapshot.

A store that raises is treated as having no snapshot, or as a failed write, and the client logs a warning.

The Ruby SDK provides separate typed methods for each flag type. Context can use string or symbol keys — symbols are automatically converted to strings.

enabled = client.bool_variation("dark-mode", { user_id: "u-123" }, false)
tier = client.string_variation("pricing-tier", { user_id: "u-123" }, "free")
limit = client.number_variation("rate-limit", { user_id: "u-123" }, 100)
config = client.json_variation("ui-config", { user_id: "u-123" }, { "theme" => "light" })

Signature (same for all typed methods):

def bool_variation(key, context, default_value)
ParameterTypeDescription
keyStringThe flag key to evaluate
contextHashUser attributes for targeting. The user_id key is used for percentage rollouts
default_valueObjectValue returned if the flag is not found or evaluation fails

These methods never raise exceptions. On any error, the default value is returned.

Returns an EvaluationDetail with the evaluated value and metadata about the evaluation decision.

detail = client.variation_detail("dark-mode", { user_id: "u-123" }, false)
detail.value # true
detail.reason # "RuleMatch"
detail.rule_id # "rule-abc"
detail.variation_key # "true"

Signature:

def variation_detail(key, context, default_value)

A Struct containing the evaluation result:

PropertyTypeDescription
valueObjectThe evaluated flag value
reasonStringWhy this value was returned
rule_idString or nilThe ID of the matched targeting rule, if applicable
variation_keyString or nilThe key of the matched variation
ValueDescription
"Fallthrough"No rules matched; the fallthrough variation was served
"RuleMatch"A targeting rule matched the context
"FlagDisabled"The flag is disabled; the off variation was served
"FlagNotFound"The flag key does not exist
"Error"An error occurred during evaluation

Records a custom analytics event.

client.track("purchase-completed", { user_id: "u-123" }, {
plan: "pro",
amount: 29.99,
})

Signature:

def track(event_key, context, metadata = nil)

Featureflip counts each event name, per environment. The metadata you pass is transmitted with the event but is not stored, and event counts are not surfaced in the app or API.

Records an Identify event for the given user. The event is counted per environment, and the attributes you pass are not stored.

This does not affect flag evaluation. Targeting rules and segments are matched against the context you pass to each variation call. identify() does not set a context for subsequent calls, and it does not persist user attributes for later targeting.

client.identify({
user_id: "u-123",
email: "user@example.com",
plan: "pro",
})

Signature:

def identify(context)

Subscribe to flag changes and your listener receives the sorted keys of every flag a change touched. One change makes one call, however many flags it hit.

unsubscribe = client.on_update do |keys|
puts "flags changed: #{keys.inspect}"
Rails.cache.delete_multi(keys.map { |k| "flag:#{k}" })
end

A flag is reported when it’s added, removed or edited. Two more cases get reported because they change what a flag evaluates to even though its own configuration stayed put: a flag whose targeting rules reference an edited segment, and a flag that depends on a changed flag through a prerequisite.

The initial flag load doesn’t fire the listener. Neither does an identical poll or a redelivered streaming event, and the list is never empty.

Listeners run on the SDK’s streaming or polling thread, so don’t block in them. A slow listener delays flag delivery and can stall the connection. Do the minimum and push real work onto a queue or a worker thread. After a streaming outage the fallback poller and the stream briefly overlap, and then two threads can call your listener at once. Make it thread-safe.

If a listener raises, the SDK logs the error and moves on. The other listeners still run and flag delivery isn’t affected.

on_update returns a callable that unsubscribes. Call it twice and nothing happens the second time. A dispatch already in flight can still reach the listener once after you unsubscribe. Closing a client handle drops every subscription that handle made and leaves other handles on the same SDK key alone.

unsubscribe.call

The singleton API exposes the same method:

Featureflip.on_update { |keys| refresh(keys) }

Signature:

def on_update(listener = nil, &block)

Pass either a block or any object that responds to call, not both.

Shuts down the client, stops streaming or polling, and flushes remaining events.

client.close

Can be called multiple times safely.

Forces all buffered events to be sent to the server immediately.

client.flush

Respawns background threads after a fork. Required for Puma, Unicorn, and Passenger.

client.restart

Ruby web servers that use forking (Puma, Unicorn, Passenger) kill background threads in worker processes. Call restart after fork to respawn streaming/polling threads:

config/puma.rb
on_worker_boot do
Featureflip.restart
end
# config/unicorn.rb
after_fork do |server, worker|
Featureflip.restart
end

Creates a test client with fixed flag values. No network calls are made and no background threads are started.

client = Featureflip::Client.for_testing(
"dark-mode" => true,
"pricing-tier" => "enterprise",
"rate-limit" => 500,
)
client.bool_variation("dark-mode", {}, false) # true
client.string_variation("pricing-tier", {}, "free") # "enterprise"
client.bool_variation("unknown", {}, false) # false (default)

Signature:

def self.for_testing(flags)
ParameterTypeDescription
flagsHashA hash mapping flag keys to their values
RSpec.describe MyService do
let(:client) { Featureflip::Client.for_testing("premium-feature" => true) }
it "enables premium feature" do
service = MyService.new(feature_client: client)
expect(service.premium_enabled?).to be true
end
end

All public types are available from the Featureflip namespace:

require "featureflip"
Featureflip::Client
Featureflip::Config
Featureflip::Models::EvaluationDetail
ExceptionDescription
Featureflip::ErrorBase exception for all SDK errors
Featureflip::InitializationErrorRaised when initialization fails or times out
Featureflip::ConfigurationErrorRaised for invalid configuration values