eugenemind.com
← Înapoi la blog

22 august 2026 · 10 min. de citit

Logging și analytics în iOS: ghid practic

#engineering #observability #mobile

Logging-ul și analytics sunt adăugate de multe ori aproape mecanic. Punem un SDK, trimitem câteva evenimente, bifăm task-ul și mergem mai departe. Problema e că ele rezolvă lucruri diferite. Dacă nu ne gândim la asta de la început, după câteva luni putem avea multe date și foarte puține răspunsuri.

Logging-ul este pentru dezvoltatori

Logurile sunt în primul rând pentru dezvoltatori. Scriem acolo evenimente importante și erori, apoi aproape că nu ne uităm la ele cât timp totul merge bine. Testul adevărat vine când ceva se strică. Dacă logging-ul a fost făcut fără o structură, găsirea informației utile devine rapid frustrantă.

De obicei ne amintim de ele prea târziu: când bug-ul e deja în producție sau când un flow depinde de prea multe sisteme externe ca să-l putem testa bine. Atunci devine evident că e mai simplu să gândești logging-ul și analytics înainte de release, nu după.

Logurile nu transformă codul prost în cod bun, iar analytics nu previne bug-urile. Dar ambele pot scurta mult timpul până înțelegi unde s-a rupt ceva.

La ce folosește analytics

Analytics este în primul rând pentru produs. Câți oameni folosesc funcționalitatea? Unde ies din flow? La ce pas scade conversia? Asta ar trebui să te ajute să vezi.

Uneltele principale pentru logging

Trei unelte concrete vor apărea de-a lungul postării, iată ce are fiecare în interior.

os.Logger (nativ, Apple)

  • La ce folosește: loguri structurate direct pe dispozitiv
  • Preț: gratuit, parte din iOS SDK
  • Backend propriu: nu e nevoie, totul rămâne pe dispozitiv
  • Lucrul cu datele: doar local (Console.app, Xcode, sysdiagnose), fără dashboard, fără agregare pe utilizatori
  • Capabilități avansate: subsystem/category și nivelurile sunt deja separate la nivel de API

Firebase Crashlytics

  • La ce folosește: crash reporting
  • Preț: gratuit, parte din Firebase
  • Backend propriu: nu e nevoie, administrat de Google
  • Lucrul cu datele: dashboard cu crash-uri grupate după stack trace, căutare după versiune și dispozitiv
  • Capabilități avansate: -

Sentry

  • La ce folosește: error tracking și performance
  • Preț: tarif free cu plafon de evenimente, plătit peste
  • Backend propriu: fie managed (sentry.io), fie self-hosted (open source)
  • Lucrul cu datele: dashboard-uri cu filtre pe release/environment, căutare prin breadcrumbs
  • Capabilități avansate: prinde App Hangs/ANR, colectează breadcrumbs automat, scrubbing configurabil

De unde începi

Versiunea aplicației și numărul de build sunt deja colectate de majoritatea SDK-urilor. Partea mai interesantă e ce trebuie să proiectezi tu.

1. Un ID anonim care supraviețuiește reinstalării. Aplicația trebuie să poată identifica atât utilizatorul autentificat, cât și pe cel neautentificat. Pentru utilizatorul autentificat folosim user ID-ul primit de la backend sau de la sistemul de autentificare. Pentru cel neautentificat putem genera un anonymous ID, de exemplu un UUID, și îl putem păstra în Keychain ca să rămână disponibil și după reinstalarea aplicației.

enum AnonymousUserID {
    static var current: String {
        if let existing = Keychain.shared.string(forKey: "analytics_uid") {
            return existing
        }
        let id = UUID().uuidString
        Keychain.shared.set(id, forKey: "analytics_uid")
        return id
    }
}

Următoarea întrebare este unde trimiți ID-ul. Să apelezi direct Analytics.setUserId, Crashlytics.crashlytics().setUserID și SentrySDK.setUser în fiecare loc din cod e o idee proastă, trei apeluri către vendori împrăștiate prin toată aplicația, iar când înlocuiești un instrument trebuie să le vânezi pe toate manual. Mai bine ascunzi SDK-urile în spatele unei singure interfețe și apelezi doar pe aceea:

protocol AppLoggerProtocol {
    func setUserId(_ id: String)
    func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any])
}

final class AppLogger {
    static let shared = AppLogger(destinations: [
        CrashlyticsDestination(), SentryDestination(), NativeLoggerDestination(),
    ])

    private let destinations: [AppLoggerProtocol]
    init(destinations: [AppLoggerProtocol]) { self.destinations = destinations }

    func setUserId(_ id: String) {
        destinations.forEach { $0.setUserId(id) }
    }

    func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any] = [:]) {
        destinations.forEach { $0.log(level, event, domain: domain, metadata: metadata) }
    }
}

AppLogger.shared.setUserId(AnonymousUserID.current)

Fiecare *Destination este doar un wrapper mic peste un singur SDK: CrashlyticsDestination.setUserId doar apelează intern Crashlytics.crashlytics().setUserID, și tot așa. Apare un instrument nou sau dispare unul vechi, și se schimbă un singur destination, nu zeci de locuri din aplicație. De aici încolo, AppLogger.shared e exact acea interfață unică.

După login, apelează setUserId încă o dată cu user ID-ul real. Așa poți lega mai ușor ce s-a întâmplat înainte și după autentificare.

2. Severity și domain sunt două axe diferite, nu le amesteca.

Severity (debug/info/notice/error/fault) răspunde la o întrebare simplă: cât de grav este?

Domain (checkout/auth/onboarding) răspunde la alta: unde s-a întâmplat? Le contopești și ajungi fie cu trei niveluri pentru tot proiectul fără nicio filtrare pe funcționalitate, fie cu zece „domenii” încurcate cu severity, prin care nimeni nu se mai descurcă. Domenii bine gândite îți permit apoi să grupezi ușor erorile într-un dashboard, vezi imediat ce parte a aplicației se strică cel mai des, de-aia merită stabilite din timp, nu adăugate pe parcurs.

De dragul simplității, domeniile din exemplele de mai jos sunt doar câteva cazuri de enum. Într-un proiect real, tiparea merită gândită separat, un enum unic pentru toată aplicația, câte unul pe modul, sau altceva, în funcție de structura proiectului, dar o valoare tipată e aproape mereu mai bună decât un string brut: face căutarea în cod și refactorizarea mult mai simple.

AppLogger.shared.log(.error, "payment_failed", domain: .checkout, metadata: ["reason": "timeout"])
AppLogger.shared.log(.info, "screen_appeared", domain: .checkout)

os.Logger-ul nativ de la Apple are deja această separare încorporată implicit: category este practic domain, iar apelul .error/.info este severity, separate la nivel de API, nu printr-o convenție de echipă:

import os

private let checkoutLog = Logger(subsystem: "com.example.app", category: "checkout")
checkoutLog.error("payment_failed reason=timeout")
checkoutLog.info("screen_appeared")

Exact de-aia lista de destinations de la inițializarea AppLogger include și NativeLoggerDestination, pur și simplu trimite domain mai departe în category și severity în nivelul os.Logger, iar logurile structurate rămân disponibile local (Console.app, sysdiagnose) chiar și fără rețea și fără niciun SDK de la vendori.

3. Loghează explicit tranzițiile de stare ale aplicației. Multe bug-uri enervante sunt legate de lifecycle. Utilizatorul pune aplicația în background, revine, primește un push, intră în același flow pe altă cale și apoi iese din nou. Nu poți testa manual toate combinațiile. Nu poți testa manual secvența asta, dar se va întâmpla în producție. Poți lega asta prin NotificationCenter, sau, dacă aplicația e construită pe UIScene, direct în metodele UIWindowSceneDelegate, mai precis pentru aplicații cu mai multe scene:

NotificationCenter.default.addObserver(
    forName: UIApplication.didEnterBackgroundNotification,
    object: nil, queue: .main
) { _ in AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle) }

Sau la fel, în metoda de delegate, dacă aplicația e construită pe UIScene:

func sceneDidEnterBackground(_ scene: UIScene) {
    AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle)
}

Înainte să adaugi propriul hook, merită verificat dacă un SDK conectat nu loghează deja asta: Sentry are session tracking automat, legat exact de aceste tranziții foreground/background, și nu trebuie duplicat manual.

4. Breadcrumbs, dar curățate de secrete, nu lăsate brute. Sentry, de exemplu, colectează automat breadcrumbs, request-uri de rețea, navigare, tap-uri, ceea ce e util, dar exact de-aia contează să cureți secretele din timp: dacă un token e trimis ca query parameter, Sentry îl loghează cu bucurie și pe el. Curățarea trebuie legată înainte ca evenimentul să plece, nu după, nu te baza pe faptul că sistemul își dă singur seama:

SentrySDK.configureScope { scope in
    scope.setBeforeBreadcrumb { crumb in
        if var url = crumb.data?["url"] as? String {
            url = redactQueryParams(url, keys: ["token", "access_token"])
            crumb.data?["url"] = url
        }
        return crumb
    }
}

5. Proprietăți de utilizator, acolo unde e posibil, dar doar ce chiar se folosește într-un dashboard, nu „ca să fie”.

6. Stiva de ecrane, teritoriu avansat, greu de urmărit manual. Cu tranziții custom, modale afișate peste tab bar-uri și navigation controllere imbricate, să-ți dai seama „pe ce ecran e de fapt utilizatorul” nu e ceva trivial. Unele SDK-uri preiau asta automat: Firebase Analytics face swizzling pe viewDidAppear și trimite singur screen_view, dar numele ecranului ajunge să fie numele clasei, nu întotdeauna ce vrei într-un dashboard.

// screen_view automat de la Firebase va arăta "CheckoutReviewViewController".
// Suprascrie explicit acolo unde numele clasei nu spune nimic util:
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
    AnalyticsParameterScreenName: "checkout_review",
    AnalyticsParameterScreenClass: String(describing: type(of: self)),
])

7. Un punct în care un utilizator sau un tester poate trimite loguri explicit. Trimiterea automată nu e suficientă în toate cazurile. Dacă un tester prinde un bug ciudat, un buton „Trimite loguri” e mult mai util decât să-l rogi să-și amintească fiecare pas. E important ca acest punct de intrare să rămână accesibil în orice stare a aplicației, logat sau delogat: un bug poate apărea la fel de ușor chiar pe ecranul de login, înainte ca utilizatorul să se fi autentificat. Asta înseamnă să bufferezi logurile local pe disc chiar înainte să ajungă undeva, apoi să le trimiți la o acțiune explicită:

enum DiagnosticLogBuffer {
    static func append(_ line: String) {
        // buffer circular cu dimensiune limitată, persistat pe disc
    }

    static func flushAndSend(reason: String) {
        SupportAPI.uploadDiagnostics(readAll(), reason: reason)
    }
}

// Un punct de intrare vizibil, meniu de debug, ecran de suport, shake gesture
Button("Trimite diagnostic") {
    DiagnosticLogBuffer.flushAndSend(reason: "user_initiated")
}

Nu ai nevoie neapărat de propriul server pentru asta. Pentru o echipă mică există o variantă mai simplă. Mai simplu e să exporți buffer-ul într-un fișier și să-l dai native share sheet-ului, atât pentru un tester, cât și pentru un utilizator obișnuit, dacă un bug ajunge totuși în producție: cel care trimite decide dacă merge pe Slack, AirDrop sau email. De obicei există deja un loc evident pentru asta pe Slack, un canal de suport sau de echipă unde ajung oricum genul ăsta de mesaje -, așa că nu mai trebuie inventată o destinație, iar tu nu trebuie să ridici nicio infrastructură pentru asta:

func shareDiagnosticLogs(from viewController: UIViewController) {
    let fileURL = DiagnosticLogBuffer.exportToFile()
    let activity = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
    viewController.present(activity, animated: true)
}

Ce trimiți către analytics

Când ai deja o interfață unică, apare tentația să trimiți totul în analytics. Fiecare tap, fiecare ecran, fiecare schimbare mică de stare. Eu aș evita asta. Dacă trimiți acolo fiecare fleac, dashboard-ul se transformă rapid în același zgomot despre care era vorba la începutul postării, doar că de data asta pe partea de produs, nu pe cea tehnică.

Alege cu grijă: evenimente importante de produs (checkout_started, payment_failed, onboarding_completed), și merită adăugate acolo și erorile critice care chiar rup fluxul utilizatorului. Sunt mai utile văzute direct în funnel, lângă pasul unde s-au întâmplat, decât într-un log separat, rupt de contextul acelui utilizator. Detaliile tehnice brute, stack trace-uri, stări intermediare, informații de debug, nu au ce căuta acolo: pentru asta există logging-ul și crash reporting-ul, discutate mai sus.

Fiecare unealtă are limite

Fiecare instrument are limite. Unele devin scumpe repede, altele au căutare slabă, iar unele sunt excelente pentru un tip de problemă și slabe pentru altul. De obicei e mai inteligent să rulezi mai multe sisteme deodată decât să mizezi pe unul singur: fiecare are puncte forte și slabe diferite, și asta o afli cu adevărat doar pe un proiect comercial real.

Mai este un detaliu practic la crash reporting: raportul nu ajunge pe server exact în momentul crash-ului. În acel punct procesul deja cade, așa că SDK-ul îl salvează de obicei local mai întâi. Firebase Crashlytics, de exemplu, îl încarcă doar la următoarea lansare a aplicației. Dacă utilizatorul nu mai redeschide aplicația, raportul ăla s-ar putea să nu ajungă niciodată pe server, sau să ajungă foarte târziu.

Sentry e construit altfel și prinde ceva ce un crash reporter obișnuit nici nu poate vedea, hang-uri ale aplicației (App Hangs pe iOS, ANR pe Android): situații în care thread-ul principal e blocat câteva secunde, dar aplicația nu se crapă efectiv.

Pentru orice e cu adevărat critic, să aștepți „următoarea lansare” nu e mereu convenabil, și aici e util exact mecanismul de la punctul 7 de mai sus: dacă aplicația are deja un loc explicit unde se adună toate datele și se trimit (același share sheet), poți refolosi asta și aici, în loc să construiești un canal separat doar pentru urgențe.

Și merită să ții cont separat de limite, mai ales la uneltele plătite, și să ai mereu o cale de a opri un sistem de logging dacă începe el însuși să creeze problema.