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:
@@ -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
|
||||
}
|
||||
|
||||
54
models/migrations/v1_26/v366.go
Normal file
54
models/migrations/v1_26/v366.go
Normal 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
272
models/pages/experiment.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user