Monitoring iOS App Intents and Apple Intelligence Extensions in Production
Modern iOS apps increasingly rely on App Intents to power Siri, Shortcuts, and now Apple Intelligence experiences. But once you ship, visibility into how those background app extensions behave on real devices becomes challenging. This guide focuses on production-grade iOS App Intents debugging and telemetry, shows how to integrate with Apple Intelligence using App Entities, and explains how to measure Siri intents performance in a safe, privacy-conscious way.
You’ll learn how to:
- Instrument App Intents with os.Logger and signposts
- Aggregate App Intents telemetry across background app extensions in iOS
- Use MetricKit to gather crash and performance diagnostics (including extension data)
- Integrate App Entities so Apple Intelligence can resolve your domain models
- Debug with Console, Instruments, and SiriKit diagnostics in practice
Version prerequisites
- Xcode 16+
- Swift 5.10+
- iOS 16+ for App Intents; iOS 18+ for Apple Intelligence integration benefits
- BackgroundTasks, MetricKit, OSLog, AppIntents frameworks
Why production monitoring for App Intents matters
App Intents run inside a background extension process with strict time, memory, and CPU limits. Cold starts are real: the system can terminate the extension between invocations. Apple Intelligence and Siri may invoke your intents with highly varied natural language and entity resolution states. Without proper instrumentation you won’t know:
- How often intents fail or get cancelled by the system/user
- Where latency comes from (cold start, network, database, or UI rendering in AppIntentsUI)
- Which phrases/users scenarios are hardest to resolve
- Whether a new build regresses Siri intents performance
A production-ready monitoring strategy is essential.
Architecture overview: App Intents, Apple Intelligence, and extensions
- App Intents are defined in your code and executed in a background app extension. Prefer a dedicated “App Intents Extension” target to keep dependencies minimal.
- Apple Intelligence (iOS 18) uses system-level natural language understanding to interpret user requests and resolve your App Entities. You integrate by providing AppEntity and EntityQuery implementations, plus AppShortcuts for discoverability.
- Extensions are short-lived. Don’t schedule long work or background tasks from the extension. Persist any telemetry to an App Group and let the main app aggregate and upload later via BGTaskScheduler.
- MetricKit delivers daily payloads to the main app that include metrics and diagnostics for the app and its extensions.
Telemetry strategy for background app extensions in iOS
Goals
- Minimal overhead in extension process
- Consistent logs across app and extensions
- Aggregation and upload only from the main app
- Privacy-first: avoid sensitive content in logs; give users an opt-in control
Key building blocks
- os.Logger for structured, privacy-aware logs
- OSSignposter for performance intervals and Instruments integration
- App Group storage for durable telemetry between extension and app
- BGProcessingTask in the app to upload metrics
- MetricKit for system-collected stability and performance data (including extensions)
Production-ready logging with os.Logger and signposts
Define a unified subsystem and categories you ’ll use in both the app and the App Intents extension.
// Shared module (SPM) used by app + extension
import os
public enum Log {
public static let subsystem = "com.acme.todo"
public static let intents = Logger(subsystem: subsystem, category: "AppIntents")
public static let metrics = Logger(subsystem: subsystem, category: "Metrics")
}
Measure key intervals with signposts. Instruments can aggregate these automatically.
import os
public struct TelemetryEvent: Codable {
public enum Outcome: String, Codable { case success, failure, cancelled }
public let name: String
public let startedAt: Date
public let endedAt: Date?
public let durationMs: Double?
public let outcome: Outcome?
public let coldStart: Bool
public let error: String?
}
public actor TelemetryClient {
public static let shared = TelemetryClient()
private let signposter = OSSignposter()
private var isWarm = false
private let fileURL: URL
public init(appGroupId: String = "group.com.acme.todo") {
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!
self.fileURL = container.appendingPathComponent("telemetry.jsonl")
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}
}
public struct IntervalToken {
let name: StaticString
let state: OSSignpostIntervalState
let start: ContinuousClock.Instant
let coldStart: Bool
}
public func begin(_ name: StaticString, meta: [String: String] = [:]) -> IntervalToken {
let cold = !isWarm; isWarm = true
if !meta.isEmpty {
Log.intents.log("\(name, privacy: .public) meta=\(String(describing: meta), privacy: .public) coldStart=\(cold)")
} else {
Log.intents.log("\(name, privacy: .public) coldStart=\(cold)")
}
let state = signposter.beginInterval(name)
return IntervalToken(name: name, state: state, start: .now, coldStart: cold)
}
public func end(_ token: IntervalToken, outcome: TelemetryEvent.Outcome, error: Error? = nil) async {
signposter.endInterval(token.name, token.state)
let durMs = Double(ContinuousClock().duration(from: token.start, to: .now).components.attoseconds) / 1_000_000_000_000.0
let event = TelemetryEvent(
name: String(describing: token.name),
startedAt: Date(),
endedAt: Date(),
durationMs: durMs,
outcome: outcome,
coldStart: token.coldStart,
error: error.map { String(describing: $0) }
)
await persist(event)
}
private func persist(_ event: TelemetryEvent) async {
do {
let data = try JSONEncoder().encode(event)
if let handle = try? FileHandle(forWritingTo: fileURL) {
try handle.seekToEnd()
try handle.write(contentsOf: data)
try handle.write(contentsOf: Data("\n".utf8))
try handle.close()
}
} catch {
Log.metrics.error("Telemetry persist failed: \(error.localizedDescription, privacy: .public)")
}
}
}
Notes
- Use privacy annotations to avoid leaking PII in logs.
- Signposts are lightweight and ideal for Siri intents performance measurements.
- The JSONL file is a simple, append-only buffer. The main app will batch and upload.
Implementing a monitored App Intent
Below is a realistic intent that creates a task via your backend. It instruments start/end, handles cancellation, and ensures fast execution.
import AppIntents
struct CreateTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Create Task"
static var description = IntentDescription("Create a task in your Acme account.")
@Parameter(title: "Title")
var title: String
@Parameter(title: "Due Date")
var dueDate: Date?
static var parameterSummary: some ParameterSummary {
Summary("Create \(\.$title) due \(\.$dueDate)")
}
func perform() async throws -> some IntentResult & ProvidesDialog {
let token = await TelemetryClient.shared.begin("CreateTask", meta: ["titleLength": "\(title.count)"])
do {
try Task.checkCancellation()
let created = try await TaskAPI.shared.createTask(title: title, dueDate: dueDate)
await TelemetryClient.shared.end(token, outcome: .success)
return .result(
dialog: IntentDialog("Created “\(created.title)”.")
)
} catch is CancellationError {
await TelemetryClient.shared.end(token, outcome: .cancelled)
throw CancellationError()
} catch {
await TelemetryClient.shared.end(token, outcome: .failure, error: error)
throw error
}
}
}
// Minimal network layer for the example
actor TaskAPI {
static let shared = TaskAPI()
private let session = URLSession(configuration: {
let c = URLSessionConfiguration.ephemeral
c.waitsForConnectivity = false
c.timeoutIntervalForRequest = 5
c.timeoutIntervalForResource = 8
return c
}())
struct CreatedTask: Decodable { let id: String; let title: String }
func createTask(title: String, dueDate: Date?) async throws -> CreatedTask {
struct Body: Encodable { let title: String; let dueDate: Date? }
var req = URLRequest(url: URL(string: "https://api.acme.com/tasks")!)
req.httpMethod = "POST"
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(Body(title: title, dueDate: dueDate))
let (data, resp) = try await session.data(for: req)
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(CreatedTask.self, from: data)
}
}
Best practices for performance
- Keep the App Intents extension small: avoid heavy frameworks and large static inits.
- Aim for
<300mscold-start visible work;<1send-to-end latency for great Siri UX. - Support cancellation early: check Task.isCancelled at network boundaries.
- Fail fast with actionable dialog messages; the system can present them to the user.
Apple Intelligence integration: App Entities and queries
Apple Intelligence benefits when your app provides entities and resolvers. This improves natural-language understanding and disambiguation in Siri and system experiences.
import AppIntents
struct TaskEntity: AppEntity, Identifiable {
static var typeDisplayName = LocalizedStringResource("Task")
static var typeDisplayNamePlural = LocalizedStringResource("Tasks")
static var defaultQuery = TaskQuery()
var id: String
var title: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(title)")
}
}
struct TaskQuery: EntityQuery {
// Suggested items for disambiguation UI
func suggestedEntities() async throws -> [TaskEntity] {
let token = await TelemetryClient.shared.begin("TaskQuery.suggested")
defer { Task { await TelemetryClient.shared.end(token, outcome: .success) } }
let recent = try await TaskStore.shared.fetchRecent()
return recent.map { TaskEntity(id: $0.id, title: $0.title) }
}
// Resolve by identifiers (used internally by the system)
func entities(for identifiers: [TaskEntity.ID]) async throws -> [TaskEntity] {
let token = await TelemetryClient.shared.begin("TaskQuery.byId", meta: ["count": "\(identifiers.count)"])
defer { Task { await TelemetryClient.shared.end(token, outcome: .success) } }
let items = try await TaskStore.shared.fetch(by: identifiers)
return items.map { TaskEntity(id: $0.id, title: $0.title) }
}
// Free-text matching for natural language phrases
func entities(matching string: String) async throws -> [TaskEntity] {
let token = await TelemetryClient.shared.begin("TaskQuery.search", meta: ["q": String(string.prefix(32))])
defer { Task { await TelemetryClient.shared.end(token, outcome: .success) } }
let results = try await TaskStore.shared.search(text: string)
return results.map { TaskEntity(id: $0.id, title: $0.title) }
}
}
Declare app-level phrases for discovery:
struct AcmeShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
[
AppShortcut(
intent: CreateTaskIntent(),
phrases: [
"Create a task in Acme",
"Add a todo in Acme"
],
shortTitle: "Create Task",
systemImageName: "plus.circle"
)
]
}
}
Why this matters
- EntityQuery helps Apple Intelligence resolve “create a task due tomorrow for marketing” by mapping user language to your domain models.
- Instrumenting queries provides insight into resolution failures, a frequent source of SiriKit diagnostics in production.
Aggregating and uploading telemetry from the app
Extensions can’t schedule background tasks. Read the App Group file in the app and upload with BGTaskScheduler.
App registration (AppDelegate/SceneDelegate):
import BackgroundTasks
import MetricKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.acme.todo.telemetry-upload", using: nil) { task in
guard let task = task as? BGProcessingTask else { return }
Task { await TelemetryUploader.handle(task) }
}
MetricsManager.shared.start() // MetricKit
return true
}
func applicationDidEnterBackground(_ application: UIApplication) {
TelemetryUploader.schedule()
}
}
Uploader implementation:
import BackgroundTasks
enum TelemetryUploader {
static let identifier = "com.acme.todo.telemetry-upload"
static func schedule() {
let r = BGProcessingTaskRequest(identifier: identifier)
r.requiresNetworkConnectivity = true
r.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try? BGTaskScheduler.shared.submit(r)
}
static func telemetryURL(appGroupId: String = "group.com.acme.todo") -> URL {
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)!
return container.appendingPathComponent("telemetry.jsonl")
}
static func handle(_ task: BGProcessingTask) async {
let file = telemetryURL()
defer { task.setTaskCompleted(success: true) }
guard let data = try? Data(contentsOf: file), !data.isEmpty else { return }
// Upload to your endpoint (respect user consent + App Privacy)
do {
var req = URLRequest(url: URL(string: "https://metrics.acme.com/ingest")!)
req.httpMethod = "POST"
req.addValue("application/x-ndjson", forHTTPHeaderField: "Content-Type")
req.httpBody = data
let (_, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return }
try? FileManager.default.removeItem(at: file)
} catch { /* Transient error – leave file for next run */ }
}
}
Privacy and App Store considerations
- Provide a user-facing toggle for analytics; don’t log PII or raw voice text.
- Declare analytics in your Privacy Nutrition Labels and Privacy Manifest.
- Don’t upload Apple-provided Siri transcripts or any content outside your scope.
MetricKit: system diagnostics for app and extensions
MetricKit delivers daily payloads to your app. These include extension crashes, hangs, and performance metrics.
import MetricKit
import os
final class MetricsManager: NSObject, MXMetricManagerSubscriber {
static let shared = MetricsManager()
func start() {
MXMetricManager.shared.add(self)
}
// Delivered roughly every 24 hours on device (not Simulator)
func didReceive(_ payloads: [MXMetricPayload]) {
for p in payloads {
Log.metrics.log("MX metrics: \(String(describing: p.jsonRepresentation()), privacy: .private)")
// Optionally upload as part of your metrics pipeline
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for d in payloads {
Log.metrics.log("MX diagnostics: \(String(describing: d.jsonRepresentation()), privacy: .private)")
}
}
}
Notes
- You’ll see extension attribution in payload JSON. Use this to track crashes or high memory in your App Intents extension.
- Payloads are not delivered in development immediately; use a real device, run for at least 24h, and ensure the app remains installed.
iOS App Intents debugging: tools and workflows
Use these workflows for practical SiriKit diagnostics and iOS App Intents debugging in production-like environments.
Console.app filters
- Connect your device and open Console.app (macOS).
- Filter by subsystem “com.acme.todo” or category “AppIntents” to view your logs.
- Filter for process “assistantd” and “Siri” to observe system-side errors and utterance handling.
- Inspect your signposts by enabling “Points of Interest” in Console or use Instruments.
Instruments sessions
- Use the “Points of Interest” template to view signpost intervals from your extension.
- Combine with “Time Profiler” or “Allocations” to find cold-start hotspots or excessive allocations.
Shortcuts debugging
- Add your intent to a Shortcut and run repeatedly.
- Validate parameter resolution, error dialogs, and latency with signposts over multiple runs.
Common issues
- Extension timeouts: keep network timeouts aggressive (5–8s) and fail fast with useful dialog text.
- Memory spikes: avoid loading large frameworks or data at init; lazy-load only what you need.
- Cancellation: always check Task.isCancelled before and after network calls.
