Experiments

Table of Contents

The site runs a client-side feature-gate SDK modeled on Statsig's client payload shape. There is no server: the gate registry is a static EDN file served from /data/experiments.edn and evaluated in the browser.

The user id is a persistent uuid in localStorage under wal-sh.exp.uid — the local analogue of Statsig's analyticsAnonymousId.

Why

Two use cases pulled the same shape:

  • Tool rollouts. Ship a new /tools/ SPA behind a gate, ramp the percentage, and check nothing regresses before flipping to 100%.
  • Content variants. Split-test a page's layout, copy, or ordering with the same primitives, and record which arm the reader saw.

The 5 tools scaffolded from RubyConf 2026 (message-format, mutation, fib, gc-viz, fair-queue) each ship as a feature gate. Turn one off and its dropdown/link in site/tools/index disappears at render time.

Shape

Two record types, both keyed by string id:

  • feature-gates — return a boolean. Wrong id = false (fail-closed).
  • dynamic-configs — return a structured value. Wrong id = nil.

Both types share a :rules list. Each rule has:

Field Meaning
:id rule name (opaque; used for exposure attribution)
:percentage 0-100. Omitted = 100.
:salt changes the bucketing partition without renaming gates
:value the value returned when this rule matches
:secondary-exposures list of { :gate id :value bool } dependency checks

Rules are matched top-to-bottom; the first that matches wins. A :default rule (no percentage) at the bottom is the fallthrough.

Bucketing

Bucket assignment is deterministic on djb2(user-id ":" salt ":" gate-id) mod 10000. Djb2 was picked to match Statsig's hash_used: "djb2" — same-user-same-gate always land in the same bucket, which means a 50% rollout is stable across page loads.

(require '[wal-sh.experiments.core :as e])
(def user {:analytics-anonymous-id "aaaa-bbbb"})
(e/bucket (:analytics-anonymous-id user) "tool.gc-viz" "gc")

That 8642 falls above 5000, so the 50% gc-viz gate returns false for this user. Change the salt from gc to gc2 and the bucketing partition rotates — the user's arm changes without renaming the gate.

Currently gated

Sourced from data/experiments.edn. Change there, no rebuild required: reload the page and the browser client refetches.

Gate Rollout Depends on Purpose
tool.message-format 100% RubyConf 2026 — Fung
tool.mutation 100% RubyConf 2026 — Schirp
tool.fib 100% RubyConf 2026 — d'Oliveira
tool.gc-viz 50% tool.mutation RubyConf 2026 — Zhu (canary)
tool.fair-queue 100% RubyConf 2026 — Baygeldin
ui.tools-nav-experiments-section 100% Show RubyConf-adjacent bucket

The tool.gc-viz canary demonstrates two features at once: percentage-based rollout (50%) and secondary exposure (only users already in the tool.mutation arm are eligible).

Exposures

The client records an exposure event whenever gate? or config-value resolves. Events are POSTed to the local crowsnest receiver at localhost:8127/sightings when it's running; otherwise buffered in sessionStorage. See crowsnest for the receiver.

Statsig shape reference

The record shape came from watching the wire on events.atlassian.com/teameurope/agenda — Atlassian ships Statsig's Java server SDK (v3.1.3) with djb2 hashing and analyticsAnonymousId as the primary id. We keep the same primitives but replace the service call with a static file, which is enough for a single-author site.

Related