diff --git a/satellite/abtesting/service.go b/satellite/abtesting/service.go new file mode 100644 index 000000000..2f989109f --- /dev/null +++ b/satellite/abtesting/service.go @@ -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 + } +} diff --git a/satellite/api.go b/satellite/api.go index 89809d057..361443ab9 100644 --- a/satellite/api.go +++ b/satellite/api.go @@ -27,6 +27,7 @@ import ( "storj.io/storj/private/lifecycle" "storj.io/storj/private/server" "storj.io/storj/private/version/checker" + "storj.io/storj/satellite/abtesting" "storj.io/storj/satellite/accounting" "storj.io/storj/satellite/analytics" "storj.io/storj/satellite/buckets" @@ -176,6 +177,10 @@ type API struct { Service *analytics.Service } + ABTesting struct { + Service *abtesting.Service + } + Buckets struct { 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 peer.Metainfo.Metabase = metabaseDB @@ -603,6 +616,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB, peer.Mail.Service, peer.Marketing.PartnersService, peer.Analytics.Service, + peer.ABTesting.Service, peer.Console.Listener, config.Payments.StripeCoinPayments.StripePublicKey, pricing, diff --git a/satellite/console/consoleweb/consoleapi/abtesting.go b/satellite/console/consoleweb/consoleapi/abtesting.go new file mode 100644 index 000000000..610ef8ce6 --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/abtesting.go @@ -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))) + } +} diff --git a/satellite/console/consoleweb/consoleapi/abtesting_test.go b/satellite/console/consoleweb/consoleapi/abtesting_test.go new file mode 100644 index 000000000..91f7f1307 --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/abtesting_test.go @@ -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) + }() + }) +} diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 563301b42..827c56433 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -34,6 +34,7 @@ import ( "storj.io/common/storj" "storj.io/common/uuid" "storj.io/storj/private/web" + "storj.io/storj/satellite/abtesting" "storj.io/storj/satellite/analytics" "storj.io/storj/satellite/console" "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 web.RateLimiterConfig + ABTesting abtesting.Config + console.Config } @@ -120,6 +123,7 @@ type Server struct { mailService *mailservice.Service partners *rewards.PartnersService analytics *analytics.Service + abTesting *abtesting.Service listener net.Listener server http.Server @@ -201,7 +205,7 @@ func (a *apiAuth) RemoveAuthCookie(w http.ResponseWriter) { } // 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{ log: logger, config: config, @@ -210,6 +214,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc mailService: mailService, partners: partners, analytics: analytics, + abTesting: abTesting, stripePublicKey: stripePublicKey, ipRateLimiter: web.NewIPRateLimiter(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("/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) paymentsRouter := router.PathPrefix("/api/v0/payments").Subrouter() paymentsRouter.Use(server.withAuth) @@ -463,6 +475,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) { NativeTokenPaymentsEnabled bool PasswordMinimumLength int PasswordMaximumLength int + ABTestingEnabled bool } 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.PasswordMinimumLength = console.PasswordMinimumLength data.PasswordMaximumLength = console.PasswordMaximumLength + data.ABTestingEnabled = server.config.ABTesting.Enabled templates, err := server.loadTemplates() if err != nil || templates.index == nil { diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 1f8d0fe3b..6c08dda6e 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -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 # 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 # console.account-activation-redirect-url: ""