iOS Diagnostic Logs & Export - scopetech/Mobile-Public GitHub Wiki

iOS Diagnostic Logs & Export

How to capture SDK/app diagnostic logs and export them as a shareable bundle, so Scope support receives complete logs. Companion to iOS SmartDrive framework. Last updated: 20 Jul, 2026.

When you raise a support issue, Scope needs the app's diagnostic logs. This page shows the reference logging pipeline used by the demo app: capture events into a local store, roll them up into a per-day ZIP with an identifying header, and share that ZIP by email or the share sheet.

Important

The exported header is only useful when the user is authorized โ€” it reads SDApiManager.userId(), username(), unitSerialNumber(), baseUrl() and so on. If those come out empty, the problem is the session, not the logging. See iOS Session & App Lifecycle Management.

Table of contents

1. Overview

The pipeline has four stages:

capture            store                 build                  share
โ”€โ”€โ”€โ”€โ”€โ”€             โ”€โ”€โ”€โ”€โ”€                 โ”€โ”€โ”€โ”€โ”€                  โ”€โ”€โ”€โ”€โ”€
logEvent(...)  โ”€โ”€โ–บ LogEventManager  โ”€โ”€โ–บ  ReportManager      โ”€โ”€โ–บ  ReportComposer
health checks      (Core Data,           (per-day .txt in a      (email / share sheet)
SDK callbacks       7-day retention)      ZIP + header)
server errors

Everything funnels through one global logEvent(...) call into a Core Data store. On export, the store is grouped by day into text files, zipped with a header that identifies the user/vehicle/device, and handed to the share sheet.

2. The log store

2.1. Set up the Core Data store

The store is a small Core Data model with a single entity. Set it up once, in this order.

Step 1 โ€” Add the data model. File โ–ธ New โ–ธ File โ–ธ Data Model. The filename you choose is the model name, and you must pass the same name to NSPersistentContainer(name:) later. Note it down.

Step 2 โ€” Add the LogEvent entity with exactly these attributes:

Attribute Type
date Date
group Integer 16 (maps to LogGroup from SCPSmartDrive)
text String
type Integer 16 (maps to Trace / Warning / Error)

Step 3 โ€” Set Codegen to Manual/None. Select the entity, open the Data Model Inspector, and set Codegen = Manual/None. This is required because you provide the NSManagedObject subclass by hand (Step 4). Leaving it on the default "Class Definition" makes Xcode generate a second LogEvent, which fails to build with "invalid redeclaration of 'LogEvent'".

Step 4 โ€” Add the subclass files (from the demo):

class LogEvent: NSManagedObject {}

extension LogEvent {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<LogEvent> {
        return NSFetchRequest<LogEvent>(entityName: "LogEvent")
    }
    @NSManaged public var date: NSDate?
    @NSManaged public var type: NSNumber?
    @NSManaged public var group: NSNumber?
    @NSManaged public var text: String?
}

Step 5 โ€” Point the container at your model. In LogEventManager, NSPersistentContainer(name:) takes the model filename from Step 1 โ€” not a fixed string:

NSPersistentContainer(name: "Ayalon_iOS")   // must equal your .xcdatamodeld filename

Warning

Two mismatches account for almost every first-run failure here:

  • Container name โ‰  model filename โ†’ loadPersistentStores fails and the app hits fatalError on the first log. (The demo's model is SmartDriveDemo; in this screenshot it is Ayalon_iOS โ€” use whatever yours is called.)
  • Codegen left on "Class Definition" while also shipping the manual subclass โ†’ duplicate-LogEvent build error.

Note

Confirm the .xcdatamodeld is a member of your app target (File Inspector โ–ธ Target Membership), or the model won't be found at runtime.

2.2. LogEventManager

A singleton that owns the Core Data stack and writes on a background context. Key responsibilities: write a log, keep 7 days of history, save on background/terminate, capture crashes, and observe SDK server errors.

class LogEventManager: NSObject {
    static let shared = LogEventManager()
    private let entityName = "LogEvent"
    static let daysLimit = 7

    private lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "YourModelName")
        container.loadPersistentStores { _, error in
            if let error = error { fatalError("Core Data: \(error)") }
        }
        return container
    }()
    private lazy var privateManagedObjectContext = persistentContainer.newBackgroundContext()

    private override init() {
        super.init()
        // Trim old logs when the app becomes active
        NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive),
                                               name: UIApplication.didBecomeActiveNotification, object: nil)
        // Capture SDK server errors automatically
        NotificationCenter.default.addObserver(self, selector: #selector(logServerError(_:)),
                                               name: Notification.Name(SDApiManager.serverErrorNotificationName()), object: nil)
    }

    @objc private func appDidBecomeActive() { deleteExpiredLogs() }

    fileprivate func saveLog(_ type: LogType, group: LogGroup, groupInfo: String?, description: String) {
        privateManagedObjectContext.perform {
            let log = NSEntityDescription.insertNewObject(forEntityName: self.entityName,
                                                          into: self.privateManagedObjectContext) as! LogEvent
            log.date = NSDate()
            log.type = NSNumber(value: type.rawValue)
            log.group = NSNumber(value: group.rawValue)
            log.text = description
            try? self.privateManagedObjectContext.save()
        }
    }

    func saveContext() {
        let context = persistentContainer.viewContext
        if context.hasChanges { try? context.save() }
    }

    // exportLogs(from:completion:), deleteExpiredLogs(), saveCrash(), logServerError()
    // โ€” copy verbatim from the demo's LogEventManager.swift
}

The server-error observer is important: it automatically logs every failed API response (URL, request body, HTTP status, error reason) without you adding a call at each site:

@objc private func logServerError(_ notification: NSNotification) {
    guard let response = notification.userInfo?[SDApiManager.responseKeyNotificationName()] as? CCAPIResponse else { return }
    logEvent(.API, description: "Server error occured - [user: \(SDApiManager.userId()), url: \(response.requestUrl ?? ""), code: \(response.httpStatusCode), reason: \(response.errorReason ?? "")]")
}

2.3. Global log functions

Expose two free functions so logging is a one-liner anywhere in the app:

func logEvent(_ group: LogGroup, groupInfo: String? = nil, description: String) {
    LogEventManager.shared.saveLog(.Trace, group: group, groupInfo: groupInfo, description: description)
}

func logCrash(description: String) {
    LogEventManager.shared.saveCrash(description: description)
}

LogGroup is provided by SCPSmartDrive (cases like .TripStart, .SLC, .API, .AppBackground). Use LogEventManager.logGroupTitle(_:) from the demo to render readable names if you display logs in-app.

3. What to log and where

Wire logEvent (and the health check) at the points the demo does โ€” these are what make a log useful for diagnosing session and trip issues:

  • App lifecycle (AppDelegate): initializeFeatures, onDidBecomeActive, onDidEnterBackground, onTerminate.
  • Health snapshots: SmartDriveHelper.logHealthCheck(trigger:) at launch, background, trip start, and trip end. This one snapshot captures authorization, permissions, recorder state, and trip counts โ€” the single most useful entry for support.
  • Trip callbacks (TripWrapper delegate): tripStarted, tripEnded.
  • Server errors: handled automatically by the observer in ยง2.2.

Tip

Call saveContext() from applicationDidEnterBackground and applicationWillTerminate so buffered logs are flushed before suspension โ€” otherwise the most relevant entries (right before a crash or termination) can be lost.

4. Building the report

ReportManager reads the store and produces a ZIP: one dd-MMM-yyyy.txt per day (last 7 days), each line HH:mm:ss - message, prefixed by an identifying header.

Dependency: ZIPFoundation. The report builder is the only part of this pipeline that needs it. The demo pulls it in via CocoaPods โ€” its entire Podfile is:

platform :ios, '15.0'
workspace 'SmartDriveDemo.xcworkspace'

target 'SmartDriveDemo' do
  use_frameworks!

  pod 'ZIPFoundation'
end

Then pod install, and open the generated .xcworkspace (not the .xcodeproj). Set platform to match your app's deployment target. If you already consume the SDK via Swift Package Manager, you can instead add ZIPFoundation as an SPM dependency and skip CocoaPods entirely โ€” either works. General CocoaPods/SPM coexistence is covered on the Consolidated SPM page ยง6.

Tip

Two CocoaPods issues come up often on these projects. Both are fixed in the Podfile, not by editing build settings by hand:

  • Deployment-target warnings from pods โ†’ normalize them in a post_install hook rather than per-pod.
  • Build script sandboxing errors โ†’ set ENABLE_USER_SCRIPT_SANDBOXING to NO.
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
      config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
    end
  end
end

The header is what lets support match logs to an account and device:

<App> vX.Y(build), <bundleId>
USER ID: <SDApiManager.userId()>
USERNAME: <SDApiManager.username()>
PVU: <SDApiManager.pvu()>
VEHICLE UNIT SERIAL NUMBER: <SDApiManager.unitSerialNumber()>
VEHICLE NAME: <SDApiManager.vehicleName()>
BASE URL: <SDApiManager.baseUrl()>
SYSTEM: <iOS version>
DEVICE: <model identifier>
APP TYPE: <SMART | BEACON>
LOG CREATED - <timestamp>

Copy ReportManager.swift from the demo as-is. It depends on AppDelegate.documentDirectory (where the ZIP is written) and AppDelegate.shared.appType; provide those, or replace with your own document-directory URL and app-type string.

ReportManager.shared.createLogData { data in
    // `data` is the zipped bundle; ReportManager.shared.dataURL is the file on disk
}

5. Sharing the report

ReportComposer presents an action sheet with Email (MFMailComposeViewController, attaches the ZIP) and More (UIActivityViewController share sheet). Trigger it from any screen โ€” the demo uses an Export logs button:

@IBAction func exportLogs(_ sender: UIButton) {
    ReportComposer.shared.showDialog(at: self)
}

Copy ReportComposer.swift from the demo. Email requires a device with Mail configured (MFMailComposeViewController.canSendMail()); the share sheet works regardless (AirDrop, Files, messaging apps).

6. Sending logs to Scope support

Ask the user to reproduce the issue, then tap Export logs and send the ZIP. Before sending, open the first .txt and confirm the header fields are populated โ€” an empty USER ID / BASE URL means the log was captured while logged out (see ยง7).

7. Troubleshooting

Symptom Likely cause Fix
Header fields (USER ID, BASE URL) empty Log captured while not authorized โ€” a session problem Fix session handling; see the lifecycle page
ZIP is empty / "no logs" logEvent never called, or logs older than 7 days Wire ยง3; check retention window
Recent entries missing Context not flushed before suspend/terminate Call saveContext() on background and terminate
Email option does nothing Mail not set up on device Use the share sheet (More) instead
No health-check entries SmartDriveHelper.logHealthCheck not called Add it at launch/background/trip start/trip end

Reference

  • Demo files: LogEventManager.swift, LogEventManager extensions, ReportManager.swift, ReportComposer.swift, LogEvent+CoreData*.
  • Flutter Diagnostic Logs & Export โ€” the Flutter equivalent.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ