Mert Özen Her Satırda Daha İleri
Proje Yönetimi Product Management'a Giriş — #07

Product Metric'lerini Kodda Ölçmek: Angular ve ASP.NET Core ile Event Tracking ve Cohort Analizi

Mert Özen 8 Kas 2026 14 dk 3 görüntülenme
Product Metric'lerini Kodda Ölçmek: Angular ve ASP.NET Core ile Event Tracking ve Cohort Analizi

Product metric'leri, kodda yaşayan bir event taxonomy ile başlar: object_action adları, izinli property listesi ve şema sürümü. Angular'da consent'e saygılı bir AnalyticsService, ASP.NET Core Minimal API ve EF Core ile bir event tablosu yazıyor; SQL Server'da haftalık retention cohort'unu ve sıralı activation funnel'ını gerçek sorgu çıktısıyla hesaplıyor, KVKK/GDPR açısından dikkat edilecekleri ve hazır araç ile kendi tablon arasındaki farkı konuşuyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve EF (Entity Framework) Core 10.0.14 ile derlenip SQL (Structured Query Language) Server 2025'e karşı çalıştırılmıştır; Angular service'i Angular 22.2.5 ve TypeScript 6.0.3 ile tip kontrolünden geçirilmiştir.

Önce Tahmin Et

Bookstore'da üç ekip aynı ay product analytics'e başladı. Katalog ekibi event'ine AddToCart dedi, mobil web ekibi cart-add, kampanya ekibi ise Sepete Eklendi. Üç ay sonra ürün sahibi "son çeyrekte kaç kişi sepete kitap ekledi?" diye soruyor.

Bu sorunun cevabını tek bir sorguyla verebilir misin? Bir de şunu düşün: kampanya ekibi, ileride müşterilerle eşleştirmek için her event'e müşterinin e-posta adresini de property olarak eklemiş. Bu bir kolaylık mı, yoksa bir sorun mu?

Event Taxonomy: Önce İsimler

Product metric'lerinin hammaddesi event'lerdir: kullanıcının üründe yaptığı anlamlı bir şeyin kaydı. Event'leri koda dağıtmadan önce bir event taxonomy yazmak, yani hangi event'lerin, hangi isimle, hangi property'lerle gönderileceğini tek bir yerde tanımlamak gerekir. Baştaki üç isim bunun neden önemli olduğunu gösteriyor: aynı davranışın üç adı varsa, onu saymak üç sorgu, üç eşleştirme ve sonsuz tartışma demektir.

Yaygın ve sade bir kural object_action adlandırmadır: önce davranışın nesnesi, sonra geçmiş zamanda eylem, küçük harf ve alt çizgiyle. book_viewed, cart_item_added, checkout_started, order_placed, account_created. Bu kural aynı nesneye ait event'leri alfabetik listede yan yana getirir ve "eklendi mi, eklenecek mi?" belirsizliğini ortadan kaldırır.

Property'ler de taxonomy'nin parçasıdır ve iki kural taşır. Birincisi, izin verilen property'ler bir listede durur; listede olmayan property reddedilir. İkincisi, bir event'in şeması değiştiğinde sürümü artar. Bookstore'da cart_item_added event'ine ikinci sürümde sepete hangi sayfadan eklendiğini söyleyen source property'si eklendi; v1 ve v2 bir süre birlikte kabul ediliyor, sorgular sürümü bilerek yazılıyor.

Sunucu: Minimal API ve EF Core

Event'leri ASP.NET Core'da tek bir Minimal API (Application Programming Interface) endpoint'iyle topluyoruz ve EF Core ile SQL Server'daki bir tabloya yazıyoruz. Taxonomy kodun içinde, EventCatalog class'ında duruyor; böylece event listesi bir wiki sayfasında değil, derlenen ve review edilen kodda yaşıyor:

using System.Collections.Frozen;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Analytics;

public sealed class ProductEvent
{
    public long Id { get; set; }
    public required string Name { get; set; }
    public int SchemaVersion { get; set; }
    public Guid AnonymousId { get; set; }
    public int? CustomerId { get; set; }
    public DateTime OccurredAtUtc { get; set; }
    public string PropertiesJson { get; set; } = "{}";
}

public sealed class AnalyticsDbContext(DbContextOptions<AnalyticsDbContext> options) : DbContext(options)
{
    public DbSet<ProductEvent> Events => Set<ProductEvent>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ProductEvent>(e =>
        {
            e.ToTable("ProductEvents");
            e.Property(x => x.Name).HasMaxLength(64).IsUnicode(false);
            e.Property(x => x.OccurredAtUtc).HasColumnType("datetime2(0)");
            e.HasIndex(x => new { x.Name, x.OccurredAtUtc });
            e.HasIndex(x => x.CustomerId);
        });
    }
}

public static class EventCatalog
{
    // object_action adlandırma; şema değişince sürüm artar, eski sürüm bir süre kabul edilir
    public static readonly FrozenDictionary<string, int[]> Versions = new Dictionary<string, int[]>
    {
        ["account_created"] = [1],
        ["book_viewed"] = [1],
        ["cart_item_added"] = [1, 2],
        ["checkout_started"] = [1],
        ["order_placed"] = [1],
    }.ToFrozenDictionary();

    // Yalnız bu property'ler kabul edilir: e-posta, ad, adres gibi PII buraya giremez
    public static readonly FrozenSet<string> AllowedProperties =
        new[] { "book_id", "price_bucket", "source", "step" }.ToFrozenSet();
}

public sealed record TrackEventRequest(
    string Name,
    int SchemaVersion,
    Guid AnonymousId,
    Dictionary<string, string>? Properties);

Tablo bilinçli olarak dar: event'in adı, sürümü, anonim kimlik, varsa müşteri kimliği, zaman ve property'ler JSON (JavaScript Object Notation) olarak. (Name, OccurredAtUtc) index'i funnel ve cohort sorgularının en sık kullandığı filtreye, CustomerId index'i cohort'un join'ine hizmet ediyor. Endpoint:

using System.Security.Claims;
using System.Text.Json;
using Bookstore.Analytics;
using Microsoft.EntityFrameworkCore;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AnalyticsDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Analytics")));
builder.Services.AddSingleton(TimeProvider.System);

WebApplication app = builder.Build();

app.MapPost("/api/events", async (
    TrackEventRequest request,
    ClaimsPrincipal user,
    AnalyticsDbContext db,
    TimeProvider clock,
    CancellationToken ct) =>
{
    Dictionary<string, string> properties = request.Properties ?? [];
    Dictionary<string, string[]> errors = [];

    if (!EventCatalog.Versions.TryGetValue(request.Name, out int[]? versions)
        || !versions.Contains(request.SchemaVersion))
        errors["name"] = [$"Unknown event '{request.Name}' v{request.SchemaVersion}."];

    string[] unknown = [.. properties.Keys.Where(k => !EventCatalog.AllowedProperties.Contains(k))];
    if (unknown.Length > 0)
        errors["properties"] = [$"Not allowed: {string.Join(", ", unknown)}."];

    if (errors.Count > 0)
        return Results.ValidationProblem(errors);

    // Müşteri kimliği istemciden değil, oturumdan gelir
    int? customerId = int.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out int id) ? id : null;

    db.Events.Add(new ProductEvent
    {
        Name = request.Name,
        SchemaVersion = request.SchemaVersion,
        AnonymousId = request.AnonymousId,
        CustomerId = customerId,
        // Zamanı sunucu yazar: istemci saatine güvenmiyoruz
        OccurredAtUtc = clock.GetUtcNow().UtcDateTime,
        PropertiesJson = JsonSerializer.Serialize(properties),
    });
    await db.SaveChangesAsync(ct);

    return Results.Accepted();
});

using (IServiceScope scope = app.Services.CreateScope())
    await scope.ServiceProvider.GetRequiredService<AnalyticsDbContext>().Database.EnsureCreatedAsync();

app.Run();

Üç karar dikkat çekmeli. Müşteri kimliği request body'den değil, oturumun claim'inden alınıyor; istemci başka bir müşteri adına event gönderemez. Zamanı sunucu yazıyor, çünkü kullanıcının bilgisayar saati yanlış olabilir. Bilinmeyen event'ler ve izin verilmeyen property'ler 400 ile reddediliyor; analytics verisini kirletmektense hatayı erken görmek daha ucuz. EnsureCreatedAsync bu örneği kısa tutmak için; gerçek bir projede şemayı EF Core serisinde anlatılan migration'larla yönetirsin. Connection string'i ConnectionStrings__Analytics environment variable'ından veriyoruz.

API'yi çalıştırıp iki istek gönderelim: biri geçerli, biri hem bilinmeyen bir sürüm hem de e-posta property'si taşıyan:

$ curl -i -X POST http://localhost:5317/api/events -H "Content-Type: application/json" \
    -d '{"name":"cart_item_added","schemaVersion":2,"anonymousId":"3f2b8c4e-6a1d-4e2f-9b7a-0c5d1e8f4a21","properties":{"book_id":"9780134685991","source":"book_page"}}'
HTTP/1.1 202 Accepted
Content-Length: 0
Date: Sun, 08 Nov 2026 09:28:39 GMT
Server: Kestrel

$ curl -i -X POST http://localhost:5317/api/events -H "Content-Type: application/json" \
    -d '{"name":"cart_item_added","schemaVersion":3,"anonymousId":"3f2b8c4e-6a1d-4e2f-9b7a-0c5d1e8f4a21","properties":{"book_id":"9780134685991","email":"ada@example.com"}}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 09:28:39 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"name":["Unknown event 'cart_item_added' v3."],"properties":["Not allowed: email."]}}

İlk istek 202 Accepted dönüyor; ikinci istek iki ayrı validation hatasıyla reddediliyor. Tabloya yazılan satır:

Id Name SchemaVersion CustomerId OccurredAtUtc PropertiesJson
-- ---- ------------- ---------- ------------- --------------
83771 cart_item_added 2 NULL 2026-11-08 09:28:40 {"book_id":"9780134685991","source":"book_page"}

CustomerId boş, çünkü curl ile oturum açmadan gönderdik. OccurredAtUtc saniyeye yuvarlanmış: kolonu datetime2(0) tanımladık, product analytics için milisaniye gerekmiyor.

İstemci: Angular'da AnalyticsService

Angular tarafında event göndermeyi tek bir service'te topluyoruz. Component'ler HttpClient'ı doğrudan çağırmaz; analytics.track('cart_item_added', { book_id: book.isbn, source: 'book_page' }) der:

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

// Event adları object_action biçiminde ve sunucudaki EventCatalog ile aynı
export type EventName =
  | 'account_created'
  | 'book_viewed'
  | 'cart_item_added'
  | 'checkout_started'
  | 'order_placed';

type PropertyKey = 'book_id' | 'price_bucket' | 'source' | 'step';
export type EventProperties = Partial<Record<PropertyKey, string>>;

const SCHEMA_VERSIONS: Record<EventName, number> = {
  account_created: 1,
  book_viewed: 1,
  cart_item_added: 2,
  checkout_started: 1,
  order_placed: 1,
};

@Injectable({ providedIn: 'root' })
export class ConsentService {
  // Çerez banner'ındaki "analytics" seçimi; varsayılan kapalı
  readonly analyticsAllowed = signal(false);
}

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  private readonly http = inject(HttpClient);
  private readonly consent = inject(ConsentService);
  private readonly storageKey = 'bs_anon_id';

  track(name: EventName, properties: EventProperties = {}): void {
    // Consent yoksa ne cihaza kimlik yazılır ne de istek gider
    if (!this.consent.analyticsAllowed()) {
      return;
    }

    this.http
      .post('/api/events', {
        name,
        schemaVersion: SCHEMA_VERSIONS[name],
        anonymousId: this.anonymousId(),
        properties,
      })
      .subscribe({
        // Analytics hatası kullanıcının akışını asla bozmamalı
        error: () => undefined,
      });
  }

  private anonymousId(): string {
    let id = localStorage.getItem(this.storageKey);
    if (!id) {
      id = crypto.randomUUID();
      localStorage.setItem(this.storageKey, id);
    }
    return id;
  }
}

EventName ve property anahtarları union type olarak yazıldı; track('AddToCart') ya da { email: '...' } derleme hatası verir. Böylece baştaki üç farklı isim sorunu, sunucuya gelmeden derleyicide yakalanır. Sunucudaki allowlist ise ikinci savunma hattıdır, çünkü her istemci bizim Angular kodumuz değildir.

Consent kontrolü service'in ilk satırında: kullanıcı çerez banner'ında analytics'e izin vermediyse ne localStorage'a anonim bir kimlik yazılır ne de istek gider. Anonim kimlik crypto.randomUUID() ile üretilir ve kişiyi tanımlamaz. İsteğin hatası bilinçli olarak yutuluyor: analytics'in çökmesi, kullanıcının sepete kitap eklemesini asla engellememeli. Bu service'i Angular 22.2.5 paketleri ve TypeScript 6.0.3 ile tip kontrolünden geçirdik; bir Angular uygulamasının içinde tarayıcıda uçtan uca çalıştırmadık, endpoint'e giden isteği yukarıda curl ile gösterdik.

Haftalık Retention Cohort'u

Verinin değerini görmek için bir hafta değil, birkaç hafta gerekiyor. Bu yüzden veritabanına 7 Eylül–1 Kasım 2026 arası sekiz haftalık simüle edilmiş event verisi yükledik: 83.770 event, 4.029 kayıtlı müşteri ve kayıt olmayan misafirler. Veri gerçek müşterilerden değil, gerçekçi oranlarla üretilmiş; aşağıdaki sayılar ise bu veri üzerinde çalışan sorguların gerçek çıktısı.

Retention cohort'u, kullanıcıları başladıkları haftaya göre gruplar ve her grubun sonraki haftalarda ne kadarının geri geldiğini gösterir. Sorgu SQL Server 2022 ile gelen DATETRUNC fonksiyonunu kullanıyor; iso_week haftayı pazartesiden başlatır:

/* Haftalık retention cohort'u: kayıt haftasına göre grupla, sonraki haftalarda aktif olanları say */
DECLARE @lastFullWeek date = '2026-10-26';

WITH cohorts AS (
    SELECT CustomerId, CAST(DATETRUNC(iso_week, MIN(OccurredAtUtc)) AS date) AS CohortWeek
    FROM dbo.ProductEvents
    WHERE Name = 'account_created'
    GROUP BY CustomerId
),
activity AS (
    SELECT DISTINCT e.CustomerId,
           DATEDIFF(week, c.CohortWeek, CAST(DATETRUNC(iso_week, e.OccurredAtUtc) AS date)) AS WeekNo
    FROM dbo.ProductEvents AS e
    JOIN cohorts AS c ON c.CustomerId = e.CustomerId
    WHERE e.Name <> 'account_created'
)
SELECT CONVERT(char(10), c.CohortWeek, 23) AS Cohort,
       COUNT(DISTINCT c.CustomerId) AS Users,
       CAST(100.0 * COUNT(DISTINCT CASE WHEN a.WeekNo = 1 THEN a.CustomerId END)
            / COUNT(DISTINCT c.CustomerId) AS decimal(4, 1)) AS W1,
       CASE WHEN DATEADD(week, 2, c.CohortWeek) <= @lastFullWeek THEN
       CAST(100.0 * COUNT(DISTINCT CASE WHEN a.WeekNo = 2 THEN a.CustomerId END)
            / COUNT(DISTINCT c.CustomerId) AS decimal(4, 1)) END AS W2,
       CASE WHEN DATEADD(week, 4, c.CohortWeek) <= @lastFullWeek THEN
       CAST(100.0 * COUNT(DISTINCT CASE WHEN a.WeekNo = 4 THEN a.CustomerId END)
            / COUNT(DISTINCT c.CustomerId) AS decimal(4, 1)) END AS W4
FROM cohorts AS c
LEFT JOIN activity AS a ON a.CustomerId = c.CustomerId
WHERE DATEADD(week, 1, c.CohortWeek) <= @lastFullWeek
GROUP BY c.CohortWeek
ORDER BY c.CohortWeek;

Gerçek çıktı:

Cohort Users W1 W2 W4
------ ----- -- -- --
2026-09-07 526 39.4 23.4 21.3
2026-09-14 510 37.1 23.3 23.1
2026-09-21 454 39.6 27.3 21.1
2026-09-28 555 42.7 25.2 21.1
2026-10-05 454 42.1 26.4 NULL
2026-10-12 539 39.3 27.5 NULL
2026-10-19 470 39.1 NULL NULL
(7 rows affected)

Tablo şöyle okunur: 7 Eylül haftasında kayıt olan 526 müşterinin %39,4'ü bir sonraki hafta, %23,4'ü iki hafta sonra, %21,3'ü dört hafta sonra yeniden aktif oldu. NULL'lar ölçülemeyen hücreler: 5 Ekim cohort'unun dördüncü haftası henüz tamamlanmadı. Bu tamamlanmamış haftaları sıfır yazmak, en yeni cohort'ları en kötü gösterir ve sık yapılan bir hatadır; sorgudaki @lastFullWeek kontrolü bunu engelliyor.

Okunacak asıl şey eğri: retention ilk haftada %37–43'e düşüyor ve ikinci haftadan sonra %21–27 civarında düzleşiyor. Düzleşen bir eğri, ürünün bir çekirdek kullanıcı grubu için value ürettiğini söyler. Cohort'lar arasında belirgin bir trend yok; Eylül'deki cohort ile Ekim'deki aynı davranıyor. Yeni bir feature retention'ı iyileştirirse, onu bu tabloda feature'dan sonraki cohort'ların satırlarında görürsün.

Activation Funnel

Funnel, kullanıcıların ardışık adımlardan ne kadarını tamamladığını gösterir. Sorgunun kritik kısmı sıralama: bir kullanıcı ancak sepete ekleme olayı ürün görüntülemesinden sonraysa "sepete ekledi" sayılır. Aksi hâlde sıralamadan bağımsız bir sayım, adımları atlayanları da funnel'a sokar:

/* Activation funnel: her adım bir öncekinden SONRA gerçekleşmiş olmalı */
WITH steps AS (
    SELECT AnonymousId,
           MIN(CASE WHEN Name = 'book_viewed' THEN OccurredAtUtc END) AS Viewed,
           MIN(CASE WHEN Name = 'cart_item_added' THEN OccurredAtUtc END) AS Carted,
           MIN(CASE WHEN Name = 'checkout_started' THEN OccurredAtUtc END) AS CheckedOut,
           MIN(CASE WHEN Name = 'order_placed' THEN OccurredAtUtc END) AS Ordered
    FROM dbo.ProductEvents
    WHERE OccurredAtUtc >= '2026-10-05' AND OccurredAtUtc < '2026-11-02'
    GROUP BY AnonymousId
),
funnel AS (
    SELECT COUNT(Viewed) AS Viewed,
           COUNT(CASE WHEN Carted > Viewed THEN 1 END) AS Carted,
           COUNT(CASE WHEN CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS CheckedOut,
           COUNT(CASE WHEN Ordered > CheckedOut AND CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS Ordered
    FROM steps
)
SELECT Viewed, Carted, CheckedOut, Ordered,
       CAST(100.0 * Ordered / Viewed AS decimal(4, 1)) AS ConversionPct,
       CAST(100.0 - 100.0 * Ordered / Carted AS decimal(4, 1)) AS CartAbandonmentPct
FROM funnel;
Viewed Carted CheckedOut Ordered ConversionPct CartAbandonmentPct
------ ------ ---------- ------- ------------- ------------------
11279 5192 3116 1729 15.3 66.7
(1 rows affected)

Son dört haftada bir kitap görüntüleyen 11.279 anonim ziyaretçinin 5.192'si sepete ekledi, 1.729'u siparişi tamamladı. Sepete ekleyenlerin %66,7'si siparişi tamamlamadan çıktı: önceki yazılarda sepet terk problemi olarak konuştuğumuz sayı tam olarak bu. Aynı sorguyu misafir ve kayıtlı müşteri diye bölünce:

/* Aynı funnel, misafir ve kayıtlı müşteri diye ikiye bölünmüş */
WITH steps AS (
    SELECT AnonymousId,
           CASE WHEN MAX(CustomerId) IS NULL THEN 'guest' ELSE 'registered' END AS Segment,
           MIN(CASE WHEN Name = 'book_viewed' THEN OccurredAtUtc END) AS Viewed,
           MIN(CASE WHEN Name = 'cart_item_added' THEN OccurredAtUtc END) AS Carted,
           MIN(CASE WHEN Name = 'checkout_started' THEN OccurredAtUtc END) AS CheckedOut,
           MIN(CASE WHEN Name = 'order_placed' THEN OccurredAtUtc END) AS Ordered
    FROM dbo.ProductEvents
    WHERE OccurredAtUtc >= '2026-10-05' AND OccurredAtUtc < '2026-11-02'
    GROUP BY AnonymousId
)
SELECT Segment,
       COUNT(CASE WHEN Carted > Viewed THEN 1 END) AS Carted,
       COUNT(CASE WHEN CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS CheckedOut,
       COUNT(CASE WHEN Ordered > CheckedOut AND CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS Ordered,
       CAST(100.0 - 100.0 * COUNT(CASE WHEN Ordered > CheckedOut AND CheckedOut > Carted AND Carted > Viewed THEN 1 END)
            / COUNT(CASE WHEN Carted > Viewed THEN 1 END) AS decimal(4, 1)) AS CartAbandonmentPct
FROM steps
GROUP BY Segment
ORDER BY Segment;
Segment Carted CheckedOut Ordered CartAbandonmentPct
------- ------ ---------- ------- ------------------
guest 3424 1998 957 72.1
registered 1768 1118 772 56.3
(2 rows affected)

Misafirlerde terk %72,1, kayıtlı müşterilerde %56,3. Sepete ekleyenlerin üçte ikisi misafir. 02. yazıdaki problem tanımının ve 03. yazıdaki Opportunity Solution Tree'nin sayıları buradan geliyor; one-click checkout fikrinin neden listenin başında olmadığı da bu tabloda: kart saklamak kayıtlı müşteriye hitap ediyor, terkin büyüğü ise misafirde.

Gizlilik: KVKK ve GDPR Açısından Dikkat Edilecekler

Kendi event tablonu tuttuğunda, kişisel veri işliyorsan Türkiye'de KVKK (Kişisel Verilerin Korunması Kanunu, 6698 sayılı) ve Avrupa'daki kullanıcıların için GDPR (General Data Protection Regulation) kapsamında yükümlülüklerin olur. Bu bir hukuki tavsiye değil; şirketinin hukuk ekibiyle konuşman gereken konuları gösteren bir kontrol listesi:

  • PII (Personally Identifiable Information) gönderme: e-posta, ad, telefon, adres event property'si olmaz. Baştaki kampanya ekibinin e-posta property'si tam olarak bu yüzden sorun; sunucudaki allowlist onu reddediyor.
  • Consent: analytics için çerez ya da cihaz kimliği kullanıyorsan, kullanıcının iznini al ve izin yoksa hiçbir şey gönderme.
  • Saklama süresi: event'leri ne kadar tutacağına karar ver ve bunu otomatikleştir; örneğin 13 aydan eski satırları silen bir scheduled job.
  • IP (Internet Protocol) adresi ve user agent'ı ihtiyaç yoksa kaydetme; bizim tablomuzda ikisi de yok.
  • Silme talebi: bir müşteri verisinin silinmesini istediğinde CustomerId ile event'lerini silebilmeli ya da anonimleştirebilmelisin.

Hazır bir product analytics aracı (PostHog, Amplitude, Mixpanel gibi) bu konuların bir kısmını, funnel ve cohort ekranlarını, bot filtrelemeyi ve ölçeklemeyi hazır getirir; karşılığında verin bir üçüncü tarafta durur ve maliyet event sayısıyla büyür. Kendi tablon ise tam kontrol ve SQL'in esnekliğini verir, ama ekranları, ölçeklemeyi ve gizlilik süreçlerini sen yazarsın. Çoğu ekip için makul bir başlangıç, taxonomy'yi kendisinin sahiplendiği bir araçla başlamaktır; taxonomy araçtan bağımsızdır ve araç değiştiğinde de geçerli kalır.

Product analytics ile observability de aynı şey değil. Observability serisinde anlatılacak log, metric ve trace'ler sistemin nasıl çalıştığını anlatır: bir isteğin süresi, bir exception, bir pod'un belleği. Product analytics ise kullanıcının ne yaptığını anlatır. İkisi aynı veritabanını ya da aynı pipeline'ı paylaşabilir, ama sahipleri, saklama süreleri ve gizlilik kuralları farklıdır.

Üretimde Dikkat

Hacim büyüdüğünde tek tek insert pahalılaşır. Günde birkaç yüz bin event'e çıktığında endpoint'i bir queue'ya yazan ve arkada toplu insert yapan bir yapıya çevirmek, tabloyu da tarihe göre partition'lamak gerekir. Message Queue'larla Çalışmak serisindeki pattern'ler burada doğrudan işe yarar.

Event'lerin bir kısmı hiç gelmez. Ad blocker'lar, consent vermeyen kullanıcılar ve sayfayı istek bitmeden kapatanlar yüzünden istemci tarafı event'ler her zaman eksiktir. Siparişin kendisi gibi kritik sayıları event'ten değil, sipariş tablosundan say; event'leri oranlar ve davranış için kullan.

Bot trafiğini ayıkla. Arama motoru botları ve tarayıcı otomasyonları book_viewed sayısını şişirir. Sunucu tarafında bilinen bot user agent'larını filtrelemek ve bunu metric tanımına yazmak gerekir.

Zaman dilimine dikkat et. Event'ler UTC (Coordinated Universal Time) olarak saklanıyor; Türkiye UTC+3 olduğu için gece 22:00'den sonra verilen siparişler UTC'de bir sonraki güne düşebilir. Haftalık cohort'larda etkisi küçüktür, ama günlük raporlarda gün sınırını hangi saat dilimine göre çizdiğini açıkça yaz.

Sık Düşülen Tuzaklar

İlk tuzak taxonomy'siz başlamak. Her ekip event'ini kendi adlandırırsa, birkaç ay sonra elindeki veri yüz farklı isim ve hiçbir ortak anlam taşır. Taxonomy'yi koda yazmak ve review'dan geçirmek, bunu engellemenin en ucuz yolu.

İkinci tuzak her şeyi track etmek. Her tıklamayı event olarak göndermek veriyi değil gürültüyü büyütür. Önceki yazıdaki metric ağacından başla ve yalnız o metric'leri hesaplamak için gereken event'leri tanımla.

Üçüncüsü tamamlanmamış dönemi sıfır saymak. Cohort tablosunda henüz gelmemiş haftayı %0 göstermek, en yeni cohort'u en kötü gösterir ve "son değişiklik retention'ı bozdu" gibi yanlış sonuçlara yol açar.

Dördüncüsü sıralamasız funnel. Adımların sırasını kontrol etmeyen bir funnel, önce sipariş verip sonra aynı kitabın sayfasına dönen kullanıcıyı da "dönüşmüş" sayar ve oranları olduğundan iyi gösterir.

Küçük Bir Deneme

★☆☆ EventCatalog'a search_performed event'ini ve result_count property'sini ekle, Angular service'teki union type'ları da güncelle. Bir property'yi sunucuya eklemeyi unutup yalnız istemcide eklersen ne olur?

★★☆ Cohort sorgusunu "aktif olmak" yerine "sipariş vermek" üzerine yeniden yaz: her cohort'ta sonraki haftalarda sipariş verenlerin oranı. Eğri, aktiflik eğrisinden nasıl farklı?

★★★ 13 aydan eski event'leri silen bir scheduled job yaz (BackgroundService ya da Hangfire ile) ve silmeyi büyük tabloda kilit sorunu yaratmayacak şekilde parçalar hâlinde yap. Silinen satır sayısını log'la.

Event'leri topladık ve funnel'ı ölçtük. Bir sonraki yazıda bu veriyle karar vermeyi öğreneceğiz: ürün sayfasında kargo ücreti gibi bir değişikliği gerçekten işe yarayıp yaramadığını A/B testiyle nasıl ölçeceğimizi, sample size'ı, peeking problemini ve sample ratio mismatch'i Microsoft.FeatureManagement ile konuşacağız.