Skip to content

Commit 92b3b44

Browse files
andrehjrclaude
andcommitted
Show and clear split_override cookies from the dashboard
"Force for current user" sets a split_override cookie, but the dashboard gave no sign it had done so. Because an override is not counted as a participant unless store_override is set, a pinned experiment reads zero participants while still rendering an alternative, which looks like the experiment is broken. The dashboard now lists the overrides active in the browser at the top of the page, explains why the numbers can read zero, marks the pinned alternative in the experiment's table, and can clear them one at a time or all at once. The cookie name is now a constant shared by the helper and the dashboard, instead of being spelled out at each use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 41a59cb commit 92b3b44

10 files changed

Lines changed: 285 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ If you have an experiment called `button_color` with alternatives called `red` a
168168

169169
will always have red buttons. This won't be stored in your session or count towards to results, unless you set the `store_override` configuration option.
170170

171+
The dashboard's "Force for current user" button does the same thing for your browser, by setting a `split_override` cookie. Because an override is not counted, an experiment you have pinned can keep reading zero participants. The dashboard lists any overrides active in your browser at the top of the page, and lets you clear them individually or all at once.
172+
171173
In the event you want to disable all tests without having to know the individual experiment names, add a `SPLIT_DISABLE` query parameter.
172174

173175
http://myawesomesite.com?SPLIT_DISABLE=true

‎lib/split.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ module Split
3030
extend self
3131
attr_accessor :configuration
3232

33+
OVERRIDE_COOKIE_NAME = "split_override"
34+
3335
# Accepts:
3436
# 1. A redis URL (valid for `Redis.new(url: url)`)
3537
# 2. an options hash compatible with `Redis.new`

‎lib/split/dashboard.rb‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,21 @@ class Dashboard < Sinatra::Base
4545
experiment = Split::ExperimentCatalog.find(params[:experiment])
4646
alternative = Split::Alternative.new(params[:alternative], experiment.name)
4747

48-
cookies = JSON.parse(request.cookies["split_override"]) rescue {}
49-
cookies[experiment.name] = alternative.name
50-
response.set_cookie("split_override", { value: cookies.to_json, path: "/" })
48+
write_overrides(active_overrides.merge(experiment.name => alternative.name))
49+
50+
redirect url("/")
51+
end
52+
53+
post "/clear_override" do
54+
overrides = active_overrides
55+
overrides.delete(params[:experiment])
56+
write_overrides(overrides)
57+
58+
redirect url("/")
59+
end
60+
61+
post "/clear_overrides" do
62+
response.delete_cookie(Split::OVERRIDE_COOKIE_NAME, path: "/")
5163

5264
redirect url("/")
5365
end

‎lib/split/dashboard/helpers.rb‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
# frozen_string_literal: true
22

33
require "bigdecimal"
4+
require "json"
45

56
module Split
67
module DashboardHelpers
78
def h(text)
89
Rack::Utils.escape_html(text)
910
end
1011

12+
def active_overrides
13+
raw = request.cookies[Split::OVERRIDE_COOKIE_NAME]
14+
return {} if raw.nil? || raw.empty?
15+
16+
overrides = JSON.parse(raw)
17+
overrides.is_a?(Hash) ? overrides : {}
18+
rescue JSON::ParserError
19+
{}
20+
end
21+
22+
def write_overrides(overrides)
23+
if overrides.empty?
24+
response.delete_cookie(Split::OVERRIDE_COOKIE_NAME, path: "/")
25+
else
26+
response.set_cookie(Split::OVERRIDE_COOKIE_NAME, { value: overrides.to_json, path: "/" })
27+
end
28+
end
29+
1130
def url(*path_parts)
1231
[ request.env["SCRIPT_NAME"], path_parts ].join("/").squeeze("/")
1332
end

‎lib/split/dashboard/public/style.css‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ th, td { text-align: left; font-weight: normal; }
3838
--green-hover: light-dark(#267346, #45b075);
3939
--better: light-dark(#16a34a, #4ade80);
4040
--worse: light-dark(#dc2626, #f87171);
41+
--warn-bg: light-dark(#fef3c7, #3a2f12);
42+
--warn-border: light-dark(#fcd34d, #6b551c);
43+
--warn-text: light-dark(#92400e, #fbbf24);
4144
--shadow-color: light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.4));
4245
--shadow: 0 1px 3px var(--shadow-color), 0 1px 2px var(--shadow-color);
4346
--radius: 8px;
@@ -203,6 +206,22 @@ a {
203206
font-weight: 400;
204207
}
205208

209+
.experiment h2 .override {
210+
margin-left: 6px;
211+
padding: 2px 8px;
212+
border-radius: 999px;
213+
background: var(--warn-bg);
214+
color: var(--warn-text);
215+
font-size: 0.75em;
216+
font-weight: 600;
217+
}
218+
219+
.experiment table em.override-flag {
220+
color: var(--warn-text);
221+
font-style: normal;
222+
font-weight: 600;
223+
}
224+
206225
.experiment table em {
207226
font-style: italic;
208227
font-size: 0.9em;
@@ -250,9 +269,63 @@ a {
250269
color: var(--worse);
251270
}
252271

272+
.overrides {
273+
background: var(--warn-bg);
274+
border: 1px solid var(--warn-border);
275+
border-radius: var(--radius);
276+
padding: 14px 18px;
277+
margin-bottom: 20px;
278+
}
279+
280+
.overrides-header {
281+
display: flex;
282+
align-items: center;
283+
justify-content: space-between;
284+
gap: 12px;
285+
}
286+
287+
.overrides-header h2 {
288+
margin: 0;
289+
font-size: 1rem;
290+
font-weight: 600;
291+
color: var(--warn-text);
292+
}
293+
294+
.overrides .overrides-intro {
295+
margin: 8px 0 12px;
296+
color: var(--text);
297+
font-size: 0.85rem;
298+
line-height: 1.5;
299+
}
300+
301+
.overrides-list {
302+
list-style: none;
303+
margin: 0;
304+
padding: 0;
305+
display: flex;
306+
flex-wrap: wrap;
307+
gap: 8px;
308+
}
309+
310+
.overrides-list li {
311+
display: flex;
312+
align-items: center;
313+
gap: 8px;
314+
padding: 6px 10px;
315+
background: var(--surface);
316+
border: 1px solid var(--warn-border);
317+
border-radius: 6px;
318+
font-size: 0.85rem;
319+
}
320+
321+
.overrides-pair {
322+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
323+
}
324+
253325
.experiment a.button,
254326
.experiment button,
255327
.experiment input[type="submit"],
328+
.overrides input[type="submit"],
256329
.dashboard-controls input[type="submit"],
257330
.dashboard-controls input[type="button"] {
258331
padding: 6px 14px;
@@ -273,19 +346,23 @@ a {
273346
.experiment button:hover,
274347
.experiment input[type="submit"]:hover,
275348
.experiment input[type="submit"]:focus,
349+
.overrides input[type="submit"]:hover,
350+
.overrides input[type="submit"]:focus,
276351
.dashboard-controls input[type="submit"]:hover,
277352
.dashboard-controls input[type="button"]:hover {
278353
background: var(--btn-bg-hover);
279354
}
280355

281356
.experiment input[type="submit"].red,
282-
.experiment a.button.red {
357+
.experiment a.button.red,
358+
.overrides input[type="submit"].red {
283359
background: var(--red);
284360
color: #fff;
285361
}
286362

287363
.experiment input[type="submit"].red:hover,
288-
.experiment a.button.red:hover {
364+
.experiment a.button.red:hover,
365+
.overrides input[type="submit"].red:hover {
289366
background: var(--red-hover);
290367
}
291368

‎lib/split/dashboard/views/_experiment.erb‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
<h2>
1313
Experiment: <%= experiment.name %>
1414
<% if experiment.version > 1 %><span class='version'>v<%= experiment.version %></span><% end %>
15+
<% if (override = active_overrides[experiment.name]) %>
16+
<span class='override' title='Your browser is pinned to this alternative'>Override:<%= h override.to_s %></span>
17+
<% end %>
1518
<% unless goal.nil? %><span class='goal'>Goal:<%= goal %></span><% end %>
1619
<% metrics = @metrics.select {|metric| metric.experiments.include? experiment} %>
1720
<% unless metrics.empty? %>
@@ -52,6 +55,9 @@
5255
<% if alternative.control? %>
5356
<em>control</em>
5457
<% end %>
58+
<% if active_overrides[experiment.name] == alternative.name %>
59+
<em class="override-flag">forced for you</em>
60+
<% end %>
5561
<form action="<%= url('force_alternative') + '?experiment=' + experiment.name %>" method='post'>
5662
<input type='hidden' name='alternative' value='<%= h alternative.name %>'>
5763
<input type="submit" value="Force for current user" class="green">

‎lib/split/dashboard/views/index.erb‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
1+
<% overrides = active_overrides %>
2+
<% unless overrides.empty? %>
3+
<div class="overrides">
4+
<div class="overrides-header">
5+
<h2>Overrides active in this browser</h2>
6+
<form action="<%= url "/clear_overrides" %>" method='post'>
7+
<input type="submit" value="Clear all" class="red">
8+
</form>
9+
</div>
10+
11+
<p class="overrides-intro">
12+
These pin <strong>your own</strong> visits to a fixed alternative. Other users are unaffected.
13+
<% if Split.configuration.store_override %>
14+
Your visits are still counted as participants.
15+
<% else %>
16+
Your visits are <strong>not counted</strong> as participants, so these experiments can read
17+
zero below even though the page rendered an alternative for you.
18+
<% end %>
19+
</p>
20+
21+
<ul class="overrides-list">
22+
<% overrides.sort.each do |experiment_name, alternative_name| %>
23+
<li>
24+
<span class="overrides-pair">
25+
<%= h experiment_name %> &rarr; <strong><%= h alternative_name.to_s %></strong>
26+
</span>
27+
<form action="<%= url "/clear_override" %>" method='post'>
28+
<input type='hidden' name='experiment' value='<%= h experiment_name %>'>
29+
<input type="submit" value="Clear" class="red">
30+
</form>
31+
</li>
32+
<% end %>
33+
</ul>
34+
</div>
35+
<% end %>
36+
137
<% if @experiment_names.any? %>
238
<p class="intro">The list below contains all the registered experiments along with the number of test participants, completed and conversion rate currently in the system.</p>
339

‎lib/split/helper.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ def override_alternative_by_params(experiment_name)
127127
def override_alternative_by_cookies(experiment_name)
128128
return unless request_present?
129129

130-
if request.cookies && request.cookies.key?("split_override")
131-
experiments = JSON.parse(request.cookies["split_override"]) rescue {}
130+
if request.cookies && request.cookies.key?(Split::OVERRIDE_COOKIE_NAME)
131+
experiments = JSON.parse(request.cookies[Split::OVERRIDE_COOKIE_NAME]) rescue {}
132132
experiments[experiment_name]
133133
end
134134
end

‎spec/dashboard_helpers_spec.rb‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,48 @@
4040
end
4141
end
4242
end
43+
44+
describe "active_overrides" do
45+
let(:request) { double(cookies: cookies) }
46+
47+
context "with no override cookie" do
48+
let(:cookies) { {} }
49+
50+
it "is empty" do
51+
expect(active_overrides).to eq({})
52+
end
53+
end
54+
55+
context "with an empty override cookie" do
56+
let(:cookies) { { "split_override" => "" } }
57+
58+
it "is empty" do
59+
expect(active_overrides).to eq({})
60+
end
61+
end
62+
63+
context "with a valid override cookie" do
64+
let(:cookies) { { "split_override" => '{"link_color":"blue"}' } }
65+
66+
it "returns the experiment to alternative mapping" do
67+
expect(active_overrides).to eq("link_color" => "blue")
68+
end
69+
end
70+
71+
context "with a malformed override cookie" do
72+
let(:cookies) { { "split_override" => "not json" } }
73+
74+
it "is empty rather than raising" do
75+
expect(active_overrides).to eq({})
76+
end
77+
end
78+
79+
context "with a cookie holding JSON that is not an object" do
80+
let(:cookies) { { "split_override" => '["link_color"]' } }
81+
82+
it "is empty" do
83+
expect(active_overrides).to eq({})
84+
end
85+
end
86+
end
4387
end

0 commit comments

Comments
 (0)