Cloudflare Workers ile A/B Testing Uygulaması

A/B testini genellikle uygulama katmanında yaparız; React’ta bir state, backend’de bir feature flag servisi, belki Optimizely veya LaunchDarkly gibi pahalı araçlar. Ama Cloudflare Workers ile bu işi tamamen edge’de, sıfır gecikmeyle ve neredeyse sıfır maliyetle yapabileceğinizi biliyor muydunuz? DNS katmanına bu kadar yakın çalışmak başta tuhaf gelebilir ama bir kez tadını alınca uygulamanıza hiç dokunmadan trafik yönetimi yapabildiğinizi görünce bakış açınız değişiyor.

Cloudflare Workers Nedir, Neden A/B Testing?

Cloudflare Workers, Cloudflare’in global ağı üzerinde çalışan serverless bir JavaScript/TypeScript runtime ortamıdır. V8 isolate’leri üzerinde çalışır, yani geleneksel Node.js değil, Chrome’un aynı motoru. Bu sayede cold start süresi neredeyse sıfır, global dağıtım otomatik.

A/B testing için Workers kullanmanın asıl avantajı şu: Kullanıcı isteği sunucunuza ulaşmadan önce yakalanıyor, orada karar veriliyor ve kullanıcı doğru versiyona yönlendiriliyor. Ne React bundle’ınızı büyütüyorsunuz, ne de backend kodunuzu karmaşıklaştırıyorsunuz.

Gerçek dünya senaryosu olarak şunu düşünelim: E-ticaret sitenizin ana sayfasında iki farklı “Sepete Ekle” butonu tasarımı test ediyorsunuz. Bir versiyonda buton kırmızı, diğerinde yeşil. Kullanıcıların %50’sine A versiyonu, %50’sine B versiyonu gösteriyorsunuz ve dönüşüm oranlarını karşılaştırıyorsunuz. Tüm bunları Workers ile yapabilirsiniz.

Hazırlık: Wrangler CLI Kurulumu

Cloudflare Workers geliştirmek için Wrangler CLI’ye ihtiyacınız var. Node.js 16+ kurulu olduğunu varsayıyorum.

npm install -g wrangler

# Cloudflare hesabınıza giriş yapın
wrangler login

# Yeni bir Workers projesi oluşturun
wrangler init ab-testing-worker
cd ab-testing-worker

Wrangler login komutu sizi tarayıcıya yönlendirecek, Cloudflare hesabınızla giriş yapıp yetki vereceksiniz. Bu adımdan sonra wrangler.toml dosyası oluşmuş olacak.

# wrangler.toml temel konfigürasyonu
cat wrangler.toml

Oluşan wrangler.toml dosyasını şu şekilde düzenliyoruz:

# wrangler.toml
name = "ab-testing-worker"
main = "src/index.js"
compatibility_date = "2024-01-01"

[vars]
VARIANT_A_WEIGHT = "50"
VARIANT_B_WEIGHT = "50"
VARIANT_A_URL = "https://v1.siteniz.com"
VARIANT_B_URL = "https://v2.siteniz.com"

[[kv_namespaces]]
binding = "AB_STORE"
id = "your-kv-namespace-id"

KV namespace oluşturmak için:

# KV namespace oluştur
wrangler kv:namespace create "AB_STORE"

# Çıktıdan gelen id'yi wrangler.toml'a ekleyin
# Preview için ayrı namespace
wrangler kv:namespace create "AB_STORE" --preview

Temel A/B Testing Worker

Şimdi asıl kodu yazalım. En basit haliyle bir A/B testi şöyle görünür:

// src/index.js - Temel A/B Testing Worker
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    
    // Statik dosyaları ve API çağrılarını bypass et
    if (url.pathname.startsWith('/api/') || 
        url.pathname.startsWith('/static/') ||
        url.pathname.includes('.')) {
      return fetch(request);
    }

    // Kullanıcının mevcut varyantını cookie'den al
    const cookieHeader = request.headers.get('Cookie') || '';
    const existingVariant = getCookieValue(cookieHeader, 'ab_variant');
    
    let variant;
    
    if (existingVariant && ['A', 'B'].includes(existingVariant)) {
      // Mevcut kullanıcı, aynı varyantı göster (sticky session)
      variant = existingVariant;
    } else {
      // Yeni kullanıcı, rastgele varyant ata
      variant = assignVariant(
        parseInt(env.VARIANT_A_WEIGHT), 
        parseInt(env.VARIANT_B_WEIGHT)
      );
    }

    // İlgili origin'e isteği yönlendir
    const targetUrl = variant === 'A' ? env.VARIANT_A_URL : env.VARIANT_B_URL;
    const targetRequest = new Request(
      targetUrl + url.pathname + url.search, 
      request
    );

    const response = await fetch(targetRequest);
    
    // Response'u klonla ve cookie ekle
    const newResponse = new Response(response.body, response);
    
    if (!existingVariant) {
      newResponse.headers.append(
        'Set-Cookie', 
        `ab_variant=${variant}; Path=/; Max-Age=2592000; SameSite=Lax`
      );
    }
    
    // Hangi varyantın sunulduğunu header'a ekle (analytics için)
    newResponse.headers.set('X-AB-Variant', variant);
    
    return newResponse;
  }
};

function assignVariant(weightA, weightB) {
  const total = weightA + weightB;
  const random = Math.random() * total;
  return random < weightA ? 'A' : 'B';
}

function getCookieValue(cookieHeader, name) {
  const cookies = cookieHeader.split(';').map(c => c.trim());
  for (const cookie of cookies) {
    const [key, value] = cookie.split('=');
    if (key.trim() === name) return value?.trim();
  }
  return null;
}

Bu temel worker birkaç önemli şeyi hallediyor: Sticky session (aynı kullanıcı her seferinde aynı varyantı görür), ağırlıklı dağıtım ve analytics için header bilgisi.

Gelişmiş Senaryo: Kullanıcı Segmentasyonu

Gerçek dünyada sadece %50/%50 bölme yetmez. Belirli kullanıcı segmentlerine farklı varyantlar göstermek isteyebilirsiniz. Örneğin mobil kullanıcılara B varyantını, desktop kullanıcılara A varyantını test edebilirsiniz.

// src/advanced.js - Segmentasyon destekli A/B Testing
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const userAgent = request.headers.get('User-Agent') || '';
    const country = request.cf?.country || 'XX';
    
    // Segment tespiti
    const segment = detectSegment(userAgent, country);
    
    // Segment bazlı konfigürasyon
    const config = await getSegmentConfig(env, segment);
    
    const cookieHeader = request.headers.get('Cookie') || '';
    const cookieKey = `ab_variant_${config.experimentId}`;
    const existingVariant = getCookieValue(cookieHeader, cookieKey);
    
    let variant;
    
    if (existingVariant) {
      variant = existingVariant;
    } else {
      // KV'den aktif experiment bilgisini al
      const experimentData = await env.AB_STORE.get(
        `experiment:${config.experimentId}`, 
        { type: 'json' }
      );
      
      if (!experimentData || !experimentData.active) {
        // Experiment aktif değil, default'a gönder
        return fetch(request);
      }
      
      variant = assignVariantBySegment(segment, experimentData);
      
      // Kullanıcı sayısını güncelle (background task)
      ctx.waitUntil(
        incrementVariantCount(env, config.experimentId, variant)
      );
    }

    const targetOrigin = getOriginForVariant(env, variant);
    const modifiedRequest = new Request(
      targetOrigin + url.pathname + url.search,
      {
        method: request.method,
        headers: request.headers,
        body: request.body,
        redirect: 'follow'
      }
    );

    const response = await fetch(modifiedRequest);
    const newResponse = new Response(response.body, response);
    
    if (!existingVariant) {
      const maxAge = 7 * 24 * 60 * 60; // 7 gün
      newResponse.headers.append(
        'Set-Cookie',
        `${cookieKey}=${variant}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax`
      );
    }
    
    newResponse.headers.set('X-AB-Variant', variant);
    newResponse.headers.set('X-AB-Segment', segment);
    
    return newResponse;
  }
};

function detectSegment(userAgent, country) {
  const isMobile = /Mobile|Android|iPhone|iPad/i.test(userAgent);
  const isEU = ['DE', 'FR', 'IT', 'ES', 'NL', 'PL'].includes(country);
  
  if (isMobile && isEU) return 'mobile_eu';
  if (isMobile) return 'mobile';
  if (isEU) return 'desktop_eu';
  return 'desktop';
}

function assignVariantBySegment(segment, experimentData) {
  const segmentConfig = experimentData.segments?.[segment] || experimentData.default;
  const random = Math.random() * 100;
  return random < segmentConfig.weightA ? 'A' : 'B';
}

async function incrementVariantCount(env, experimentId, variant) {
  const key = `stats:${experimentId}:${variant}`;
  const current = parseInt(await env.AB_STORE.get(key) || '0');
  await env.AB_STORE.put(key, String(current + 1));
}

KV Store ile Experiment Yönetimi

Experiment konfigürasyonlarını kod içine gömmek yerine KV Store’da tutmak çok daha esnek bir yaklaşım. Bu sayede deployment yapmadan experiment’leri açıp kapatabilirsiniz.

# Experiment konfigürasyonunu KV'ye yükle
wrangler kv:key put --binding=AB_STORE 
  "experiment:checkout-button-2024" 
  '{"active":true,"weightA":50,"weightB":50,"segments":{"mobile":{"weightA":30,"weightB":70},"desktop":{"weightA":60,"weightB":40}}}'

# Experiment istatistiklerini görüntüle
wrangler kv:key get --binding=AB_STORE "stats:checkout-button-2024:A"
wrangler kv:key get --binding=AB_STORE "stats:checkout-button-2024:B"

# Experiment'i devre dışı bırak
wrangler kv:key put --binding=AB_STORE 
  "experiment:checkout-button-2024" 
  '{"active":false}'

Analytics Entegrasyonu

A/B testi yapmak kadar, sonuçları ölçmek de önemli. Workers üzerinden Cloudflare Analytics Engine’e veri gönderebilirsiniz.

// src/analytics.js - Analytics event gönderimi
async function trackEvent(env, eventData) {
  // Cloudflare Analytics Engine'e veri yaz
  if (env.AB_ANALYTICS) {
    env.AB_ANALYTICS.writeDataPoint({
      blobs: [
        eventData.experimentId,
        eventData.variant,
        eventData.segment,
        eventData.path,
        eventData.country
      ],
      doubles: [eventData.timestamp],
      indexes: [eventData.sessionId]
    });
  }
  
  // Aynı zamanda harici analytics servisine de gönder
  if (env.ANALYTICS_ENDPOINT) {
    await fetch(env.ANALYTICS_ENDPOINT, {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${env.ANALYTICS_TOKEN}`
      },
      body: JSON.stringify(eventData)
    }).catch(err => console.error('Analytics error:', err));
  }
}

export async function handleRequest(request, env, ctx) {
  const url = new URL(request.url);
  const variant = determineVariant(request, env);
  
  // Analytics'i background'da gönder (response'u bekletme)
  ctx.waitUntil(trackEvent(env, {
    experimentId: 'homepage-redesign-2024',
    variant: variant,
    segment: detectSegment(request),
    path: url.pathname,
    country: request.cf?.country,
    sessionId: getSessionId(request),
    timestamp: Date.now()
  }));
  
  return serveVariant(request, variant, env);
}

Multivariate Testing: İkiden Fazla Varyant

Bazen sadece A/B değil, A/B/C veya daha fazla varyant test etmek gerekir. Worker’ı buna göre genişletelim:

// src/multivariate.js - Çoklu varyant desteği
export default {
  async fetch(request, env, ctx) {
    const config = {
      variants: [
        { id: 'control', weight: 40, origin: env.ORIGIN_CONTROL },
        { id: 'variant_b', weight: 30, origin: env.ORIGIN_B },
        { id: 'variant_c', weight: 20, origin: env.ORIGIN_C },
        { id: 'variant_d', weight: 10, origin: env.ORIGIN_D }
      ]
    };
    
    const url = new URL(request.url);
    const cookieHeader = request.headers.get('Cookie') || '';
    let selectedVariant = getCookieValue(cookieHeader, 'mv_variant');
    
    // Mevcut varyant hala geçerli mi kontrol et
    const isValidVariant = config.variants.some(v => v.id === selectedVariant);
    
    if (!selectedVariant || !isValidVariant) {
      selectedVariant = weightedRandom(config.variants);
      
      ctx.waitUntil(
        recordAssignment(env, selectedVariant, request.cf?.country)
      );
    }
    
    const variantConfig = config.variants.find(v => v.id === selectedVariant);
    
    const targetRequest = new Request(
      variantConfig.origin + url.pathname + url.search,
      request
    );
    
    const response = await fetch(targetRequest);
    const newResponse = new Response(response.body, response);
    
    if (!isValidVariant) {
      newResponse.headers.append(
        'Set-Cookie',
        `mv_variant=${selectedVariant}; Path=/; Max-Age=1209600; SameSite=Lax`
      );
    }
    
    newResponse.headers.set('X-MV-Variant', selectedVariant);
    return newResponse;
  }
};

function weightedRandom(variants) {
  const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
  let random = Math.random() * totalWeight;
  
  for (const variant of variants) {
    random -= variant.weight;
    if (random <= 0) return variant.id;
  }
  
  return variants[variants.length - 1].id;
}

async function recordAssignment(env, variantId, country) {
  const date = new Date().toISOString().split('T')[0];
  const key = `assignments:${date}:${variantId}:${country || 'unknown'}`;
  const current = parseInt(await env.AB_STORE.get(key) || '0');
  await env.AB_STORE.put(key, String(current + 1), { 
    expirationTtl: 90 * 24 * 60 * 60 // 90 gün
  });
}

Worker’ı Deploy Etme ve DNS Ayarları

Kodu yazdık, şimdi deploy edelim ve Cloudflare DNS üzerinden yönlendirmeyi ayarlayalım.

# Development'ta test et
wrangler dev

# Staging'e deploy et
wrangler deploy --env staging

# Production'a deploy et
wrangler deploy --env production

# Deploy edilen worker'ın loglarını izle
wrangler tail --env production

# Belirli bir filtre ile logları izle
wrangler tail --env production --filter "variant=B"

Cloudflare Dashboard üzerinden DNS ayarları için Workers Route tanımlamanız gerekiyor:

  • Workers Routes: siteniz.com/* adresini worker’a yönlendirin
  • Zone ID: Cloudflare Dashboard’dan zone ID’nizi alın
  • Route Pattern: siteniz.com/ veya sadece belirli path’ler için siteniz.com/checkout/*

wrangler.toml dosyasına route eklemek için:

# wrangler.toml'a route ekle
cat >> wrangler.toml << 'EOF'

[[routes]]
pattern = "siteniz.com/*"
zone_name = "siteniz.com"

[env.staging]
name = "ab-testing-worker-staging"

[[env.staging.routes]]
pattern = "staging.siteniz.com/*"
zone_name = "siteniz.com"
EOF

Sık Karşılaşılan Sorunlar ve Çözümleri

Cache invalidation problemi: Cloudflare cache’i, farklı kullanıcılara farklı varyant göstermek istediğinizde sorun çıkarabilir. Cache-Control header’larını dikkatli yönetmeniz gerekiyor.

  • Cache-Control: private header’ı ekleyerek Cloudflare’in response’u cache’lememesini sağlayın
  • Vary: Cookie header’ı ile cookie bazlı cache ayrımı yapın
  • Workers route’unuzda “Bypass Cache on Cookie” kuralı tanımlayın

Redirect loop sorunu: Eğer hem origin sunucunuzda hem de Worker’da yönlendirme varsa loop’a girebilirsiniz. Origin isteğine X-AB-Forwarded: true gibi bir header ekleyip, Worker’da bu header varsa bypass edin.

Cookie güvenliği: Üretim ortamında cookie’lere mutlaka HttpOnly ve Secure flag’lerini ekleyin. Aksi halde XSS ile kullanıcının varyantı değiştirilebilir.

Yüksek KV maliyeti: Her request’te KV okuma yaparsanız maliyet artar. Experiment konfigürasyonunu env değişkenlerine taşıyın, sadece sayaçlar için KV kullanın.

Sonuç

Cloudflare Workers ile A/B testing yapmak, geleneksel yöntemlere göre birkaç konuda ciddi avantaj sağlıyor. En önemlisi uygulamanıza hiç dokunmadan, tamamen altyapı katmanında test yapabiliyorsunuz. Bu hem frontend ekibini rahatlatıyor hem de test sonuçlarına göre anında değişiklik yapma imkanı sunuyor.

Pratik olarak şunu öneririm: Önce basit bir %50/%50 testle başlayın, sistemi oturttuktan sonra segmentasyon ve analytics entegrasyonunu ekleyin. KV Store ile experiment yönetimini merkeze alın ki deployment yapmadan deneyleri kontrol edebilin.

Maliyete gelince: Workers’ın ücretsiz planı günde 100.000 istek içeriyor. Orta ölçekli bir site için paid plan olan Workers Paid’e geçmeniz gerekse bile $5/ay ile başlıyor ve bu rakam, Optimizely veya LaunchDarkly gibi araçların aylık yüzlerce dolar olan fiyatlarıyla kıyaslandığında çok cazip. Hem kontrolü elinizde tutuyorsunuz hem de edge’de sıfır gecikmeyle çalışıyor.

Bir sonraki adım olarak Cloudflare Analytics Engine ile Worker metriklerini özel bir dashboard’a çekmeyi ve istatistiksel anlamlılık hesaplamasını Workers içine entegre etmeyi deneyebilirsiniz. Ama bu konular ayrı bir yazının konusu.

Bir yanıt yazın

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