internal/frontend: allow modifying experiments by query param
If the server is serving page stats, let the user manipulate the
active experiments by query param. The query param string
?exp=A&exp=!B
will enable experiment A and disable experiment B.
Change-Id: I66ad04f0377cf42380e4c0be3a5513da3f89674a
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/257557
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go
index 7a3f9dc..09f250c 100644
--- a/internal/experiment/experiment.go
+++ b/internal/experiment/experiment.go
@@ -43,7 +43,7 @@
return &Set{set: set}
}
-// NewContext stores the provided experiment set in the context.
+// NewContext stores a set constructed from the provided experiment names in the context.
func NewContext(ctx context.Context, experimentNames ...string) context.Context {
return context.WithValue(ctx, contextKey{}, NewSet(experimentNames...))
}
diff --git a/internal/frontend/details.go b/internal/frontend/details.go
index 8ef059c..cf23898 100644
--- a/internal/frontend/details.go
+++ b/internal/frontend/details.go
@@ -119,6 +119,11 @@
}
}
ctx := r.Context()
+ // If page statistics are enabled, use the "exp" query param to adjust
+ // the active experiments.
+ if s.serveStats {
+ ctx = setExperimentsFromQueryParam(ctx, r)
+ }
// Validate the fullPath and requestedVersion that were parsed.
if err := validatePathAndVersion(ctx, ds, urlInfo.fullPath, urlInfo.requestedVersion); err != nil {
return err
@@ -459,3 +464,43 @@
tag.Upsert(keyVersionType, v),
}, versionTypeResults.M(1))
}
+
+func setExperimentsFromQueryParam(ctx context.Context, r *http.Request) context.Context {
+ if err := r.ParseForm(); err != nil {
+ log.Errorf(ctx, "ParseForm: %v", err)
+ return ctx
+ }
+ return newContextFromExps(ctx, r.Form["exp"])
+}
+
+// newContextFromExps adds and removes experiments from the context's experiment
+// set, creates a new set with the changes, and returns a context with the new
+// set. Each string in expMods can be either an experiment name, which means
+// that the experiment should be added, or "!" followed by an experiment name,
+// meaning that it should be removed.
+func newContextFromExps(ctx context.Context, expMods []string) context.Context {
+ var (
+ exps []string
+ remove = map[string]bool{}
+ )
+ set := experiment.FromContext(ctx)
+ for _, exp := range expMods {
+ if strings.HasPrefix(exp, "!") {
+ exp = exp[1:]
+ if set.IsActive(exp) {
+ remove[exp] = true
+ }
+ } else if !set.IsActive(exp) {
+ exps = append(exps, exp)
+ }
+ }
+ if len(exps) == 0 && len(remove) == 0 {
+ return ctx
+ }
+ for _, a := range set.Active() {
+ if !remove[a] {
+ exps = append(exps, a)
+ }
+ }
+ return experiment.NewContext(ctx, exps...)
+}
diff --git a/internal/frontend/details_test.go b/internal/frontend/details_test.go
index 7256a7c..3dd62bb 100644
--- a/internal/frontend/details_test.go
+++ b/internal/frontend/details_test.go
@@ -8,10 +8,12 @@
"context"
"net/http"
"net/url"
+ "sort"
"testing"
"github.com/google/go-cmp/cmp"
"golang.org/x/pkgsite/internal"
+ "golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/stdlib"
)
@@ -235,3 +237,31 @@
type fakeDataSource struct {
internal.DataSource
}
+
+func TestNewContextFromExps(t *testing.T) {
+ for _, test := range []struct {
+ mods []string
+ want []string
+ }{
+ {
+ mods: []string{"c", "a", "b"},
+ want: []string{"a", "b", "c"},
+ },
+ {
+ mods: []string{"d", "a"},
+ want: []string{"a", "b", "c", "d"},
+ },
+ {
+ mods: []string{"d", "!b", "!a", "c"},
+ want: []string{"c", "d"},
+ },
+ } {
+ ctx := experiment.NewContext(context.Background(), "a", "b", "c")
+ ctx = newContextFromExps(ctx, test.mods)
+ got := experiment.FromContext(ctx).Active()
+ sort.Strings(got)
+ if !cmp.Equal(got, test.want) {
+ t.Errorf("mods=%v:\ngot %v\nwant %v", test.mods, got, test.want)
+ }
+ }
+}