Skip to content

experiments: server-side bucket selector + UpgradeButton variants (P1 of pricing experiments)#41

Merged
mastermanas805 merged 1 commit into
masterfrom
pricing/p1-ab-server-fresh
May 12, 2026
Merged

experiments: server-side bucket selector + UpgradeButton variants (P1 of pricing experiments)#41
mastermanas805 merged 1 commit into
masterfrom
pricing/p1-ab-server-fresh

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

  • Adds internal/experiments — deterministic SHA256(identifier+salt) mod N variant selector with a compile-time registry. First experiment registered: upgrade_button with variants {control, urgent, value}.
  • GET /auth/me now returns an experiments map (e.g. {"upgrade_button": "urgent"}) so the dashboard learns every active bucket in one round-trip. Identifier is team_id.
  • POST /api/v1/experiments/converted writes an audit_log row (kind = experiment.conversion, metadata = {experiment, variant, action_taken}). Server-side guard rejects unknown experiments, invalid variants, and variants that don't match the server's own bucket for the caller.

Test plan

  • go test ./internal/experiments/... — 7 tests pass (determinism, ~33/33/33 distribution across 1000 ids, salt isolation, valid-variant invariant)
  • go test ./internal/handlers/ -run 'TestExperiments|TestGetCurrentUser_IncludesExperiments' — 6 tests pass against the real test-pg
  • make test-unit — all packages green except a pre-existing plans/TestAll_ReturnsAllPlans failure (master/HEAD also fails this; verified via git stash)
  • Dashboard PR consumes experiments.upgrade_button from /auth/me and fires the conversion endpoint on click (separate PR, dashboard pricing/p1-ab-button-fresh)

Audit row written (sample)

kind     = experiment.conversion
actor    = user
summary  = user converted on experiment <code>upgrade_button</code>
metadata = {"variant": "urgent", "experiment": "upgrade_button", "action_taken": "checkout_started"}

Follow-ups (intentionally not in this PR)

  • OpenAPI 3.1 entries for the new endpoint and the experiments field on /auth/me (the spec file is 1800 lines and the dashboard doesn't read it).
  • Anonymous-tier bucketing on the unauthenticated provision endpoints — /auth/me is auth-only so there's no fingerprint fallback path here. Add when an experiment needs to target the anon funnel.

🤖 Generated with Claude Code

… of pricing experiments)

Adds an internal/experiments package with a deterministic
SHA256(identifier+salt) mod len(variants) selector and a registry of
active experiments. The first registered experiment is UpgradeButton
with variants {control, urgent, value}.

The variant assignment is exposed on GET /auth/me as a new
`experiments` map keyed by experiment name, so the dashboard learns
the user's bucket for every active experiment in one round-trip.

POST /api/v1/experiments/converted records a conversion in the audit
log with kind = "experiment.conversion" and metadata = {experiment,
variant, action_taken}. The handler verifies the variant matches the
server's bucket for the caller (rejects stale clients) before
writing.

Bucketing identifier:
  - /auth/me — team_id (caller is always authenticated here)

Tests:
  - internal/experiments: 7 tests (determinism, distribution ~33/33/33
    across 1000 ids, salt isolation, valid-variant invariant)
  - internal/handlers: 6 tests (/auth/me embeds the field, POST writes
    audit_log, rejects unknown experiment / invalid variant / variant
    mismatch / no-auth)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant