ruby
47 lines · 8 steps
How A/B test cohorts are assigned in Rails
A base controller sticks each visitor into a stable experiment variant using signed cookies and a hashed bucket.
Explained by
highlit
1class ApplicationController < ActionController::Base
2 EXPERIMENTS = {
3 checkout_button_color: %w[control blue green],
4 onboarding_flow: %w[control streamlined]
5 }.freeze
6
7 before_action :assign_experiment_cohorts
8
9 private
10
11 def assign_experiment_cohorts
12 @cohorts = EXPERIMENTS.each_with_object({}) do |(experiment, variants), memo|
13 memo[experiment] = cohort_for(experiment, variants)
14 end
15 end
16
17 def cohort_for(experiment, variants)
18 cookie_key = "ab_#{experiment}"
19 existing = cookies.signed[cookie_key]
20 return existing if existing && variants.include?(existing)
21
22 variant = variants[bucket(experiment) % variants.size]
23 cookies.signed[cookie_key] = {
24 value: variant,
25 expires: 30.days.from_now,
26 httponly: true
27 }
28
29 Ahoy.instance.track(
30 "$experiment",
31 experiment: experiment,
32 variant: variant
33 )
34
35 variant
36 end
37
38 def bucket(experiment)
39 seed = current_user&.id || cookies.permanent.signed[:visitor_id] ||= SecureRandom.uuid
40 Digest::MD5.hexdigest("#{experiment}:#{seed}").to_i(16)
41 end
42
43 def variant?(experiment, name)
44 @cohorts[experiment] == name.to_s
45 end
46 helper_method :variant?
47end
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Deriving assignment from a hash of a stable seed makes cohort membership deterministic and consistent across requests.
- 2Persisting the chosen variant in a signed cookie lets you honor a prior assignment before recomputing anything.
- 3Exposing a query helper keeps view code declarative while all the bucketing logic stays in one place.
Related explainers
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
ruby
class UserAgentParser BROWSERS = [ [/Edg\/([\d.]+)/, "Edge"], [/OPR\/([\d.]+)/, "Opera"],
Parsing user-agent strings in Ruby
regex
pattern-matching
lookup-tables
Intermediate
8 steps
ruby
class LogAggregator BUCKET_FORMAT = "%Y-%m-%dT%H:%M" def initialize(entries)
Bucketing log entries by the minute in Ruby
aggregation
hashing
enumerable
Intermediate
5 steps
ruby
class WeeklySignupsReport DEFAULT_WEEKS = 12 def initialize(weeks: DEFAULT_WEEKS, source: User.all)
Building a weekly signups report in Rails
service object
aggregation
group by
Intermediate
7 steps
ruby
class SnippetHighlighter CONTEXT_RADIUS = 60 MAX_TERMS = 8
Building search-result snippets in Ruby
regular-expressions
text-processing
search
Intermediate
9 steps
ruby
require "openssl" require "base32" class TOTP
How TOTP one-time codes work in Ruby
hmac
authentication
totp
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/how-a-b-test-cohorts-are-assigned-in-rails-explained-ruby-2cde/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.