Cloudflare Workers ile A/B Test Altyapısı Kurma

Edge computing’in güzelliği şu: kullanıcıya en yakın noktada karar verebiliyorsunuz. A/B testi söz konusu olduğunda bu durum muazzam bir avantaja dönüşüyor. Geleneksel A/B test yaklaşımlarında ya client-side JavaScript yükü taşırsınız ya da origin sunucunuza ekstra yük bindirirsiniz. Cloudflare Workers ile bu denklemi tamamen değiştiriyoruz: istek kullanıcıdan çıkar, Cloudflare’in edge noktasına gelir, orada hangi varyanta gideceğine karar verilir ve kullanıcı hiçbir gecikme fark etmeden doğru içeriği alır.

Bu yazıda gerçek bir e-ticaret senaryosu üzerinden, production’a hazır bir A/B test altyapısı kuracağız. Sadece “merhaba dünya” seviyesinde değil, cookie yönetimi, trafik bölme mantığı, analytics entegrasyonu ve fallback mekanizmaları dahil.

A/B Test Mimarisini Anlamak

Cloudflare Workers tabanlı A/B testinin temel akışı şöyle çalışır: kullanıcı isteği Workers’a gelir, worker kullanıcının hangi grupta olduğunu belirler (ya mevcut cookie’den okur ya yeni atar), isteği ilgili origin’e veya path’e yönlendirir ve response’u gerekirse modifiye ederek döner.

Bu yaklaşımın geleneksel yöntemlere göre avantajları:

  • Sıfır layout shift: JavaScript yüklenmeden önce doğru varyant sunuluyor
  • Bot filtreleme: Bilinen bot user-agent’larını test dışında tutabiliyorsunuz
  • Merkezi kontrol: Tek bir yerden tüm testleri yönetiyorsunuz
  • Düşük gecikme: Origin’e gitmeden edge’de karar veriliyor

Proje Yapısını Kurmak

Wrangler CLI ile başlıyoruz:

npm install -g wrangler
wrangler login
wrangler init ab-test-worker --type javascript
cd ab-test-worker

wrangler.toml dosyasını düzenleyelim:

cat > wrangler.toml << 'EOF'
name = "ab-test-worker"
main = "src/index.js"
compatibility_date = "2024-01-01"

[vars]
EXPERIMENT_CONFIG = '{"checkout_flow": {"variants": ["control", "simplified"], "weights": [50, 50], "enabled": true}}'

[[kv_namespaces]]
binding = "AB_TEST_KV"
id = "buraya_kv_namespace_id_gelecek"
preview_id = "buraya_preview_id_gelecek"

[triggers]
routes = ["yourdomain.com/*"]
EOF

KV namespace oluşturmak için:

wrangler kv:namespace create "AB_TEST_KV"
wrangler kv:namespace create "AB_TEST_KV" --preview

Çıktıdaki ID’leri wrangler.toml dosyasına yapıştırın.

Temel Worker Kodunu Yazmak

src/index.js dosyasını oluşturalım:

cat > src/index.js << 'EOF'
import { handleRequest } from './handler.js';
import { parseExperimentConfig } from './config.js';

export default {
  async fetch(request, env, ctx) {
    try {
      const config = parseExperimentConfig(env.EXPERIMENT_CONFIG);
      return await handleRequest(request, env, ctx, config);
    } catch (error) {
      // Hata durumunda trafiği normal akışa yönlendir
      console.error('AB Test Worker hatası:', error.message);
      return fetch(request);
    }
  }
};
EOF

src/config.js dosyası:

cat > src/config.js << 'EOF'
export function parseExperimentConfig(configJson) {
  try {
    return JSON.parse(configJson);
  } catch {
    return {};
  }
}

export function getExperimentForPath(config, pathname) {
  // URL path'ine göre hangi experiment aktif olduğunu belirle
  const pathExperimentMap = {
    '/checkout': 'checkout_flow',
    '/product': 'product_page_layout',
    '/': 'homepage_hero'
  };

  for (const [pathPrefix, experimentName] of Object.entries(pathExperimentMap)) {
    if (pathname.startsWith(pathPrefix) && config[experimentName]) {
      const experiment = config[experimentName];
      if (experiment.enabled) {
        return { name: experimentName, ...experiment };
      }
    }
  }

  return null;
}

export function isBot(userAgent) {
  if (!userAgent) return false;
  const botPatterns = [
    'googlebot', 'bingbot', 'slurp', 'duckduckbot',
    'baidu', 'yandex', 'sogou', 'exabot', 'facebot',
    'ia_archiver', 'lighthouse', 'pagespeed'
  ];
  const ua = userAgent.toLowerCase();
  return botPatterns.some(pattern => ua.includes(pattern));
}
EOF

Trafik Bölme ve Variant Atama Mantığı

Bu kısım A/B testinin kalbi. Kullanıcıya deterministik ama rastgele bir variant atamamız gerekiyor:

cat > src/variant.js << 'EOF'
const COOKIE_PREFIX = 'ab_';
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 gün

export function getVariantFromCookie(request, experimentName) {
  const cookieHeader = request.headers.get('Cookie') || '';
  const cookieName = `${COOKIE_PREFIX}${experimentName}`;
  const cookies = cookieHeader.split(';').map(c => c.trim());

  for (const cookie of cookies) {
    const [name, value] = cookie.split('=');
    if (name.trim() === cookieName) {
      return value ? decodeURIComponent(value.trim()) : null;
    }
  }
  return null;
}

export function assignVariant(variants, weights) {
  // Ağırlıklı random seçim
  const totalWeight = weights.reduce((sum, w) => sum + w, 0);
  let random = Math.random() * totalWeight;

  for (let i = 0; i < variants.length; i++) {
    random -= weights[i];
    if (random <= 0) {
      return variants[i];
    }
  }

  return variants[variants.length - 1];
}

export function setVariantCookie(response, experimentName, variantName) {
  const cookieName = `${COOKIE_PREFIX}${experimentName}`;
  const cookieValue = `${cookieName}=${encodeURIComponent(variantName)}; Path=/; Max-Age=${COOKIE_MAX_AGE}; SameSite=Lax; Secure`;

  const newResponse = new Response(response.body, response);
  newResponse.headers.append('Set-Cookie', cookieValue);
  return newResponse;
}

export async function assignAndStoreVariant(experimentName, variants, weights, env) {
  const variant = assignVariant(variants, weights);

  // KV'ye kaydet (analytics için)
  if (env.AB_TEST_KV) {
    const timestamp = Date.now();
    const key = `assignment:${experimentName}:${timestamp}:${Math.random().toString(36).substr(2, 9)}`;
    await env.AB_TEST_KV.put(key, JSON.stringify({
      experiment: experimentName,
      variant,
      timestamp,
      ttl: 86400 * 30
    }), { expirationTtl: 86400 * 30 });
  }

  return variant;
}
EOF

Ana Handler Fonksiyonu

cat > src/handler.js << 'EOF'
import { getExperimentForPath, isBot } from './config.js';
import {
  getVariantFromCookie,
  assignAndStoreVariant,
  setVariantCookie
} from './variant.js';

export async function handleRequest(request, env, ctx, config) {
  const url = new URL(request.url);
  const userAgent = request.headers.get('User-Agent') || '';

  // Bot'ları her zaman control grubuna yönlendir
  if (isBot(userAgent)) {
    return fetch(request);
  }

  // Bu path için aktif experiment var mı?
  const experiment = getExperimentForPath(config, url.pathname);

  if (!experiment) {
    // Aktif experiment yoksa normal isteği ilet
    return fetch(request);
  }

  // Mevcut cookie'den variant oku
  let variant = getVariantFromCookie(request, experiment.name);
  let isNewAssignment = false;

  if (!variant || !experiment.variants.includes(variant)) {
    // Yeni variant ata
    variant = await assignAndStoreVariant(
      experiment.name,
      experiment.variants,
      experiment.weights,
      env
    );
    isNewAssignment = true;
  }

  // İsteği modifiye et ve origin'e gönder
  const modifiedRequest = buildVariantRequest(request, url, variant, experiment);

  // Origin'den cevabı al
  const originResponse = await fetch(modifiedRequest);

  // Response'u modifiye et
  let finalResponse = addVariantHeaders(originResponse, experiment.name, variant);

  // Gerekirse cookie set et
  if (isNewAssignment) {
    finalResponse = setVariantCookie(finalResponse, experiment.name, variant);
  }

  return finalResponse;
}

function buildVariantRequest(request, url, variant, experiment) {
  const newUrl = new URL(url.toString());

  // Variant'a göre farklı path'e yönlendir
  if (variant !== 'control') {
    // Örnek: /checkout -> /checkout-v2
    newUrl.pathname = url.pathname.replace(
      /^(/[^/]+)/,
      `$1-${variant.replace('_', '-')}`
    );
  }

  // X-AB-Variant header'ı ekle (origin sunucu bu bilgiyi kullanabilir)
  const headers = new Headers(request.headers);
  headers.set('X-AB-Experiment', experiment.name);
  headers.set('X-AB-Variant', variant);

  return new Request(newUrl.toString(), {
    method: request.method,
    headers,
    body: request.body,
    redirect: 'follow'
  });
}

function addVariantHeaders(response, experimentName, variant) {
  const newResponse = new Response(response.body, response);
  newResponse.headers.set('X-AB-Experiment', experimentName);
  newResponse.headers.set('X-AB-Variant', variant);
  // Cache'lenmesini önle
  newResponse.headers.set('Cache-Control', 'private, no-store');
  return newResponse;
}
EOF

HTML Response Manipulation ile İçerik Değiştirme

Bazen aynı sayfanın içeriğini dinamik olarak değiştirmeniz gerekir. Cloudflare’in HTMLRewriter API’si tam bu iş için:

cat > src/rewriter.js << 'EOF'
export function applyVariantContent(response, variant, experiment) {
  // Sadece HTML response'ları modifiye et
  const contentType = response.headers.get('Content-Type') || '';
  if (!contentType.includes('text/html')) {
    return response;
  }

  const variantRules = getVariantRules(experiment, variant);

  if (!variantRules || variantRules.length === 0) {
    return response;
  }

  let rewriter = new HTMLRewriter();

  for (const rule of variantRules) {
    rewriter = rewriter.on(rule.selector, {
      element(element) {
        if (rule.action === 'setAttribute') {
          element.setAttribute(rule.attribute, rule.value);
        } else if (rule.action === 'setInnerContent') {
          element.setInnerContent(rule.content, { html: rule.isHtml || false });
        } else if (rule.action === 'addClass') {
          const currentClass = element.getAttribute('class') || '';
          element.setAttribute('class', `${currentClass} ${rule.className}`.trim());
        } else if (rule.action === 'remove') {
          element.remove();
        }
      }
    });
  }

  return rewriter.transform(response);
}

function getVariantRules(experiment, variant) {
  // Bu kurallar KV'den veya environment variable'dan okunabilir
  const rules = {
    'checkout_flow': {
      'simplified': [
        {
          selector: '.checkout-steps',
          action: 'setAttribute',
          attribute: 'data-variant',
          value: 'simplified'
        },
        {
          selector: '#checkout-headline',
          action: 'setInnerContent',
          content: 'Hızlı Ödeme',
          isHtml: false
        },
        {
          selector: '.upsell-banner',
          action: 'remove'
        }
      ]
    },
    'homepage_hero': {
      'bold': [
        {
          selector: '.hero-section',
          action: 'addClass',
          className: 'hero-bold-variant'
        },
        {
          selector: '.hero-cta',
          action: 'setInnerContent',
          content: '<strong>Hemen Başla</strong> - Ücretsiz',
          isHtml: true
        }
      ]
    }
  };

  return rules[experiment.name]?.[variant] || [];
}
EOF

Analytics ve Metrik Toplama

Test sonuçlarını ölçmeden A/B testi anlamsız. Workers Analytics Engine ile entegrasyon:

cat > src/analytics.js << 'EOF'
export async function trackImpression(request, experiment, variant, env, ctx) {
  const url = new URL(request.url);
  const country = request.cf?.country || 'unknown';
  const device = getDeviceType(request.headers.get('User-Agent') || '');

  const eventData = {
    timestamp: Date.now(),
    experiment: experiment.name,
    variant,
    path: url.pathname,
    country,
    device,
    type: 'impression'
  };

  // Analytics Engine'e yaz (non-blocking)
  if (env.AB_ANALYTICS) {
    ctx.waitUntil(
      env.AB_ANALYTICS.writeDataPoint({
        blobs: [
          experiment.name,
          variant,
          url.pathname,
          country,
          device
        ],
        doubles: [1],
        indexes: [`${experiment.name}:${variant}`]
      })
    );
  }

  // KV'ye aggregate counter yaz
  if (env.AB_TEST_KV) {
    ctx.waitUntil(incrementCounter(env.AB_TEST_KV, experiment.name, variant));
  }
}

async function incrementCounter(kv, experiment, variant) {
  const key = `counter:${experiment}:${variant}:${getDayKey()}`;
  const current = await kv.get(key);
  const count = current ? parseInt(current) + 1 : 1;
  await kv.put(key, count.toString(), { expirationTtl: 86400 * 90 });
}

function getDayKey() {
  const now = new Date();
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}

function getDeviceType(userAgent) {
  const ua = userAgent.toLowerCase();
  if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) return 'tablet';
  if (/mobile|iphone|ipod|blackberry|android.*mobi|windows phone/i.test(ua)) return 'mobile';
  return 'desktop';
}
EOF

Deploy ve Test

Geliştirme ortamında test etmek için:

# Local geliştirme sunucusu başlat
wrangler dev --local

# Başka bir terminalde test et
curl -v http://localhost:8787/checkout 
  -H "User-Agent: Mozilla/5.0 (compatible; normal browser)"

# Cookie ile variant'ı sabitleyerek test
curl -v http://localhost:8787/checkout 
  -H "Cookie: ab_checkout_flow=simplified" 
  -H "User-Agent: Mozilla/5.0 (compatible; test)"

# Bot testi (control'e düşmeli)
curl -v http://localhost:8787/checkout 
  -H "User-Agent: Googlebot/2.1"

Production’a deploy etmek için:

# Önce staging'e deploy et
wrangler deploy --env staging

# Staging testleri geçtikten sonra production
wrangler deploy --env production

# KV verilerini kontrol et
wrangler kv:key list --namespace-id=YOUR_NAMESPACE_ID --prefix="counter:"

# Günlük sayaçları sorgula
wrangler kv:key get "counter:checkout_flow:simplified:2024-01-15" 
  --namespace-id=YOUR_NAMESPACE_ID

Gerçek Dünya Senaryosu: E-Ticaret Checkout Testi

Diyelim ki “Sepete Ekle” butonunun rengini test ediyorsunuz. Mevcut turuncu buton mu daha iyi dönüşüm sağlıyor, yoksa yeşil buton mu? Ayrıca tek sayfalı checkout mı daha iyi, yoksa adım adım ilerleyen mi?

Bu senaryoda dikkat etmeniz gereken birkaç kritik nokta var:

  • Segment bütünlüğü: Kullanıcı login olduktan sonra da aynı variant’ta kalmalı. Cookie çözümünün yanına userId bazlı bir hash mekanizması ekleyebilirsiniz.
  • Mutually exclusive testler: Aynı anda birden fazla test çalışıyorsa etkileşimlerden kaçınmak için testleri birbirinden izole edin.
  • Mobil vs desktop: Özellikle checkout akışında mobil kullanıcılar farklı davranır. request.cf.deviceType ile segmentasyon yapabilirsiniz.
  • Trafik rampası: Yeni bir varyantı önce %5 trafikle başlatın, metrikler stabil görününce %50’ye çıkarın.

Wrangler’ın environment variables özelliğiyle experiment konfigürasyonunu kod değişikliği yapmadan güncelleyebilirsiniz:

# Canlı bir testi kapatmak için
wrangler secret put EXPERIMENT_CONFIG << 'EOF'
{"checkout_flow": {"variants": ["control", "simplified"], "weights": [100, 0], "enabled": false}}
EOF

# Yeni bir testi aktive etmek için
wrangler secret put EXPERIMENT_CONFIG << 'EOF'
{"checkout_flow": {"variants": ["control", "simplified"], "weights": [80, 20], "enabled": true}, "homepage_hero": {"variants": ["control", "bold"], "weights": [50, 50], "enabled": true}}
EOF

Sonuç

Cloudflare Workers ile kurduğumuz bu A/B test altyapısı, birkaç önemli özelliği bir arada sunuyor: kullanıcı deneyimini bozmayan gecikme profili, merkezi yönetim, güvenilir cookie tabanlı kullanıcı gruplama ve analytics entegrasyonu.

Bu altyapıyı daha da geliştirmek için yapabilecekleriniz:

  • Feature flags entegrasyonu: LaunchDarkly veya Unleash ile Workers’ı birleştirerek hem A/B testi hem de özellik açma/kapama ihtiyaçlarını tek noktadan yönetebilirsiniz.
  • Durable Objects kullanımı: Eğer KV’nin eventual consistency’si yeterli gelmiyorsa, Durable Objects ile kullanıcı durumunu daha kesin bir şekilde yönetebilirsiniz.
  • Istatistiksel anlamlılık hesabı: KV’de biriken impression ve conversion verilerini bir dashboard’a bağlayarak ne zaman testin istatistiksel olarak anlamlı olduğunu takip edin. Genel kural olarak minimum 1000 kullanıcı ve %95 güven aralığı iyi bir başlangıç noktasıdır.
  • Webhook entegrasyonu: Test sonuçları belirli eşiklere ulaştığında Slack veya PagerDuty’e bildirim gönderin.

Edge computing’de A/B testi, artık büyük şirketlerin lüksü değil. Workers’ın free planında bile 100.000 günlük istek limitiyle küçük ve orta ölçekli projeler için bu altyapı tamamen ücretsiz çalışır. Tek yapmanız gereken yukarıdaki kodu kendi senaryonuza uyarlamak.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir