2
0

feat(pages): add A/B testing framework for landing pages

Implement comprehensive A/B testing system for landing page optimization:
- Database models for experiments, variants, and events
- AI-powered variant generation and analysis
- Visitor tracking with conversion metrics
- Experiment lifecycle management (draft/active/paused/completed)
- Email notifications for experiment results
- Cron job for automated experiment monitoring
- UI for viewing experiment results and statistics
This commit is contained in:
2026-03-07 12:39:42 -05:00
parent 64b4a9ceed
commit 3a8bdd936c
27 changed files with 1371 additions and 84 deletions

View File

@@ -440,6 +440,7 @@ func prepareMigrationTasks() []*migration {
newMigration(363, "Add keep_packages_private to user", v1_26.AddKeepPackagesPrivateToUser),
newMigration(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost),
newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository),
newMigration(366, "Add page experiment tables for A/B testing", v1_26.AddPageExperimentTables),
}
return preparedMigrations
}

View File

@@ -0,0 +1,54 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
func AddPageExperimentTables(x *xorm.Engine) error {
type PageExperiment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
Status string `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"`
CreatedByAI bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
}
type PageVariant struct {
ID int64 `xorm:"pk autoincr"`
ExperimentID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
IsControl bool `xorm:"NOT NULL DEFAULT false"`
Weight int `xorm:"NOT NULL DEFAULT 50"`
ConfigOverride string `xorm:"TEXT"`
Impressions int64 `xorm:"NOT NULL DEFAULT 0"`
Conversions int64 `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
type PageEvent struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
VariantID int64 `xorm:"INDEX DEFAULT 0"`
ExperimentID int64 `xorm:"INDEX DEFAULT 0"`
VisitorID string `xorm:"VARCHAR(64) INDEX"`
EventType string `xorm:"VARCHAR(32) NOT NULL"`
EventData string `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
if err := x.Sync(new(PageExperiment)); err != nil {
return err
}
if err := x.Sync(new(PageVariant)); err != nil {
return err
}
return x.Sync(new(PageEvent))
}

272
models/pages/experiment.go Normal file
View File

@@ -0,0 +1,272 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"context"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// ExperimentStatus represents the status of an A/B test experiment.
type ExperimentStatus string
const (
ExperimentStatusDraft ExperimentStatus = "draft"
ExperimentStatusActive ExperimentStatus = "active"
ExperimentStatusPaused ExperimentStatus = "paused"
ExperimentStatusCompleted ExperimentStatus = "completed"
ExperimentStatusApproved ExperimentStatus = "approved"
)
func init() {
db.RegisterModel(new(PageExperiment))
db.RegisterModel(new(PageVariant))
db.RegisterModel(new(PageEvent))
}
// PageExperiment tracks an A/B test on a landing page.
type PageExperiment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
Status ExperimentStatus `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"`
CreatedByAI bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
Variants []*PageVariant `xorm:"-"`
}
// TableName returns the table name for PageExperiment.
func (e *PageExperiment) TableName() string {
return "page_experiment"
}
// PageVariant is one arm of an A/B test experiment.
type PageVariant struct {
ID int64 `xorm:"pk autoincr"`
ExperimentID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
IsControl bool `xorm:"NOT NULL DEFAULT false"`
Weight int `xorm:"NOT NULL DEFAULT 50"`
ConfigOverride string `xorm:"TEXT"`
Impressions int64 `xorm:"NOT NULL DEFAULT 0"`
Conversions int64 `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// TableName returns the table name for PageVariant.
func (v *PageVariant) TableName() string {
return "page_variant"
}
// ConversionRate returns the conversion rate for this variant.
func (v *PageVariant) ConversionRate() float64 {
if v.Impressions == 0 {
return 0
}
return float64(v.Conversions) / float64(v.Impressions)
}
// PageEvent tracks visitor interactions with a landing page.
type PageEvent struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
VariantID int64 `xorm:"INDEX DEFAULT 0"`
ExperimentID int64 `xorm:"INDEX DEFAULT 0"`
VisitorID string `xorm:"VARCHAR(64) INDEX"`
EventType string `xorm:"VARCHAR(32) NOT NULL"`
EventData string `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// TableName returns the table name for PageEvent.
func (e *PageEvent) TableName() string {
return "page_event"
}
// Valid event types
const (
EventTypeImpression = "impression"
EventTypeCTAClick = "cta_click"
EventTypeScrollDepth = "scroll_depth"
EventTypeClick = "click"
EventTypeStar = "star"
EventTypeFork = "fork"
EventTypeClone = "clone"
)
// CreateExperiment creates a new experiment.
func CreateExperiment(ctx context.Context, exp *PageExperiment) error {
_, err := db.GetEngine(ctx).Insert(exp)
return err
}
// GetExperimentByID returns an experiment by ID.
func GetExperimentByID(ctx context.Context, id int64) (*PageExperiment, error) {
exp := new(PageExperiment)
has, err := db.GetEngine(ctx).ID(id).Get(exp)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return exp, nil
}
// GetExperimentsByRepoID returns all experiments for a repository.
func GetExperimentsByRepoID(ctx context.Context, repoID int64) ([]*PageExperiment, error) {
experiments := make([]*PageExperiment, 0, 10)
return experiments, db.GetEngine(ctx).Where("repo_id = ?", repoID).
Desc("created_unix").Find(&experiments)
}
// GetActiveExperimentByRepoID returns the currently active experiment for a repo, if any.
func GetActiveExperimentByRepoID(ctx context.Context, repoID int64) (*PageExperiment, error) {
exp := new(PageExperiment)
has, err := db.GetEngine(ctx).
Where("repo_id = ? AND status = ?", repoID, ExperimentStatusActive).
Get(exp)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return exp, nil
}
// GetAllActiveExperiments returns all active experiments across all repos.
func GetAllActiveExperiments(ctx context.Context) ([]*PageExperiment, error) {
experiments := make([]*PageExperiment, 0, 50)
return experiments, db.GetEngine(ctx).
Where("status = ?", ExperimentStatusActive).
Find(&experiments)
}
// UpdateExperiment updates an experiment.
func UpdateExperiment(ctx context.Context, exp *PageExperiment) error {
_, err := db.GetEngine(ctx).ID(exp.ID).AllCols().Update(exp)
return err
}
// UpdateExperimentStatus updates just the status of an experiment.
func UpdateExperimentStatus(ctx context.Context, id int64, status ExperimentStatus) error {
_, err := db.GetEngine(ctx).ID(id).Cols("status").
Update(&PageExperiment{Status: status})
return err
}
// CreateVariant creates a new variant for an experiment.
func CreateVariant(ctx context.Context, variant *PageVariant) error {
_, err := db.GetEngine(ctx).Insert(variant)
return err
}
// GetVariantByID returns a variant by ID.
func GetVariantByID(ctx context.Context, id int64) (*PageVariant, error) {
variant := new(PageVariant)
has, err := db.GetEngine(ctx).ID(id).Get(variant)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return variant, nil
}
// GetVariantsByExperimentID returns all variants for an experiment.
func GetVariantsByExperimentID(ctx context.Context, experimentID int64) ([]*PageVariant, error) {
variants := make([]*PageVariant, 0, 5)
return variants, db.GetEngine(ctx).
Where("experiment_id = ?", experimentID).
Find(&variants)
}
// IncrementVariantImpressions increments the impression counter for a variant.
func IncrementVariantImpressions(ctx context.Context, variantID int64) error {
_, err := db.GetEngine(ctx).Exec(
"UPDATE `page_variant` SET impressions = impressions + 1 WHERE id = ?", variantID)
return err
}
// IncrementVariantConversions increments the conversion counter for a variant.
func IncrementVariantConversions(ctx context.Context, variantID int64) error {
_, err := db.GetEngine(ctx).Exec(
"UPDATE `page_variant` SET conversions = conversions + 1 WHERE id = ?", variantID)
return err
}
// CreatePageEvent records a visitor event.
func CreatePageEvent(ctx context.Context, event *PageEvent) error {
_, err := db.GetEngine(ctx).Insert(event)
return err
}
// GetEventCountByVariant returns event counts grouped by event type for a variant.
func GetEventCountByVariant(ctx context.Context, variantID int64) (map[string]int64, error) {
type countResult struct {
EventType string `xorm:"event_type"`
Count int64 `xorm:"cnt"`
}
results := make([]countResult, 0)
err := db.GetEngine(ctx).Table("page_event").
Select("event_type, COUNT(*) as cnt").
Where("variant_id = ?", variantID).
GroupBy("event_type").
Find(&results)
if err != nil {
return nil, err
}
counts := make(map[string]int64, len(results))
for _, r := range results {
counts[r.EventType] = r.Count
}
return counts, nil
}
// GetEventCountsByExperiment returns event counts for all variants in an experiment.
func GetEventCountsByExperiment(ctx context.Context, experimentID int64) (map[int64]map[string]int64, error) {
type countResult struct {
VariantID int64 `xorm:"variant_id"`
EventType string `xorm:"event_type"`
Count int64 `xorm:"cnt"`
}
results := make([]countResult, 0)
err := db.GetEngine(ctx).Table("page_event").
Select("variant_id, event_type, COUNT(*) as cnt").
Where("experiment_id = ?", experimentID).
GroupBy("variant_id, event_type").
Find(&results)
if err != nil {
return nil, err
}
counts := make(map[int64]map[string]int64)
for _, r := range results {
if counts[r.VariantID] == nil {
counts[r.VariantID] = make(map[string]int64)
}
counts[r.VariantID][r.EventType] = r.Count
}
return counts, nil
}
// RecordRepoAction records a repo action (star, fork, clone) as a page event
// if the repo has an active experiment.
func RecordRepoAction(ctx context.Context, repoID int64, eventType string) {
exp, err := GetActiveExperimentByRepoID(ctx, repoID)
if err != nil || exp == nil {
return
}
_ = CreatePageEvent(ctx, &PageEvent{
RepoID: repoID,
ExperimentID: exp.ID,
EventType: eventType,
})
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"code.gitcaddy.com/server/v3/models/db"
pages_model "code.gitcaddy.com/server/v3/models/pages"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
@@ -25,7 +26,7 @@ func init() {
// StarRepo or unstar repository.
func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star bool) error {
return db.WithTx(ctx, func(ctx context.Context) error {
err := db.WithTx(ctx, func(ctx context.Context) error {
staring := IsStaring(ctx, doer.ID, repo.ID)
if star {
@@ -64,6 +65,10 @@ func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star
return nil
})
if err == nil && star {
pages_model.RecordRepoAction(ctx, repo.ID, pages_model.EventTypeStar)
}
return err
}
// IsStaring checks if user has starred given repository.