DEV Community

Koray Yalçın
Koray Yalçın

Posted on

UTM parametrelerini CRM'e doğru taşımak: İlk ve son temas, normalize sözlük, kanal eşleme tablosu ve geriye dönük yeniden hesaplama

Pazarlama toplantılarında en sık duyduğum cümlelerden biri şudur: "Reklam paneli 400 lead diyor, CRM 310 diyor, satış ekibi ise kampanyanın hiç satış getirmediğini söylüyor." Bu üç rakam genellikle aynı gerçeği farklı kayıplarla anlatır. Kayıpların büyük kısmı da reklamdaki tıklama ile CRM'deki fırsat kartı arasındaki o kısa yolda, yani UTM parametrelerinin taşınmasında yaşanır. Ben Koray Yalçın; lead yönetimi, CRM mimarisi, dijital pazarlama ve yapay zekâ destekli otomasyon üzerine, çoğunlukla gayrimenkul ve PropTech projelerinde çalışıyorum. Konut projelerinde kampanya başına bütçeler yüksek, satış döngüleri uzun olduğu için "bu satış hangi reklamdan geldi" sorusunun cevabı doğrudan bütçe kararına dönüşüyor.

UTM mapping, en basit tanımıyla, reklam ve kampanya bağlantılarındaki UTM parametrelerinin CRM'deki lead ve fırsat kayıtlarına otomatik ve tutarlı biçimde yazılmasıdır. Bu yazıda konuyu pazarlama tarafından değil, geliştirici tarafından ele alıyorum: parametreleri sayfaya ilk girişte nasıl yakalayacağınız, form ve oturum boyunca nasıl koruyacağınız, ham değeri ve normalize değeri neden ayrı tutmanız gerektiği, kanal eşlemesini neden kod yerine veri olarak saklamanız gerektiği ve eşleme kuralları değiştiğinde geçmişi nasıl yeniden hesaplayacağınız. Örnekler JavaScript ve PostgreSQL üzerinden.

Sorun nerede başlıyor: UTM bir kez görünür, sonra kaybolur

UTM parametreleri (utm_source, utm_medium, utm_campaign, utm_content, utm_term) yalnızca kullanıcının geldiği ilk URL'de bulunur. Kullanıcı sitede ikinci sayfaya geçtiğinde, galeriye baktığında ya da form sayfasına gittiğinde adres çubuğunda artık yoktur. Formu gönderen sayfa çoğu zaman UTM'yi hiç görmemiştir. Bu yüzden "form gönderilirken URL'deki parametreleri okuyalım" yaklaşımı, tek sayfalık kampanya açılış sayfaları dışında neredeyse her zaman veri kaybettirir.

İkinci sorun, aynı ziyaretçinin birden fazla kez gelmesidir. Konut alıcısı bir hafta önce Instagram reklamından gelip bakar, üç gün sonra Google'da proje adını arayıp tekrar gelir, sonunda bir e-posta bülteninden gelip formu doldurur. Hangi kaynağı yazacağınız bir tercih meselesidir ve bu tercihi kodun içine gömülü bir varsayım olarak değil, açık bir veri modeli olarak yapmanız gerekir.

Üçüncü sorun ise dağınıklıktır. Aynı kaynak için facebook, Facebook, fb, meta, ig ve instagram değerlerini aynı hafta içinde görmek olağandır. Ajans değiştiğinde ya da bir ekip arkadaşı kampanya bağlantısını elle yazdığında yeni varyantlar eklenir. Normalize edilmemiş UTM verisiyle kurulan rapor, birkaç ay içinde güvenilmez hale gelir.

Yakalama katmanı: İlk temas ve son temas ayrı saklanır

Benim önerdiğim temel kural şudur: tarayıcı tarafında hem ilk temas hem son temas bilgisi tutulur ve form gönderiminde ikisi birden sunucuya iletilir. İlk temas, ziyaretçinin bu cihazda UTM ile geldiği ilk ziyarettir ve bir kez yazıldıktan sonra değişmez. Son temas, UTM taşıyan en son ziyarettir ve her yeni kampanyalı girişte güncellenir. UTM'siz bir dönüş (doğrudan giriş ya da organik arama) son temas bilgisini silmez; aksi halde kullanıcı yer imine kaydettiği sayfadan döndüğü anda tüm kampanya bilgisi kaybolur.

const KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"];
const CLICK_IDS = ["gclid", "fbclid", "wbraid", "gbraid", "ttclid"];
const STORE = "kt_attr_v1";
const MAX_AGE_DAYS = 90;

function readTouch() {
  const p = new URLSearchParams(window.location.search);
  const touch = {};
  for (const k of [...KEYS, ...CLICK_IDS]) {
    const v = p.get(k);
    if (v) touch[k] = v.slice(0, 200);
  }
  if (Object.keys(touch).length === 0) return null;
  touch.landing_path = window.location.pathname;
  touch.referrer_host = document.referrer ? new URL(document.referrer).hostname : null;
  touch.ts = new Date().toISOString();
  return touch;
}

function captureAttribution() {
  const now = Date.now();
  let state = {};
  try { state = JSON.parse(localStorage.getItem(STORE)) || {}; } catch (e) { state = {}; }
  if (state.first && now - Date.parse(state.first.ts) > MAX_AGE_DAYS * 864e5) state = {};

  const touch = readTouch();
  if (touch) {
    if (!state.first) state.first = touch;
    state.last = touch;
    state.touch_count = (state.touch_count || 0) + 1;
  }
  localStorage.setItem(STORE, JSON.stringify(state));
  return state;
}
Enter fullscreen mode Exit fullscreen mode

Burada birkaç tasarım kararı var. Değerleri 200 karakterle kısaltıyorum, çünkü bazen reklam platformları ya da hatalı şablonlar çok uzun değerler üretir ve bunlar hem depolamayı hem raporları bozar. Tıklama kimliklerini (gclid, fbclid gibi) UTM ile aynı yerde ama ayrı alanlarda tutuyorum; çünkü daha sonra kapanan satışı reklam platformuna geri bildirmek istediğinizde UTM değil tıklama kimliği işe yarar. Süre sınırı koyuyorum, çünkü bir yıl önceki bir reklam tıklamasını bugünkü forma bağlamak çoğu işletme için anlamlı değildir. Doksan gün bir örnek değerdir; kendi satış döngünüze göre seçmelisiniz.

Çerez onayı konusunu da baştan çözmek gerekir. Bu bilgileri yerel depolamaya yazmak, kullandığınız onay yönetimi yapısına ve hukuki değerlendirmenize göre onay gerektirebilir. Pratikte iki yol görüyorum: onay verilene kadar parametreleri yalnızca oturum boyunca bellekte tutup form gönderiminde iletmek ya da onay sonrasında kalıcı depolamaya yazmak. Hangisini seçerseniz seçin, CRM'e gelen kayıtta "atıf verisi hangi onay durumunda toplandı" bilgisini de saklamanızı öneririm.

Form ve sunucu tarafı: Tarayıcıya güvenmeyin, ama ondan gelen veriyi kaybetmeyin

Form gönderiminde atıf durumu gizli alanlar ya da JSON gövdesinin bir parçası olarak sunucuya gider. Sunucu bu veriyi doğrulamadan CRM'e yazmamalıdır; çünkü sonuçta kullanıcının değiştirebileceği bir veridir. Doğrulamanın amacı sahteciliği tamamen engellemek değil, bozuk ve tehlikeli değerleri ayıklamaktır: izin verilen anahtarlar dışındakileri atmak, uzunluğu sınırlamak, kontrol karakterlerini temizlemek ve zaman damgasının makul bir aralıkta olduğunu kontrol etmek.

Sunucu tarafında ikinci bir güvence katmanı da kurulabilir. Açılış sayfası isteği sunucunuza geldiğinde, URL'deki UTM'leri birinci taraf bir çerezle eşleşen bir ziyaret kaydına yazarsanız, tarayıcı tarafındaki kod bir reklam engelleyiciye takıldığında bile atıf verisini kurtarabilirsiniz. Form gönderiminde tarayıcıdan gelen değer ile sunucudaki ziyaret kaydı karşılaştırılır; ikisi çelişirse sunucu kaydı tercih edilir ve çelişki not edilir.

Veri modeli: Ham değer, normalize değer ve temas tablosu

En sık gördüğüm hata, UTM değerlerini doğrudan lead kartındaki birkaç metin alanına yazmaktır. Bu, hem tek temas varsayımını kalıcı hale getirir hem de normalizasyon kuralları değiştiğinde geçmişi düzeltmeyi imkânsızlaştırır. Benim tercih ettiğim yapı, her teması ayrı bir satır olarak saklamak ve normalize sonucu ayrı alanlarda tutmaktır.

CREATE TABLE attribution_touch (
  id              bigserial PRIMARY KEY,
  lead_id         bigint NOT NULL REFERENCES lead(id),
  touch_role      text NOT NULL CHECK (touch_role IN ('first', 'last', 'conversion')),
  occurred_at     timestamptz NOT NULL,
  -- ham değerler: asla değiştirilmez
  raw_source      text,
  raw_medium      text,
  raw_campaign    text,
  raw_content     text,
  raw_term        text,
  click_id_type   text,
  click_id        text,
  landing_path    text,
  referrer_host   text,
  consent_state   text NOT NULL DEFAULT 'unknown',
  -- normalize sonuç: yeniden hesaplanabilir
  source_norm     text,
  medium_norm     text,
  channel         text,
  campaign_key    text,
  mapping_version int,
  UNIQUE (lead_id, touch_role)
);
Enter fullscreen mode Exit fullscreen mode

Ham alanlar bir kez yazılır ve hiç güncellenmez. Normalize alanlar ise hangi eşleme sürümüyle üretildiklerini mapping_version üzerinden belli eder. Bu ayrım, yazının ilerleyen bölümündeki geriye dönük yeniden hesaplamayı mümkün kılan şeydir. conversion rolü, formun gönderildiği oturumu temsil eder; ilk ve son temas ile aynı olabilir ama ayrı tutmak, "form gönderen ziyaret organikti ama kullanıcıyı ilk getiren reklamdı" gibi durumları görünür kılar.

Normalize sözlük ve kanal eşleme tablosu: Kural veri olarak

Normalizasyonu iki aşamada yapıyorum. Birinci aşama mekanik temizliktir: küçük harfe çevirme, baştaki ve sondaki boşlukları silme, boşluk ve tireleri alt çizgiye dönüştürme. İkinci aşama anlamsal eşlemedir: fb, facebook, meta ve ig değerlerinin hangi kanonik kaynağa karşılık geldiğine karar vermek. Bu ikinci aşamayı kodun içinde if zinciriyle yapmak, pazarlama ekibinin her yeni varyant için geliştiriciye iş açmasına yol açar. Bu yüzden eşlemeyi bir tabloya koyuyorum.

CREATE TABLE utm_alias (
  field        text NOT NULL CHECK (field IN ('source', 'medium')),
  raw_clean    text NOT NULL,
  canonical    text NOT NULL,
  PRIMARY KEY (field, raw_clean)
);

CREATE TABLE channel_rule (
  id           serial PRIMARY KEY,
  priority     int NOT NULL,
  source_norm  text,          -- NULL: herhangi biri
  medium_norm  text,          -- NULL: herhangi biri
  has_click_id text,          -- 'gclid', 'fbclid' ya da NULL
  channel      text NOT NULL, -- 'paid_social', 'paid_search', 'email', 'referral' ...
  version      int NOT NULL
);

CREATE INDEX ON channel_rule (version, priority);
Enter fullscreen mode Exit fullscreen mode

Kanal kuralları öncelik sırasına göre değerlendirilir ve ilk eşleşen kural kazanır. Tıklama kimliğini bir koşul olarak eklemek, UTM'si eksik ama gclid taşıyan ziyaretleri ücretli arama olarak sınıflandırmanızı sağlar; bu durum Google Ads'in otomatik etiketlemesi açıkken ama manuel UTM eklenmemişken sık görülür.

import re

def clean(value):
    if not value:
        return None
    v = value.strip().lower()
    v = re.sub(r"[\s\-]+", "_", v)
    v = re.sub(r"[^a-z0-9_\.]", "", v)
    return v or None

def normalize_touch(touch, aliases, rules):
    src = clean(touch.get("raw_source"))
    med = clean(touch.get("raw_medium"))
    src = aliases.get(("source", src), src)
    med = aliases.get(("medium", med), med)

    channel = "unmapped"
    for r in rules:  # priority sıralı gelir
        if r.source_norm and r.source_norm != src:
            continue
        if r.medium_norm and r.medium_norm != med:
            continue
        if r.has_click_id and r.has_click_id != touch.get("click_id_type"):
            continue
        channel = r.channel
        break

    if channel == "unmapped" and not src and not med:
        channel = "referral" if touch.get("referrer_host") else "direct"

    return {"source_norm": src, "medium_norm": med, "channel": channel}
Enter fullscreen mode Exit fullscreen mode

unmapped değerini bilerek ayrı tutuyorum. Eşleşmeyen bir değeri "diğer" kategorisine gömmek, sorunu gizler. unmapped oranı ise pazarlama ekibi için çok değerli bir sinyaldir: yeni bir ajans ya da yeni bir kampanya şablonu devreye girdiğinde bu oran yükselir ve sözlüğe eklenmesi gereken değerler hemen görünür.

Kampanya anahtarı: İsimlendirme kuralı olmadan eşleme yarım kalır

Kaynak ve ortam normalize edilse bile utm_campaign alanı genellikle en dağınık olanıdır. Konut projelerinde benim önerdiğim yaklaşım, kampanya adına sabit bir yapı vermektir: proje kodu, hedef kitle ya da pazar, teklif türü ve dönem. Örneğin prj12_uk_lansman_2026q4 gibi bir değer, alt çizgiyle ayrılmış dört parçaya bölünebilir. Bu yapıya uyan değerlerden bir campaign_key türetilir ve fırsat kartında projeyle ilişkilendirilir. Uymayan değerler yine ham haliyle saklanır, ama raporda "isimlendirme dışı" olarak işaretlenir.

Bu kuralı bağlantı üretim aşamasında uygulamak, sonradan temizlemekten çok daha ucuzdur. Pazarlama ekibine serbest metin alanı yerine, açılır listelerden kampanya bağlantısı üreten küçük bir iç araç vermek, birkaç hafta içinde veri kalitesini belirgin biçimde değiştirir. Bu aracın ürettiği değerler doğrudan utm_alias ve proje tablosu ile uyumlu olur.

Lead'den fırsata: Atıf kopyalanmaz, bağlanır

Lead bir fırsata dönüştüğünde UTM alanlarının fırsat kartına kopyalanması yaygın bir uygulamadır. Kopyalama, iki kayıt arasında zamanla ayrışan iki gerçek yaratır. Ben fırsat kaydında atıf verisini kopyalamak yerine, kaynak lead'in temas satırlarına bağlanmayı tercih ediyorum. Raporlama tarafında bir görünüm, fırsatı ilk ve son temasla birleştirir.

CREATE VIEW deal_attribution AS
SELECT d.id AS deal_id,
       d.project_id,
       d.stage,
       d.amount_gbp,
       f.channel  AS first_channel,
       f.campaign_key AS first_campaign,
       l.channel  AS last_channel,
       l.campaign_key AS last_campaign
FROM deal d
JOIN lead ld ON ld.id = d.origin_lead_id
LEFT JOIN attribution_touch f ON f.lead_id = ld.id AND f.touch_role = 'first'
LEFT JOIN attribution_touch l ON l.lead_id = ld.id AND l.touch_role = 'last';
Enter fullscreen mode Exit fullscreen mode

Bir kişinin birden fazla lead kaydı olduğunda (farklı formlar, farklı projeler) hangi lead'in fırsatın kaynağı olduğu, tekilleştirme ve birleştirme kurallarıyla belirlenir. Birleştirme sırasında temas satırları silinmemeli, birleşen kayda taşınmalıdır; böylece kişinin gerçek ilk teması korunur.

UTM'siz kanallar: Telefon, WhatsApp ve satış ofisi

Gayrimenkulde lead'lerin önemli bir kısmı forma hiç uğramaz. Kullanıcı reklamı görür, numarayı arar ya da WhatsApp'tan yazar, bazen de doğrudan satış ofisine gelir. Bu kanallarda UTM yoktur, ama atıf ihtiyacı devam eder. Uyguladığım üç yöntem şunlardır. Birincisi, kampanya bazlı takip numaraları kullanmak ve gelen aramanın numarasını bir kampanya anahtarına eşlemektir. İkincisi, WhatsApp'a yönlendiren bağlantılara önceden doldurulmuş bir mesaj içinde kısa bir referans kodu eklemek ve gelen ilk mesajda bu kodu ayrıştırmaktır. Üçüncüsü, satış ofisine gelen ziyaretçiye danışmanın "bizi nereden duydunuz" sorusunu sabit seçeneklerle sormasıdır. Bu üçünün sonucu da attribution_touch tablosuna yazılır; ham değer olarak takip numarası, referans kodu ya da beyan edilen seçenek saklanır ve aynı normalizasyon hattından geçer.

Beyan edilen kaynağın dijital atıfla çeliştiği durumlar sık görülür. Bunları birbirini ezmesine izin vermeden iki ayrı temas olarak saklamak, hem pazarlama hem satış ekibine daha dürüst bir tablo sunar.

Geriye dönük yeniden hesaplama: Eşleme değiştiğinde geçmiş de değişir

Sözlüğe yeni bir takma ad eklendiğinde ya da bir kanal kuralı değiştiğinde, geçmiş kayıtların normalize alanları eski kurallarla kalır. Ham değerleri sakladığınız için bu sorun çözülebilir: yeni kural sürümüyle tüm temasları yeniden hesaplarsınız. Bunu büyük tablolarda tek bir dev güncellemeyle değil, parçalar halinde yapmak gerekir.

def reproject(conn, new_version, batch=5000):
    aliases = load_aliases(conn)
    rules = load_rules(conn, new_version)
    last_id = 0
    while True:
        rows = conn.execute(
            """SELECT id, raw_source, raw_medium, click_id_type, referrer_host
               FROM attribution_touch
               WHERE id > %s AND (mapping_version IS NULL OR mapping_version < %s)
               ORDER BY id LIMIT %s""",
            (last_id, new_version, batch),
        ).fetchall()
        if not rows:
            break
        for r in rows:
            n = normalize_touch(r._asdict(), aliases, rules)
            conn.execute(
                """UPDATE attribution_touch
                   SET source_norm=%s, medium_norm=%s, channel=%s, mapping_version=%s
                   WHERE id=%s""",
                (n["source_norm"], n["medium_norm"], n["channel"], new_version, r.id),
            )
        conn.commit()
        last_id = rows[-1].id
Enter fullscreen mode Exit fullscreen mode

Yeniden hesaplama sonrası en önemli adım, değişen kayıtların özetini çıkarmaktır: kaç temasın kanalı değişti, hangi kanaldan hangisine geçti. Bu özet, "geçen ayın raporu neden değişti" sorusunun cevabıdır ve pazarlama ekibiyle güveni koruyan şey budur. Kapanmış dönem raporlarını dondurmak isteyen ekipler için rapor anlık görüntüsünü ayrıca saklamak da iyi bir pratiktir.

İzleme: Eşlemenin sağlığını haftalık ölçün

Kurulumdan sonra izlenmesi gereken birkaç basit gösterge var. Atıf verisi hiç olmayan dijital lead oranı, yakalama katmanında bir kırılma olduğunu gösterir; örneğin bir site güncellemesi script'i devre dışı bırakmış olabilir. unmapped kanal oranı sözlüğün güncel olup olmadığını gösterir. İsimlendirme dışı kampanya oranı, bağlantı üretim aracının kullanılıp kullanılmadığını gösterir. Reklam panelindeki dönüşüm sayısı ile CRM'deki aynı kampanyaya ait lead sayısı arasındaki fark ise tek başına kesin bir sonuç vermez ama ani sıçramalar çoğu zaman bir entegrasyon sorununa işaret eder.

Bu göstergeleri bir panoya koymak yeterli değildir; bir eşik belirleyip aşıldığında ilgili kişiye bildirim gitmesini sağlamak gerekir. Atıf hatları sessizce bozulur ve fark edildiklerinde genellikle birkaç haftalık veri çoktan kaybolmuştur.

Sık sorulan sorular

İlk temas mı son temas mı kullanmalıyım?
İkisini de saklayın, raporlama sırasında seçin. Lansman dönemindeki bilinirlik kampanyalarının etkisini görmek için ilk temas, kısa vadeli performans kampanyalarını optimize etmek için son temas daha anlamlıdır. Tek bir alan seçip diğerini atmak, sonradan geri dönülemeyen bir karardır.

UTM verisini sadece reklam platformunun panelinden takip etsem yetmez mi?
Panel, tıklama ve form doldurma düzeyinde bilgi verir. Hangi lead'in randevuya geldiği, hangisinin satışa dönüştüğü ve satış tutarı ise CRM'dedir. Bütçe kararını satış verisine göre vermek istiyorsanız atıfın CRM'de doğru yaşaması gerekir.

Ham değerleri saklamak veri tabanını şişirmez mi?
Temas başına birkaç kısa metin alanı, lead kaydının geri kalanıyla karşılaştırıldığında küçük bir yüktür. Karşılığında kural değiştiğinde geçmişi düzeltme imkânı kazanırsınız. Ham değer olmadan bu düzeltme mümkün değildir.

Reklam engelleyiciler UTM yakalamayı bozar mı?
Kendi alan adınızdan sunulan birinci taraf script'ler genellikle daha az etkilenir, ama risk tamamen ortadan kalkmaz. Açılış sayfası isteğini sunucu tarafında kaydetmek, tarayıcı tarafı başarısız olduğunda bir yedek sağlar.

Tıklama kimliklerini (gclid, fbclid) neden ayrıca saklamalıyım?
UTM size kampanyayı söyler, tıklama kimliği ise reklam platformunun kendi kaydına bağlanmanızı sağlar. Kapanan satışları reklam platformuna offline dönüşüm olarak geri bildirmek istediğinizde ihtiyaç duyacağınız bilgi budur.

Kanal eşleme kurallarını kim yönetmeli?
Kuralların kendisi pazarlama operasyonunun sorumluluğunda olmalı, ama değişiklikler sürümlü ve kayıtlı yapılmalı. Geliştirici, sürüm geçişinde yeniden hesaplamanın çalışmasını ve değişiklik özetinin üretilmesini garanti eder.

Kapanış

UTM mapping, ilk bakışta birkaç gizli form alanından ibaret görünür. Gerçekte ise yakalama, saklama, normalizasyon, eşleme ve yeniden hesaplamadan oluşan küçük bir veri hattıdır. İlk ve son teması ayrı tutmak, ham değeri hiç değiştirmemek, eşlemeyi kod yerine sürümlü veri olarak saklamak, UTM'siz kanalları aynı hatta dahil etmek ve hattın sağlığını düzenli ölçmek, "reklam ne kadar satış getirdi" sorusuna savunulabilir bir cevap vermenin temelidir. Benim deneyimimde bütçe toplantılarındaki tartışmaların önemli bir kısmı, rakamların kendisinden değil, bu hattın hiç kurulmamış olmasından kaynaklanır.

Koray Yalçın — lead yönetimi, CRM mimarisi ve PropTech sistemleri

Top comments (0)