Introduction

When you start operating an iOS app, you quickly face these questions:

  • Is the app crashing? Where?
  • How many downloads and sessions do we have?
  • How can we prompt users on old versions to update?
  • How can we encourage reviews?

Most guides recommend Firebase Crashlytics + Firebase Analytics, but this means sending user device information, usage data, and crash logs to Google’s servers. You’ll also need to declare “data collection and tracking” when submitting to the App Store.

For indie apps or academic-purpose apps, is this level of data collection really necessary?

This article presents an approach to iOS app operations that does not send user data to external services, using only Apple-native tools and open-source libraries. It is based on real-world experience with KotenOCR, an App Store-published OCR app for classical Japanese texts.

Architecture Overview

PurposeFirebase StackThis Article’s StackExternal Data Sent
Crash monitoringCrashlyticsMetricKitNone
AnalyticsFirebase AnalyticsApp Store Connect Analytics APINone
Update promptsRemote ConfigSiren (iTunes Lookup API)None*
Review promptsIn-App MessagingSKStoreReviewRequestNone
Crash log viewerCrashlytics ConsoleXcode OrganizerNone

*Siren sends a version-check request to the iTunes Lookup API (Apple’s public API), but no user personal data is included.

1. MetricKit — Crash Monitoring

MetricKit is an Apple-native framework available from iOS 13+. It delivers crash reports, hang diagnostics, and performance metrics directly within the app.

Implementation

import MetricKit

class MetricKitManager: NSObject, MXMetricManagerSubscriber {
    static let shared = MetricKitManager()

    func start() {
        MXMetricManager.shared.add(self)
    }

    // iOS 15+: Immediate crash/hang notification
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            if let crashDiagnostics = payload.crashDiagnostics {
                for crash in crashDiagnostics {
                    let description = crash.callStackTree.jsonRepresentation()
                    // Log or save locally
                }
            }
        }
    }

    // iOS 14+: 24-hour performance metrics
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            // Launch time, memory usage, disk I/O, etc.
        }
    }
}
```text

Call `MetricKitManager.shared.start()` at app launch.

### Key Characteristics

- **No external data transfer**  Data stays within the app. Not even sent to Apple's servers
- **Immediate notification**  From iOS 15, `didReceive(_: [MXDiagnosticPayload])` is called right after a crash
- **Beyond crashes**  Also captures launch time, memory usage, and battery metrics

### Difference from Xcode Organizer

Xcode Organizer (Xcode > Window > Organizer > Crashes) shows crash logs without any code changes, but has limitations:

- Requires users to opt in to "Share Diagnostics" in iOS Settings
- Needs a certain user base before data appears
- Takes days to weeks to accumulate

MetricKit is more practical in early stages when your user base is small.

## 2. App Store Connect Analytics API  Analytics

While the App Store Connect web console provides analytics, the API enables automation and detailed analysis.

### Setup: JWT Authentication

The App Store Connect API uses JWT for authentication.

```python
import jwt
import time
import requests

KEY_ID = "YOUR_KEY_ID"
ISSUER_ID = "YOUR_ISSUER_ID"

with open("AuthKey.p8", "r") as f:
    private_key = f.read()

payload = {
    "iss": ISSUER_ID,
    "iat": int(time.time()),
    "exp": int(time.time()) + 1200,
    "aud": "appstoreconnect-v1"
}
token = jwt.encode(payload, private_key, algorithm="ES256",
                   headers={"kid": KEY_ID})
headers = {"Authorization": f"Bearer {token}"}
```text

### Creating a Report Request

To use the Analytics API, first create (or retrieve an existing) report request.

```python
APP_ID = "YOUR_APP_ID"

# Check existing report requests
r = requests.get(
    f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/analyticsReportRequests",
    headers=headers
)
report_requests = r.json()["data"]

# Create one if none exists (ONGOING for continuous reports)
if not report_requests:
    r = requests.post(
        f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/analyticsReportRequests",
        headers=headers,
        json={
            "data": {
                "type": "analyticsReportRequests",
                "attributes": {"accessType": "ONGOING"},
                "relationships": {
                    "app": {"data": {"type": "apps", "id": APP_ID}}
                }
            }
        }
    )
```text

### Fetching Reports

```python
import gzip
import csv
import io
from collections import defaultdict

REPORT_REQUEST_ID = report_requests[0]["id"]

def fetch_report(report_name):
    """Fetch all instances of a report and combine them."""
    r = requests.get(
        f"https://api.appstoreconnect.apple.com/v1/analyticsReportRequests/"
        f"{REPORT_REQUEST_ID}/reports",
        headers=headers, params={"limit": 200}
    )

    for report in r.json()["data"]:
        if report["attributes"]["name"] == report_name:
            r2 = requests.get(
                f"https://api.appstoreconnect.apple.com/v1/analyticsReports/"
                f"{report['id']}/instances",
                headers=headers, params={"limit": 10}
            )

            all_data = ""
            for inst in r2.json().get("data", []):
                r3 = requests.get(
                    f"https://api.appstoreconnect.apple.com/v1/"
                    f"analyticsReportInstances/{inst['id']}/segments",
                    headers=headers
                )
                for seg in r3.json().get("data", []):
                    url = seg["attributes"].get("url")
                    if url:
                        r4 = requests.get(url)
                        text = gzip.decompress(r4.content).decode("utf-8")
                        lines = text.strip().split("\n")
                        if not all_data:
                            all_data = text.strip()
                        else:
                            all_data += "\n" + "\n".join(lines[1:])
            return all_data
    return None
```text

### Available Reports

| Report Name | Category | Contents |
|------------|---------|----------|
| App Downloads Standard | COMMERCE | Download counts (new/redownload, by device, territory) |
| App Sessions Standard | APP_USAGE | Session count, average duration |
| App Store Discovery and Engagement Standard | APP_STORE_ENGAGEMENT | Impressions, page views, taps |
| App Store Installation and Deletion Standard | APP_USAGE | Install and deletion counts |
| App Crashes | APP_USAGE | Crash counts |

### Real Output Example

Data from KotenOCR shortly after its public release:

```text
=== Downloads ===
  2026-03-20: New  95 / Re-DL   2 / Total  97
  2026-03-21: New 348 / Re-DL  14 / Total 362
  2026-03-22: New 104 / Re-DL 188 / Total 292
  2026-03-23: New  23 / Re-DL 231 / Total 254
  Total:     New 570 / Re-DL 435 / Total 1,005

=== Sessions ===
  2026-03-20: 159 sessions / avg 81s
  2026-03-21: 345 sessions / avg 85s
  2026-03-22: 126 sessions / avg 84s
  2026-03-23:  50 sessions / avg 115s

=== App Store Engagement ===
  2026-03-19: Impression: 79 / Page view: 14 / Tap: 19
  2026-03-20: Impression: 172 / Page view: 315 / Tap: 336
  2026-03-21: Impression: 596 / Page view: 576 / Tap: 600
  2026-03-22: Impression: 193 / Page view: 106 / Tap: 101

=== Installs / Deletions ===
  2026-03-20: Install  63 / Delete   0
  2026-03-21: Install 249 / Delete   0
  2026-03-22: Install  66 / Delete   0
  2026-03-23: Install  56 / Delete   0
```text

Data latency is approximately 1-2 days. The web UI typically shows data through the previous day.

## 3. Siren  Update Prompts

[Siren](https://github.com/ArtSabintsev/Siren) is an open-source library that compares the app's version against the App Store's latest version and shows an update dialog.

### Installation

Add via Swift Package Manager:

```text
https://github.com/ArtSabintsev/Siren (6.1.0+)
```text

### Implementation

```swift
import Siren

// In SwiftUI, call this in onAppear (NOT in init())
private func configureSiren() {
    let siren = Siren.shared
    siren.rulesManager = RulesManager(
        majorUpdateRules: .critical,   // Major: cannot skip
        minorUpdateRules: .annoying,   // Minor: cannot skip (can defer)
        patchUpdateRules: .default     // Patch: can skip
    )
    siren.wail()
}
```text

### Important Notes

- **In SwiftUI, call in `onAppear`**  At `App.init()` time, UIWindow does not exist yet, so Siren won't work
- **Testing**  Siren compares against the App Store version via iTunes Lookup API. To test, temporarily set a lower version in Info.plist's `CFBundleShortVersionString`
- **Post-release delay**  By default, Siren won't show the dialog until 1 day after the new version's release (configurable via `showAlertAfterCurrentVersionHasBeenReleasedForDays`)

### Alert Types

| Rule | Behavior |
|------|----------|
| `.critical` | "Update" button only. Cannot skip |
| `.annoying` | "Update" + "Next time". Cannot skip but can defer |
| `.default` | "Update" + "Next time" + "Skip" |
| `.relaxed` | Same as `.default` but shown less frequently |

## 4. SKStoreReviewRequest  Review Prompts

Apple's native review prompt API. No additional frameworks needed.

```swift
import StoreKit

func requestReviewIfAppropriate() {
    let count = UserDefaults.standard.integer(forKey: "ocrSuccessCount")
    // Request review after the 3rd successful OCR
    if count == 3 {
        if let scene = UIApplication.shared.connectedScenes
            .first(where: { $0.activationState == .foregroundActive })
            as? UIWindowScene {
            SKStoreReviewController.requestReview(in: scene)
        }
    }
}
```text

### Key Points

- Apple controls whether the dialog actually appears; calling this method doesn't guarantee it
- Limited to **3 times per year**
- Most effective when called right after a "success moment" for the user

## 5. Reflections and Limitations

### What This Stack Covers

- Basic download and session tracking
- Crash detection and investigation
- User update prompts
- Review prompts

I've been running KotenOCR with this exact stack, and it provides sufficient information for an indie app.

### Limitations

- **No per-screen or per-feature analytics**  Questions like "which screen is most used?" or "what's the translation feature usage rate?" require in-app event tracking
- **No real-time data**  Analytics API data has a 1-2 day delay
- **Unstructured MetricKit crash data**  Stack trace JSON parsing must be implemented manually
- **No A/B testing or gradual rollouts**  These require Firebase Remote Config or similar services

### When You Actually Need Firebase

- Detailed user behavior analysis is required
- Real-time crash notifications are needed
- Feature flags or remote configuration are needed
- Push notifications are required

## Summary

| Tool | Purpose | Implementation Effort |
|------|---------|----------------------|
| MetricKit | Crash & performance monitoring | Low |
| App Store Connect Analytics API | Downloads, sessions, engagement | Medium (Python) |
| Siren | Update prompt dialog | Low |
| SKStoreReviewRequest | Review prompt | Minimal |
| Xcode Organizer | Crash log viewer | None |

Even without Firebase, you can cover most operational needs for an indie iOS app. The privacy advantage is significant  you can truthfully tell users "we don't collect your data," and your privacy policy obligations are minimal.

Start with this stack, and add Firebase later only if you actually need it. A gradual approach is the way to go.

## Related Articles

- [KotenOCR: An iOS App for Offline Kuzushiji Recognition](/posts/kotenocr-ios-app/)
- [Submitting an iOS App Using Only the App Store Connect API](/posts/appstore-connect-api-guide/)
- [Practical iOS Memory Optimization and Crash Fixes](/posts/ios-memory-crash-fixes/)