GA4 ile Site İçi Ölçüm: Event Modeli, Angular SPA'da page_view, Custom Event'ler ve Key Event'ler
Angular SPA'da page_view'i ya enhanced measurement'ın history takibiyle ya da elle gönderirsin, ikisini birden değil; elle gönderiyorsan Router başlığı NavigationEnd'den sonra yazdığı için TitleStrategy içinde gönder. GA4'ün event modelini, key event'leri, Razor sayfalarında CSP nonce ile gtag'i, blog için anlamlı event'leri ve kişisel veri göndermeyen Measurement Protocol kodunu anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10), Angular 22.2 (Angular 22.2'nin desteklediği TypeScript 6.0 ile) ve Playwright 1.65'in Chromium'u ile denenmiştir; Google'a hiçbir istek gönderilmedi, ölçüm kimliği G-TEST123 bir yer tutucu. Bu yazıda resmî belgelerden yararlanılmıştır: Google Analytics Help ve Google Analytics geliştirici belgeleri, 15 Kasım 2026'daki hâliyle.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web'e GA4'ü (Google Analytics 4; Google'ın web analitik ürünü) ekliyorsun. Google'ın kurulum snippet'ini index.html'e yapıştırdın, ayrıca Router'ın NavigationEnd event'inde elle bir page_view gönderiyorsun. Data stream'de enhanced measurement açık. Okur ana sayfaya geliyor, sonra iki yazı açıyor. Sence GA4 kaç page_view görür? Ve elle gönderdiğin page_view'lerin page_title'ı doğru mu olur?
Universal Analytics'ten Event Modeline
Universal Analytics'in standart property'leri 1 Temmuz 2023'te veri işlemeyi bıraktı; Analytics 360 için son tarih 1 Temmuz 2024'tü. GA4'te session ve pageview tipleri yok, her şey bir event ve parametreleri. Event'ler dört gruba ayrılıyor: GA4'ün kendisinin topladıkları (session_start, first_visit, user_engagement gibi), data stream ayarından açılan enhanced measurement event'leri, Google'ın adını ve parametrelerini önerdiği recommended event'ler ve senin tanımladığın custom event'ler.
Enhanced measurement'ın yaptıkları bir blog için önemli: her sayfa yüklemesinde ve tarayıcı history değişikliğinde page_view; sayfanın yüzde 90'ına ilk kez inildiğinde scroll; başka bir domain'e giden link'e tıklamada outbound click; q, s, search gibi query parametreleriyle view_search_results; PDF, zip ve benzeri uzantılı bir link'e tıklamada file_download; form event'leri ve YouTube video event'leri. Yani PDF indirme ve GitHub'a giden link tıklaması kod yazmadan geliyor.
Bir de adlandırma değişti: Google, işin başarısı için önemli eylemleri ölçen event'lere artık "key event" diyor. "Conversion" kelimesi Google Ads tarafına kaldı: bir key event'ten Ads'e paylaşılan, kampanya ölçümü ve teklif optimizasyonu için kullanılan eylem. Analytics'te bir event'i Admin'deki Key events sayfasından key event olarak işaretliyorsun; key event olarak işaretli bir event'in adını değiştirirsen işaret düşüyor.
Angular SPA'da page_view
SPA (Single Page Application; tek sayfalık uygulama) navigasyonu tarayıcıya yeni bir sayfa yüklemiyor, History API ile URL'yi değiştiriyor. Google'ın SPA rehberi iki yol tarif ediyor: History API kullanan uygulamalarda enhanced measurement'ın "Page changes based on browser history events" ayarını açık bırakmak ya da sanal page view'i elle göndermek. İkisi birlikte açık olursa her navigasyon iki kez sayılıyor. Dikkat edilecek ayrıntı: config komutuna send_page_view: false vermek yalnız ilk yüklemedeki otomatik page_view'i kapatıyor; history değişikliğindeki page_view'i kapatmak için data stream ayarındaki o seçeneği kapatman gerekiyor. Başlangıçtaki sorunun cevabı bu yüzden "her navigasyon için iki": biri enhanced measurement'tan, biri senin koddan.
Elle göndermeyi seçtik, çünkü başlığı ve URL'yi biz kontrol etmek istiyoruz. Tag'i yükleyen küçük fonksiyon:
// gtag.js'i bir kez yükler. page_view'leri AnalyticsService gönderdiği için
// config komutunun otomatik page_view'i kapalı.
type Gtag = (...args: unknown[]) => void;
declare global {
interface Window {
dataLayer: unknown[];
gtag: Gtag;
}
}
export function loadGoogleTag(doc: Document, measurementId: string): Gtag {
const win = doc.defaultView!;
win.dataLayer = win.dataLayer || [];
win.gtag = function gtag() {
// gtag.js array değil, arguments object'ini okur
win.dataLayer.push(arguments);
};
win.gtag('js', new Date());
win.gtag('config', measurementId, { send_page_view: false });
const script = doc.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
doc.head.appendChild(script);
return win.gtag;
}
Bir not: Kasım 2026'da gtag.js referans sayfası gtag('config') komutunu deprecated olarak işaretliyor ve ayarları Google tag arayüzünden ya da gtag('set') ile yapmayı öneriyor; GA4'ün page view dokümanı ise otomatik page_view'i kapatmak için hâlâ config örneğini veriyor. Biz page view dokümanındaki biçimi kullandık.
İlk denemede page_view'i Router'ın NavigationEnd event'inde gönderdim. Build edilmiş uygulamayı Playwright'ın Chromium'unda gezen küçük bir script (web/lab/spa-pageviews.mjs), Google'a giden istekleri engelleyip dataLayer'a düşen çağrıları yazdırıyor. Çıktı bir hatayı gösterdi:
js <Date>
config G-TEST123 {"send_page_view":false}
get G-TEST123 client_id <callback>
event page_view {"page_location":"http://127.0.0.1:52714/","page_referrer":"","page_title":"BookstoreWeb"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"http://127.0.0.1:52714/","page_title":"Bookstore Blog"}
event code_copy {"language":"bash"}
event series_navigation {"series":"analytics","direction":"next"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/weekly-report","page_referrer":"http://127.0.0.1:52714/blog/search-console-api","page_title":"search-console-api - Bookstore Blog"}
Her page_title bir önceki sayfanın başlığı; ilki ise index.html'deki statik başlık. Router NavigationEnd'i gönderdiğinde document.title henüz güncellenmemiş oluyor. Çözüm, page view'i başlığı set eden yere taşımak: Angular'ın TitleStrategy'sini genişletip başlıktan hemen sonra page_view göndermek.
import { Injectable, inject } from '@angular/core';
import { DefaultTitleStrategy, RouterStateSnapshot } from '@angular/router';
import { AnalyticsService } from './analytics.service';
// NavigationEnd, router document.title'ı güncellemeden önce tetiklenir;
// page_view'i burada göndermek her görüntülemeye kendi başlığını verir.
@Injectable()
export class AnalyticsTitleStrategy extends DefaultTitleStrategy {
private readonly analytics = inject(AnalyticsService);
override updateTitle(snapshot: RouterStateSnapshot): void {
super.updateTitle(snapshot);
this.analytics.pageView();
}
}
import { isPlatformBrowser } from '@angular/common';
import { DOCUMENT, Injectable, PLATFORM_ID, inject } from '@angular/core';
import { ANALYTICS_CONFIG } from './analytics.config';
import { loadGoogleTag } from './google-tag';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly doc = inject(DOCUMENT);
private readonly config = inject(ANALYTICS_CONFIG);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private gtag?: (...args: unknown[]) => void;
private previousUrl = '';
start(): void {
// SSR sırasında ya da measurement ID yoksa tag yok
if (!this.isBrowser || !this.config.measurementId || this.gtag) {
return;
}
this.gtag = loadGoogleTag(this.doc, this.config.measurementId);
this.rememberClientId();
this.previousUrl = this.doc.referrer;
}
// Router document.title'ı set ettikten sonra AnalyticsTitleStrategy çağırır.
pageView(): void {
const location = this.doc.location.href;
this.gtag?.('event', 'page_view', {
page_location: location,
page_referrer: this.previousUrl,
page_title: this.doc.title,
});
this.previousUrl = location;
}
codeCopied(language: string): void {
this.gtag?.('event', 'code_copy', { language });
}
seriesNavigated(series: string, direction: 'next' | 'previous'): void {
this.gtag?.('event', 'series_navigation', { series, direction });
}
// Server, Measurement Protocol event'leri için bu birinci taraf çerezi okur.
private rememberClientId(): void {
this.gtag?.('get', this.config.measurementId, 'client_id', (clientId: string) => {
this.doc.cookie = `bs_ga_cid=${clientId}; Path=/; Max-Age=${60 * 60 * 24 * 90}; SameSite=Lax; Secure`;
});
}
}
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { TitleStrategy, provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { ANALYTICS_CONFIG } from './analytics/analytics.config';
import { AnalyticsTitleStrategy } from './analytics/analytics-title.strategy';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
{ provide: TitleStrategy, useClass: AnalyticsTitleStrategy },
{ provide: ANALYTICS_CONFIG, useValue: { measurementId: 'G-TEST123' } },
],
};
Düzeltmeden sonraki çıktı:
$ node spa-pageviews.mjs http://127.0.0.1:52714
js <Date>
config G-TEST123 {"send_page_view":false}
get G-TEST123 client_id <callback>
event page_view {"page_location":"http://127.0.0.1:52714/","page_referrer":"","page_title":"Bookstore Blog"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"http://127.0.0.1:52714/","page_title":"search-console-api - Bookstore Blog"}
event code_copy {"language":"bash"}
event series_navigation {"series":"analytics","direction":"next"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/weekly-report","page_referrer":"http://127.0.0.1:52714/blog/search-console-api","page_title":"weekly-report - Bookstore Blog"}
Her görüntülemenin kendi başlığı ve doğru page_referrer'ı var. isPlatformBrowser kontrolü SSR (Server-Side Rendering; sayfanın sunucuda üretilmesi) için: sunucuda TitleStrategy çalışsa da tag yüklenmediği için pageView() hiçbir şey göndermiyor; hydration sonrası tarayıcıdaki ilk navigasyon tek page_view üretiyor. Bu yazının kodu henüz consent sormuyor; tag'i ziyaretçinin iznine bağlamak bir sonraki yazının konusu, bu hâliyle production'a çıkma.
Razor Sayfalarında gtag ve CSP Nonce
Bookstore'un Razor ile üretilen sayfaları sıkı bir CSP (Content Security Policy; tarayıcıya hangi script'lerin çalışabileceğini söyleyen header) kullanıyor ve inline script'e izin vermiyor. Google'ın tag platformu rehberi bu durum için nonce öneriyor: her response'ta rastgele bir değer üretilip hem header'a hem inline script etiketine yazılıyor. Rehberin "Ads özellikleri olmadan Google Analytics" listesi script-src-elem için https://www.googletagmanager.com, img-src için buna ek olarak https://*.google-analytics.com, connect-src için ayrıca https://*.google.com istiyor.
using System.Buffers.Text;
using System.Security.Cryptography;
namespace Bookstore.Analytics.Web.Security;
// Her response için rastgele bir nonce; inline Google tag script'i aynı değeri taşır.
// Base64url: düz base64'teki '+' ve '=' karakterlerini Razor attribute içinde HTML-encode ederdi.
public sealed class CspNonce
{
public string Value { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(16));
}
public static class CspMiddleware
{
public static IApplicationBuilder UseContentSecurityPolicy(this IApplicationBuilder app) =>
app.Use(async (context, next) =>
{
CspNonce nonce = context.RequestServices.GetRequiredService<CspNonce>();
// Google'ın CSP rehberindeki "Ads özellikleri olmadan GA4" listesi
context.Response.Headers.ContentSecurityPolicy =
$"default-src 'self'; " +
$"script-src-elem 'self' 'nonce-{nonce.Value}' https://www.googletagmanager.com; " +
"img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; " +
"connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com";
await next(context);
});
}
@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)
{
<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('js', new Date());
gtag('config', '@id');
</script>
}
İlk sürümde nonce düz base64'tü ve test düştü: Razor attribute içindeki + ve = karakterlerini HTML-encode ettiği için sayfadaki değer header'dakiyle birebir aynı görünmüyordu. Tarayıcı entity'leri çözdüğü için çalışırdı, ama base64url hem testi hem okumayı sadeleştiriyor. Yerel çalıştırmadan header ve sayfa:
$ curl -s -D - http://127.0.0.1:52704/ -o index.html | grep -i content-security
$ grep -n script index.html
Content-Security-Policy: default-src 'self'; script-src-elem 'self' 'nonce-TjL27PX7wddKJ8ooXesT5g' https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com
6: <script async src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"></script>
7: <script nonce="TjL27PX7wddKJ8ooXesT5g">
12: </script>
Teknik Blog İçin Anlamlı Event'ler
Bir blog'da "okur ne yaptı?" sorusunun cevabı birkaç event'te: kodu kopyaladı mı, serinin sonraki yazısına geçti mi, PDF'i indirdi mi, GitHub'daki koda gitti mi. Son ikisi enhanced measurement'tan geliyor. İlk ikisi için AnalyticsService'teki codeCopied ve seriesNavigated metotları code_copy ve series_navigation adında custom event gönderiyor; çıktıda ikisini de görüyorsun. Custom parametreleri (language, direction) raporlarda görmek için GA4'te custom dimension olarak kaydetmen gerekiyor. Key event adayı olarak bir blog için en anlamlısı muhtemelen seri içi gezinme: okurun bir yazıdan sonra kaldığını gösteriyor.
Search Console'u GA4'e bağlamak için GA4 property'sinde Editor rolü ve Search Console property'sinin doğrulanmış sahibi olmak gerekiyor; bir web data stream yalnız bir Search Console property'sine bağlanabiliyor. Bağlantıdan sonra "Google Organic Search Queries" ve "Google Organic Search Traffic" raporları geliyor, ama Library'de yayımlanmamış olarak; görünmeleri için yayımlaman gerekiyor. Veri yaklaşık 48 saat gecikmeyle geliyor ve Search Console gibi en fazla 16 ay.
Measurement Protocol ile Server-side Event
PDF indirmesini sunucu tarafında saymak için Measurement Protocol var. Google'ın dokümanı protokolün gtag'in otomatik toplamasını tamamlamak için olduğunu, yerine geçmediğini vurguluyor: web için her istekte bir client_id gerekiyor ve bu, tarayıcıdaki tag'in ürettiği kimlik. Bookstore'da Angular uygulaması gtag('get', ..., 'client_id', ...) ile kimliği alıp bs_ga_cid adında birinci taraf bir çereze yazıyor; PDF endpoint'i bu çerez varsa event gönderiyor:
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace Bookstore.Analytics.Web.Analytics;
public sealed class Ga4MeasurementProtocolClient(
HttpClient http, IOptions<AnalyticsOptions> options, ILogger<Ga4MeasurementProtocolClient> logger)
{
public async Task<bool> SendAsync(
string clientId, string eventName, IReadOnlyDictionary<string, object> parameters, CancellationToken ct)
{
AnalyticsOptions o = options.Value;
if (string.IsNullOrEmpty(o.MeasurementId) || string.IsNullOrEmpty(o.ApiSecret))
{
return false;
}
EventParameterGuard.EnsureNoPersonalData(parameters);
string path = $"mp/collect?measurement_id={Uri.EscapeDataString(o.MeasurementId)}" +
$"&api_secret={Uri.EscapeDataString(o.ApiSecret)}";
Payload payload = new(clientId, [new Event(eventName, parameters)]);
// Endpoint bozuk bir event'e bile 2xx döner; payload'ları geliştirirken
// /debug/mp/collect ile kontrol et.
using HttpResponseMessage response = await http.PostAsJsonAsync(new Uri(o.Endpoint, path), payload, ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Measurement Protocol returned {Status}", (int)response.StatusCode);
}
return response.IsSuccessStatusCode;
}
private sealed record Payload(
[property: JsonPropertyName("client_id")] string ClientId,
[property: JsonPropertyName("events")] Event[] Events);
private sealed record Event(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("params")] IReadOnlyDictionary<string, object> Params);
}
app.MapGet("/downloads/{name}.pdf", async (
string name, HttpContext context, Ga4MeasurementProtocolClient ga, ILogger<Program> logger) =>
{
if (!SeriesPdfs.TryGet(name, out byte[] pdf))
{
return Results.NotFound();
}
if (ClientIdCookie.Read(context.Request) is { } clientId)
{
try
{
await ga.SendAsync(clientId, "pdf_download_served",
new Dictionary<string, object> { ["file_name"] = $"{name}.pdf" }, context.RequestAborted);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
// analytics indirmeyi asla bozmamalı
logger.LogWarning(ex, "Measurement Protocol call failed");
}
}
return Results.File(pdf, "application/pdf", $"{name}.pdf");
});
İki önemli kural. Birincisi, endpoint bozuk bir event'e de hata kodu dönmüyor; payload'ları geliştirirken /debug/mp/collect doğrulama sunucusuyla kontrol etmek gerekiyor. Limitler: istek başına en fazla 25 event, event başına 25 parametre, event ve parametre adları en fazla 40, parametre değerleri en fazla 100 karakter; session_start gibi bazı adlar ayrılmış. İkincisi, kişisel veri gönderilmiyor: EventParameterGuard e-posta gibi görünen değerleri ve email, name gibi anahtarları reddediyor. Testler sahte bir handler ile hem gönderilen JSON'u hem çerez yokken hiçbir şey gönderilmediğini doğruluyor:
$ dotnet test --filter "FullyQualifiedName~GoogleTagTests|FullyQualifiedName~EventParameterGuardTests"
Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8, Duration: 515 ms - Bookstore.Analytics.Tests.dll (net10.0)
Üretimde Dikkat
Kişisel veri. Google'ın politikası Google'ın kişiyi tanımlayabileceği hiçbir verinin gönderilmemesini istiyor ve sızıntının en sık URL'ler, sayfa başlıkları, arama terimleri ve custom parametreler üzerinden olduğunu söylüyor. Web stream'lerinde e-posta adreslerini ve seçtiğin query parametrelerini ayıklayan bir data redaction ayarı var, ama Google bunu "best-effort" olarak tanımlıyor; asıl önlem kaynağında temizlemek. Konunun KVKK (Kişisel Verilerin Korunması Kanunu) tarafı Veri Gizliliği ve KVKK serisinin "Log, Telemetry ve Analytics'te Kişisel Veri" yazısında.
Data retention. GA4 kullanıcı ve event düzeyindeki veriyi 2 ya da 14 ay tutuyor (360'ta daha uzun seçenekler var). Bu ayar standart raporları değil, exploration ve funnel raporlarını etkiliyor; hangi süreyi seçtiğine bilinçli karar ver ve aydınlatma metninle tutarlı tut.
Internal trafik. Data stream'de bir IP kuralıyla kendi trafiğini traffic_type parametresiyle işaretleyip bir data filter ile dışarıda bırakabiliyorsun. Filtre önce Testing durumunda deneniyor; Active olunca etkisi kalıcı, dışarıda kalan veri bir daha işlenmiyor. Property başına 10 filtre sınırı var ve etkinleşmesi 24-36 saat sürebiliyor.
api_secret yalnız sunucuda. Measurement Protocol secret'ı Angular koduna ya da repository'ye girmiyor; environment variable'dan okunuyor.
Sık Düşülen Tuzaklar
Enhanced measurement'ın history takibi açıkken elle de page_view göndermek. Her navigasyon iki kez sayılır ve session başına sayfa metric'leri şişer.
NavigationEnd'de başlık okumak. Router başlığı daha sonra güncelliyor; raporlarda her sayfa bir öncekinin başlığıyla görünür.
Event parametresine kişisel veri koymak. Arama kutusuna yazılan metin, form alanları ya da URL'deki e-posta, fark etmeden GA4'e gidebiliyor.
Internal trafik filtresini sonradan eklemek. Filtre geçmiş veriyi düzeltmiyor; ekibin kendi ziyaretleri o güne kadar raporlarda kalıyor.
Küçük Bir Deneme
★☆☆ spa-pageviews.mjs'i çalıştır, sonra AnalyticsService'i geçici olarak NavigationEnd'e geri bağla ve çıktıdaki başlık kaymasını kendin gör.
★★☆ Bir series_complete event'i ekle: okur serinin son yazısının sonuna geldiğinde bir kez gönderilsin. Hangi parametreleri koyarsın, hangisini asla koymazsın?
★★★ Ga4MeasurementProtocolClient'a bir doğrulama modu ekle: development ortamında istekleri /debug/mp/collect'e göndersin ve validationMessages boş değilse uyarı loglasın. Sahte handler ile test et.
GA4 çalışıyor, ama bu hâliyle ziyaretçiye sormadan çerez yazıyor. Bir sonraki yazıda KVKK'nın çerez rehberini, Google Consent Mode'u ve çerezsiz alternatifleri ölçüm açısından ele alıyoruz.