Mert Özen Her Satırda Daha İleri
SEO ve GEO Analytics ve Ölçüm — #05

Analytics'te Gizlilik ve Consent: KVKK Çerez Rehberi, Consent Mode ve Cookieless Alternatifler

Mert Özen 15 Kas 2026 11 dk 4 görüntülenme
Analytics'te Gizlilik ve Consent: KVKK Çerez Rehberi, Consent Mode ve Cookieless Alternatifler

Analytics tag'ini consent'ten önce yüklemezsin ve consent oranı değişince GA4'ün sayısı trafik değişmeden düşer; bunu consent'ten etkilenmeyen bir kaynakla ayırırsın. KVKK çerez rehberinin ölçüm için anlamını, ASP.NET Core'da ITrackingConsentFeature'ı, Angular'da consent'e bağlı yüklemeyi, Consent Mode'un basic ve advanced farkını, 2026 değişikliğini ve çerezsiz alternatifleri anlatıyoruz. Hukuki tavsiye değildir.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10), Angular 22.2 (TypeScript 6.0) ve Playwright 1.65'in Chromium'u ile denenmiştir. Bu yazıda resmî belgelerden yararlanılmıştır: KVKK'nın (Kişisel Verileri Koruma Kurumu) Çerez Uygulamaları Hakkında Rehber'i (KVKK Yayınları No: 69, Temmuz 2025), Google'ın Consent Mode ve Google Analytics belgeleri ve Microsoft Learn, 15 Kasım 2026'daki hâliyle. Bu yazı hukuki tavsiye değildir; hangi çerezin hangi hukuki sebebe dayandığına hukuk danışmanın karar verir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) blog'unda 26 Ekim'de çerez banner'ı değişti: eskiden büyük ve renkli bir "Kabul et" butonu vardı, yenisinde "Kabul et" ve "Reddet" aynı boyutta ve aynı renkte. Bir hafta sonra GA4'te (Google Analytics 4; Google'ın web analitik ürünü) organic search session'ları yaklaşık üçte bir düşmüş görünüyor; Search Console'daki click'ler ise yerinde. Selin "SEO'da (arama motoru optimizasyonu) bir şey mi bozuldu?" diye soruyor. Sence trafik mi düştü, yoksa ölçüm mü? Bunu hangi veriyle ayırırsın?

KVKK Çerez Rehberi: Geliştiricinin Bilmesi Gerekenler

Kurum'un Çerez Uygulamaları Hakkında Rehber'inin güncel baskısı Temmuz 2025 tarihli. Rehberin çerezleri nasıl sınıflandırdığını, Kriter A ve Kriter B ayrımını ve banner'da "kabul" ve "reddet"in eşit sunulmasını Veri Gizliliği ve KVKK serisinin "Aydınlatma, Açık Rıza ve Çerezler" yazısında ayrıntılı anlattık; orada consent kaydını tutan bir tablo ve Angular'da bir banner da var. Burada ölçüm açısından önemli iki noktaya bakıyoruz.

Birincisi, açık rızaya tabi çerezler rıza alınmadan önce yerleştirilmiyor ve rıza aktif bir eylemle alınıyor; siteye girmiş olmak rıza sayılmıyor. Analytics tarafında bunun teknik karşılığı açık: tag, ziyaretçi izin vermeden yüklenmez. İkincisi, rehberin 5.9 bölümü birinci taraf analitik çerezlerin belirli koşullarla Kriter B kapsamında, yani açık rıza olmadan değerlendirilebileceğini söylüyor: amaç sitenin işleyişi ve hedef kitlesinin ölçülmesiyle sınırlı olmalı, yalnız anonim istatistik üretilmeli, siteler arası takip yapılmamalı; rehber IP'nin maskelenmesini, çerez ömrünün makul tutulmasını ve verinin üçüncü taraflara iletilmemesini de alınabilecek tedbirler arasında sayıyor. Üçüncü taraf bir araç olan GA4 bu tarife kolay oturmuyor; Bookstore bu yüzden GA4'ü rızaya bağlıyor. Bu bir mühendislik tercihi; senin sitende kararı hukuk ekibin verir. Avrupa Birliği'ndeki okurlar için GDPR (Avrupa Birliği'nin Genel Veri Koruma Tüzüğü) ve çerez kuralları ayrıca değerlendirilmeli.

ASP.NET Core'un consent desteği iki parçadan oluşuyor. CookiePolicyOptions.CheckConsentNeeded true dönünce, UseCookiePolicy middleware'i zorunlu olarak işaretlenmemiş (IsEssential olmayan) çerezleri consent olmadan tarayıcıya göndermiyor. ITrackingConsentFeature ise isteğin consent durumunu (CanTrack) veriyor ve GrantConsent/WithdrawConsent ile .AspNet.Consent çerezini yazıp siliyor.

builder.Services.AddRazorPages();
builder.Services.Configure<CookiePolicyOptions>(options =>
{
    // zorunlu olmayan çerezler (ve analytics'imiz) consent'i bekler
    options.CheckConsentNeeded = _ => true;
    options.MinimumSameSitePolicy = SameSiteMode.Lax;
});
builder.Services.AddScoped<CspNonce>();
builder.Services.Configure<AnalyticsOptions>(builder.Configuration.GetSection(AnalyticsOptions.SectionName));
builder.Services.AddHttpClient<Ga4MeasurementProtocolClient>(http => http.Timeout = TimeSpan.FromSeconds(2));

WebApplication app = builder.Build();

app.UseContentSecurityPolicy();
app.UseStaticFiles();
app.UseCookiePolicy();
app.MapRazorPages();
app.MapAnalyticsConsent();
using Microsoft.AspNetCore.Http.Features;

namespace Bookstore.Analytics.Web.Analytics;

public static class ConsentEndpoints
{
    public static IEndpointRouteBuilder MapAnalyticsConsent(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder group = app.MapGroup("/consent/analytics");

        // .AspNet.Consent çerezini yazar
        group.MapPost("/grant", (HttpContext context) =>
        {
            context.Features.Get<ITrackingConsentFeature>()!.GrantConsent();
            return Results.NoContent();
        });

        group.MapPost("/withdraw", (HttpContext context) =>
        {
            context.Features.Get<ITrackingConsentFeature>()!.WithdrawConsent();
            // GA4 _ga ve _ga_<container-id> yazar; bizimki bs_ga_cid
            foreach (string name in context.Request.Cookies.Keys
                         .Where(n => n == "_ga" || n.StartsWith("_ga_", StringComparison.Ordinal) || n == ClientIdCookie.Name))
            {
                context.Response.Cookies.Delete(name);
            }

            return Results.NoContent();
        });

        return app;
    }

    public static bool CanTrack(this HttpContext context) =>
        context.Features.Get<ITrackingConsentFeature>()?.CanTrack ?? false;
}

Razor tarafında Google tag'i artık yalnız CanTrack doğruysa basılıyor ve tag'in ilk komutu consent durumu:

@using Microsoft.Extensions.Options
@using Bookstore.Analytics.Web.Analytics
@using Bookstore.Analytics.Web.Security
@inject IOptions<AnalyticsOptions> Analytics
@inject CspNonce Nonce
@if (Analytics.Value.MeasurementId is { Length: > 0 } id && Context.CanTrack())
{
    <script async src="https://www.googletagmanager.com/gtag/js?id=@id"></script>
    <script nonce="@Nonce.Value">
        window.dataLayer = window.dataLayer || [];
        function gtag() { dataLayer.push(arguments); }
        gtag('consent', 'default', {
            'analytics_storage': 'granted',
            'ad_storage': 'denied',
            'ad_user_data': 'denied',
            'ad_personalization': 'denied'
        });
        gtag('js', new Date());
        gtag('config', '@id');
    </script>
}

PDF endpoint'i de Measurement Protocol event'ini yalnız context.CanTrack() doğruysa gönderiyor; önceki yazıdaki bs_ga_cid çerezi tarayıcıda kalmış olsa bile. Yerel çalıştırmada akış şöyle:

$ curl -s http://127.0.0.1:52704/ | grep -c googletagmanager
0
$ curl -si -X POST http://127.0.0.1:52704/consent/analytics/grant | grep -i set-cookie
Set-Cookie: .AspNet.Consent=yes; expires=Mon, 15 Nov 2027 10:19:33 GMT; path=/; samesite=lax
$ curl -s -H "Cookie: .AspNet.Consent=yes" http://127.0.0.1:52704/ | grep -c googletagmanager
1
$ curl -si -X POST -H "Cookie: .AspNet.Consent=yes; _ga=GA1.1.1.1" http://127.0.0.1:52704/consent/analytics/withdraw | grep -i set-cookie
Set-Cookie: .AspNet.Consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax
Set-Cookie: _ga=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax

.AspNet.Consent bir yıl geçerli ve HttpOnly değil; Angular uygulamasının aynı kararı okuyabilmesi için bu işimize geliyor. Geri almada consent çerezi ve GA4'ün _ga ve _ga_<container-id> çerezleri siliniyor. Bir sınır: ASP.NET Core'un consent'i tek bir evet-hayır; analytics, reklam ve kişiselleştirme gibi birden çok kategori varsa kararı kendin, sürümüyle ve zamanıyla kaydetmen gerekiyor. Testler dört durumu kontrol ediyor: consent yokken sayfada tag ve Set-Cookie yok, PDF indiriliyor ama GA4'e bir şey gitmiyor, grant çerezi yazıyor, withdraw çerezleri siliyor.

$ dotnet test --filter "FullyQualifiedName~ConsentTests"
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 420 ms - Bookstore.Analytics.Tests.dll (net10.0)

Angular'da Consent'e Bağlı Yükleme

bookstore-web'de karar bir signal. ConsentService başlangıçta .AspNet.Consent çerezine bakıyor; "hayır"ı da localStorage'da hatırlıyor ki banner her ziyarette tekrar çıkmasın:

import { HttpClient } from '@angular/common/http';
import { DOCUMENT, Injectable, inject, signal } from '@angular/core';

export type ConsentState = 'unknown' | 'granted' | 'denied';

@Injectable({ providedIn: 'root' })
export class ConsentService {
  private readonly http = inject(HttpClient);
  private readonly doc = inject(DOCUMENT);
  private static readonly DeniedKey = 'analytics-consent';

  readonly analytics = signal<ConsentState>(this.read());

  grant(): void {
    // .AspNet.Consent'i server yazar; Razor sayfaları ve PDF endpoint'i okur
    this.http.post('/consent/analytics/grant', null).subscribe(() => {
      this.doc.defaultView?.localStorage.removeItem(ConsentService.DeniedKey);
      this.analytics.set('granted');
    });
  }

  deny(): void {
    // "hayır"ı da hatırla; banner her ziyarette çıkmasın
    this.doc.defaultView?.localStorage.setItem(ConsentService.DeniedKey, 'denied');
    this.http.post('/consent/analytics/withdraw', null).subscribe(() => this.analytics.set('denied'));
  }

  reopen(): void {
    this.analytics.set('unknown');
  }

  private read(): ConsentState {
    if (this.doc.cookie.split('; ').includes('.AspNet.Consent=yes')) {
      return 'granted';
    }
    return this.doc.defaultView?.localStorage?.getItem(ConsentService.DeniedKey) === 'denied' ? 'denied' : 'unknown';
  }
}
export class App {
  constructor() {
    const analytics = inject(AnalyticsService);
    const consent = inject(ConsentService);
    effect(() => {
      if (consent.analytics() === 'granted') {
        analytics.start();
      } else {
        analytics.stop();
      }
    });
  }
}

Tag artık yalnız granted durumunda yükleniyor ve yüklenirken ilk komut gtag('consent', 'default', ...): analytics için izin var, reklam türlerinin hepsi kapalı. Consent açık bir sayfadayken verildiyse AnalyticsService o sayfayı bir kez sayıyor; geri alınırsa tag'e analytics_storage: 'denied' gönderip bu sayfadan sonra hiçbir event göndermiyor. Üç ziyareti Playwright'ın Chromium'unda denedim; Google'a giden istekler engelli, script yalnız gtag.js'in istenip istenmediğini sayıyor:

$ node consent-check.mjs http://127.0.0.1:52714
## reject: gtag.js requested 0 time(s)
## accept: gtag.js requested 1 time(s)
  consent default {"analytics_storage":"granted","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied"}
  config G-TEST123 {"send_page_view":false}
  event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"","page_title":"search-console-api - Bookstore Blog"}
## returning visitor with consent cookie: gtag.js requested 1 time(s)
  consent default {"analytics_storage":"granted","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied"}
  config G-TEST123 {"send_page_view":false}
  event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"","page_title":"search-console-api - Bookstore Blog"}

Reddeden ziyaretçide gtag.js hiç istenmedi ve dataLayer boş kaldı. İlk denemede banner hiç görünmedi; konsolda NG0200 (Angular'ın circular dependency hata kodu) vardı: AnalyticsService'e Router inject etmiştim, Router da TitleStrategy üzerinden AnalyticsService'e bağlı olduğu için bir circular dependency oluşmuştu. Service'in Router yerine "ilk navigasyon bitti mi?" bilgisini TitleStrategy'den alması sorunu çözdü.

Consent Mode bir banner değil, Google tag'lerine ziyaretçinin kararını bildiren bir API. Türleri ad_storage, ad_user_data, ad_personalization, analytics_storage ve functionality_storage, personalization_storage, security_storage. default komutu her sayfada config ve event komutlarından önce gelmeli.

Google iki uygulama biçimi tarif ediyor. Basic'te tag'ler ziyaretçi banner'la etkileşene kadar hiç yüklenmiyor; red durumunda Google'a consent durumu bile gitmiyor. Advanced'de tag'ler sayfayla yükleniyor, varsayılan durum denied ve red durumunda çerezsiz ölçüm ping'leri gönderiliyor. Ölçüm açısından fark büyük: GA4'ün behavioral modeling'i, yani rıza vermeyen kullanıcıların davranışını rıza verenlerden tahmin etmesi, yalnız advanced uygulamada çalışıyor. Bunun için de günde en az 1.000 analytics_storage='denied' event'inin en az 7 gün boyunca gelmesi ve son 28 günün en az 7'sinde günde en az 1.000 rıza vermiş kullanıcı olması gerekiyor; reporting identity "Blended" olmalı. Modellenen veri user ve session metric'lerine uygulanıyor, event sayılarına ve BigQuery export'una uygulanmıyor. Bookstore blog'u gibi küçük bir sitede bu eşiklere zaten ulaşılmaz; basic uygulamada GA4'ün gösterdiği sayı, rıza veren ziyaretçilerin sayısı.

2026'nın bir değişikliği de var. Google Analytics Help'teki "Updates to Google Analytics Data Controls" sayfasına göre 15 Haziran 2026'dan itibaren Google Analytics tag'inin topladığı Google Ads çerezleri ve ID'leri için tek kontrol Consent Mode oluyor; Google Signals ayarı yalnız Google Analytics verisini oturum açmış kullanıcı bilgisiyle davranış raporları için ilişkilendirmeyi kontrol ediyor. Sayfa reklam kişiselleştirmesinin yalnız ad_personalization ile kontrol edileceğini ve IP adreslerinin şifrelenerek bağlı Ads hesabına aktarılacağını da yazıyor, ama 15 Kasım 2026 itibarıyla bu ikisinin tarihlerini vermiyor. Ads kullanmayan Bookstore'da reklam türlerinin hepsi her durumda denied.

Baştaki soruya dönelim. Banner değişikliği, trafiği değil rıza oranını değiştirir; GA4 yalnız rıza verenleri gördüğü için grafik düşer. Bunu ayırmanın yolu consent'ten etkilenmeyen bir kaynakla karşılaştırmak: serinin ilk yazısındaki log sınıflandırıcısının saydığı Google landing'leri. Aşağıdaki kod ikisini haftalık topluyor ve oranı veriyor:

using System.Globalization;

namespace Bookstore.Analytics.Consent;

public sealed record CoverageDay(DateOnly Date, int LogGoogleLandings, int Ga4OrganicSessions);

public sealed record CoverageWeek(DateOnly From, int LogGoogleLandings, int Ga4OrganicSessions)
{
    // Session ile landing isteği farklı birimler: oranın seviyesine değil, nasıl değiştiğine bak.
    public double Coverage => LogGoogleLandings == 0 ? 0 : (double)Ga4OrganicSessions / LogGoogleLandings;
}

public static class CoverageEstimator
{
    public static IEnumerable<CoverageDay> ReadCsv(string path) =>
        File.ReadLines(path).Skip(1).Where(l => l.Length > 0).Select(l => l.Split(',')).Select(c => new CoverageDay(
            DateOnly.Parse(c[0], CultureInfo.InvariantCulture),
            int.Parse(c[1], CultureInfo.InvariantCulture),
            int.Parse(c[2], CultureInfo.InvariantCulture)));

    // İlk günden başlayan yalnız tam haftalar; böylece haftanın günleri karşılaştırılabilir kalır.
    public static List<CoverageWeek> Weekly(IEnumerable<CoverageDay> days)
    {
        List<CoverageDay> ordered = days.OrderBy(d => d.Date).ToList();
        return ordered
            .Select((day, index) => (day, week: index / 7))
            .GroupBy(x => x.week)
            .Where(g => g.Count() == 7)
            .Select(g => new CoverageWeek(
                g.First().day.Date,
                g.Sum(x => x.day.LogGoogleLandings),
                g.Sum(x => x.day.Ga4OrganicSessions)))
            .ToList();
    }
}

data/coverage-daily.csv 28 günlük sentetik bir veri seti: banner değişikliğinin etkisini göstermek için hazırlandı, gerçek bir sitenin verisi değil.

$ dotnet run --project src/Bookstore.Analytics.Cli -- coverage data/coverage-daily.csv
week from     log landings  ga4 sessions  coverage
2026-10-12          1865          1157       62%
2026-10-19          1939          1202       62%
2026-10-26          1810           742       41%
2026-11-02          1903           781       41%

ga4 sessions -32%, log landings +2%

GA4 session'ları yüzde 32 düşmüş, log'daki Google landing'leri ise yüzde 2 artmış; oran tam banner haftasında 62'den 41'e kırılıyor. Trafik değil ölçüm kapsamı değişmiş. Session ile landing isteği aynı birim değil, o yüzden oranın seviyesine değil kırılmasına bakıyoruz.

Çerezsiz Alternatifler

Rıza oranından bağımsız bir sayı istiyorsan çerezsiz araçlar bir seçenek. Aşağıdaki tablo her aracın kendi dokümanına dayanıyor; "banner gerekmez" gibi ifadeler üreticinin iddiası, hukuki bir değerlendirme değil.

AraçNasıl sayarNe kaybedersinNot
PlausibleÇerez yok; günlük kimlik, günlük salt, domain, IP ve user agent'ın hash'i; salt 24 saatte bir siliniyorGünler arası tekil ziyaretçi, ayrıntılı kullanıcı yolculuğuÜretici banner gerekmediğini söylüyor; ham IP saklanmıyor
UmamiWeb tracker çerez kullanmıyorGeri dönen ziyaretçi ayrımı sınırlıAçık kaynak, self-hosted kurulabiliyor; uyumun yapılandırmaya bağlı olduğunu kendisi yazıyor
Matomo, çerezsiz mod_paq.push(['disableCookies']) ile analytics çerezleri kapanıyorGeri dönen ziyaretçi sayılarının doğruluğu düşüyorSelf-hosted; bazı zorunlu çerezler yine yazılabiliyor
Cloudflare Web AnalyticsÇerez ve localStorage yok; tekil kişi değil ziyaret sayıyorTekil ziyaretçi, ayrıntılı event analiziJavaScript beacon'ı yine tarayıcıda çalışıyor
Yalnız sunucu log'uHer istek; Google landing'leri Referer ileSayfa içi davranış; JavaScript ölçümü yokScript yok; IP ve user agent saklama süresi yine düşünülmeli

Doğruluk açısından hiçbiri GA4'ün rıza veren kullanıcılar için verdiği ayrıntıyı vermiyor; karşılığında rıza oranından bağımsız ve daha istikrarlı bir trend veriyorlar. Bir sitede iki aracı birlikte kullanmak da mümkün: çerezsiz bir araç trendi, GA4 ise rıza veren azınlığın ayrıntılı davranışını gösterir.

Üretimde Dikkat

Banner değişikliklerini kaydet. Metin, buton düzeni ya da varsayılan değer değiştiğinde tarihi ve ne değiştiğini not et; Search Console'daki bir annotation ya da bir değişiklik dosyası işini görür. Aksi hâlde aylar sonra bir kırılmayı açıklayamazsın.

Consent oranını ayrı izle. Banner'ın kaç kez gösterildiğini ve kaç kararın kabul ya da red olduğunu kişisel veri tutmadan sunucu tarafında say; GA4'teki bir düşüşün yorumlanması bu sayıya bağlı.

Default komutunu en başa koy. Tag'i bir tag manager'dan yüklüyorsan bile consent varsayılanı tag'den önce çalışmalı; aksi hâlde ilk hit consent durumu olmadan gider.

Geri almayı kolay yap. Banner kapandıktan sonra küçük bir "Cookie settings" butonu kalsın; rehber, rızanın geri alınabilmesini ve bunun için panele kolay erişimi bekliyor.

Sık Düşülen Tuzaklar

"Anonim IP yeter" varsayımı. IP'nin maskelenmesi KVKK rehberinde birinci taraf analitik için sayılan tedbirlerden yalnız biri; çerez yine bir cihazı tanıyor, araç üçüncü tarafsa veri yine dışarı gidiyor.

Varsayılanı "kabul" olan banner. Önceden işaretli kutular, "devam ederek kabul edersiniz" metni ya da büyük renkli bir kabul butonunun yanında soluk bir red bağlantısı, rehberin aradığı özgür ve aktif rızayla bağdaşmıyor.

Consent kaynaklı düşüşü trafik düşüşü sanmak. GA4'ü consent'ten etkilenmeyen bir kaynakla, Search Console ya da log ile karşılaştırmadan "trafik düştü" deme.

Basic modda modellenmiş veri beklemek. Behavioral modeling advanced uygulama ve yüksek trafik eşikleri istiyor; küçük bir sitede GA4 yalnız rıza verenleri gösterir.

Küçük Bir Deneme

★☆☆ consent-check.mjs'e dördüncü bir ziyaret ekle: ziyaretçi önce kabul etsin, sonra "Cookie settings"ten reddetsin. Reddetten sonra dataLayer'a hangi çağrılar düşüyor?

★★☆ Server tarafına banner kararlarını sayan bir endpoint ekle: yalnız tarih ve karar (kabul ya da red), IP ve user agent olmadan. Haftalık kabul oranını veren bir test yaz.

★★★ CoverageEstimator'a bir kırılma dedektörü ekle: haftalık oran bir önceki dört haftanın en düşük değerinin altına inerse "consent veya tag değişikliği olabilir" uyarısı versin. Sentetik veriyle test et.

Ölçümün gizlilik tarafını konuştuk. Bir sonraki yazıda Core Web Vitals'a geçiyoruz: Google'ın SEO etkisi hakkında gerçekte ne söylediği, CrUX'in field data'sı ve web-vitals ile kendi RUM'unu (Real User Monitoring; gerçek kullanıcılardan ölçüm) kurmak.