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.
Install
Add the avsb gem to your Gemfile and run bundle install.
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.
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.
Include the controller concern
include Avsb::Middleware::Rails::Controller in ApplicationController adds an avsb_client helper to every action and view.
Read a flag
Every read takes default_value: and a context: and returns an Avsb::Flag, never a bare value.
Track a conversion
Call track with the same context. Exposures are automatic, so experiment results appear without any extra call.
Test without the network
Pass a datafile straight to the client and switch events and polling off.
Add the gem to your Gemfile:
gem "avsb"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:
bundle installStore your SDK key in credentials or an environment variable:
# Rails credentialsrails credentials:edit# add: avsb_sdk_key: sdk_production_ttqm0eaj4vth1krcb2xn# or an environment variableexport AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xnAn 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:
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 }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:
class ApplicationController < ActionController::Base include Avsb::Middleware::Rails::Controller # avsb_client is now available in controllers and, as a helper, in viewsendRead a flag in an action:
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) endendEvaluate 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:
<% if @checkout.value %> <%= render "checkout/new" %><% else %> <%= render "checkout/legacy" %><% end %>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.
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" }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:
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)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:
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}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
# 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)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:
avsb_client.track_purchase(context, { "orderId" => "ORD-1001", "total" => 99.95, "currency" => "USD", "items" => [{ "sku" => "SKU-1", "price" => 99.95, "quantity" => 1 }]})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.
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 nowCallbacks are the non-blocking alternative, and fire immediately when the client is already ready:
Avsb.client.on_ready { |result| Rails.logger.info("avsb ready: #{result[:source]}") }Avsb.client.on_error { |error| Sentry.capture_exception(error) }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.
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:
# config.rurequire "avsb"client = Avsb::Server.new(sdk_key: ENV.fetch("AVSB_SDK_KEY"))use Avsb::Middleware::Rack, client: clientrun MyAppclient = env["avsb.client"]Testing
Pass a datafile straight to the client. With events: false and polling_interval: nil the test touches no network at all.
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") endendTo exercise a controller against that client, point Avsb.client at it for the example:
before { Avsb.client = client }Avsb::TestUtils::SpyStickyBucketService records every sticky lookup and save for assertions, and takes a preset: Hash keyed "user_id:flag_key".
What's next
- Multi-context targeting: combine user and organization attributes in one evaluation.
- Sticky bucketing: keep a visitor on the variation they first saw, backed by Redis or DynamoDB.
- Decision logging: every decision the client made, with the reason trail.
- Credentials: all four A vs B credentials and which one to reach for.