# App Store Operator event entegrasyon rehberi: Flutter

> **Durum:** v1 taslağı, 2026-10-23. Burada anlatılan API planlanmış durumdadır ve henüz yayında değildir. API yayına girdiğinde bu satır kaldırılır.
>
> **Kimin için:** Bir Flutter uygulamasının iOS build'ine event izleme ekleyen Flutter geliştiricileri ve bu belgeyi verdikleri yapay zekâ kodlama ajanları. Belge kendi içinde eksiksizdir: bir ajan entegrasyonu yalnızca bu dosyayla tamamlayabilir. App Store Operator şimdilik yalnızca iOS uygulamalarını kapsar: tracker Android, web veya masaüstü build'lerinden hiçbir şey göndermez (Android desteği planlanıyor).

---

## Yapay zekâ kodlama ajanları için: önce bunu okuyun

Bir Flutter uygulamasına event izleme ekliyorsunuz; böylece uygulamanın sahibi App Store Operator'da funnel'lar oluşturabilecek. Uygulamanın iOS build'i event'leri `X-Write-Key` header'ıyla `POST https://api.app-store-operator.com/v1/events` adresine gönderecek. Diğer tüm platformlarda tracker hiçbir şey yapmaz.

**Adımlar, sırasıyla**

1. **Yazma anahtarını alın.** Bu belgede hâlâ `YOUR_WRITE_KEY` metni geçiyorsa geliştiriciden anahtarını isteyin (Dashboard → uygulaması → Events → Setup; anahtar `aso_wk_` ile başlar). Asla bir anahtar uydurmayın ve yer tutucuyu kodda bırakmayın.
2. **Tracker'ı ekleyin.** [§5](#5-referans-kod) bölümünde listelenen dört paketi `flutter pub add` ile ekleyin, ardından §5'teki dosyayı uygulamaya `lib/event_tracker.dart` olarak hiç değiştirmeden kopyalayın. iOS uygulamasının native uzantıları (widget, paylaşım, bildirim uzantıları) varsa bunlardan izleme yapmayın.
3. **Uygulamaya bağlayın**, [§5.1](#51-uygulamaya-bağlama) bölümündeki gibi: `main()` içinde, `WidgetsFlutterBinding.ensureInitialized()` çağrısından sonra `configure(writeKey: ...)` metodunu bir kez çağırın ve `app_opened` event'ini izleyin; ardından uygulama arka plana geçtiğinde `flush()` çağırın ve arka plandan her dönüşte `app_opened` event'ini yeniden izleyin.
4. **`track(...)` çağrılarını ekleyin**: [§6](#6-hangi-event-izlenmeli) içindeki event'lerden, akışı bu uygulamada bulunan her biri için. Akışı bulunmayan event'leri atlayın.
5. **`identify` ve `reset` çağrılarını** yalnızca uygulamanın kendi oturum açma sistemi varsa ekleyin ([§5.2](#52-event-takibi)).
6. **Bitirirken** [§9 Tamamlanma kontrol listesi](#9-tamamlanma-kontrol-listesi)'ni uygulayın: her maddeyi tamamlandı, uygulanamaz veya beklemede olarak, bunu gösteren dosya ve satırla birlikte raporlayın.

**Kurallar**

- `lib/event_tracker.dart` dosyasının davranışını (paketleme, yeniden denemeler, endpoint, kuyruk, yalnızca iOS kontrolü) değiştirmeyin. Uygulamaya uymuyorsa yeniden yazmak yerine nedenini raporlayın.
- §5'teki dört paket (`path_provider`, `shared_preferences`, `package_info_plus`, `device_info_plus`) dışında hiçbir üçüncü taraf bağımlılık eklemeyin. Uygulama bunlardan birini zaten kullanıyorsa onu koruyun; bir sürüm çakışması varsa `flutter pub add` bunu bildirir.
- Tracker'ın Android, web veya masaüstünde çalışmasını sağlamayın. App Store Operator şimdilik yalnızca iOS install'larını sayar.
- Kişiyi doğrudan tanımlayan veri göndermeyin: ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı metin ([§7](#7-gizlilik)). Uygulama App Store'un Çocuklar (Kids) kategorisindeyse durun ve geliştiriciye söyleyin: bu tracker eklenmemelidir ([§7.8](#78-çocuklara-yönelik-uygulamalar)). Uygulama analitik için kullanıcılardan onay istiyorsa, izlemenin bu onayı beklemesi gerekip gerekmediğini geliştiriciye sorun ([§7.5](#75-gizlilik-politikanız-ve-kullanıcı-onayı)).
- Özellik değerleri düzdür: `String`, `int`, `double`, `bool` veya `null`. İç içe map veya liste yoktur ([§4.3](#43-event-nesnesi)); tracker diğer tüm değerleri atar.
- §6'daki event adlarını tam olarak yazıldığı gibi kullanın. §6'nın kapsamadığı bir akış için event adı vermeden önce geliştiriciye sorun.
- `identify` çağrısını yalnızca uygulamanın kendi kalıcı dahili kullanıcı kimliğiyle yapın; asla e-posta, telefon numarası veya adla yapmayın. Bunun hangi kimlik olduğunu anlayamıyorsanız sorun.
- Bu dosyanın en üstündeki durum satırı bunu söylediği sürece **API henüz yayında değildir**. Bu durumda her şeyi kurup bağlayın, ancak §9'daki ağ kontrollerini beklemede olarak raporlayın; başarısız bir istek kodunuzdaki bir hata değildir.

Yazma anahtarı tasarım gereği herkese açıktır (yalnızca tek bir uygulama için event yazabilir), bu nedenle kaynak kodda durabilir.

---

## 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` (1–100 event'lik paket) |
| Kullanıcı bağlama | Bir event hem `anonymous_id` hem `user_id` taşıdığında otomatik; ya da `POST /v1/identify` |
| İçerik türü | `application/json`, UTF-8 |
| Hız sınırı | Yazma anahtarı başına dakikada 10.000 event (tüm install'lar toplamı) ve install (`anonymous_id`) başına dakikada 300 event |
| Referans kod için minimum sürümler | Dart 3.11 ve Flutter 3.41 (§5'teki paket sürümlerinin gereği); iOS 13. Event'leri yalnızca iOS'ten gönderir |
| Veri saklama süresi | 13 ay |

## 2. Nasıl çalışır

1. Uygulamanız ilk açılışta rastgele bir **anonim kimlik** oluşturur ve onu saklar.
2. Önemsediğiniz her eylem bir **event** olur: bir ad, bir zaman, isteğe bağlı özellikler ve bazı device bağlamı bilgileri.
3. Event'ler device'ta kuyruğa alınır ve **paketler** hâlinde gönderilir. Device çevrimdışıysa event'ler bekler ve daha sonra gönderilir.
4. Kullanıcı *sizin* uygulamanızda oturum açtığında bir **kullanıcı kimliği** atayabilirsiniz. O andan itibaren event'ler iki kimliği de taşır ve App Store Operator önceki anonim event'lerle sonraki oturum açılmış event'leri tek bir kişi olarak değerlendirir.
5. Dashboard'da event'lerin canlı olarak geldiğini görür ve bunlardan **funnel'lar** oluşturursunuz: `onboarding_started → paywall_viewed → purchase_completed` gibi, tekil kişi sayısıyla sayılan sıralı adımlar.

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

1. App Store Operator Dashboard'unu açın → **Uygulamalar** → uygulamanız → **Events** → **Setup**.
2. Yazma anahtarını kopyalayın (`aso_wk_` ile başlar).
3. Anahtarı bir terminalden (macOS veya Linux) test edin. Komut her çalıştırmada yeni bir `event_id` ve o anki zamanı üretir, bu yüzden tekrar çalıştırabilirsiniz:

```bash
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" }
    }]
  }'
```

Beklenen yanıt: `accepted` değeri `1`, `rejected` boş. Event birkaç saniye içinde **Events → Canlı** altında görünür.

Neden yeni değerler: tekrarlanan bir `event_id` yalnızca bir kez saklanır, bu yüzden aynı kimlikle ikinci çalıştırma Canlı'da yeni bir şey göstermez; 7 günden eski bir zaman ise event'i aldığımız zamanla değiştirilir.

### 3.1 Anahtarı yenileme

Anahtarı yalnızca kötüye kullanılıyorsa yenileyin; örneğin biri anahtarla sahte event'ler gönderiyorsa. Anahtar zaten herkese açıktır, bu yüzden uygulamanızda görünür olması yenileme nedeni değildir.

1. Setup → **Anahtarı yenile**. Bunu çalışma alanının her üyesi yapabilir ve işlem kayda geçer.
2. Yeni anahtarı uygulamanıza koyun ve bir güncelleme yayınlayın.
3. Eski anahtar **30 gün** daha çalışır; böylece kullanıcılarınızın güncellemeye vakti olur. Bu süreden sonra hâlâ eski anahtarı kullanan build'ler `401` alır ve referans kod bunların event'lerini atar. Bu build'leri kullanan kişiler güncelleyene kadar sayılmaz.
4. Eski anahtar hâlâ kötüye kullanılıyorsa Setup → **Eski anahtarı şimdi iptal et** 30 günlük süresini hemen bitirir. O andan itibaren eski anahtarı kullanan build'ler sayılmaz. Bunu çalışma alanının her üyesi yapabilir ve işlem kayda geçer.
5. Eski bir anahtar 30 günlük süresi içindeyken **Anahtarı yenile** devre dışıdır. Yeniden yenilemek için önce eski anahtarı iptal edin veya 30 gününün dolmasını bekleyin.

## 4. API referansı

### 4.1 `POST /v1/events`

1 ile 100 arasında 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övdenin tamamı en fazla **1 MB** olmalıdır. Gövdedeki veya bir event'teki bilinmeyen alanlar yok sayılır.

### 4.2 Yanıt

Paketin kendisi geçerli olduğu sürece, bazı event'ler reddedilmiş olsa bile `200 OK`:

```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ış yinelenen event'ler de dahil (bkz. `event_id`).
- `rejected`: saklanmayan ve değiştirilmeden yeniden denenmemesi gereken event'ler.
- `clamped`: `occurred_at` değeri aralık dışında olduğu için sunucunun alış zamanıyla değiştirilen event'ler. Bunlar saklanır ve `accepted` içinde sayılır.

Üç alan da her zaman bulunur; `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önderilirken değil, event oluşturulurken üretin; böylece yeniden denenen bir paket iki kez sayılmaz. |
| `name` | string | evet | `^[A-Za-z0-9_.:-]{1,64}$`. Bir `$` ve ardından bu karakterlerden 1–63 tanesi (ör. `$session_start`) App Store Operator'a ayrılmıştır ve `reserved_name` ile reddedilir; geçersiz diğer her ad `invalid_name` olur. `snake_case` `object_action` biçimini kullanın, ör. `paywall_viewed`. |
| `anonymous_id` | string | evet | 1–128 karakter. Her install için sabit. |
| `user_id` | string veya null | hayır | 1–128 karakter. Kendi kalıcı kullanıcı kimliğiniz. Asla e-posta veya telefon numarası değil. |
| `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 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`. **İç içe nesne veya dizi olmaz.** |
| `context` | object | hayır | Aşağıya bakın. Önerilir: 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 tanımlayıcısı, 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 başka bir seçim yapmadıkça Dashboard yalnızca `app_store` event'lerini (ve bu alanı olmayan event'leri) gösterir |

Her `context` değeri en fazla 64 karakterlik bir string'dir. Bu kurala uymayan bir değer ve bu tabloda olmayan her anahtar, event reddedilmeden atılır.

Sunucu, isteğin ağ konumundan `country` alanını ekler. IP adresinin kendisi asla saklanmaz.

Serileştirilmiş bir event en fazla **8 KB** olmalıdır.

**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 kimliği bir kullanıcı kimliğine bağlar. Buna nadiren ihtiyaç duyarsınız: iki kimliği de taşıyan her event aynı işi yapar. Bağlantı sunucunuzda biliniyorsa kullanın.

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

Yanıt: `204 No Content`. Bir anonim kimlik için ilk bağlantı geçerli olur; aynı anonim kimlik için sonradan gelen farklı kullanıcı kimlikleri yok sayılır.

### 4.5 Hatalar

| Durum | `code` | Anlamı | İstemcinin yapacağı |
|---|---|---|---|
| 400 | `invalid_body` | Gövde geçerli bir JSON veya `{ "events": [...] }` değil, ya da 0 / 100'den fazla event var | Kodu düzeltin; yeniden denemeyin |
| 401 | `invalid_write_key` | Eksik, bilinmeyen veya iptal edilmiş anahtar | Yeniden denemeyin; paketi atın ve anahtarı kontrol edin |
| 413 | `payload_too_large` | Gövde 1 MB'tan büyük | Daha küçük paketler gönderin |
| 429 | `rate_limited` | Anahtar için dakikada 10.000 event ya da tek bir `anonymous_id` için 300 event aşıldı. İstekteki hiçbir şey saklanmadı | Paketi tutun; `Retry-After` saniye sonra yeniden deneyin |
| 5xx | `server_error` | Bizden kaynaklı hata | Paketi tutun; artan bekleme (backoff) ile yeniden deneyin |

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

**Event bazında ret kodları** (`200` yanıtının içinde):

| Kod | Ne zaman |
|---|---|
| `invalid_event` | `events` içindeki öğ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, ayrıştırılamıyor veya saat dilimi içermiyor. (Aralık dışında olmak ret nedeni değildir: bkz. `clamped`) |
| `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 özellik |
| `event_too_large` | Serileştirilmiş event 8 KB'tan büyük |

**Tek satırda yeniden deneme kuralı:** ağ hatasında, `429` ve `5xx` durumlarında aynı `event_id`'leri koruyarak yeniden deneyin; diğer her durumda paketi atın.

## 5. Referans kod

Tek bir Dart dosyası. Uygulamaya `lib/event_tracker.dart` olarak ekleyin ve hiç değiştirmeden kullanın. Dört pakete ihtiyaç duyar, başka hiçbirine değil:

```bash
flutter pub add path_provider shared_preferences package_info_plus device_info_plus
```

`flutter pub add` en son sürümleri seçer. Kod şu sürümlerle test edildi (Flutter 3.44, Dart 3.12):

```yaml
dependencies:
  path_provider: ^2.1.6
  shared_preferences: ^2.5.6
  package_info_plus: ^10.2.2
  device_info_plus: ^13.3.0
```

| Paket | Kullanım amacı |
|---|---|
| `path_provider` | Kuyruk dosyası, uygulama destek dizininde |
| `shared_preferences` | Anonim kimlik, kullanıcı kimliği ve açık/kapalı ayarı (iOS'te `UserDefaults`) |
| `package_info_plus` | `app_version` ve `build` |
| `device_info_plus` | `os_version` ve `device_model` |

İstekler `dart:io` içindeki `HttpClient` üzerinden gider, bu nedenle HTTP paketine gerek yoktur.

Ne yapar:

- **Yalnızca iOS.** Android, web ve masaüstünde her metot hemen döner ve hiçbir şey yapmaz: kimlik yok, dosya yok, istek yok. App Store Operator şimdilik iOS uygulamalarını kapsar ve bu, Android install'larını App Store funnel'larınızın dışında tutar. Android desteği planlanıyor.
- İlk izlenen event'le birlikte rastgele bir anonim kimlik (`Random.secure()` ile üretilen sürüm 4 UUID) oluşturur ve saklar (`shared_preferences` içinde). `currentAnonymousId` bu kimliği döndürür.
- Açılışlar arasında hatırlanan ve varsayılan olarak açık olan bir açma/kapama anahtarı vardır: `setEnabled(...)`. Kapatmak kimlikleri ve gönderilmemiş tüm event'leri siler; yeniden açılana kadar `track` hiçbir şey yapmaz.
- Her event'i diskteki bir kuyruk dosyasına kaydeder, böylece event'ler uygulama kapatılsa da kaybolmaz.
- Kuyruğu en fazla 100 event'lik paketler hâlinde gönderir: her 30 saniyede bir, 20 veya daha fazla event beklediğinde ve siz `flush()` çağırdığınızda.
- Çevrimdışı veya `5xx`: kuyruğu tutar ve yeniden denemeden önce bekler: 30 saniye, sonra 60, 120… en fazla 10 dakikaya kadar. `429`: kuyruğu tutar ve sunucunun `Retry-After` değeri ne kadar diyorsa o kadar bekler. Diğer her yanıt: paketi kuyruktan çıkarır.
- En fazla 1.000 event tutar; bu sınır aşılınca en eskiler atılır.
- Yalnızca debug build'lerinde (`kDebugMode`): reddedilen ve sınırlanan (clamped) event'leri, atılan paketleri ve attığı özellik değerlerini `debugPrint` ile Flutter konsoluna `[EventTracker] …` biçiminde yazar. Release build'leri hiçbir şey yazmaz.
- Debug ve profile build'lerinden gelen event'leri `debug` olarak işaretler, böylece test çalıştırmaları funnel'larınızın dışında kalır. Release build'leri `configure` metoduna verdiğiniz `environment` değerini taşır ya da hiç taşımaz (aşağıdaki **Test build'leri** bölümüne bakın).
- `track` hemen döner ve asla ağı beklemez. `configure`, `track`, `identify`, `reset` ve `setEnabled` çağırdığınız sırayla etkili olur, bu nedenle sıralama için bunları `await` etmeniz gerekmez.

`track`, `configure` çağrılmadan önce de çalışır; event'ler bir anahtar ayarlanana kadar kuyrukta bekler.

**Bir uygulama, bir anahtar.** Dashboard'daki her uygulamanın kendi yazma anahtarı vardır. Her anahtarı yalnızca o uygulamanın binary'sinde kullanın.

**Uygulama uzantılarında kullanılmaz.** iOS uygulamanızın native uzantıları (widget, paylaşım, bildirim uzantıları) varsa bunlardan izleme yapmayın. Uzantılar kendi depolama alanlarını kullanır, bu nedenle her biri kendi anonim kimliğini alır ve ayrı bir kişi olarak sayılır. Yalnızca Flutter uygulamasından izleyin.

**Test build'leri.** Bir Flutter release build'i native kod olmadan TestFlight'ı App Store'dan ayırt edemez. Bu nedenle `configure` isteğe bağlı bir `environment` alır: `"testflight"` veya `"app_store"` (diğer tüm değerler yok sayılır; debug ve profile build'leri her zaman `debug` olur). [§5.1](#51-uygulamaya-bağlama) kodu bu değeri build sırasında verilen bir define'dan okur:

```bash
flutter build ipa --dart-define=ASO_ENV=testflight
```

Define olmadan release event'leri hiçbir ortam taşımaz ve Dashboard bunları, TestFlight test kullanıcıları dahil, App Store event'i olarak sayar. Apple, TestFlight'a yüklediğiniz build'in aynısını yayınlar; bu nedenle `ASO_ENV=testflight` ile yapılmış bir build, daha sonra App Store'da yayınlarsanız bu işareti korur ve App Store kullanıcıları Dashboard'un varsayılan görünümünden kaybolur. Define'ı yalnızca hiçbir zaman yayınlanmayacak build'ler için kullanın, örneğin ayrı bir dahili test build'i; aksi hâlde kullanmayın.

```dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';

import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// Event'leri App Store Operator'a gönderir. Belgeler: https://app-store-operator.com/docs/events/flutter
///
/// Yalnızca iOS: Android, web ve masaüstünde hiçbir metot bir şey yapmaz.
class EventTracker {
  EventTracker._();

  static final EventTracker shared = EventTracker._();

  static final Uri _endpoint = Uri.parse('https://api.app-store-operator.com/v1/events');
  static const int _maxBatch = 100;
  static const int _maxQueue = 1000;
  static const int _flushAt = 20;
  static const String _anonymousIdKey = 'aso.events.anonymousId';
  static const String _userIdKey = 'aso.events.userId';
  static const String _disabledKey = 'aso.events.disabled';
  static const Set<String> _environments = {'debug', 'testflight', 'app_store'};

  static final bool _supported = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
  static final Random _random = Random.secure();

  late final Future<void> _ready = _load(); // ilk çağrıyla başlar
  final HttpClient _http = HttpClient()..connectionTimeout = const Duration(seconds: 30);

  SharedPreferencesWithCache? _prefs;
  File? _store;
  Map<String, String> _context = const {};
  String? _writeKey;
  String? _environment;
  String? _anonymousId;
  String? _userId;
  bool _enabled = true;
  List<Map<String, Object?>> _queue = [];
  bool _flushing = false;
  Timer? _timer;
  DateTime? _retryAt;
  int _backoff = 0; // saniye
  Future<void>? _writing;
  bool _dirty = false;
  bool _warned = false;

  /// Açılışta, `WidgetsFlutterBinding.ensureInitialized()` sonrasında bir kez çağırın.
  ///
  /// Debug ve profile build'leri `debug` olarak işaretlenir. Release build'leri, verilirse [environment]
  /// (`testflight` veya `app_store`) ile işaretlenir; verilmezse ortam taşımaz.
  void configure({required String writeKey, String? environment}) {
    if (!_isSupported()) return;
    _writeKey = writeKey;
    if (!kReleaseMode) {
      _environment = 'debug';
    } else if (environment != null && _environments.contains(environment)) {
      _environment = environment;
    }
    _timer ??= Timer.periodic(const Duration(seconds: 30), (_) => flush());
    unawaited(_ready);
  }

  /// 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.
  Future<String?> get currentAnonymousId async {
    if (!_isSupported()) return null;
    await _ready;
    return _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.
  Future<void> setEnabled(bool enabled) async {
    if (!_isSupported()) return;
    await _ready;
    _enabled = enabled;
    unawaited(_prefs?.setBool(_disabledKey, !enabled));
    if (enabled) return;
    _anonymousId = null;
    _userId = null;
    _queue = [];
    unawaited(_prefs?.remove(_anonymousIdKey));
    unawaited(_prefs?.remove(_userIdKey));
    _persist(); // boş kuyruk dosyayı siler
  }

  /// Bir event kaydeder. Hafiftir; hemen döner ve asla ağı beklemez.
  /// İzleme kapalıyken hiçbir şey yapmaz. Özellik değerleri: String, int, double, bool veya null.
  void track(String name, [Map<String, Object?> properties = const {}]) {
    if (!_isSupported()) return;
    final eventId = _uuid();
    final occurredAt = _now();
    final props = _flat(name, properties);
    unawaited(_ready.then((_) {
      if (!_enabled) return;
      _queue.add({
        'event_id': eventId,
        'name': name,
        'anonymous_id': _anonymousId ?? _newAnonymousId(),
        if (_userId != null) 'user_id': _userId,
        'occurred_at': occurredAt,
        'properties': props,
        'context': _context,
      });
      if (_queue.length > _maxQueue) _queue.removeRange(0, _queue.length - _maxQueue);
      _persist();
      if (_queue.length >= _flushAt) unawaited(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.
  Future<void> identify(String userId) async {
    if (!_isSupported()) return;
    await _ready;
    if (!_enabled) return;
    _userId = userId;
    unawaited(_prefs?.setString(_userIdKey, userId));
  }

  /// Oturum kapatılırken çağırın: sonraki event'ler yeni bir anonim kişiye ait olur.
  Future<void> reset() async {
    if (!_isSupported()) return;
    await _ready;
    _userId = null;
    _anonymousId = null;
    unawaited(_prefs?.remove(_userIdKey));
    unawaited(_prefs?.remove(_anonymousIdKey));
  }

  /// Kuyruktaki event'leri gönderir. Bir yeniden deneme beklemesi sürerken hiçbir şey yapmaz.
  Future<void> flush() async {
    if (!_isSupported()) return;
    await _ready;
    final writeKey = _writeKey;
    if (!_enabled || _flushing || writeKey == null || _queue.isEmpty) return;
    final retryAt = _retryAt;
    if (retryAt != null && retryAt.isAfter(DateTime.now())) return;
    _flushing = true;
    try {
      while (_queue.isNotEmpty) {
        final batch = _queue.take(_maxBatch).toList();
        final events = [
          for (final event in batch)
            {
              ...event,
              'context': {
                if (_environment != null) 'environment': _environment,
                ...(event['context'] as Map? ?? const {}),
              },
            },
        ];
        final response = await _post(writeKey, jsonEncode({'events': events}));
        if (response == null) {
          _delayRetry(null); // çevrimdışı: kuyruğu tut
          return;
        }
        if (!_enabled) return; // gönderim sırasında kapatıldı: kuyruk zaten silindi
        final (status, retryAfter, body) = response;
        if (status == 429 || status >= 500) {
          _delayRetry(status == 429 ? double.tryParse(retryAfter ?? '') : null); // kuyruğu tut
          return;
        }
        _retryAt = null;
        _backoff = 0;
        if (kDebugMode) _report(status, body);
        // 2xx: saklandı. Diğer 4xx: aynı paketi yeniden denemek başarılı olamaz.
        final sent = {for (final event in batch) event['event_id']};
        _queue.removeWhere((event) => sent.contains(event['event_id']));
        _persist();
      }
    } finally {
      _flushing = false;
    }
  }

  Future<void> _load() async {
    try {
      final prefs = await SharedPreferencesWithCache.create(
        cacheOptions: const SharedPreferencesWithCacheOptions(
          allowList: {_anonymousIdKey, _userIdKey, _disabledKey},
        ),
      );
      _prefs = prefs;
      _enabled = !(prefs.getBool(_disabledKey) ?? false);
      _anonymousId = prefs.getString(_anonymousIdKey); // ilk izlenen event'le oluşturulur
      _userId = prefs.getString(_userIdKey);
    } catch (error) {
      _debug('settings unavailable, keeping IDs in memory: $error');
    }

    try {
      final dir = await getApplicationSupportDirectory();
      await dir.create(recursive: true);
      final store = File('${dir.path}/aso-events-queue.json');
      _store = store;
      if (await store.exists()) {
        final saved = jsonDecode(await store.readAsString()) as List<dynamic>;
        _queue = [for (final event in saved) Map<String, Object?>.from(event as Map)];
      }
    } catch (error) {
      _debug('queue file unavailable, keeping events in memory: $error');
    }

    var appVersion = '', build = '', osVersion = '', deviceModel = '';
    try {
      final package = await PackageInfo.fromPlatform();
      appVersion = package.version;
      build = package.buildNumber;
    } catch (_) {}
    try {
      final ios = await DeviceInfoPlugin().iosInfo;
      osVersion = ios.systemVersion;
      deviceModel = ios.utsname.machine;
    } catch (_) {}
    _context = {
      'app_version': appVersion,
      'build': build,
      'os_version': osVersion,
      'device_model': deviceModel,
      'locale': PlatformDispatcher.instance.locale.toString(), // en_US
    };
  }

  Future<(int, String?, String)?> _post(String writeKey, String body) async {
    try {
      return await () async {
        final request = await _http.postUrl(_endpoint);
        request.headers.contentType = ContentType.json;
        request.headers.set('X-Write-Key', writeKey);
        request.add(utf8.encode(body));
        final response = await request.close();
        final text = await response.transform(utf8.decoder).join();
        return (response.statusCode, response.headers.value('retry-after'), text);
      }().timeout(const Duration(seconds: 60));
    } catch (_) {
      return null;
    }
  }

  /// Sunucu belirtirse `seconds` kadar, aksi hâlde 30 sn, 60 sn, 120 sn… en fazla 10 dakika bekler.
  void _delayRetry(double? seconds) {
    _backoff = min(max(_backoff * 2, 30), 600);
    final wait = Duration(milliseconds: ((seconds ?? _backoff) * 1000).round());
    _retryAt = DateTime.now().add(wait);
  }

  /// Kuyruğu diske her seferinde tek bir yazmayla yazar; son yazma her zaman en güncel kuyruğu içerir.
  void _persist() {
    if (_store == null) return;
    _dirty = true;
    _writing ??= _write();
  }

  Future<void> _write() async {
    while (_dirty) {
      _dirty = false;
      final store = _store!;
      try {
        if (_queue.isEmpty) {
          if (await store.exists()) await store.delete();
        } else {
          final temp = File('${store.path}.tmp');
          await temp.writeAsString(jsonEncode(_queue), flush: true);
          await temp.rename(store.path);
        }
      } catch (_) {}
    }
    _writing = null;
  }

  String _newAnonymousId() {
    final id = _uuid();
    _anonymousId = id;
    unawaited(_prefs?.setString(_anonymousIdKey, id));
    return id;
  }

  /// String, int, sonlu double, bool ve null değerleri tutar; diğer her şeyi atar.
  Map<String, Object?> _flat(String name, Map<String, Object?> properties) {
    final result = <String, Object?>{};
    properties.forEach((key, value) {
      if (value == null || value is String || value is int || value is bool ||
          (value is double && value.isFinite)) {
        result[key] = value;
      } else {
        _debug('$name: property "$key" dropped, ${value.runtimeType} is not a flat value');
      }
    });
    return result;
  }

  /// Yalnızca debug build'lerde: atılan paketleri ve reddedilen veya sınırlanan event'leri Flutter konsoluna yazdırır.
  void _report(int status, String body) {
    Object? json;
    try {
      json = jsonDecode(body);
    } catch (_) {}
    if (status < 200 || status >= 300) {
      _debug('HTTP $status, batch dropped: ${json ?? body}');
      return;
    }
    if (json is! Map) return;
    for (final key in ['rejected', 'clamped']) {
      final items = json[key];
      if (items is List && items.isNotEmpty) _debug('$key: $items');
    }
  }

  bool _isSupported() {
    if (_supported) return true;
    if (!_warned) {
      _warned = true;
      _debug('iOS only: tracking is off on ${kIsWeb ? 'web' : defaultTargetPlatform.name}');
    }
    return false;
  }

  static void _debug(String message) {
    if (kDebugMode) debugPrint('[EventTracker] $message');
  }

  static String _now() {
    final now = DateTime.now().millisecondsSinceEpoch;
    return DateTime.fromMillisecondsSinceEpoch(now, isUtc: true).toIso8601String();
  }

  /// Rastgele (sürüm 4) bir UUID.
  static String _uuid() {
    final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
    bytes[6] = (bytes[6] & 0x0f) | 0x40;
    bytes[8] = (bytes[8] & 0x3f) | 0x80;
    final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
    return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-'
        '${hex.substring(16, 20)}-${hex.substring(20)}';
  }
}
```

### 5.1 Uygulamaya bağlama

```dart
import 'dart:async';

import 'package:flutter/widgets.dart';

import 'event_tracker.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  EventTracker.shared.configure(
    writeKey: 'YOUR_WRITE_KEY',
    environment: const String.fromEnvironment('ASO_ENV'), // bkz. §5'teki "Test build'leri"
  );
  EventTracker.shared.track('app_opened', {'source': 'cold'});

  var wasInBackground = false;
  // Uygulama çalıştığı sürece kayıtlı kalır; referans tutmanız gerekmez.
  AppLifecycleListener(
    onHide: () {
      // Uygulama arka plana geçiyor: kuyruktakileri gönder.
      wasInBackground = true;
      unawaited(EventTracker.shared.flush());
    },
    onResume: () {
      if (!wasInBackground) return; // uyarılar, Denetim Merkezi ve Face ID uygulamayı yalnızca etkin olmayan duruma getirir
      wasInBackground = false;
      EventTracker.shared.track('app_opened', {'source': 'warm'});
    },
  );

  runApp(const MyApp());
}
```

`main()` zaten başka kurulum işleri yapıyorsa bunları koruyun ve bu çağrıları `WidgetsFlutterBinding.ensureInitialized()` sonrasına ve `runApp` öncesine koyun: tracker, saklanan kimliklerini ve device bilgilerini binding'e ihtiyaç duyan eklentiler üzerinden okur. `AppLifecycleListener` Flutter 3.13 veya üzerini gerektirir. Uygulamada zaten bir `WidgetsBindingObserver` varsa onun yerine onu kullanabilirsiniz: `AppLifecycleState.hidden` durumunda `flush()` çağırın ve sonraki `resumed` durumunda warm `app_opened` event'ini izleyin.

`app_opened` her resume'da değil, yalnızca arka plandan dönüşte gönderilir: sistem uyarıları, Denetim Merkezi ve Face ID de uygulamayı gizlemeden etkin olmayan duruma getirip yeniden etkinleştirir.

**Arka plan süresi.** iOS, arka plana geçen bir uygulamaya birkaç saniye tanır; bu genellikle `flush()` işleminin bitmesi için yeterlidir. Bir Flutter uygulaması native kod olmadan iOS'ten daha fazla süre isteyemez; bu nedenle uygulama önce askıya alınırsa event'ler kuyruk dosyasında kalır ve bir sonraki açılışta gönderilir.

### 5.2 Event takibi

```dart
EventTracker.shared.track('paywall_viewed', {'placement': 'onboarding'});
EventTracker.shared.track('onboarding_completed', {'duration_s': 42});

// Değişkenler de aynı şekilde çalışır: String, int, double, bool veya null değerler.
EventTracker.shared.track('onboarding_step_viewed', {'step': step, 'step_name': stepName});

// Uygulamanızda oturum açma / kapatmadan sonra:
await EventTracker.shared.identify(user.id);
await EventTracker.shared.reset();

// Onay veya uygulama içi "Analitik verilerini paylaş" anahtarı: açılışta, configure ve ilk track çağrısından önce,
// kullanıcının mevcut seçimiyle çağırın; seçim her değiştiğinde yeniden çağırın.
await EventTracker.shared.setEnabled(userAllowsAnalytics);

// Destek ekranı: kullanıcının verilerinin silinmesini isteyebilmesi için bunu gösterin (henüz hiçbir şey izlenmediyse null).
final installId = await EventTracker.shared.currentAnonymousId;
```

İzleme onayı bekliyorsa, `setEnabled` çağrısını [§5.1](#51-uygulamaya-bağlama) içindeki açılış `app_opened` event'inden önce yapın; izleme kapalıyken o çağrı hiçbir şey yapmaz ve hiçbir kimlik oluşturulmaz.

Özellik değerleri `String`, `int`, `double`, `bool` veya `null` olmalıdır. Tracker diğer tüm değerleri (bir `DateTime`, bir enum, iç içe bir map veya liste, `double.nan`) atar ve debug build'inde `[EventTracker] … dropped` yazar. Önce dönüştürün: `date.toIso8601String()`, `plan.name`.

**Satın almalar.** Satın almaları, kullanıcının satın al düğmesine dokunduğu satın alma akışında izleyin. Satın alma çağrınızı şu sarmalayıcıyla sarın:

```dart
/// Satın almanın nasıl sonuçlandığı, satın alma kodunuzun bildirdiği şekliyle.
enum PurchaseOutcome { success, cancelled, pending, failed }

/// Satın alma çağrınızı sarar. [purchase] onu çalıştırır ve nasıl sonuçlandığını bildirir.
Future<void> buy({
  required String productId,
  required double price,
  required String currency,
  required bool startsTrial,
  required Future<PurchaseOutcome> Function() purchase,
}) async {
  final base = {'product_id': productId};
  EventTracker.shared.track('purchase_started', base);
  PurchaseOutcome outcome;
  try {
    outcome = await purchase();
  } catch (_) {
    outcome = PurchaseOutcome.failed;
  }
  switch (outcome) {
    case PurchaseOutcome.success:
      EventTracker.shared.track('purchase_completed', {
        ...base,
        'price': price,
        'currency': currency,
        'trial': startsTrial,
      });
      if (startsTrial) EventTracker.shared.track('trial_started', base);
    case PurchaseOutcome.cancelled:
      EventTracker.shared.track('purchase_failed', {...base, 'reason': 'cancelled'});
    case PurchaseOutcome.failed:
      EventTracker.shared.track('purchase_failed', {...base, 'reason': 'failed'});
    case PurchaseOutcome.pending:
      break; // Ask to Buy veya ek onay: henüz tamamlanmadı, hiçbir şey izlemeyin
  }
}
```

`purchase`, mağaza kütüphanenizin satın alma metodunu saran kendi fonksiyonunuzdur. Satın alma gerçekleşip doğrulandığında `success`, kullanıcı iptal ettiğinde `cancelled`, Ask to Buy veya başka bir onay için `pending`, diğer durumlarda `failed` döndürür (hata fırlatmak `failed` sayılır). `price` (biçimlendirilmiş bir string değil, bir sayı) ve `currency` (`"USD"` gibi bir ISO 4217 kodu) ürün bilgilerinizden gelir; `startsTrial` ise paywall'unuzun zaten bildiği değerdir: bu satın alma ücretsiz bir trial başlatıyorsa true.

- **`in_app_purchase`:** `buyNonConsumable` ve `buyConsumable` yalnızca satın almayı başlatır; sonuç `purchaseStream` üzerinden gelir. `purchase` fonksiyonunuz satın almayı başlatsın ve o ürün için ilk güncellemeyi beklesin: `PurchaseStatus.purchased` `success`, `canceled` `cancelled`, `pending` `pending`, `error` ise `failed` demektir. Fiyat ve para birimi `ProductDetails.rawPrice` ve `currencyCode` değerleridir.
- **RevenueCat (`purchases_flutter`):** satın alma metotları, satın alma başarılı olduğunda döner, olmadığında bir `PlatformException` fırlatır. `PurchasesErrorCode.purchaseCancelledError` kodunu `cancelled`, `paymentPendingError` kodunu `pending`, diğer hataları `failed` olarak eşleyin.
- **Diğer kütüphaneler:** aynı çağrıları kütüphanenin satın alma metodunun etrafına koyun.

Satın almaları, `in_app_purchase` `purchaseStream` işleyiciniz gibi her işlemi alan bir dinleyiciden izlemeyin: bu dinleyici geri yüklenen satın almaları (`PurchaseStatus.restored`), yenilemeleri ve sonraki bir açılışta tamamlanan satın almaları da iletir; bunlar yeni satın alma olarak sayılır.

## 6. Hangi event izlenmeli

Funnel'lar, arkalarındaki event'ler kadar iyidir. Aşağıdaki adımlarla başlayın, sorular ortaya çıktıkça yenilerini ekleyin.

**Adlandırma kuralları**

- `snake_case`, `object_action`, geçmiş zaman: `paywall_viewed`, `purchase_completed`.
- Her eylem için tek bir ad. Varyasyonları addaki değil (`settings_paywall_viewed`), özelliklerde tutun (`placement: "settings"` ile `paywall_viewed`).
- Ada asla kimlik, tarih veya kullanıcı verisi koymayın.
- Bir funnel'ı bir özelliğe göre bölmek istediğiniz yerde, o özelliğe serbest metin değil kısa bir sabit değer listesi verin (`plan: "yearly"`). Dashboard en çok görülen 10 değeri gösterir, gerisini "Diğer" altında toplar.
- Adlar ve özellik değerleri büyük/küçük harfe duyarlıdır: `Paywall_Viewed` ve `paywall_viewed` iki farklı event'tir.

**Önerilen başlangıç seti**

Akışı uygulamanızda bulunan event'leri izleyin; gerisini atlayın. *isteğe bağlı* olarak işaretlenen özellikler gönderilmeyebilir.

| Event | Ne zaman | Özellikler |
|---|---|---|
| `app_opened` | Uygulama açılışı ve arka plandan her dönüş ([§5.1](#51-uygulamaya-bağlama) bağlantısı bunu gönderir) | `source` (`"cold"` açılış, `"warm"` dönüş) |
| `onboarding_started` | İlk onboarding ekranı gösterildi | |
| `onboarding_step_viewed` | İlki dahil her onboarding ekranı | `step` (sayı, 1'den başlar), `step_name` |
| `onboarding_completed` | Son onboarding adımı tamamlandı | `duration_s` *isteğe bağlı* (sayı) |
| `signup_completed` | Uygulamanızda hesap oluşturuldu | `method` (`"apple"`, `"google"`, `"email"`) |
| `paywall_viewed` | Paywall gösterildi | `placement` (nerede gösterildiği: `"onboarding"`, `"settings"`, `"feature_gate"`), `paywall_id` *isteğe bağlı* |
| `purchase_started` | Kullanıcı satın al düğmesine dokundu | `product_id` |
| `purchase_completed` | Satın alma başarılı ve doğrulandı, satın alma akışında ([§5.2](#52-event-takibi)). Ücretsiz trial başlatan satın almalar da dahildir | `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) ikisi de değildir: hiçbir şey izlemeyin | `product_id`, `reason` (`"cancelled"`, `"failed"`) |
| `trial_started` | Bir satın alma ücretsiz trial başlattığında `purchase_completed` event'ine **ek olarak** gönderilir | `product_id` |
| `purchase_restored` | Satın almaları geri yükleme etkin bir subscription buldu | `product_id` *isteğe bağlı* |
| Temel eyleminiz | Uygulamanızın asıl var olma nedeni olan eylem. Adını o eyleme göre verin: `scan_completed`, `workout_finished`, `photo_exported` | eylemi tanımlayan özellikler |

**Uygulamadan gelen event'lerin gösteremedikleri.** Yenilemeler, ücretli subscription'a dönüşen ücretsiz trial'lar, iptaller ve refund'lar Apple tarafında, genellikle uygulamanız kapalıyken gerçekleşir. Uygulama bunları hiç görmez; bu yüzden bu event'lerde yer almazlar ve buradaki funnel'lar satın almada veya trial başlangıcında biter.

**Bu event'lerle kurulabilecek örnek funnel'lar**

| Funnel | Adımlar | Zaman aralığı | 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 ulaşıp satın alıyor |
| Onboarding kaybı | `onboarding_step_viewed (step = 1) → (step = 2) → (step = 3) → onboarding_completed` | 1 gün | Hangi onboarding ekranı en çok kişi kaybediyor |
| Paywall dönüşümü | `paywall_viewed → purchase_started → purchase_completed`, `placement` kırılımıyla | 1 saat | Hangi paywall konumu en iyi dönüşüyor ve kullanıcılar fiyatta mı yoksa Apple'ın satın alma ekranında mı bırakıyor |
| Aktivasyon | `onboarding_completed → scan_completed` (temel eyleminiz) | 1 gün | Yeni kullanıcıların kaçı uygulamanın asıl amacına ulaşıyor |

## 7. Gizlilik

Bu bölüm hukuki danışmanlık değil, yol göstericidir. Uygulamanızın ne gönderdiğini ve kullanıcılarınızın nerede olduğunu siz bilirsiniz; bu yüzden son kararlar, tercihen kendi hukuk danışmanınızla birlikte, size aittir.

Bu bölümdeki her konu için gizlilik iletişim adresi: `privacy@app-store-operator.com`.

### 7.1 App Store Operator'a ulaşan veriler

- Event adları ve özellikleri.
- `anonymous_id`: referans kodun, izleme açıkken izlenen ilk event'le oluşturduğu ve izleme kapatılana veya `reset` çağrılana kadar o install için sakladığı rastgele bir UUID. IDFA veya diğer uygulamalarla paylaşılan başka bir tanımlayıcı değildir.
- `user_id`, yalnızca `identify` çağırırsanız.
- [§4.3](#43-event-nesnesi) içindeki device bağlamı: uygulama version'ı, build, işletim sistemi sürümü, device modeli, dil ve bölge ayarı ve build ortamı.
- İsteğin ağ konumundan çıkarılan ülke (bkz. [§7.3](#73-ip-adresi-ve-ülke)).

Referans kod event'leri yalnızca iOS build'inden gönderir; Android, web ve masaüstü build'leri hiçbir şey göndermez.

**Asla göndermeyin:** ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı serbest metin; event adlarında, özelliklerde veya `user_id` içinde. `user_id`, uygulamanızın kendi dahili kimliği olmalıdır (örneğin bir veritabanı anahtarı); e-posta, telefon numarası veya başka bir şirketin atadığı bir kimlik olmamalıdır.

### 7.2 Roller ve hizmet sağlayıcılar

- **Roller.** Uygulamanızın gönderdiği event'ler için veri sorumlusu (KVKK'daki adıyla *veri sorumlusu*) sizsiniz: neyin, neden toplanacağına siz karar verirsiniz. App Store Operator'ı işleten Nefarius, event'leri saklamak ve Dashboard'da size göstermek için sizin adınıza, veri işleyen (KVKK'daki adıyla *veri işleyen*) olarak işler.
- **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).** Her barındırılan hizmet gibi, event verilerini yalnızca bizim adımıza işleyen sağlayıcılardan yararlanırız:
  - **Cloudflare**: ağ iletimi ve güvenlik. Uygulamanızdan gelen her istek bunun üzerinden 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ünya genelinde çalışır; bu yüzden bir istek kullanıcılarınızın ülkesi dışında işlenebilir.
- **Veri işleme sözleşmesi (DPA).** Bir DPA'ya (örneğin GDPR madde 28 kapsamında), hizmet sağlayıcıların konumlarıyla birlikte güncel listesine veya uluslararası aktarımlara ilişkin güvencelerin ayrıntılarına ihtiyacınız varsa yukarıdaki gizlilik iletişim adresine yazın.

### 7.3 IP adresi ve ülke

Uygulamanızın istekleri bize Cloudflare üzerinden ulaşır; Cloudflare istekleri iletmek için IP adresine ihtiyaç duyar ve ülkeyi bu adresten belirler. App Store Operator yalnızca ülkeyi saklar (şehir veya bölge saklamaz) ve IP adresini asla saklamaz veya loglamaz.

### 7.4 Saklama süresi ve silme talepleri

- Event'ler 13 ay saklanır, ardından otomatik olarak silinir.
- Tek bir kişinin event'lerini Dashboard'dan silmek henüz mümkün değildir. Bu özellik gelene kadar, bir kullanıcı verilerinin silinmesini istediğinde (örneğin GDPR madde 17 veya KVKK madde 11 kapsamında), App Store Operator çalışma alanınızın bir üyesinin adresinden yukarıdaki gizlilik iletişim adresine şunlarla birlikte e-posta gönderin:
  - Dashboard'da göründüğü şekliyle uygulamanın adı;
  - kişinin `user_id` değeri ve/veya `anonymous_id` değeri. Bir `user_id`, ona bağlı tüm `anonymous_id` değerlerini de kapsar. Hesabı olmayan kullanıcılar için uygulamanız `anonymous_id` değerini `await EventTracker.shared.currentAnonymousId` ile, örneğin bir destek ekranında gösterebilir ([§5.2](#52-event-takibi)). Bu değeri `setEnabled(false)` veya `reset()` çağırmadan önce okuyun: ikisi de onu device'tan siler ve o olmadan o install'ın event'lerini bulamayız.
- Bu kimliklere sahip her event'i sileriz ve e-postayla onaylarız. Talepleri vakit kaybetmeden gönderin: kullanıcıya yanıt vermek için sizin de bir yasal süreniz vardır (GDPR kapsamında genellikle bir ay, KVKK kapsamında 30 gün).
- Çalışma alanının üyesi olmayan herhangi birinden gelen talepler işleme alınmaz; o kişiyi size yönlendiririz.

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

- **Gizlilik politikası.** Uygulamanızın gizlilik politikası; kullanım analitiği topladığınızı, bunun hangi verileri içerdiğini (§7.1), bu verileri sizin adınıza bir hizmet sağlayıcının (Nefarius tarafından işletilen App Store Operator) işlediğini ve verilerin 13 ay saklandığını belirtmelidir.
- **Hukuki dayanak ve onay.** Analitik için hukuki dayanağı ve önce kullanıcılarınızın onayına (açık rızasına) ihtiyaç duyup duymadığınızı siz belirlersiniz. AB ve Birleşik Krallık'ta analitik amacıyla device'ta bir tanımlayıcı saklamak veya okumak çoğu zaman onay gerektirir; KVKK kapsamında ise kullanıcıları en azından bilgilendirmeniz gerekir. Onaya ihtiyacınız varsa, açılışta her şeyden önce `EventTracker.shared.setEnabled(false)` çağırın ve kullanıcı kabul edene kadar böyle bırakın, ardından `setEnabled(true)` çağırın ([§5.2](#52-event-takibi)). İzleme kapalıyken device'ta hiçbir kimlik veya event saklanmaz (yalnızca kapalı ayarının kendisi saklanır) ve hiçbir şey gönderilmez. Tracker, `anonymous_id` değerini yalnızca izleme açıkken izlenen ilk event'le oluşturur. Kullanıcı daha sonra onayını geri çekerse `setEnabled(false)` izlemeyi durdurur ve device'taki kimlikleri ve gönderilmemiş event'leri siler. Daha önce gönderilmiş event'leri silmez: tek hukuki dayanağınız onay idiyse veya kullanıcı silinmesini isterse, önce `currentAnonymousId` değerini okuyarak silmeyi [§7.4](#74-saklama-süresi-ve-silme-talepleri) üzerinden talep edin.

### 7.6 App Tracking Transparency

App Store Operator event'lerinizi reklam veya reklam ölçümü amacıyla diğer şirketlerin uygulamalarından, web sitelerinden veya çevrimdışı kaynaklarından gelen verilerle birleştirmez, veri simsarlarıyla paylaşmaz ve device bağlamını device'ların parmak izini çıkarmak için kullanmaz. Bu nedenle entegrasyon tek başına Apple'ın tanımladığı anlamda izleme (tracking) sayılmaz ve App Tracking Transparency (ATT) iznini gerektirmez.

ATT kararı her entegrasyon için ayrı ayrı değil, uygulamanızın bütünü için verilir. Uygulamanız veya içindeki başka bir SDK bu event'leri ya da `user_id` değerinizi reklam veya reklam ölçümü amacıyla diğer şirketlerin verileriyle ilişkilendiriyorsa ya da bunları bir veri simsarıyla paylaşıyorsa, bu izlemedir: önce kullanıcının ATT iznini almanız gerekir ve gizlilik etiketiniz verilerin izleme için kullanıldığını belirtmelidir. Bkz. Apple'ın [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/) sayfası.

### 7.7 App Store gizlilik etiketi ve gizlilik manifesti

Apple'ın gizlilik sorularını uygulamanın bütünü için yanıtlarsınız ve Apple, eklediğiniz üçüncü taraf kodların topladığı verileri de dahil etmenizi ister; bu kod da buna dahildir. App Store Operator'a ulaşan veriler için tipik yanıtlar şunlardır:

| 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 örneklerinden daha kaba bir konumdur, ancak beyan etmek daha güvenli yanıttır | 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 her satırı *Linked to You* olarak beyan edin: event'ler sisteminizdeki bir hesap kimliğiyle ilişkilendirilir. Hiç `user_id` göndermiyorsanız ve event'leri başka hiçbir yerde bir hesaba veya kimliği belirleyen başka verilere bağlamıyorsanız, veri yalnızca rastgele bir install kimliğine bağlıdır ve *Not Linked to You* yanıtını verebilirsiniz. Emin değilseniz *Linked to You* yanıtını verin.
- **Tracking.** Uygulamanız §7.6'da anlatıldığı şekilde izleme yapmıyorsa *Not used to track you*.
- **Gizlilik manifesti.** Apple bazı API'ler için hem sizin kodunuzda hem de her eklentide belirtilmiş bir gerekçe ister. Bu entegrasyon için:
  - `shared_preferences` kimlikleri ve açık/kapalı ayarını `UserDefaults` içinde tutar. iOS eklentisi `UserDefaults` (`NSPrivacyAccessedAPICategoryUserDefaults`) kullanımını beyan eden kendi gizlilik manifestiyle gelir, bu nedenle tracker'ın kullanımı için bir giriş eklemezsiniz.
  - Kuyruk dosyası `dart:io` ile yazılır; Flutter motoru kullandığı dosya API'leri için kendi gizlilik manifestiyle gelir.
  - `device_info_plus` (13.3.0), tracker işletim sistemi sürümünü ve modeli istediğinde device'ın boş disk alanını da okur. Tracker bu değeri kullanmaz ve eklentinin manifesti bunun için bir gerekçe beyan etmez. Yüklemeden sonra App Store Connect `NSPrivacyAccessedAPICategoryDiskSpace` için eksik bir gerekçe bildirirse (ITMS-91053), bu kategoriyi uygulamanızın `PrivacyInfo.xcprivacy` dosyasına, Apple'ın listesinden uygulamanızın kullanımına uyan gerekçeyle ekleyin ya da eklentinin geliştiricilerine sorun.
  - Kontrol etmeniz gerekenler: uygulamanızın kendi `PrivacyInfo.xcprivacy` dosyası (Flutter bunu oluşturmaz; Xcode'da `Runner` target'ına ekleyin) etiketinizle eşleşen toplanan veri türlerini listelemelidir; App Store Connect'in her yüklemeden sonra gönderdiği e-posta da diğer eklentiler dahil tüm uygulama için eksik gerekçeleri listeler.

Apple'ın tanımları: [App privacy details on the App Store](https://developer.apple.com/app-store/app-privacy-details/) ve [Privacy manifest files](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files).

### 7.8 Çocuklara yönelik uygulamalar

Apple'ın [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) kuralları (1.3 ve 5.1.4), hizmetin çocuklar hakkında device bilgileri dahil hiçbir tanımlayıcı bilgi toplamadığı sınırlı durumlar dışında, Kids kategorisindeki uygulamalarda üçüncü taraf analitiğe izin vermez. App Store Operator kalıcı bir install kimliği, device modeli ve ülke bilgisi alır; bu yüzden **onu Kids kategorisindeki bir uygulamaya eklemeyin**. Çocuklara yönelik diğer uygulamalar için entegrasyondan önce size uygulanan kuralları (örneğin ABD'de COPPA veya GDPR'ın çocukların onayına ilişkin kuralları) hukuk danışmanınızla birlikte kontrol edin.

## 8. Doğrulama ve sorun giderme

1. Uygulamayı bir iOS simülatöründe veya device'ta çalıştırın (`flutter run`), birkaç event tetikleyin, ardından uygulamayı arka plana gönderin (bu, kuyruğu gönderir).
2. Dashboard → uygulamanız → **Events → Canlı**: event'leriniz saniyeler içinde görünür.
3. **Events → Katalog** her event adını özellik anahtarlarıyla birlikte listeler. Adların ve anahtarların kastettiğiniz gibi olduğunu kontrol edin.

Android, web ve masaüstü build'leri hiçbir şey göndermez. Debug build'inde konsol bir kez `[EventTracker] iOS only: tracking is off on android` (veya platformun adı) yazar.

**Test build'leri.** Her uygulamanın tek bir yazma anahtarı vardır ve bu anahtar test build'lerinde de App Store build'lerinde de kullanılır. Referans kod, debug ve profile build'lerinden (`flutter run`) gelen event'leri `debug` olarak; release build'lerini ise `--dart-define=ASO_ENV=...` ile verilen `environment` değeriyle (`testflight` veya `app_store`) işaretler ya da hiç işaretlemez. Dashboard varsayılan olarak `app_store` event'lerini ve ortamı olmayan event'leri gösterir; test çalıştırmalarınızı görmek için ortam filtresini **Tüm build'ler** olarak değiştirin. Define olmadan yapılan bir release build'i TestFlight'ı App Store'dan ayırt edemez; bu nedenle event'leri, TestFlight test kullanıcılarınınkiler dahil, App Store event'i sayılır ([§5](#5-referans-kod)). curl testi ortam göndermez, bu yüzden varsayılan görünümde görünür; onu `anonymous_id` değeriyle, `curl-test`, bulabilirsiniz.

| Belirti | Olası neden |
|---|---|
| Canlı'da hiçbir şey yok | Yanlış anahtar (`401`); `configure` hiç çağrılmadı; uygulama Android'de veya iOS olmayan başka bir platformda çalışıyor; 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ı; anahtar 30 günden daha önce yenilendi; ya da eski anahtar iptal edildi ([§3.1](#31-anahtarı-yenileme)) |
| Bir event eksik, diğerleri var | Reddedildi. Debug build'inde Flutter konsolu kodla birlikte `[EventTracker] rejected: …` yazar ([§4.5](#45-hatalar)) |
| Bir event'ten bir özellik eksik | Değeri `String`, `int`, `double`, `bool` veya `null` değildi. Debug build'inde konsol `[EventTracker] … dropped` yazar |
| Konsolda `[EventTracker] settings unavailable` veya `queue file unavailable` | Tracker `WidgetsFlutterBinding.ensureInitialized()` çağrısından önce çalıştı ya da `flutter pub add` sonrasında uygulama yeniden build edilmedi (uygulamayı durdurup yeniden çalıştırın: hot restart yeni eklentileri yüklemez). Bu durumda kimlikler ve event'ler yalnızca bellekte tutulur |
| Test build'inden Canlı'da hiçbir şey yok | Varsayılan görünüm App Store event'lerini ve ortam bilgisi olmayan event'leri gösterir: ortam filtresini **Tüm build'ler** olarak değiştirin |
| App Store kullanıcıları varsayılan görünümde yok | Yayınlanan build `--dart-define=ASO_ENV=testflight` ile yapıldı; sonraki version'ı bu define olmadan build edin |
| Event'ler gecikmeli ve toplu geliyor | Bir hız sınırı aşıldı (`429`), sunucu veya ağ çalışmıyordu ya da uygulama `flush()` bitmeden askıya alındı; referans kod event'leri saklar ve daha sonra gönderir |
| Zamanlar yanlış | Device saati çok sapmış: 7 günden daha eski veya 5 dakikadan daha ileri zamanlar, event'i aldığımız zamanla değiştirilir |
| Aynı kişi bir funnel'da iki kez sayılıyor | `reset()` çağrılmaması gereken bir yerde çağrılıyor ya da `identify` aynı kullanıcı için farklı kimliklerle çağrılıyor |
| Funnel adımı sıfır | Adımın event adı veya özellik filtresi tam olarak eşleşmiyor (adlar ve değerler büyük/küçük harfe duyarlıdır) |

## 9. Tamamlanma kontrol listesi

Her satır doğru olduğunda entegrasyon tamamlanmış olur. Rapor verirken her kod maddesi için dosya ve satırı belirtin; bir maddeyi gerekçesiyle *uygulanamaz* olarak, canlı API gerektiriyorsa *beklemede* olarak işaretleyin.

- [ ] `path_provider`, `shared_preferences`, `package_info_plus` ve `device_info_plus` `pubspec.yaml` içinde yer alıyor ve izleme için başka hiçbir bağımlılık eklenmedi.
- [ ] `lib/event_tracker.dart` §5'tekiyle aynı (yalnızca iOS kontrolü dahil), `flutter analyze` hata bildirmiyor ve uygulama iOS için build ediliyor (`flutter build ios --no-codesign`).
- [ ] `configure(writeKey: ...)`, `main()` içinde, `WidgetsFlutterBinding.ensureInitialized()` sonrasında ve `runApp` öncesinde, bu uygulamanın gerçek anahtarıyla bir kez çalışıyor; `YOUR_WRITE_KEY` metni projenin hiçbir yerinde geçmiyor.
- [ ] `EventTracker.shared.flush()`, uygulama arka plana geçtiğinde çalışıyor.
- [ ] `app_opened` açılışta (`source: "cold"`) ve arka plandan her dönüşte (`source: "warm"`) izleniyor.
- [ ] §6'daki event'lerden akışı bu uygulamada bulunan her biri, listelenen özelliklerle izleniyor. Her birini `event → file:line` olarak raporlayın ve atladığınız §6 event'lerini nedenleriyle listeleyin.
- [ ] Satın alma event'leri, geri yüklemeleri de ileten `purchaseStream` gibi bir dinleyicide değil, satın alma akışında (§5.2) izleniyor.
- [ ] Her event adı `^[A-Za-z0-9_.:-]{1,64}$` ile eşleşiyor ve §6 adlandırma kurallarına uyuyor; her özellik değeri bir `String`, `int`, `double`, `bool` veya `null`.
- [ ] Hiçbir `track` çağrısı ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı metin göndermiyor.
- [ ] Uygulamada hesaplar varsa: `identify` oturum açıldıktan sonra uygulamanın kendi kullanıcı kimliğiyle, `reset` ise oturum kapatıldıktan sonra çağrılıyor. Hesap yoksa ikisi de çağrılmıyor.
- [ ] *Canlı API:* §3'teki curl testi `accepted: 1` döndürüyor ve uygulamanın bir çalıştırmasından gelen event'ler Dashboard → Events → Canlı altında görünüyor.
- [ ] *Geliştirici:* yukarıdaki event ve özellik listesi; §7'ye, App Store gizlilik etiketine, uygulamanın `PrivacyInfo.xcprivacy` dosyasına, uygulamanın gizlilik politikasına ve varsa onay gerekliliğine göre kontrol etmesi ve herhangi bir build'in `--dart-define=ASO_ENV` kullanıp kullanmayacağına (§5) karar vermesi için geliştiriciye verildi. Uygulama Kids kategorisinde değil.
