Giriş yap
Kodlama ajanları için Markdown

React Native için App Store Operator event rehberi

Durum: v1 taslağı, 2026-10-23. Burada anlatılan API planlandı ama henüz yayında değil. API yayına girince bu satırı kaldıracağız.

Kimin için: Bir React Native uygulamasının (bare React Native veya Expo) iOS build'ine event takibi ekleyen React Native geliştiricileri ve bu belgeyi verdikleri yapay zekâ kodlama ajanları. Entegrasyon için gereken her şey bu dosyada; bir ajan işi yalnızca bu dosyayla bitirebilir. App Store Operator şimdilik yalnızca iOS uygulamalarını destekliyor. Bu rehberdeki tracker Android'de hiçbir şey yapmaz (Android desteği planlanıyor).


Yapay zekâ kodlama ajanıysanız önce burayı okuyun

Bir React Native uygulamasının iOS build'ine event takibi ekliyorsunuz. Uygulamanın sahibi bu event'lerle App Store Operator'da funnel'lar oluşturacak. Uygulama event'leri X-Write-Key header'ıyla POST https://api.app-store-operator.com/v1/events adresine gönderecek.

Sırasıyla yapılacaklar

  1. Yazma anahtarını alın. Bu belgede hâlâ YOUR_WRITE_KEY yazıyorsa anahtarı geliştiriciden isteyin (Dashboard → uygulama → Events → Setup; anahtar aso_wk_ ile başlar). Kendiniz anahtar uydurmayın, yer tutucuyu da kodda bırakmayın.
  2. Tracker'ı ekleyin. §5'teki iki paketi kurun. Sonra §5'teki dosyayı hiç değiştirmeden, eventTracker.ts adıyla projenin kaynak koduna kopyalayın.
  3. Uygulamaya bağlayın (§5.1). Açılışta bir kez configure(...) çağırın. Uygulama arka plana geçince EventTracker.flush() çağırın. Açılışta ve arka plandan her dönüşte app_opened event'ini izleyin.
  4. track(...) çağrılarını ekleyin. §6'daki event'lerden akışı bu uygulamada olan her biri için bir çağrı ekleyin. Akışı olmayanları atlayın.
  5. identify ve reset çağrılarını yalnızca uygulamanın kendi oturum açma (login) sistemi varsa ekleyin (§5.2).
  6. Bitirince §9 Kontrol listesi'ni madde madde gözden geçirin. Her maddeyi tamamlandı, uygulanamaz veya beklemede olarak raporlayın ve yanına bunu gösteren dosyayı ve satırı yazın.

Kurallar

  • eventTracker.ts'in davranışını (batch'leme, retry, endpoint, kuyruk, yalnızca iOS kontrolü) değiştirmeyin. Uygulamaya uymuyorsa dosyayı yeniden yazmayın, nedenini raporlayın.
  • @react-native-async-storage/async-storage ve react-native-device-info dışında üçüncü taraf bağımlılık eklemeyin. Bunlar uygulamada zaten varsa sürümlerine dokunmayın.
  • Tracker Android'de bilerek hiçbir şey yapmaz. Android'e özel kod eklemeyin, bu kontrolü de aşmaya çalışmayın.
  • Kişiyi doğrudan tanımlayan hiçbir veri göndermeyin: ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı metin (§7). Uygulama App Store'da Kids kategorisindeyse durun ve geliştiriciye bu tracker'ın eklenmemesi gerektiğini söyleyin (§7.8). Uygulama analitik için kullanıcıdan onay alıyorsa, izlemenin bu onayı bekleyip beklemeyeceğini geliştiriciye sorun (§7.5).
  • Property değerleri düz olmalı: string, number, boolean veya null. İç içe nesne veya dizi göndermeyin (§4.3). Tracker'ın tipleri buna zaten izin vermez; cast ile bu kısıtı aşmayın.
  • §6'daki event adlarını birebir kullanın. §6'da olmayan bir akışa event adı koymadan önce geliştiriciye sorun.
  • identify'a yalnızca uygulamanın kendi kalıcı, dahili kullanıcı ID'sini verin; e-posta, telefon numarası veya ad asla vermeyin. Hangi ID olduğundan emin değilseniz sorun.
  • Dosyanın en üstündeki durum satırı duruyorsa API henüz yayında değil demektir. Yine her şeyi kurup bağlayın, ama §9'daki ağ kontrollerini beklemede olarak raporlayın. İsteğin başarısız olması kodunuzda hata olduğu anlamına gelmez.

Yazma anahtarı bilerek herkese açık tasarlandı (yalnızca tek bir uygulamaya event yazabilir). Bu yüzden kaynak kodda veya uygulamanın config'inde durmasında sakınca yok.


1. Temel bilgiler

Temel URL https://api.app-store-operator.com/v1
Kimlik doğrulama Header X-Write-Key: <your write key> (Dashboard → uygulamanız → Events → Setup)
Event gönderme POST /v1/events (batch başına 1–100 event)
Kullanıcı eşleme Event hem anonymous_id hem user_id taşıyorsa otomatik; ya da POST /v1/identify
İçerik türü application/json, UTF-8
Rate limit Yazma anahtarı başına dakikada 10.000 event (tüm install'ların toplamı), install (anonymous_id) başına dakikada 300 event
Aylık event Hesap başına, tüm uygulamaların toplamı; her ayın 1'inde (UTC) sıfırlanır. Free: ayda 25.000. Planınızın limitinin %110'si aşılınca yeni event'ler bir sonraki aya kadar reddedilir (403 quota_exceeded)
Event adları Uygulama başına en fazla 500 farklı ad; bundan sonra yeni adlı bir event reddedilir (too_many_event_names). Catalog, event adı başına en fazla 200 property anahtarı listeler
Referans kod için minimum gereksinim React Native 0.76 veya Expo SDK 52, yalnızca iOS build; TypeScript veya JavaScript. Expo için Expo Go değil, bir development build gerekir
Saklama süresi 13 ay

2. Nasıl çalışır

  1. Uygulama ilk açılışta rastgele bir anonim ID üretir ve saklar.
  2. Takip etmek istediğiniz her eylem bir event olur. Event'in bir adı, bir zamanı, isteğe bağlı property'leri ve biraz device bilgisi vardır.
  3. Event'ler device'ta kuyruğa girer ve batch'ler hâlinde gönderilir. Device çevrimdışıysa event'ler bekler, bağlantı gelince gönderilir.
  4. Kullanıcı sizin uygulamanızda oturum açınca ona bir kullanıcı ID'si verebilirsiniz. Bundan sonraki event'ler iki ID'yi de taşır. App Store Operator da oturum açmadan önceki anonim event'leri ve sonrakileri aynı kişiye ait sayar.
  5. Event'lerin canlı olarak geldiğini Dashboard'da görürsünüz ve bunlardan funnel'lar kurarsınız. Funnel, tekil kişi sayısıyla ölçülen sıralı adımlardır; örneğin onboarding_started → paywall_viewed → purchase_completed.

3. Yazma anahtarınızı alın

  1. App Store Operator Dashboard'unda Uygulamalar → uygulamanız → Events → Setup'ı açın.
  2. Yazma anahtarını kopyalayın (aso_wk_ ile başlar).
  3. Anahtarı terminalden (macOS veya Linux) deneyin. Komut her seferinde yeni bir event_id ve o anki zamanı ürettiği için istediğiniz kadar çalıştırabilirsiniz:
Terminal
curl -sS https://api.app-store-operator.com/v1/events \
  -H "Content-Type: application/json" \
  -H "X-Write-Key: YOUR_WRITE_KEY" \
  -d '{
    "events": [{
      "event_id": "'"$(uuidgen)"'",
      "name": "integration_test",
      "anonymous_id": "curl-test",
      "occurred_at": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'",
      "properties": { "source": "curl" }
    }]
  }'

Yanıtta accepted değeri 1, rejected ise boş olmalı. Event birkaç saniye içinde Events → Canlı'da görünür.

Neden her seferinde yeni değer? Aynı event_id yalnızca bir kez saklanır; aynı ID ile ikinci kez çalıştırırsanız Canlı'da yeni bir şey görmezsiniz. 7 günden eski bir zaman gönderirseniz de onun yerine event'in bize ulaştığı zamanı yazarız.

3.1 Anahtarı yenileme

Anahtarı yalnızca kötüye kullanılıyorsa yenileyin, örneğin biri bu anahtarla sahte event'ler gönderiyorsa. Anahtar zaten herkese açık; uygulamanızın içinden görülebiliyor olması yenilemek için bir neden değil.

  1. Setup → Anahtarı yenile'ye tıklayın. Bunu çalışma alanındaki herkes yapabilir ve işlem kayda geçer.
  2. Yeni anahtarı uygulamaya koyun ve bir güncelleme yayınlayın.
  3. Kullanıcılar güncellemeye vakit bulsun diye eski anahtar 30 gün daha çalışır. Bu süreden sonra eski anahtarı kullanan build'ler 401 alır ve referans kod bu build'lerin event'lerini atar. Bu build'leri kullananlar güncelleyene kadar sayılmaz.
  4. Eski anahtar hâlâ kötüye kullanılıyorsa Setup → Eski anahtarı şimdi iptal et ile 30 günlük süreyi hemen bitirin. Bundan sonra eski anahtarı kullanan build'ler sayılmaz. Bunu da çalışma alanındaki herkes yapabilir ve işlem kayda geçer.
  5. Eski anahtarın 30 günü dolmadan Anahtarı yenile tekrar kullanılamaz. Bir daha yenilemek için önce eski anahtarı iptal edin ya da 30 günün dolmasını bekleyin.

4. API referansı

4.1 POST /v1/events

Tek istekte 1 ile 100 arası event gönderir.

Header'lar

Header Zorunlu Değer
Content-Type evet application/json
X-Write-Key evet uygulamanızın yazma anahtarı

Gövde

JSON
{
  "events": [ <event object>, ... ]
}

Gövde en fazla 1 MB olabilir. Gövdede veya event'lerde tanınmayan alanlar yok sayılır.

4.2 Yanıt

Batch'in kendisi geçerliyse, içindeki bazı event'ler reddedilse bile yanıt 200 OK olur:

JSON
{
  "accepted": 2,
  "rejected": [
    { "index": 2, "event_id": "…", "code": "invalid_name", "message": "Event name must match ^[A-Za-z0-9_.:-]{1,64}$" }
  ],
  "clamped": [
    { "index": 0, "event_id": "…" }
  ]
}
  • accepted: saklanan event'ler. Daha önce saklanmış tekrar event'ler de buna dahil (event_id'ye bakın).
  • rejected: saklanmayan event'ler. Bunları değiştirmeden tekrar göndermeyin.
  • clamped: occurred_at değeri izin verilen aralığın dışında kaldığı için yerine sunucuya ulaşma zamanı yazılan event'ler. Bunlar da saklanır ve accepted'a dahildir.

Üç alan da her yanıtta vardır; rejected ve clamped boş olabilir.

4.3 Event nesnesi

Alan Tür Zorunlu Kurallar
event_id string (UUID) evet Her event için benzersiz. Gönderirken değil, event'i oluştururken üretin; böylece tekrar denenen bir batch iki kez sayılmaz.
name string evet ^[A-Za-z0-9_.:-]{1,64}$. $ ile başlayıp bu karakterlerden 1–63 tane ile devam eden adlar (ör. $session_start) App Store Operator'a ayrılmıştır ve reserved_name ile reddedilir. Geçersiz diğer adlar invalid_name alır. snake_case ve object_action biçimini kullanın, ör. paywall_viewed.
anonymous_id string evet 1–128 karakter. Install başına sabittir.
user_id string veya null hayır 1–128 karakter. Sizin sisteminizdeki kalıcı kullanıcı ID'si. E-posta veya telefon numarası olamaz.
occurred_at string evet Saat dilimi içeren ISO 8601, ör. 2026-10-23T12:00:00.123Z. 7 gün öncesinden 5 dakika sonrasına kadar olan zamanlar kabul edilir.
properties object hayır Düz nesne, en fazla 50 anahtar. Anahtar 1–64 karakter. Değer string (≤ 1024 karakter), sayı, boolean veya null olabilir. İç içe nesne veya dizi olamaz.
context object hayır Aşağıya bakın. Göndermenizi öneririz; Dashboard funnel'ları bu alanlara göre filtreler ve kırılımlara ayırır.

context

Alan Örnek Notlar
app_version "2.4.0" CFBundleShortVersionString
build "412" CFBundleVersion
os_version "18.1"
device_model "iPhone16,2" Donanım kodu, pazarlama adı değil
locale "en_US" Device'ın dil ve bölge ayarı (language_REGION)
environment "app_store" "debug", "testflight" veya "app_store". Siz filtreyi değiştirmedikçe Dashboard yalnızca app_store event'lerini (ve bu alanı olmayanları) gösterir

Her context değeri en fazla 64 karakterlik bir string olmalı. Bu kurala uymayan değerler ve tabloda olmayan anahtarlar atılır; event'in kendisi reddedilmez.

Sunucu, isteğin geldiği ağ konumuna bakarak country alanını ekler. IP adresi hiçbir zaman saklanmaz.

Serialize edilmiş bir event en fazla 8 KB olabilir.

Tam örnek

JSON
{
  "event_id": "0b6f6a3e-3c3e-4d8a-8f6e-1f9b8a2c7d10",
  "name": "purchase_completed",
  "anonymous_id": "C5E1B0B2-4F7A-4E0F-9C1D-2A6B7E8F9012",
  "user_id": "u_81723",
  "occurred_at": "2026-10-23T12:04:31.512Z",
  "properties": {
    "product_id": "pro_yearly",
    "price": 39.99,
    "currency": "USD",
    "trial": true
  },
  "context": {
    "app_version": "2.4.0",
    "build": "412",
    "os_version": "18.1",
    "device_model": "iPhone16,2",
    "locale": "en_US",
    "environment": "app_store"
  }
}

4.4 POST /v1/identify (isteğe bağlı)

Event göndermeden bir anonim ID'yi bir kullanıcı ID'sine bağlar. Buna pek ihtiyacınız olmaz, çünkü iki ID'yi de taşıyan her event aynı işi görür. Eşleşmeyi yalnızca sunucunuz biliyorsa kullanın.

JSON
{ "anonymous_id": "C5E1B0B2-4F7A-4E0F-9C1D-2A6B7E8F9012", "user_id": "u_81723" }

Yanıt: 204 No Content. Bir anonim ID için ilk eşleşme geçerlidir; aynı anonim ID'ye sonradan gelen farklı kullanıcı ID'leri yok sayılır.

4.5 Hatalar

Durum code Anlamı İstemci ne yapmalı
400 invalid_body Gövde geçerli bir JSON veya { "events": [...] } değil ya da event sayısı 0 veya 100'den fazla Kodu düzeltin; tekrar denemeyin
401 invalid_write_key Anahtar eksik, tanınmıyor veya iptal edilmiş Tekrar denemeyin; batch'i atın ve anahtarı kontrol edin
413 payload_too_large Gövde 1 MB'tan büyük Daha küçük batch'ler gönderin
403 quota_exceeded Hesap aylık event limitinin %110'sini kullandı. İstekteki hiçbir şey saklanmadı Tekrar denemeyin; batch'i silin. Gönderim ayın 1'inde (UTC) ya da plan yükseltilince yeniden başlar
429 rate_limited Anahtar başına dakikada 10.000 event ya da tek bir anonymous_id için 300 event sınırı aşıldı. İstekteki hiçbir şey saklanmadı Batch'i saklayın; Retry-After kadar saniye sonra tekrar deneyin
5xx server_error Bizim tarafımızda bir hata Batch'i saklayın; artan bekleme süresiyle (backoff) tekrar deneyin

Hata gövdesi: { "code": "invalid_write_key", "message": "…" }.

Event bazında ret kodları (200 yanıtının içinde döner):

Kod Ne zaman
invalid_event events dizisindeki öğe bir JSON nesnesi değil
invalid_name name eksik veya ^[A-Za-z0-9_.:-]{1,64}$ ile eşleşmiyor
reserved_name name, $ ve ardından [A-Za-z0-9_.:-] kümesinden 1–63 karakterden oluşuyor
invalid_id event_id eksik veya UUID değil; anonymous_id eksik veya 1–128 karakterlik bir string değil; user_id var ama 1–128 karakterlik bir string veya null değil
invalid_timestamp occurred_at eksik, parse edilemiyor veya saat dilimi içermiyor. (Aralık dışında olması ret nedeni değil; clamped'e bakın)
invalid_properties properties düz bir nesne değil; bir anahtar 1–64 karakter aralığının dışında; iç içe bir nesne veya dizi var; bir string 1.024 karakterden uzun
too_many_properties 50'den fazla property
event_too_large Serialize edilmiş event 8 KB'tan büyük
too_many_event_names Uygulamada zaten 500 farklı event adı var ve bu ad yeni. Var olan bir adı kullanın, ayrıntıyı bir property'de gönderin

Kısaca retry kuralı: ağ hatasında, 429'da ve 5xx'te aynı event_id'lerle tekrar deneyin. Diğer bütün durumlarda batch'i atın.

5. Referans kod

Bare React Native veya Expo uygulamasının iOS build'i için tek bir TypeScript dosyası. Projenin kaynak koduna eventTracker.ts olarak ekleyin ve hiç değiştirmeden kullanın. İki pakete ihtiyacı var, fazlasına değil.

Paketleri kurun

Bare React Native:

Terminal
npm install @react-native-async-storage/async-storage react-native-device-info
npx pod-install

Expo:

Terminal
npx expo install @react-native-async-storage/async-storage react-native-device-info
npx expo prebuild

İki paketin de native kodu var. Expo'da development build'de veya npx expo prebuild'den sonra çalışırlar; react-native-device-info Expo Go'da olmadığı için orada çalışmazlar. Kurduktan sonra iOS uygulamasını yeniden derleyin. Uygulama bu paketlerden birini zaten kullanıyorsa sürümüne dokunmayın. Tracker @react-native-async-storage/async-storage'dan (sürüm 2 veya 3) yalnızca getItem, setItem ve removeItem'ı, react-native-device-info'dan da yalnızca getVersion, getBuildNumber, getSystemVersion ve getDeviceId'yi kullanır.

Dosya şunları yapar:

  • Yalnızca iOS'te çalışır. Diğer platformlarda (Platform.OS !== "ios") hiçbir metot bir şey yapmaz; bu sayede Android install'ları App Store funnel'larınıza karışmaz.
  • İlk event izlendiğinde rastgele bir anonim ID (UUID v4) üretir ve AsyncStorage'da saklar. currentAnonymousId() bu ID'yi döndürür.
  • setEnabled(...) ile izlemeyi açıp kapatabilirsiniz. Varsayılan olarak açıktır ve seçim uygulama yeniden açıldığında da hatırlanır. Kapatınca ID'ler ve gönderilmemiş bütün event'ler silinir; tekrar açılana kadar track hiçbir şey yapmaz.
  • Her event'i AsyncStorage'daki bir kuyruğa yazar; uygulama kapansa da event'ler kaybolmaz.
  • Kuyruğu en fazla 100 event'lik batch'ler hâlinde gönderir. Gönderim 30 saniyede bir, kuyrukta 20 veya daha fazla event biriktiğinde ve siz flush() çağırdığınızda olur.
  • Çevrimdışıyken veya 5xx aldığında kuyruğu korur ve tekrar denemeden önce bekler: 30 saniye, sonra 60, 120… en fazla 10 dakika. 429 aldığında kuyruğu korur ve sunucunun Retry-After değeri kadar bekler. Başka bir yanıt gelirse batch'i kuyruktan çıkarır.
  • Kuyrukta en fazla 1.000 event tutar; sınır aşılınca en eskileri atar.
  • Yalnızca development build'lerde (__DEV__), reddedilen ve clamp'lenen event'leri ve atılan batch'leri Metro / JS konsoluna [EventTracker] … diye yazar. Release build'ler konsola hiçbir şey yazmaz.
  • Development build'den gelen her event'i debug olarak etiketler; böylece testleriniz funnel'larınıza karışmaz. Release build ise native kod olmadan TestFlight'ı App Store'dan ayırt edemez. Bu yüzden configure, build config'i bunu zaten bilen uygulamalar için isteğe bağlı bir environment ("testflight" veya "app_store") alır (§5.1). Bu değer verilmezse release event'leri ortam bilgisi taşımaz ve Dashboard onları App Store event'i sayar.
  • flush() uygulama arka plana geçerken kuyruğu gönderir. iOS bunun için uygulamaya birkaç saniye verir; o sürede gönderilemeyenler kuyrukta kalır ve bir sonraki açılışta gider.

track'i configure'dan ve kayıtlı durum AsyncStorage'dan yüklenmeden önce de çağırabilirsiniz. Event'ler anahtar verilene kadar önce bellekte, sonra kuyrukta bekler.

Her uygulamaya bir anahtar. Dashboard'daki her uygulamanın kendi yazma anahtarı var. Bir anahtarı yalnızca kendi uygulamasının binary'sinde kullanın.

Extension'larda kullanmayın. Extension'ların (widget, share, notification extension'ları) kendi depolama alanı vardır. Bu yüzden her biri kendi anonim ID'sini alır ve ayrı bir kişi olarak sayılır. Event'leri yalnızca ana uygulamanın JavaScript kodundan izleyin.

TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";

/** Event'leri App Store Operator'a gönderir. Belgeler: https://app-store-operator.com/docs/events/react-native */

/** Bir özellik değeri. Yalnızca düz: nesne veya dizi olamaz. */
export type EventValue = string | number | boolean | null;
export type EventProperties = Record<string, EventValue>;

export type EventEnvironment = "testflight" | "app_store";

type Context = {
  app_version: string;
  build: string;
  os_version: string;
  device_model: string;
  locale: string;
  environment?: string;
};

type Event = {
  event_id: string;
  name: string;
  anonymous_id: string;
  user_id: string | null;
  occurred_at: string;
  properties: EventProperties;
  context: Context;
};

const ENDPOINT = "https://api.app-store-operator.com/v1/events";
const MAX_BATCH = 100;
const MAX_QUEUE = 1_000;
const FLUSH_AT = 20;
const FLUSH_INTERVAL_MS = 30_000;
const REQUEST_TIMEOUT_MS = 60_000;
const ANONYMOUS_ID_KEY = "aso.events.anonymousId";
const USER_ID_KEY = "aso.events.userId";
const DISABLED_KEY = "aso.events.disabled";
const QUEUE_KEY = "aso.events.queue";

// App Store Operator iOS uygulamalarını kapsar. Diğer tüm platformlarda her metot hiçbir şey yapmaz.
const SUPPORTED = Platform.OS === "ios";

class Tracker {
  private writeKey: string | null = null;
  private environment: string | undefined = __DEV__ ? "debug" : undefined;
  private isEnabled = true;
  private anonymousId: string | null = null;
  private userId: string | null = null;
  private queue: Event[] = [];
  private isFlushing = false;
  private timer: ReturnType<typeof setInterval> | null = null;
  private retryAt = 0;
  private backoffSeconds = 0;
  private cachedContext: Context | null = null;

  // Kayıtlı durum bir kez, asenkron olarak okunur. Yüklenmeden önce yapılan çağrılar burada sırayla bekler.
  private loading: Promise<void> | null = null;
  private isLoaded = false;
  private pending: Array<() => void> = [];
  private writes: Promise<void> = Promise.resolve();
  private queueWriteScheduled = false;

  /**
   * Açılışta bir kez çağırın. `environment` yalnızca release build'ler içindir: uygulamanız hangisi olduğunu
   * zaten biliyorsa "testflight" veya "app_store" verin. Geliştirme build'leri (`__DEV__`) her zaman "debug" olur.
   */
  configure(writeKey: string, options: { environment?: EventEnvironment } = {}): void {
    if (!SUPPORTED) return;
    this.writeKey = writeKey;
    if (!__DEV__ && options.environment) this.environment = options.environment;
    void this.load();
    if (this.timer !== null) return;
    this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
  }

  /**
   * Bu install'ın kimliği; ilk izlenen event'ten önce ve izleme kapalıyken null.
   * Kullanıcının verilerinin silinmesini isteyebilmesi için bir destek ekranında gösterin.
   */
  async currentAnonymousId(): Promise<string | null> {
    if (!SUPPORTED) return null;
    await this.load();
    return this.anonymousId;
  }

  /**
   * İzlemeyi açar veya kapatır ve seçimi açılışlar arasında hatırlar. İzleme varsayılan olarak açıktır.
   * Kapatmak bu install'ın kimliklerini ve henüz gönderilmemiş tüm event'leri siler; ardından `track` hiçbir şey yapmaz.
   * Uygulamanız önce kullanıcının onayını almak zorundaysa, kullanıcı kabul edene kadar açılışta `setEnabled(false)` çağırın.
   */
  setEnabled(enabled: boolean): void {
    this.whenLoaded(() => {
      this.isEnabled = enabled;
      this.write(() => (enabled ? AsyncStorage.removeItem(DISABLED_KEY) : AsyncStorage.setItem(DISABLED_KEY, "1")));
      if (enabled) return;
      this.anonymousId = null;
      this.userId = null;
      this.queue = [];
      this.write(() => AsyncStorage.removeItem(ANONYMOUS_ID_KEY));
      this.write(() => AsyncStorage.removeItem(USER_ID_KEY));
      this.write(() => AsyncStorage.removeItem(QUEUE_KEY));
    });
  }

  /** Bir event'i kaydeder. Hafiftir; ağ için asla beklemez. İzleme kapalıyken hiçbir şey yapmaz. */
  track(name: string, properties: EventProperties = {}): void {
    if (!SUPPORTED) return;
    // ID ve zaman, kayıtlı durum hâlâ yükleniyor olsa bile şimdi sabitlenir.
    const eventId = uuidV4();
    const occurredAt = new Date().toISOString();
    const props = { ...properties };
    this.whenLoaded(() => {
      if (!this.isEnabled) return;
      const anonymousId = this.anonymousId ?? this.newAnonymousId();
      this.queue.push({
        event_id: eventId,
        name,
        anonymous_id: anonymousId,
        user_id: this.userId,
        occurred_at: occurredAt,
        properties: props,
        context: this.context(),
      });
      if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
      this.persistQueue();
      if (this.queue.length >= FLUSH_AT) void this.flush();
    });
  }

  /** Kullanıcı uygulamanızda oturum açtıktan sonra çağırın. Kendi kalıcı kullanıcı kimliğinizi kullanın, asla e-posta değil. */
  identify(userId: string): void {
    this.whenLoaded(() => {
      if (!this.isEnabled) return;
      this.userId = userId;
      this.write(() => AsyncStorage.setItem(USER_ID_KEY, userId));
    });
  }

  /** Oturum kapatılırken çağırın: sonraki event'ler yeni bir anonim kişiye ait olur. */
  reset(): void {
    this.whenLoaded(() => {
      this.userId = null;
      this.anonymousId = null;
      this.write(() => AsyncStorage.removeItem(USER_ID_KEY));
      this.write(() => AsyncStorage.removeItem(ANONYMOUS_ID_KEY));
    });
  }

  /** Kuyruktaki event'leri gönderir. Bir yeniden deneme beklemesi sürerken hiçbir şey yapmaz. */
  async flush(): Promise<void> {
    if (!SUPPORTED) return;
    await this.load();
    const writeKey = this.writeKey;
    if (!this.isEnabled || this.isFlushing || writeKey === null || this.queue.length === 0) return;
    if (this.retryAt > Date.now()) return;
    this.isFlushing = true;
    try {
      while (this.queue.length > 0) {
        const batch = this.queue.slice(0, MAX_BATCH);
        const environment = this.environment;
        const events = batch.map((event) =>
          event.context.environment !== undefined || environment === undefined
            ? event
            : { ...event, context: { ...event.context, environment } },
        );

        let response: Response;
        const abort = new AbortController();
        const timeout = setTimeout(() => abort.abort(), REQUEST_TIMEOUT_MS);
        try {
          response = await fetch(ENDPOINT, {
            method: "POST",
            headers: { "Content-Type": "application/json", "X-Write-Key": writeKey },
            body: JSON.stringify({ events }),
            signal: abort.signal,
          });
        } catch {
          this.delayRetry(null); // çevrimdışı: kuyruğu tut
          return;
        } finally {
          clearTimeout(timeout);
        }
        if (!this.isEnabled) return; // gönderim sırasında kapatıldı: kuyruk zaten silindi

        const status = response.status;
        if (status === 429 || status >= 500) {
          const header = response.headers.get("Retry-After");
          const retryAfter = header === null ? Number.NaN : Number(header);
          this.delayRetry(status === 429 && retryAfter >= 0 ? retryAfter : null); // kuyruğu tut
          return;
        }
        this.retryAt = 0;
        this.backoffSeconds = 0;
        if (__DEV__) await report(status, response);
        // 2xx: saklandı. Diğer 4xx: aynı batch'i tekrar denemenin anlamı yok.
        const sent = new Set(batch.map((event) => event.event_id));
        this.queue = this.queue.filter((event) => !sent.has(event.event_id));
        this.persistQueue();
      }
    } finally {
      this.isFlushing = false;
    }
  }

  /** Sunucu belirtirse `seconds` kadar, aksi hâlde 30 sn, 60 sn, 120 sn… en fazla 10 dakika bekler. */
  private delayRetry(seconds: number | null): void {
    this.backoffSeconds = Math.min(Math.max(this.backoffSeconds * 2, 30), 600);
    this.retryAt = Date.now() + (seconds ?? this.backoffSeconds) * 1000;
  }

  private newAnonymousId(): string {
    const id = uuidV4();
    this.anonymousId = id;
    this.write(() => AsyncStorage.setItem(ANONYMOUS_ID_KEY, id));
    return id;
  }

  private context(): Context {
    this.cachedContext ??= {
      app_version: DeviceInfo.getVersion(),
      build: DeviceInfo.getBuildNumber(),
      os_version: DeviceInfo.getSystemVersion(),
      device_model: DeviceInfo.getDeviceId(), // donanım tanımlayıcısı, ör. "iPhone16,2"
      locale: deviceLocale(),
    };
    return this.cachedContext;
  }

  private whenLoaded(operation: () => void): void {
    if (!SUPPORTED) return;
    if (this.isLoaded) {
      operation();
      return;
    }
    this.pending.push(operation);
    void this.load();
  }

  private load(): Promise<void> {
    this.loading ??= this.readStoredState();
    return this.loading;
  }

  private async readStoredState(): Promise<void> {
    try {
      const [disabled, anonymousId, userId, queue] = await Promise.all([
        AsyncStorage.getItem(DISABLED_KEY),
        AsyncStorage.getItem(ANONYMOUS_ID_KEY),
        AsyncStorage.getItem(USER_ID_KEY),
        AsyncStorage.getItem(QUEUE_KEY),
      ]);
      this.isEnabled = disabled === null;
      this.anonymousId = anonymousId; // ilk izlenen event'le oluşturulur
      this.userId = userId;
      const stored: unknown = queue === null ? [] : JSON.parse(queue);
      if (Array.isArray(stored)) this.queue = stored as Event[];
    } catch {
      // Depolama okunamıyor: boş bir kuyrukla başla.
    }
    this.isLoaded = true;
    // Yükleme sırasında yapılan çağrılar şimdi sırayla çalışır; event'leri kayıtlı olanların arkasına eklenir.
    const pending = this.pending;
    this.pending = [];
    for (const operation of pending) operation();
  }

  /** Kuyruğu her değişiklik grubu için bir kez, zaten bekleyen yazmalardan sonra kaydeder. */
  private persistQueue(): void {
    if (this.queueWriteScheduled) return;
    this.queueWriteScheduled = true;
    this.write(() => {
      this.queueWriteScheduled = false;
      return AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
    });
  }

  /** Depolama yazmaları art arda çalışır, böylece yapıldıkları sırayla gerçekleşir. */
  private write(operation: () => Promise<void>): void {
    this.writes = this.writes.then(operation).catch(() => undefined);
  }
}

/** Yalnızca geliştirme build'lerinde: atılan batch'leri ve reddedilen veya clamp'lenen event'leri konsola yazdırır. */
async function report(status: number, response: Response): Promise<void> {
  let json: Record<string, unknown> = {};
  try {
    const parsed: unknown = await response.json();
    if (parsed !== null && typeof parsed === "object") json = parsed as Record<string, unknown>;
  } catch {
    // JSON değil: yalnızca durum kodunu yaz.
  }
  if (status < 200 || status >= 300) {
    console.log(`[EventTracker] HTTP ${status}, batch dropped:`, json);
    return;
  }
  for (const key of ["rejected", "clamped"]) {
    const items = json[key];
    if (Array.isArray(items) && items.length > 0) console.log(`[EventTracker] ${key}:`, items);
  }
}

/** Rastgele bir UUID v4. Hermes'te crypto.randomUUID yoktur, bu yüzden rastgele baytlardan oluşturulur. */
function uuidV4(): string {
  const bytes = new Uint8Array(16);
  const crypto = (globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => Uint8Array } }).crypto;
  if (crypto?.getRandomValues) {
    crypto.getRandomValues(bytes);
  } else {
    for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
  }
  let nibble = 0;
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
    const byte = bytes[nibble >> 1] ?? 0;
    const value = nibble % 2 === 0 ? byte >> 4 : byte & 0x0f;
    nibble++;
    return (char === "x" ? value : (value & 0x3) | 0x8).toString(16);
  });
}

/** `language_REGION` biçiminde device dili, ör. "en_US" (bölge yoksa yalnızca "en"). */
function deviceLocale(): string {
  try {
    const tag = Intl.DateTimeFormat().resolvedOptions().locale; // ör. "en-US", "zh-Hans-CN"
    const [language = "", ...subtags] = (tag.split("-u-")[0] ?? "").split("-");
    const region = subtags.find((part) => /^([A-Z]{2}|\d{3})$/.test(part));
    return region ? `${language}_${region}` : language;
  } catch {
    return "";
  }
}

export const EventTracker = new Tracker();

5.1 Uygulamaya bağlama

Tracker'ı yapılandırma ve cold app_opened event'ini gönderme işini, uygulamanın kök dosyasında modül seviyesinde yapın. Böylece ikisi de açılış başına bir kez çalışır; re-render'da veya StrictMode altında ikinci kez çalışmaz. Bare React Native'de bu dosya App.tsx, Expo Router'da app/_layout.tsx.

TypeScript
import { useEffect } from "react";
import { AppState } from "react-native";
import { EventTracker } from "./eventTracker";

// Modül kapsamı: her açılışta bir kez çalışır.
EventTracker.configure("YOUR_WRITE_KEY");
EventTracker.track("app_opened", { source: "cold" });

export default function App() {
  useEffect(() => {
    let wasInBackground = false;
    const subscription = AppState.addEventListener("change", (state) => {
      if (state === "background") {
        wasInBackground = true;
        void EventTracker.flush();
      } else if (state === "active" && wasInBackground) {
        wasInBackground = false;
        EventTracker.track("app_opened", { source: "warm" });
      }
    });
    return () => subscription.remove();
  }, []);

  return <RootNavigator />;
}

app_opened her active geçişinde değil, yalnızca arka plandan dönüşte gönderilir. Çünkü sistem uyarıları, Denetim Merkezi ve Face ID, uygulama hiç arka plana geçmeden onu inactive, sonra tekrar active yapar.

Geliştirme sırasında o dosyayı düzenlerseniz Fast Refresh modül seviyesindeki kodu tekrar çalıştırabilir ve fazladan bir cold app_opened gider. Bu event'ler debug etiketlidir, varsayılan görünümde çıkmaz.

TestFlight'ı App Store'dan ayırmak. Uygulamanın build config'i bir release build'in hangi kanala gideceğini zaten biliyorsa (örneğin EAS build profilinde veya Xcode scheme'inde tanımlı bir ortam değişkeni), bu bilgiyi configure'a verin. Development build'ler bunu yok sayar ve debug kalır.

TypeScript
EventTracker.configure("YOUR_WRITE_KEY", {
  environment: IS_TESTFLIGHT_BUILD ? "testflight" : "app_store",
});

IS_TESTFLIGHT_BUILD yerine uygulamanın config'inde hangi değer varsa onu kullanın. Böyle bir değer yoksa environment'ı hiç vermeyin.

5.2 Event takibi

TypeScript
import { EventTracker } from "./eventTracker";

EventTracker.track("paywall_viewed", { placement: "onboarding" });
EventTracker.track("onboarding_completed", { duration_s: 42 });

// Uygulamanızda oturum açıldıktan / kapatıldıktan sonra:
EventTracker.identify(user.id);
EventTracker.reset();

// Onay ya da uygulama içi "Analitik verilerini paylaş" ayarı: açılışta, configure ve ilk track'ten önce
// kullanıcının güncel seçimiyle çağırın; seçim değiştikçe tekrar çağırın.
EventTracker.setEnabled(userAllowsAnalytics);

// Destek ekranı: kullanıcı verilerinin silinmesini isteyebilsin diye bunu gösterin (henüz hiçbir şey izlenmediyse null).
const installId = await EventTracker.currentAnonymousId();

track, identify, reset ve setEnabled hemen döner; render handler'ları ve buton callback'leri dahil her yerden çağrılabilir. flush ve currentAnonymousId promise döndürür.

İzleme kullanıcı onayına bağlıysa setEnabled'ı §5.1'deki açılış app_opened event'inden önce çağırın. İzleme kapalıyken o çağrı hiçbir şey yapmaz ve ID üretilmez. Kullanıcının seçimini asenkron okuyorsanız önce modül seviyesinde setEnabled(false) çağırın, kayıtlı seçim "evet" çıkarsa setEnabled(true) çağırın. Çağrılar yapıldıkları sırayla işlenir.

Property değerlerinin tipi EventValue: string | number | boolean | null. Nesne veya dizi verirseniz TypeScript hata verir.

Satın almalar. Satın almaları, kullanıcının satın al butonuna bastığı akışın içinde, uygulamanın kullandığı kütüphanenin (react-native-iap, RevenueCat veya başka biri) satın alma çağrısının etrafında izleyin. Geri yüklemelerin, yenilemelerin veya başka device'lardan yapılan satın almaların da düştüğü bir listener ya da callback'te izlemeyin. Aşağıdaki wrapper, satın alma çağrınızı dört sonuçtan birini döndüren bir fonksiyon olarak alır.

TypeScript
import { EventTracker } from "./eventTracker";

export type PurchaseOutcome = "success" | "cancelled" | "pending" | "error";

/**
 * `purchase`, uygulamanın kendi satın alma çağrısıdır ve bir sonuca eşlenir:
 * tamamlandı ve doğrulandı → "success"; kullanıcı iptal etti → "cancelled";
 * Ask to Buy veya başka bir ertelenmiş onay → "pending"; diğer her şey → "error" (veya hata fırlatın).
 */
export async function buy(
  product: { id: string; price: number; currency: string },
  startsTrial: boolean,
  purchase: () => Promise<PurchaseOutcome>,
): Promise<PurchaseOutcome> {
  const base = { product_id: product.id };
  EventTracker.track("purchase_started", base);
  let outcome: PurchaseOutcome;
  try {
    outcome = await purchase();
  } catch {
    outcome = "error";
  }
  switch (outcome) {
    case "success":
      EventTracker.track("purchase_completed", {
        ...base,
        price: product.price,
        currency: product.currency,
        trial: startsTrial,
      });
      if (startsTrial) EventTracker.track("trial_started", base);
      break;
    case "cancelled":
      EventTracker.track("purchase_failed", { ...base, reason: "cancelled" });
      break;
    case "error":
      EventTracker.track("purchase_failed", { ...base, reason: "failed" });
      break;
    case "pending":
      break; // Ask to Buy veya ek onay: henüz tamamlanmadı, hiçbir şey izlemeyin
  }
  return outcome;
}

Satın al butonunda bunu, paywall'da gösterilen ürünle ve kütüphanenin satın alma metodunu çalıştırıp sonucunu eşleyen bir fonksiyonla çağırın:

TypeScript
const outcome = await buy(product, startsTrial, async () => {
  const result = await yourPurchaseCall(product.id); // kütüphanenin satın alma metodu
  return toPurchaseOutcome(result); // "success" | "cancelled" | "pending" | "error"
});

product.price sayı olarak fiyat, product.currency ise ISO 4217 para birimi kodu ("USD"); ikisi de kütüphanenin ürün nesnesinden gelir. startsTrial paywall'unuzun zaten bildiği bir değer: bu satın alma ücretsiz trial başlatıyorsa true. Birçok kütüphane, kullanıcının vazgeçmesini bir iptal flag'i veya hata kodu taşıyan bir exception olarak fırlatır; bunu "error" değil "cancelled" olarak eşleyin. Kütüphane sonucu yalnızca bir purchase listener'ı üzerinden bildiriyorsa purchase_completed event'ini sadece kullanıcının bu akışta az önce başlattığı satın alma için izleyin.

6. Hangi event'ler izlenmeli

Bir funnel, arkasındaki event'ler kadar iyidir. Aşağıdaki event'lerle başlayın, yeni sorular çıktıkça yenilerini ekleyin.

Adlandırma kuralları

  • snake_case, object_action, geçmiş zaman: paywall_viewed, purchase_completed.
  • Her eyleme tek bir ad verin. Farklılıkları adda değil (settings_paywall_viewed), property'lerde tutun (placement: "settings" ile paywall_viewed).
  • Ada ID, tarih veya kullanıcı verisi koymayın.
  • Funnel'ı bir property'ye göre bölmek istiyorsanız o property'ye serbest metin değil, az sayıda sabit değer verin (plan: "yearly"). Dashboard en sık görülen 10 değeri gösterir, gerisini "Diğer" altında toplar.
  • Adlar ve property değerleri büyük/küçük harfe duyarlıdır: Paywall_Viewed ile paywall_viewed iki ayrı event'tir.

Önerilen başlangıç seti

Akışı uygulamanızda olan event'leri izleyin, gerisini atlayın. isteğe bağlı diye işaretli property'leri göndermeyebilirsiniz.

Event Ne zaman Property'ler
app_opened Uygulama açılışı ve arka plandan her dönüş (§5.1'deki kod bunu gönderir) source ("cold" açılış, "warm" dönüş)
onboarding_started İlk onboarding ekranı açıldı
onboarding_step_viewed İlki dahil her onboarding ekranı açıldığında step (sayı, 1'den başlar), step_name
onboarding_completed Son onboarding adımı bitti duration_s isteğe bağlı (sayı)
signup_completed Uygulamanızda hesap oluşturuldu method ("apple", "google", "email")
paywall_viewed Paywall açıldı placement (nerede açıldığı: "onboarding", "settings", "feature_gate"), paywall_id isteğe bağlı
purchase_started Kullanıcı satın al butonuna bastı product_id
purchase_completed Satın alma tamamlandı ve doğrulandı; satın alma akışının içinde izlenir (§5.2). Ücretsiz trial başlatan satın almalar da buna dahil product_id, price (sayı), currency, trial (boolean)
purchase_failed Kullanıcı vazgeçti veya satın alma başarısız oldu. Ask to Buy (beklemede) bunların hiçbiri sayılmaz; o durumda hiçbir şey izlemeyin product_id, reason ("cancelled", "failed")
trial_started Satın alma ücretsiz trial başlattıysa purchase_completed'a ek olarak gönderilir product_id
purchase_restored Satın almaları geri yükleme aktif bir subscription buldu product_id isteğe bağlı
Ana eyleminiz Uygulamanızın asıl işi olan eylem. Adını o eyleme göre koyun: scan_completed, workout_finished, photo_exported eylemi anlatan property'ler

Uygulama event'lerinin göremedikleri. Yenilemeler, ücretli subscription'a dönen trial'lar, iptaller ve refund'lar Apple tarafında, çoğu zaman uygulamanız kapalıyken olur. Uygulama bunları hiç görmediği için bu event'lerde de yoklar. Buradaki funnel'lar satın almada veya trial başlangıcında biter.

Bu event'lerle kurabileceğiniz örnek funnel'lar

Funnel Adımlar Süre Yanıtladığı soru
İlk session onboarding_started → onboarding_completed → paywall_viewed (placement = onboarding) → purchase_completed 1 gün Yeni kullanıcıların kaçı paywall'a gelip satın alıyor
Onboarding kaybı onboarding_step_viewed (step = 1) → (step = 2) → (step = 3) → onboarding_completed 1 gün En çok kullanıcı hangi onboarding ekranında bırakıyor
Paywall dönüşümü paywall_viewed → purchase_started → purchase_completed, placement kırılımıyla 1 saat Hangi paywall konumu daha iyi dönüştürüyor; kullanıcılar fiyatı görünce mi, Apple'ın satın alma ekranında mı bırakıyor
Aktivasyon onboarding_completed → scan_completed (ana eyleminiz) 1 gün Yeni kullanıcıların kaçı uygulamanın asıl işini yapıyor

7. Gizlilik

Bu bölüm hukuki tavsiye değil, yol göstermek için var. Uygulamanızın ne gönderdiğini ve kullanıcılarınızın nerede olduğunu siz biliyorsunuz. Son karar sizin; mümkünse hukuk danışmanınızla birlikte verin.

Bu bölümdeki her konu için gizlilik iletişim adresimiz: [email protected].

7.1 App Store Operator'a ulaşan veriler

  • Event adları ve property'leri.
  • anonymous_id: rastgele bir UUID. Referans kod bunu izleme açıkken ilk event'te üretir ve izleme kapatılana veya reset() çağrılana kadar o install için AsyncStorage'da tutar. IDFA değildir, başka uygulamalarla paylaşılan bir tanımlayıcı da değildir.
  • user_id (yalnızca identify çağırırsanız).
  • §4.3'teki device bilgileri: uygulama version'ı, build, işletim sistemi sürümü, device modeli, dil ayarı ve biliniyorsa build ortamı.
  • İsteğin geldiği ağ konumundan çıkarılan ülke (§7.3).

Yalnızca iOS install'ları veri gönderir; Android'de referans kod hiçbir şey göndermez.

Asla göndermeyin: ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı serbest metin. Bunlar ne event adında, ne property'lerde, ne de user_id'de olmalı. user_id uygulamanızın kendi dahili ID'si olmalı (örneğin bir veritabanı anahtarı); e-posta, telefon numarası veya başka bir şirketin verdiği bir ID olmamalı.

7.2 Roller ve hizmet sağlayıcılar

  • Roller. Uygulamanızın gönderdiği event'lerin veri sorumlusu (KVKK'daki adıyla veri sorumlusu) sizsiniz: neyin, neden toplanacağına siz karar verirsiniz. App Store Operator'ı işleten Nefarius ise event'leri saklamak ve Dashboard'da size göstermek için sizin adınıza işler; yani veri işleyendir (KVKK'daki adıyla veri işleyen).
  • Yapmadıklarımız. Event verilerini satmayız, reklam için kullanmayız ve başka müşterilerle paylaşmayız.
  • Hizmet sağlayıcılar (alt işleyenler). Barındırılan her hizmet gibi biz de event verilerini yalnızca bizim adımıza işleyen sağlayıcılarla çalışırız:
    • Cloudflare: ağ iletimi ve güvenlik. Uygulamanızdan gelen her istek Cloudflare'den geçer.
    • Sunucu barındırma sağlayıcımız: sunucuları ve event'lerin saklandığı veritabanını çalıştırır.
  • Konumlar. Cloudflare dünyanın her yerinde çalıştığı için bir istek, kullanıcınızın bulunduğu ülkenin dışında işlenebilir.
  • Veri işleme sözleşmesi (DPA). DPA (örneğin GDPR madde 28 kapsamında), hizmet sağlayıcıların konumlarıyla güncel listesi veya yurt dışına aktarım güvencelerinin ayrıntıları gerekiyorsa yukarıdaki gizlilik adresine yazın.

7.3 IP adresi ve ülke

Uygulamanızın istekleri bize Cloudflare üzerinden gelir. Cloudflare isteği iletmek için IP adresine ihtiyaç duyar ve ülkeyi bu adresten bulur. App Store Operator yalnızca ülkeyi saklar (şehir veya bölge saklamaz); IP adresini hiçbir zaman saklamaz ve loglamaz.

7.4 Saklama süresi ve silme talepleri

  • Event'ler 13 ay saklanır, sonra otomatik olarak silinir.
  • Tek bir kişinin event'lerini Dashboard'dan silmek henüz mümkün değil. Bu özellik gelene kadar, bir kullanıcı verilerinin silinmesini isterse (örneğin GDPR madde 17 veya KVKK madde 11 kapsamında) App Store Operator çalışma alanınızdaki birinin e-posta adresinden yukarıdaki gizlilik adresine yazın. E-postaya şunları ekleyin:
    • Uygulamanın Dashboard'da görünen adı;
    • Kişinin user_id'si ve/veya anonymous_id'si. Bir user_id, ona bağlı bütün anonymous_id'leri de kapsar. Hesabı olmayan kullanıcılar için anonymous_id'yi await EventTracker.currentAnonymousId() ile alıp örneğin bir destek ekranında gösterebilirsiniz (§5.2). Bu değeri setEnabled(false) veya reset() çağırmadan önce okuyun. İkisi de değeri device'tan siler ve bu değer olmadan o install'ın event'lerini bulamayız.
  • Bu ID'leri taşıyan bütün event'leri siler, sonucu e-postayla bildiririz. Talepleri bekletmeden gönderin; kullanıcıya yanıt vermek için sizin de yasal bir süreniz var (GDPR'da genelde bir ay, KVKK'da 30 gün).
  • Çalışma alanınızın üyesi olmayan birinden gelen talepleri işleme almayız; o kişiyi size yönlendiririz.

7.5 Gizlilik politikanız ve kullanıcı onayı

  • Gizlilik politikası. Uygulamanızın gizlilik politikasında şunlar yazmalı: kullanım analitiği topladığınız, bunun hangi verileri kapsadığı (§7.1), bu verileri sizin adınıza bir hizmet sağlayıcının (Nefarius'un işlettiği App Store Operator) işlediği ve verilerin 13 ay saklandığı.
  • Hukuki dayanak ve onay. Analitiğin hukuki dayanağına ve önce kullanıcıdan onay (açık rıza) almanız gerekip gerekmediğine siz karar verirsiniz. AB ve Birleşik Krallık'ta analitik için device'ta bir tanımlayıcı saklamak veya okumak çoğu zaman onay gerektirir; KVKK'da ise kullanıcıyı en azından bilgilendirmeniz gerekir. Onay gerekiyorsa açılışta her şeyden önce EventTracker.setEnabled(false) çağırın, kullanıcı kabul edene kadar öyle kalsın, kabul edince de EventTracker.setEnabled(true) çağırın (§5.2). İzleme kapalıyken device'ta hiçbir ID veya event saklanmaz (yalnızca "kapalı" ayarının kendisi saklanır) ve hiçbir şey gönderilmez. Tracker anonymous_id'yi yalnızca izleme açıkken ilk event'te üretir. Kullanıcı onayını sonradan geri çekerse setEnabled(false) izlemeyi durdurur, device'taki ID'leri ve gönderilmemiş event'leri siler. Daha önce gönderilmiş event'leri ise silmez. Tek hukuki dayanağınız onaysa veya kullanıcı silinmelerini isterse önce currentAnonymousId() değerini okuyun, sonra §7.4'teki gibi silme talebi gönderin.

7.6 App Tracking Transparency

App Store Operator event'lerinizi reklam veya reklam ölçümü için başka şirketlerin uygulamalarından, sitelerinden ya da çevrimdışı kaynaklarından gelen verilerle birleştirmez. Event'leri veri simsarlarıyla paylaşmaz, device bilgilerini fingerprinting için kullanmaz. Bu yüzden entegrasyon tek başına Apple'ın tanımıyla tracking sayılmaz ve App Tracking Transparency (ATT) izni gerektirmez.

ATT kararı entegrasyon başına değil, uygulamanın tamamı için verilir. Uygulamanız veya içindeki başka bir SDK bu event'leri ya da user_id'nizi reklam veya reklam ölçümü için başka şirketlerin verileriyle eşliyorsa ya da bir veri simsarıyla paylaşıyorsa bu tracking olur. O zaman önce kullanıcıdan ATT izni almanız gerekir ve gizlilik etiketinizde verilerin tracking için kullanıldığı yazmalıdır. Ayrıntılar için Apple'ın User Privacy and Data Use sayfasına bakın.

7.7 App Store gizlilik etiketi ve gizlilik manifesti

Apple'ın gizlilik sorularını uygulamanın tamamı için yanıtlarsınız. Apple, eklediğiniz üçüncü taraf kodların topladığı verileri de saymanızı ister; bu kod da onlardan biri. App Store Operator'a giden veriler için genelde şu yanıtlar verilir:

Apple veri türü Ne zaman beyan edilir Amaç
Usage Data → Product Interaction Her zaman Analytics
Identifiers → Device ID Her zaman; anonymous_id install başına kalıcı bir tanımlayıcıdır Analytics
Location → Coarse Location Her zaman; ülke, kullanıcının ağ konumundan çıkarılır. Apple'ın örneklerindekinden de kaba bir konum, ama beyan etmek daha güvenli Analytics
Purchases → Purchase History §6'daki satın alma event'lerini izliyorsanız Analytics
Identifiers → User ID identify çağırıyorsanız veya user_id gönderiyorsanız Analytics
  • Linked to You. user_id gönderiyorsanız yukarıdaki bütün satırları Linked to You olarak beyan edin, çünkü event'ler sisteminizdeki bir hesap ID'siyle eşleşir. Hiç user_id göndermiyorsanız ve event'leri başka bir yerde de bir hesaba veya kimliği belli eden başka verilere bağlamıyorsanız, veri yalnızca rastgele bir install ID'sine bağlıdır; Not Linked to You diyebilirsiniz. Emin değilseniz Linked to You seçin.
  • Tracking. Uygulamanız §7.6'da anlatılan anlamda tracking yapmıyorsa Not used to track you.
  • Gizlilik manifesti. Referans kod JavaScript olduğu için Apple'ın gerekçe istediği API'lerin hiçbirini kendisi çağırmaz. Ama iki paketin native kodu çağırır ve her paket bunu beyan eden kendi PrivacyInfo.xcprivacy dosyasıyla gelir. Bu rehberi yazdığımız sırada güncel olan sürümlerde @react-native-async-storage/async-storage dosya zaman damgasını (NSPrivacyAccessedAPICategoryFileTimestamp, C617.1), react-native-device-info ise UserDefaults (CA92.1), dosya zaman damgası (C617.1), sistem açılış zamanı (NSPrivacyAccessedAPICategorySystemBootTime, 35F9.1) ve disk alanını (NSPrivacyAccessedAPICategoryDiskSpace, 85F4.1) beyan eder. Yine de sizin kontrol etmeniz gerekenler:
    • Uygulamayı Xcode'da arşivleyip gizlilik raporunu oluşturun (Organizer → arşive sağ tıklayın → Generate Privacy Report). Raporda bu gerekçeler görünmeli. Görünmüyorsa uygulamanın kendi manifest'ine ekleyin: bare React Native'de ios/<YourApp>/PrivacyInfo.xcprivacy (React Native şablonu bunu oluşturur); Expo'da uygulama yapılandırmasındaki ios.privacyManifests, ardından yeniden prebuild.
    • Uygulamanın kendi manifest'indeki toplanan veri türleri (NSPrivacyCollectedDataTypes) etiketinizle uyuşmalı. Paketler bunların hiçbirini beyan etmez, çünkü event toplayan onlar değil, sizin uygulamanız.
    • Paketlerden birini güncelledikten sonra PrivacyInfo.xcprivacy dosyasına tekrar bakın; beyan edilen gerekçeler değişebilir.

Apple'ın tanımları: App privacy details on the App Store ve Privacy manifest files.

7.8 Çocuklara yönelik uygulamalar

Apple'ın App Review Guidelines kuralları (1.3 ve 5.1.4), Kids kategorisindeki uygulamalarda üçüncü taraf analitiğe izin vermez. Tek istisna, hizmetin çocuklar hakkında device bilgileri dahil hiçbir tanımlayıcı bilgi toplamadığı sınırlı durumlardır. App Store Operator kalıcı bir install ID'si, device modeli ve ülke bilgisi aldığı için Kids kategorisindeki bir uygulamaya eklemeyin. Çocuklara yönelik başka bir uygulamanız varsa, entegrasyondan önce size hangi kuralların uygulandığını (örneğin ABD'de COPPA veya GDPR'ın çocuk onayı kuralları) hukuk danışmanınızla kontrol edin.

8. Doğrulama ve sorun giderme

  1. iOS uygulamasını çalıştırın (simülatör veya device, debug build), birkaç event tetikleyin, sonra uygulamayı arka plana alın (kuyruk bu sırada gönderilir).
  2. Dashboard → uygulamanız → Events → Canlı'yı açın. Event'leriniz birkaç saniye içinde görünür.
  3. Events → Katalog her event adını property anahtarlarıyla birlikte listeler. Adların ve anahtarların istediğiniz gibi olduğunu kontrol edin.

Test build'leri. Her uygulamanın tek bir yazma anahtarı var; test build'leri de App Store build'leri de aynı anahtarı kullanır. Referans kod development build'den (__DEV__) gelen her event'i debug olarak etiketler. TestFlight build'leri dahil release build'lerin event'leri configure'a verilen environment değerini taşır (§5.1). Bu değer verilmezse ortam bilgisi taşımazlar ve App Store event'i sayılırlar; o zaman TestFlight testleri de varsayılan görünümde çıkar. Dashboard varsayılan olarak app_store event'lerini ve ortam bilgisi olmayanları gösterir. Development testlerinizi görmek için ortam filtresini Tüm build'ler yapın. curl testi ortam bilgisi göndermediği için varsayılan görünümde çıkar; anonymous_id değeri curl-test olan event'e bakın.

Belirti Olası neden
Canlı'da hiçbir şey yok Anahtar yanlış (401); configure hiç çağrılmamış; uygulama Android'de çalışıyor (tracker orada hiçbir şey yapmaz); ya da kuyrukta 20'den az event var ve uygulama henüz arka plana geçmedi (30 sn bekleyin)
401 invalid_write_key Anahtarda yazım hatası var; anahtar 30 günden uzun süre önce yenilenmiş; ya da eski anahtar iptal edilmiş (§3.1)
Bir event eksik, diğerleri geliyor Event reddedilmiş. Development build'de Metro / JS konsolunda hata koduyla birlikte [EventTracker] rejected: … görürsünüz (§4.5)
Test build'inden Canlı'ya hiçbir şey gelmiyor Varsayılan görünüm yalnızca App Store event'lerini ve ortam bilgisi olmayanları gösterir. Ortam filtresini Tüm build'ler yapın
Uygulama açılışta AsyncStorage veya DeviceInfo'dan gelen bir native modül hatasıyla çöküyor (modül null ya da bulunamadı) Paketler kurulduktan sonra iOS uygulaması yeniden derlenmemiş. npx pod-install çalıştırıp yeniden derleyin ya da yeni bir Expo development build alın. Expo Go react-native-device-info'yu çalıştıramaz
Event'ler gecikmeli ve toplu geliyor Rate limit aşılmış (429) ya da sunucu veya ağ bir süre çalışmamış. Referans kod event'leri saklar ve sonra gönderir
Zamanlar yanlış Device saati çok kaymış. 7 günden eski veya 5 dakikadan ileri zamanların yerine event'in bize ulaştığı zamanı yazarız
Aynı kişi bir funnel'da iki kez sayılıyor reset() yanlış bir yerde çağrılıyor ya da identify aynı kullanıcı için farklı ID'lerle çağrılıyor
Funnel adımı sıfır Adımdaki event adı veya property filtresi birebir eşleşmiyor (adlar ve değerler büyük/küçük harfe duyarlıdır)
Geliştirme sırasında fazladan cold app_opened Fast Refresh modül seviyesindeki kodu tekrar çalıştırmış (§5.1); development event'leri zaten debug etiketlidir

9. Kontrol listesi

Bütün maddeler doğruysa entegrasyon bitmiştir. Raporda her kod maddesi için dosyayı ve satırı yazın. Uymayan bir maddeyi gerekçesiyle uygulanamaz, canlı API gerektiren bir maddeyi beklemede olarak işaretleyin.

  • @react-native-async-storage/async-storage ve react-native-device-info kurulu (pod'lar kurulmuş ya da yeni bir Expo development build alınmış); başka bağımlılık eklenmedi.
  • eventTracker.ts projenin kaynak kodunda §5'teki hâliyle duruyor; iOS uygulaması derleniyor ve açılıyor.
  • configure(...) açılışta, modül seviyesinde, bu uygulamanın gerçek anahtarıyla bir kez çağrılıyor; projede hiçbir yerde YOUR_WRITE_KEY kalmadı.
  • EventTracker.flush(), AppState background'a geçince çağrılıyor.
  • app_opened açılışta (source: "cold") ve arka plandan her dönüşte (source: "warm") izleniyor; her active geçişinde değil.
  • §6'daki event'lerden akışı bu uygulamada olan her biri, tablodaki property'lerle izleniyor. Her birini event → file:line diye raporlayın; atladığınız §6 event'lerini de nedenleriyle yazın.
  • Satın alma event'leri, geri yüklemelerin veya yenilemelerin de düştüğü bir listener'da değil, satın alma akışında, satın alma çağrısının etrafında izleniyor.
  • Her event adı ^[A-Za-z0-9_.:-]{1,64}$ ile eşleşiyor ve §6'daki adlandırma kurallarına uyuyor.
  • Hiçbir track çağrısı ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı metni göndermiyor.
  • Uygulamada hesap sistemi varsa identify oturum açıldıktan sonra uygulamanın kendi kullanıcı ID'siyle, reset de oturum kapatıldıktan sonra çağrılıyor. Hesap sistemi yoksa ikisi de çağrılmıyor.
  • Canlı API: §3'teki curl testi accepted: 1 döndürüyor ve iOS uygulamasını çalıştırınca gelen event'ler Dashboard → Events → Canlı'da görünüyor.
  • Geliştirici: yukarıdaki event ve property listesi geliştiriciye verildi. Geliştirici bunu §7'ye, App Store gizlilik etiketine, uygulamanın gizlilik raporundaki gerekçe isteyen API kayıtlarına (§7.7), gizlilik politikasına ve varsa onay şartına göre kontrol edecek. Uygulama Kids kategorisinde değil.