Ruby SDK

Evaluate flags in your Ruby and Rails servers, with no network call per evaluation and nothing extra to set up under Puma or Unicorn.

The Ruby SDK keeps a copy of an environment’s flags in your process and evaluates them there, so an evaluation never waits for the network and your users’ attributes never leave your servers. It needs Ruby 3.2 or later and the standard library only.

Install

# Gemfile
gem "featflags"
gem "featflags-openfeature"  # optional: the OpenFeature provider
gem "featflags-otel"         # optional: the OpenTelemetry hook

The gems are on RubyGems: featflags (the client), featflags-openfeature and featflags-otel.

Create a client

Create one client per environment key when your app boots, and share it: it’s safe across threads. On a server, use the environment’s server key (sk_…, under Environment → Keys), which reaches every flag; keep it secret.

require "featflags"

FLAGS = Featflags::Client.new(
  ENV.fetch("FEATFLAGS_KEY"),        # sk_…
  "https://flags.featflags.io",
  events_url: "https://events.featflags.io",
)
at_exit { FLAGS.close }

Without events_url the client sends nothing back: no evaluation counts for the flag’s page, and no experiment data.

Keyword Default What
events_url off Where evaluation counts, experiment exposures and track events go.
poll_interval 5 s for sk_… keys, 60 s otherwise How often the client checks for new flags, ±10% jitter. Zero or less turns polling off.
flush_interval 60 s How often counts, exposures and events are sent. They also go once 5,000 exposures and events are waiting. The first flush waits up to one extra interval at random, so a fleet that starts together doesn’t send together.
on_exposure none Called with a Featflags::Exposure the first time each (context key, flag, variant) is evaluated, to forward to your own analytics.
bootstrap none Flags to evaluate against until the first fetch answers: a Featflags::Document, or its JSON as text or a hash.
timeout 10 s HTTP timeout for fetching flags and sending counts.
membership_cache 1024 List shards kept in memory (about 2 MB) when a project’s lists are too big to download whole.
membership_timeout 0.5 s How long one evaluation waits for list shards it doesn’t have yet.
hooks none Featflags::Hooks run around every evaluation.
logger Logger on stderr at WARN Where warnings go. Pass Rails.logger in a Rails app.
experiment_key none The environment’s experiment key, xk_…, for experiments.

The constructor fetches the flags once, bounded by timeout. If that fails, the client starts anyway: it serves bootstrap, or your fallbacks with reason not_found, until a later poll succeeds.

Evaluate flags

Each typed method takes the flag key, a context and the fallback your code gets when the flag is missing, the context is invalid or the value has another type:

user = { key: "u-alice", plan: "pro", groups: ["qa"] }

if FLAGS.bool_variation("new-checkout", user, false)
  # …
end

limit = FLAGS.number_variation("upload-limit-mb", user, 50)
theme = FLAGS.string_variation("theme", user, "light")
config = FLAGS.json_variation("search-config", user, {})

number_variation returns an Integer or a Float as the JSON has it. json_variation returns a fresh copy of arrays and hashes on every call.

To know why a context got a value, use the …_detail methods (bool_variation_detail, string_variation_detail, …). They return a Featflags::Detail with value, variant, reason, and rule_id or prerequisite when they apply:

d = FLAGS.string_variation_detail("pricing-page", user, "v1")
Rails.logger.info("pricing-page: #{d.value} (#{d.reason}, rule #{d.rule_id})")

Concepts lists the reasons.

Contexts

A context is a hash with string or symbol names. key is required; kind defaults to user.

{ key: "u-alice", email: "alice@acme.com" }                            # a user
{ kind: "org", key: "acme", plan: "team" }                             # another kind
{ kind: "multi", user: { key: "u-alice" }, org: { key: "acme", plan: "team" } }

Kinds and keys are strings (kind: :org is invalid). Attribute values are strings, numbers, booleans or arrays of those; nil, hashes, symbols and nested arrays count as missing. An invalid context evaluates to the fallback with reason error.

To evaluate many flags for one context, parse it once with Featflags::Context.new(hash) and pass that. valid? says whether it’s valid, kinds lists its kinds and key(kind) returns a kind’s key. More on contexts in Concepts.

Lists and private segments

Targets and segment lists of up to 64 keys travel with the flags. Longer lists, up to millions of keys, are split into small shards the client fetches separately:

  • When all of an environment’s lists total under 50 MB, the client downloads every shard in the background as soon as the flags arrive.
  • Otherwise it fetches a shard the first time an evaluation needs it, and keeps the most recently used ones (membership_cache).

An evaluation that needs a shard the client doesn’t have yet waits for it on its thread, at most membership_timeout (0.5 s, shared by every list lookup in the evaluation). Past that, the list doesn’t match, the shard keeps loading, and the reason is membership_pending: the value is what the flag serves without that list, and later evaluations get the full answer.

Private segments can’t be read by a browser on its own. Your backend computes a token per context kind with membership_token and hands it to the browser at sign-in:

tokens = FLAGS.membership_token({ key: user_id }) # {"user" => "8ecc…"}

The browser passes them to identify(context, { membershipTokens }) in the TypeScript SDK. membership_token is empty when the environment has no private lists, or for an invalid context.

Staying up to date

The client checks for new flags every 5 seconds with a server key (60 seconds with a client key), and only downloads them when they’ve changed. It never goes back to older flags, and if featflags can’t be reached it keeps serving the last flags it had. version says which version of the flags it’s using.

refresh fetches the flags now and flush sends counts now; both raise on failure (Featflags::HTTPError, Featflags::DocumentError, or the network error).

Forking servers

Under Puma in cluster mode with preload_app!, Unicorn with preload_app true, Pitchfork or Resque, a client built at boot is forked into every worker. The SDK handles this itself: in each forked worker, every open client starts polling and sending counts again. What the parent had counted stays with the parent, so nothing is sent twice. You don’t need an on_worker_boot or after_fork hook; the initializer above is enough.

The one exception is a fork that bypasses Ruby (Process.daemon, or a C extension calling fork(2) directly): call FLAGS.after_fork in the child there.

Experiments

To run experiments, give the client events_url and the environment’s experiment key, xk_…. It’s shown once when you generate it; keep it with your other secrets, since featflags never holds it.

FLAGS = Featflags::Client.new(ENV.fetch("FEATFLAGS_KEY"), "https://flags.featflags.io",
  events_url: "https://events.featflags.io",
  experiment_key: ENV.fetch("FEATFLAGS_EXPERIMENT_KEY"))

on = FLAGS.bool_variation("new-checkout", user, false) # counts as an exposure if an experiment served it
FLAGS.track(user, "checkout.completed", order.total)    # an event key and an optional number
FLAGS.track(user, "page.error")
  • An evaluation an experiment served is an exposure, sent once per user, variant and period.
  • track sends the event to every running experiment whose metrics count it, and nothing when none does, so tracking freely costs nothing. It doesn’t need the user to have been exposed in this process: the exposure often happened in the browser.
  • Event keys match [a-z0-9_.:-]{1,64}, and the value is an optional number below 10¹⁵. A bad key or value is logged once and dropped, never raised.
  • Users are sent as an ID made from their key with your experiment key, different in every experiment. Nothing else about them is sent.
  • After you rotate the experiment key, a deploy still holding the old one sends nothing, and logs it once.

Browsers and mobile apps shouldn’t hold the experiment key, since anything in an app is public. Give each signed-in user their own seeds instead, alongside their membership tokens:

seeds = FLAGS.experiment_seeds(user) # {"fingerprint" => "e178…", "seeds" => {"user" => "d457…"}}; nil without a key
render json: { membershipTokens: FLAGS.membership_token(user), experimentSeeds: seeds }

The client SDKs take them in identify. How units and seeds are computed is in the SDK specification.

Hooks

A hook runs around every evaluation, for logging, metrics or tracing. Subclass Featflags::Hook and override the stages you need: before_evaluation runs in the order hooks were added, after_evaluation in reverse, with the detail the caller gets. A stage that raises is logged and skipped.

class Timer < Featflags::Hook
  def name = "timer"

  def before_evaluation(series, data)
    data.merge(start: Process.clock_gettime(Process::CLOCK_MONOTONIC))
  end

  def after_evaluation(series, data, detail)
    Rails.logger.info("#{series.method} #{series.flag_key}: #{detail.reason} in #{Process.clock_gettime(Process::CLOCK_MONOTONIC) - data[:start]}s")
    data
  end
end

FLAGS = Featflags::Client.new(key, "https://flags.featflags.io", hooks: [Timer.new])

track runs no hooks. The full contract is in the SDK specification.

OpenTelemetry

The featflags-otel gem adds a feature_flag.evaluation event, with OpenTelemetry’s semantic convention attributes, to the current span:

require "featflags/otel"

FLAGS = Featflags::Client.new(key, "https://flags.featflags.io", hooks: [Featflags::Otel::Hook.new])

OpenFeature

The featflags-openfeature gem is a provider for the OpenFeature Ruby SDK (openfeature-sdk):

require "featflags/openfeature"

OpenFeature::SDK.configure do |config|
  config.set_provider(Featflags::OpenFeature::Provider.new(FLAGS)) # the provider doesn't close the client
end

client = OpenFeature::SDK.build_client
on = client.fetch_boolean_value(flag_key: "new-checkout", default_value: false,
  evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u-alice", plan: "pro"))

The targeting key is the context key. Add a kind field for another kind, or kind: "multi" with each member as a hash under its kind, each with its own targetingKey. Pass dates as RFC 3339 strings or epoch milliseconds: Time values count as missing. Flag metadata carries the featflags reason, and rule_id and prerequisite when set. How reasons map to OpenFeature’s is on the OpenFeature page.

Shutting down

close stops polling and sends what’s left to send. Call it before a short-lived process exits (a Rake task, a script), or in at_exit as above, or that process’s last counts are lost.