Ruby on Rails

This guide targets Ruby on Rails 7.1 or later on Ruby 3.2 or later. The avsb gem holds one client per process: it keeps the datafile in memory, refreshes it in the background, and batches events. Rails gets a controller concern that exposes that client to actions and views. By the end you will be reading typed flags in controllers, tracking conversions, and flushing cleanly on shutdown.

1

Install

Add the avsb gem to your Gemfile and run bundle install.

2

Add your SDK key

Open your A vs B project, go to Environments in the project sidebar, and copy the SDK key for the environment this deployment talks to. Store it in Rails credentials or an environment variable.

3

Build the client in an initialiser

Write config/initializers/avsb.rb yourself. There is no generator. One Avsb::Server per process, kept on Avsb.client.

4

Include the controller concern

include Avsb::Middleware::Rails::Controller in ApplicationController adds an avsb_client helper to every action and view.

5

Read a flag

Every read takes default_value: and a context: and returns an Avsb::Flag, never a bare value.

6

Track a conversion

Call track with the same context. Exposures are automatic, so experiment results appear without any extra call.

7

Test without the network

Pass a datafile straight to the client and switch events and polling off.

Add the gem to your Gemfile:

Ruby
gem "avsb"
Ruby1 line
Publishing in progress

Version 1.0.1 of the avsb gem is on its way to RubyGems with this release. Until it arrives there, bundle install reports that it cannot find the gem.

Then install:

Shell
bundle install
Shell1 line

Store your SDK key in credentials or an environment variable:

Shell
# Rails credentialsrails credentials:edit# add:  avsb_sdk_key: sdk_production_ttqm0eaj4vth1krcb2xn# or an environment variableexport AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
Shell6 lines
Your SDK key is public
Your SDK key is a public identifier, not a secret: it is safe to ship in browser and mobile bundles, it can only fetch that environment's flag configuration and send events, and it can never read or change anything in your dashboard. Credentials covers all four A vs B credentials and which one to reach for.

An SDK key is sdk_, then the environment name, then a generated id. The SDK checks that shape when you build the client, so a pasted dashboard URL or a truncated copy is reported at boot instead of turning into a 404 later.

Create config/initializers/avsb.rb:

Ruby
Avsb.configure do |config|  config.sdk_key          = Rails.application.credentials.avsb_sdk_key || ENV["AVSB_SDK_KEY"]  config.polling_interval = 60endAvsb.client = Avsb::Server.new(  sdk_key: Avsb.configuration.sdk_key,  logger: Rails.logger,  polling_interval: Avsb.configuration.polling_interval)at_exit { Avsb.client&.close }
Ruby12 lines

Avsb.configure only stores settings. Nothing reads them for you and nothing builds the client for you, which is why the snippet passes them to Avsb::Server.new and assigns the result to Avsb.client. That accessor is what the controller concern reads, so skipping the assignment leaves avsb_client returning nil.

Include the concern in app/controllers/application_controller.rb:

Ruby
class ApplicationController < ActionController::Base  include Avsb::Middleware::Rails::Controller  # avsb_client is now available in controllers and, as a helper, in viewsend
Ruby4 lines

Read a flag in an action:

Ruby
class CheckoutController < ApplicationController  def show    context = {      "kind" => "user",      "key"  => current_user.id.to_s,      "plan" => current_user.plan    }    @checkout = avsb_client.get_bool_flag("checkout_v2", default_value: false,                                          context: context)    @theme = avsb_client.get_string_flag("ui_theme", default_value: "default",                                         context: context)  endend
Ruby14 lines

Evaluate everything a response needs at the top of the action and pass the values to the view, rather than calling the client from inside templates:

ERB
<% if @checkout.value %>  <%= render "checkout/new" %><% else %>  <%= render "checkout/legacy" %><% end %>
ERB5 lines

Context and identity

A context is a plain Hash in the wire shape: string keys, with "kind" and "key" required. "key" is the bucketing identity.

Ruby
context = {  "kind"        => "user",  "key"         => current_user.id.to_s,  "plan"        => "pro",       # targeting attributes, any JSON value  "beta_tester" => true}# Avsb::EvalContext builds one for youAvsb::EvalContext.user("alice", plan: "pro").to_h# => { "kind" => "user", "key" => "alice", "plan" => "pro" }
Ruby10 lines

Evaluating with an empty "key" buckets every caller identically and makes experiment data meaningless, so the SDK warns once per process and names the call to add.

Several kinds at once use the multi shape, with string keys all the way down:

Ruby
context = {  "kind"         => "multi",  "user"         => { "kind" => "user",         "key" => current_user.id.to_s },  "organization" => { "kind" => "organization", "key" => current_user.org_key,                      "tier" => "enterprise" }}flag = avsb_client.get_bool_flag("enterprise_feature", default_value: false,                                 context: context)
Ruby9 lines

When a rule buckets on organization.key, everyone in the same organization gets the same variation regardless of their own user key.

What a read gives you back

Every getter returns an Avsb::Flag:

Ruby
flag = avsb_client.get_bool_flag("checkout_v2", default_value: false, context: context)flag.value           # Object          the evaluated value, your typeflag.variation_key   # String, nil     nil for default, not_found, not_readyflag.source          # String          why this value, for example "rule"flag.rule_id         # String, nil     the rule or holdout that decidedflag.reasons         # Array<String>   frozen decision trail, in orderflag.enabled?        # true, false     a real decision AND a truthy valueflag.exists?         # true, false     false for not_found and not_readyflag.to_h            # Hash{Symbol=>Object}
Ruby10 lines

The four getters are get_bool_flag, get_string_flag, get_number_flag, and get_json_flag. Each takes (flag_key, default_value:, context: nil) and each requires default_value:. You get that default back when the flag is missing, when the flag is off, or when no datafile has loaded yet.

The getters do not coerce and do not raise. flag.value is whatever JSON.parse produced for that variation, so calling get_bool_flag on a flag whose variations hold strings gives you the string. Read flag.value when you want the value and flag.enabled? when you want a gate.

flag.enabled? is true only when a rule, holdout, bandit, sticky assignment, or override decided the value and that value is truthy. Truthy means what it means in JavaScript, because the reference engine is TypeScript and every SDK must answer identically: false, 0, "", and nil are falsy, and everything else is truthy, including the strings "0" and "false".

Tracking conversions

Ruby
# Avsb::Server#track(event_name, context:, revenue: nil, value: nil) -> voidavsb_client.track("signup_completed", context: context)avsb_client.track("checkout_completed", context: context, revenue: 49.99)avsb_client.track("items_per_order", context: context, value: 3)
Ruby4 lines

event_name is the metric key from your dashboard. An unknown key is stored, so a typo shows up as an empty metric rather than an error. revenue is money in decimal major units. value is the number for average-value metrics.

Purchases take a string-keyed Hash and send immediately rather than waiting for a batch. "orderId" and "total" are required; "currency", "subtotal", "shipping", "tax", "discount", "coupon", "test", and "items" are optional:

Ruby
avsb_client.track_purchase(context, {  "orderId"  => "ORD-1001",  "total"    => 99.95,  "currency" => "USD",  "items"    => [{ "sku" => "SKU-1", "price" => 99.95, "quantity" => 1 }]})
Ruby6 lines

Exposures are automatic. Every A/B test, bandit, and holdout decision records one, so nothing extra is needed to make experiment results appear.

Startup and shutdown

Building the client never blocks: the first fetch runs on a background thread, and refreshes run every polling_interval seconds after that. A flag read before the first fetch settles returns your default_value: with source "not_ready" and logs a warning naming the call to add, so it fails loudly rather than quietly serving the wrong thing.

Ruby
Avsb.client.wait_for_ready(timeout: 5)  # => { success: true, source: "network" }Avsb.client.ready?                      # a datafile is loaded and readable nowAvsb.client.degraded?                   # a refresh failed, so this datafile is olderAvsb.client.pending_event_count         # events queued and not yet sentAvsb.client.flush                       # send queued events now
Ruby5 lines

Callbacks are the non-blocking alternative, and fire immediately when the client is already ready:

Ruby
Avsb.client.on_ready { |result| Rails.logger.info("avsb ready: #{result[:source]}") }Avsb.client.on_error { |error| Sentry.capture_exception(error) }
Ruby2 lines

close stops the refresh thread and flushes queued events, so a shutting-down process does not throw away conversions it just recorded. The at_exit hook in the initialiser covers Puma's SIGTERM path, where Puma finishes in-flight requests before the process exits.

Forked servers

Puma and Unicorn in clustered mode fork workers after boot, and the refresh thread does not survive a fork. Build the client in on_worker_boot (Puma) or after_fork (Unicorn) instead of the initialiser when you run more than one worker per process.

Plain Rack applications

Outside Rails, the gem ships a Rack middleware that puts the client in the Rack env:

Ruby
# config.rurequire "avsb"client = Avsb::Server.new(sdk_key: ENV.fetch("AVSB_SDK_KEY"))use Avsb::Middleware::Rack, client: clientrun MyApp
Ruby6 lines
Ruby
client = env["avsb.client"]
Ruby1 line

Testing

Pass a datafile straight to the client. With events: false and polling_interval: nil the test touches no network at all.

Ruby
require "rails_helper"RSpec.describe "checkout flag" do  let(:datafile) do    {      "version" => 2,      "sdkKey"  => "sdk_production_xxxxxxxxxxxxxxxx",      "flags"   => [{        "id" => "flag_checkout", "key" => "checkout_v2", "type" => "boolean",        "enabled" => true, "defaultVariationId" => "var_off",        "variations" => [          { "id" => "var_off", "key" => "off", "value" => false },          { "id" => "var_on",  "key" => "on",  "value" => true }        ],        "overrides" => [],        "rules" => [{          "id" => "rule_all", "type" => "targeted_delivery", "enabled" => true,          "audienceIds" => [], "hashAttribute" => "user.key",          "trafficAllocation" => nil,          "variations" => [{ "variationId" => "var_on", "percentage" => 1 }]        }]      }],      "audiences" => []    }  end  let(:client) do    Avsb::Server.new(sdk_key: "sdk_production_xxxxxxxxxxxxxxxx", datafile: datafile,                     events: false, polling_interval: nil)  end  after { client.close }  it "serves the on variation" do    flag = client.get_bool_flag("checkout_v2", default_value: false,                                context: { "kind" => "user", "key" => "alice" })    expect(flag.value).to be true    expect(flag.enabled?).to be true    expect(flag.source).to eq("rule")  end  it "returns the default for a flag the datafile does not carry" do    flag = client.get_bool_flag("no_such_flag", default_value: false,                                context: { "kind" => "user", "key" => "alice" })    expect(flag.value).to be false    expect(flag.exists?).to be false    expect(flag.source).to eq("not_found")  endend
Ruby51 lines

To exercise a controller against that client, point Avsb.client at it for the example:

Ruby
before { Avsb.client = client }
Ruby1 line

Avsb::TestUtils::SpyStickyBucketService records every sticky lookup and save for assertions, and takes a preset: Hash keyed "user_id:flag_key".

What's next

Was this helpful?