2
0
Files
gitcaddy-server/services/pages/experiment.go
logikonline 3a8bdd936c 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
2026-03-07 12:39:42 -05:00

194 lines
5.6 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
pages_model "code.gitcaddy.com/server/v3/models/pages"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/modules/ai"
"code.gitcaddy.com/server/v3/modules/json"
"code.gitcaddy.com/server/v3/modules/log"
pages_module "code.gitcaddy.com/server/v3/modules/pages"
"code.gitcaddy.com/server/v3/modules/setting"
)
// experimentTokenSecret is derived from the app's secret key
func experimentTokenSecret() []byte {
h := sha256.Sum256([]byte(setting.SecretKey + ":page-experiment"))
return h[:]
}
// CreateExperimentToken creates an HMAC-signed token for experiment approval.
func CreateExperimentToken(experimentID int64, action string) string {
data := fmt.Sprintf("%d:%s:%d", experimentID, action, time.Now().Unix())
mac := hmac.New(sha256.New, experimentTokenSecret())
mac.Write([]byte(data))
sig := hex.EncodeToString(mac.Sum(nil))
return hex.EncodeToString([]byte(data)) + "." + sig
}
// VerifyExperimentToken verifies an experiment approval token and returns the experiment ID.
func VerifyExperimentToken(_ context.Context, tokenStr string) (string, error) {
parts := strings.SplitN(tokenStr, ".", 2)
if len(parts) != 2 {
return "", errors.New("invalid token format")
}
dataBytes, err := hex.DecodeString(parts[0])
if err != nil {
return "", errors.New("invalid token encoding")
}
data := string(dataBytes)
// Verify HMAC
mac := hmac.New(sha256.New, experimentTokenSecret())
mac.Write(dataBytes)
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) {
return "", errors.New("invalid token signature")
}
// Parse data: "experimentID:action:timestamp"
dataParts := strings.SplitN(data, ":", 3)
if len(dataParts) != 3 {
return "", errors.New("invalid token data")
}
// Check expiry (7 days)
ts, err := strconv.ParseInt(dataParts[2], 10, 64)
if err != nil {
return "", errors.New("invalid token timestamp")
}
if time.Since(time.Unix(ts, 0)) > 7*24*time.Hour {
return "", errors.New("token expired")
}
return dataParts[0], nil
}
// GenerateExperiment uses the AI sidecar to create an A/B test experiment for a landing page.
func GenerateExperiment(ctx context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) (*pages_model.PageExperiment, error) {
if !ai.IsEnabled() {
return nil, errors.New("AI service is not enabled")
}
client := ai.GetClient()
configJSON, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("failed to marshal config: %w", err)
}
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
RepoID: repo.ID,
Task: "ab_test_generate",
Context: map[string]string{
"landing_config": string(configJSON),
"repo_name": repo.Name,
"repo_description": repo.Description,
"instruction": `Analyze this landing page config and create an A/B test experiment.
Return valid JSON with this exact structure:
{
"name": "Short experiment name",
"variants": [
{
"name": "Variant A",
"config_override": { ... partial landing config fields to override ... },
"weight": 50
}
]
}
Focus on high-impact changes: headlines, CTAs, value propositions.
Keep variants meaningfully different but plausible.
The control variant (original config) is added automatically — do NOT include it.
Return 1-3 variants. Each variant's config_override should be a partial
LandingConfig with only the fields that differ from the control.`,
},
})
if err != nil {
return nil, fmt.Errorf("AI task failed: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("AI task error: %s", resp.Error)
}
// Parse AI response
var result struct {
Name string `json:"name"`
Variants []struct {
Name string `json:"name"`
ConfigOverride map[string]any `json:"config_override"`
Weight int `json:"weight"`
} `json:"variants"`
}
if err := json.Unmarshal([]byte(resp.Result), &result); err != nil {
return nil, fmt.Errorf("failed to parse AI response: %w", err)
}
if result.Name == "" || len(result.Variants) == 0 {
return nil, errors.New("AI returned empty experiment")
}
// Create experiment
exp := &pages_model.PageExperiment{
RepoID: repo.ID,
Name: result.Name,
Status: pages_model.ExperimentStatusDraft,
CreatedByAI: true,
}
if err := pages_model.CreateExperiment(ctx, exp); err != nil {
return nil, fmt.Errorf("failed to create experiment: %w", err)
}
// Create control variant
controlVariant := &pages_model.PageVariant{
ExperimentID: exp.ID,
Name: "Control",
IsControl: true,
Weight: 50,
}
if err := pages_model.CreateVariant(ctx, controlVariant); err != nil {
return nil, fmt.Errorf("failed to create control variant: %w", err)
}
// Create AI-generated variants
remainingWeight := 50
for i, v := range result.Variants {
overrideJSON, err := json.Marshal(v.ConfigOverride)
if err != nil {
log.Error("Failed to marshal variant %d config: %v", i, err)
continue
}
weight := v.Weight
if weight <= 0 {
weight = remainingWeight / (len(result.Variants) - i)
}
remainingWeight -= weight
variant := &pages_model.PageVariant{
ExperimentID: exp.ID,
Name: v.Name,
IsControl: false,
Weight: weight,
ConfigOverride: string(overrideJSON),
}
if err := pages_model.CreateVariant(ctx, variant); err != nil {
log.Error("Failed to create variant %s: %v", v.Name, err)
}
}
return exp, nil
}