console/abTesting: add support for AB testing

This change adds support for AB testing using flagship.io

Change-Id: I3e12f5d6cd7248d69adc2c684e4bcff2aadda1df
This commit is contained in:
Wilfred Asomani 2022-09-13 10:40:55 +00:00 committed by Storj Robot
parent 689732188b
commit 2dc2669e22
6 changed files with 378 additions and 1 deletions

View File

@ -0,0 +1,159 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package abtesting
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/storj/satellite/console"
)
// Error - console ab testing error type.
var Error = errs.Class("consoleapi ab testing error")
// Config contains configurations for the AB testing service.
type Config struct {
Enabled bool `help:"whether or not AB testing is enabled" default:"false"`
APIKey string `help:"the Flagship API key"`
EnvId string `help:"the Flagship environment ID"`
FlagshipURL string `help:"the Flagship API URL" default:"https://decision.flagship.io/v2"`
HitTrackingURL string `help:"the Flagship environment ID" default:"https://ariane.abtasty.com"`
}
// ABService is an interface for AB test methods.
type ABService interface {
// GetABValues gets AB test values for a specific user. It returns a default value on error.
GetABValues(ctx context.Context, user console.User) (values map[string]interface{}, err error)
// SendHit sends an "action" event to flagship.
SendHit(ctx context.Context, user console.User, action string)
}
// Service is a service that exposes all ab testing functionality.
type Service struct {
log *zap.Logger
Config Config
}
// NewService is a constructor for AB service.
func NewService(log *zap.Logger, config Config) *Service {
return &Service{
log: log,
Config: config,
}
}
// GetABValues gets AB test values for a specific user.
func (s *Service) GetABValues(ctx context.Context, user console.User) (values map[string]interface{}, err error) {
reqBody, err := json.Marshal(map[string]interface{}{
"visitor_id": user.ID,
})
if err != nil {
err = Error.Wrap(err)
s.log.Error("failed to encode request body", zap.Error(err))
return nil, err
}
// We're getting all AB test campaigns for a user.
// see: https://docs.developers.flagship.io/docs/decision-api#campaigns.
path := fmt.Sprintf("%s/%s/campaigns", s.Config.FlagshipURL, s.Config.EnvId)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(reqBody))
if err != nil {
err = Error.Wrap(err)
s.log.Error("flagship: failed to communicate with API", zap.Error(err))
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", s.Config.APIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
err = Error.Wrap(err)
s.log.Error("flagship: failed to communicate with API", zap.Error(err))
return nil, err
}
defer func() {
err = resp.Body.Close()
if err != nil {
s.log.Error("failed to close response body", zap.Error(Error.Wrap(err)))
}
}()
if resp.StatusCode != http.StatusOK {
s.log.Error("flagship: hit request response is not OK", zap.String("Status", resp.Status))
return nil, err
}
var campaigns struct {
Campaigns []struct {
Variation struct {
Modifications struct {
Value map[string]interface{} `json:"value"`
} `json:"modifications"`
} `json:"variation"`
} `json:"campaigns"`
}
err = json.NewDecoder(resp.Body).Decode(&campaigns)
if err != nil {
s.log.Error("flagship: failed to decode response; returning default", zap.Error(Error.Wrap(err)))
return nil, err
}
values = make(map[string]interface{})
for _, c := range campaigns.Campaigns {
for k, val := range c.Variation.Modifications.Value {
values[k] = val
}
}
return values, nil
}
// SendHit sends an "action" event to flagship.
func (s *Service) SendHit(ctx context.Context, user console.User, action string) {
// https://docs.developers.flagship.io/docs/universal-collect-documentation
reqBody, err := json.Marshal(map[string]interface{}{
"cid": s.Config.EnvId,
"vid": user.ID,
"ea": action,
"ec": "Action Tracking",
"ds": "APP",
"t": "EVENT",
})
if err != nil {
s.log.Error("failed to encode hit json request", zap.Error(Error.Wrap(err)))
return
}
req, err := http.NewRequestWithContext(ctx, "POST", s.Config.HitTrackingURL, bytes.NewReader(reqBody))
if err != nil {
s.log.Error("flagship: failed to send hit", zap.Error(Error.Wrap(err)))
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
s.log.Error("flagship: failed to send hit", zap.Error(Error.Wrap(err)))
return
}
defer func() {
err = resp.Body.Close()
if err != nil {
s.log.Error("failed to close response body", zap.Error(Error.Wrap(err)))
}
}()
if resp.StatusCode != http.StatusOK {
s.log.Error("flagship: hit request response is not OK", zap.String("Status", resp.Status))
return
}
}

View File

@ -27,6 +27,7 @@ import (
"storj.io/storj/private/lifecycle" "storj.io/storj/private/lifecycle"
"storj.io/storj/private/server" "storj.io/storj/private/server"
"storj.io/storj/private/version/checker" "storj.io/storj/private/version/checker"
"storj.io/storj/satellite/abtesting"
"storj.io/storj/satellite/accounting" "storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/analytics" "storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/buckets" "storj.io/storj/satellite/buckets"
@ -176,6 +177,10 @@ type API struct {
Service *analytics.Service Service *analytics.Service
} }
ABTesting struct {
Service *abtesting.Service
}
Buckets struct { Buckets struct {
Service *buckets.Service Service *buckets.Service
} }
@ -424,6 +429,14 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
}) })
} }
{ // setup AB test service
peer.ABTesting.Service = abtesting.NewService(peer.Log.Named("abtesting:service"), config.Console.ABTesting)
peer.Services.Add(lifecycle.Item{
Name: "abtesting:service",
})
}
{ // setup metainfo { // setup metainfo
peer.Metainfo.Metabase = metabaseDB peer.Metainfo.Metabase = metabaseDB
@ -603,6 +616,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Mail.Service, peer.Mail.Service,
peer.Marketing.PartnersService, peer.Marketing.PartnersService,
peer.Analytics.Service, peer.Analytics.Service,
peer.ABTesting.Service,
peer.Console.Listener, peer.Console.Listener,
config.Payments.StripeCoinPayments.StripePublicKey, config.Payments.StripeCoinPayments.StripePublicKey,
pricing, pricing,

View File

@ -0,0 +1,88 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/storj/satellite/abtesting"
"storj.io/storj/satellite/console"
)
// ErrABAPI - console ab testing api error type.
var ErrABAPI = errs.Class("consoleapi ab testing error")
// ABTesting is an api controller that exposes all ab testing functionality.
type ABTesting struct {
log *zap.Logger
service abtesting.ABService
}
// NewABTesting is a constructor for AB testing controller.
func NewABTesting(log *zap.Logger, service abtesting.ABService) *ABTesting {
return &ABTesting{
log: log,
service: service,
}
}
// GetABValues gets AB test values for a specific user.
func (a *ABTesting) GetABValues(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
user, err := console.GetUser(ctx)
if err != nil {
ServeJSONError(a.log, w, http.StatusUnauthorized, err)
return
}
values, err := a.service.GetABValues(ctx, *user)
if err != nil {
ServeJSONError(a.log, w, http.StatusInternalServerError, err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(values)
if err != nil {
a.log.Error("Could not encode AB values", zap.Error(ErrABAPI.Wrap(err)))
}
}
// SendHit sends an event to flagship.
func (a *ABTesting) SendHit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
action := mux.Vars(r)["action"]
if action == "" {
ServeJSONError(a.log, w, http.StatusBadRequest, errs.New("parameter 'action' can't be empty"))
return
}
user, err := console.GetUser(ctx)
if err != nil {
ServeJSONError(a.log, w, http.StatusUnauthorized, err)
return
}
a.service.SendHit(ctx, *user, action)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(map[string]string{
"message": "Upgrade hit acknowledged",
})
if err != nil {
a.log.Error("failed to write json error response", zap.Error(ErrABAPI.Wrap(err)))
}
}

View File

@ -0,0 +1,87 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/testcontext"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
)
func TestABMethodsOnError(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.ABTesting.Enabled = true
config.Console.ABTesting.APIKey = "APIKey"
config.Console.ABTesting.EnvId = "EnvId"
config.Console.ABTesting.FlagshipURL = "FlagshipURL"
config.Console.ABTesting.HitTrackingURL = "HitTrackingURL"
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
service := sat.API.ABTesting.Service
newUser := console.CreateUser{
FullName: "AB-Tester",
ShortName: "",
Email: "ab@test.test",
}
user, err := sat.AddUser(ctx, newUser, 1)
require.NoError(t, err)
tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName})
require.NoError(t, err)
client := http.Client{}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+planet.Satellites[0].API.Console.Listener.Addr().String()+"/api/v0/ab/values", nil)
require.NoError(t, err)
expire := time.Now().AddDate(0, 0, 1)
cookie := http.Cookie{
Name: "_tokenKey",
Path: "/",
Value: tokenInfo.Token.String(),
Expires: expire,
}
req.AddCookie(&cookie)
resp, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
defer func() {
err = resp.Body.Close()
require.NoError(t, err)
}()
values, err := service.GetABValues(ctx, *user)
require.Error(t, err)
require.Nil(t, values)
req, err = http.NewRequestWithContext(ctx, "POST", "http://"+planet.Satellites[0].API.Console.Listener.Addr().String()+"/api/v0/ab/hit/upgrade-account", nil)
require.NoError(t, err)
req.AddCookie(&cookie)
hitResp, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, hitResp.StatusCode)
defer func() {
err = hitResp.Body.Close()
require.NoError(t, err)
}()
})
}

View File

@ -34,6 +34,7 @@ import (
"storj.io/common/storj" "storj.io/common/storj"
"storj.io/common/uuid" "storj.io/common/uuid"
"storj.io/storj/private/web" "storj.io/storj/private/web"
"storj.io/storj/satellite/abtesting"
"storj.io/storj/satellite/analytics" "storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console" "storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleweb/consoleapi" "storj.io/storj/satellite/console/consoleweb/consoleapi"
@ -106,6 +107,8 @@ type Config struct {
// RateLimit defines the configuration for the IP and userID rate limiters. // RateLimit defines the configuration for the IP and userID rate limiters.
RateLimit web.RateLimiterConfig RateLimit web.RateLimiterConfig
ABTesting abtesting.Config
console.Config console.Config
} }
@ -120,6 +123,7 @@ type Server struct {
mailService *mailservice.Service mailService *mailservice.Service
partners *rewards.PartnersService partners *rewards.PartnersService
analytics *analytics.Service analytics *analytics.Service
abTesting *abtesting.Service
listener net.Listener listener net.Listener
server http.Server server http.Server
@ -201,7 +205,7 @@ func (a *apiAuth) RemoveAuthCookie(w http.ResponseWriter) {
} }
// NewServer creates new instance of console server. // NewServer creates new instance of console server.
func NewServer(logger *zap.Logger, config Config, service *console.Service, oidcService *oidc.Service, mailService *mailservice.Service, partners *rewards.PartnersService, analytics *analytics.Service, listener net.Listener, stripePublicKey string, pricing paymentsconfig.PricingValues, nodeURL storj.NodeURL) *Server { func NewServer(logger *zap.Logger, config Config, service *console.Service, oidcService *oidc.Service, mailService *mailservice.Service, partners *rewards.PartnersService, analytics *analytics.Service, abTesting *abtesting.Service, listener net.Listener, stripePublicKey string, pricing paymentsconfig.PricingValues, nodeURL storj.NodeURL) *Server {
server := Server{ server := Server{
log: logger, log: logger,
config: config, config: config,
@ -210,6 +214,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
mailService: mailService, mailService: mailService,
partners: partners, partners: partners,
analytics: analytics, analytics: analytics,
abTesting: abTesting,
stripePublicKey: stripePublicKey, stripePublicKey: stripePublicKey,
ipRateLimiter: web.NewIPRateLimiter(config.RateLimit), ipRateLimiter: web.NewIPRateLimiter(config.RateLimit),
userIDRateLimiter: NewUserIDRateLimiter(config.RateLimit), userIDRateLimiter: NewUserIDRateLimiter(config.RateLimit),
@ -291,6 +296,13 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
authRouter.Handle("/reset-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResetPassword))).Methods(http.MethodPost) authRouter.Handle("/reset-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResetPassword))).Methods(http.MethodPost)
authRouter.Handle("/refresh-session", server.withAuth(http.HandlerFunc(authController.RefreshSession))).Methods(http.MethodPost) authRouter.Handle("/refresh-session", server.withAuth(http.HandlerFunc(authController.RefreshSession))).Methods(http.MethodPost)
if config.ABTesting.Enabled {
abController := consoleapi.NewABTesting(logger, abTesting)
abRouter := router.PathPrefix("/api/v0/ab").Subrouter()
abRouter.Handle("/values", server.withAuth(http.HandlerFunc(abController.GetABValues))).Methods(http.MethodGet)
abRouter.Handle("/hit/{action}", server.withAuth(http.HandlerFunc(abController.SendHit))).Methods(http.MethodPost)
}
paymentController := consoleapi.NewPayments(logger, service) paymentController := consoleapi.NewPayments(logger, service)
paymentsRouter := router.PathPrefix("/api/v0/payments").Subrouter() paymentsRouter := router.PathPrefix("/api/v0/payments").Subrouter()
paymentsRouter.Use(server.withAuth) paymentsRouter.Use(server.withAuth)
@ -463,6 +475,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
NativeTokenPaymentsEnabled bool NativeTokenPaymentsEnabled bool
PasswordMinimumLength int PasswordMinimumLength int
PasswordMaximumLength int PasswordMaximumLength int
ABTestingEnabled bool
} }
data.ExternalAddress = server.config.ExternalAddress data.ExternalAddress = server.config.ExternalAddress
@ -507,6 +520,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
data.NativeTokenPaymentsEnabled = server.config.NativeTokenPaymentsEnabled data.NativeTokenPaymentsEnabled = server.config.NativeTokenPaymentsEnabled
data.PasswordMinimumLength = console.PasswordMinimumLength data.PasswordMinimumLength = console.PasswordMinimumLength
data.PasswordMaximumLength = console.PasswordMaximumLength data.PasswordMaximumLength = console.PasswordMaximumLength
data.ABTestingEnabled = server.config.ABTesting.Enabled
templates, err := server.loadTemplates() templates, err := server.loadTemplates()
if err != nil || templates.index == nil { if err != nil || templates.index == nil {

View File

@ -88,6 +88,21 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# expiration time for account recovery and activation tokens # expiration time for account recovery and activation tokens
# console-auth.token-expiration-time: 24h0m0s # console-auth.token-expiration-time: 24h0m0s
# the Flagship API key
# console.ab-testing.api-key: ""
# whether or not AB testing is enabled
# console.ab-testing.enabled: false
# the Flagship environment ID
# console.ab-testing.env-id: ""
# the Flagship API URL
# console.ab-testing.flagship-url: https://decision.flagship.io/v2
# the Flagship environment ID
# console.ab-testing.hit-tracking-url: https://ariane.abtasty.com
# url link for account activation redirect # url link for account activation redirect
# console.account-activation-redirect-url: "" # console.account-activation-redirect-url: ""