Appearance
Analytics & Telemetry
Anonymous opt-in telemetry — consent flow, device identity, data collected, API endpoint, and KV schema.
Overview
LucidPal collects a small amount of anonymous device telemetry, but only with explicit user consent. Consent is requested once, after the user's first completed Agent response. If denied, no data is ever sent. If granted, a one-time payload is sent to the API and stored permanently in Cloudflare KV.
For the psychology behind the consent prompt timing and copy, see Freemium Psychology.
Consent State Machine
Managed by AnalyticsConsentManager (@MainActor final class).
.unknown ──(grant)──▶ .granted ──▶ sendTelemetry()
──(deny)───▶ .deniedPersisted in UserDefaults under key analyticsConsent (raw string value). Loaded synchronously at init — no async wait.
shouldShowPrompt
swift
var shouldShowPrompt: Bool {
consent == .unknown && hasDeliveredFirstValue
}hasDeliveredFirstValue is a separate UserDefaults flag (analyticsFirstValueDelivered) set by markFirstValueDelivered(). This two-flag design ensures the prompt never fires before the user has seen value from the app.
Trigger in ContentView
swift
// 1. Mark value delivered when Agent finishes a task
.onChange(of: agentViewModel.taskState) { _, state in
if case .done = state { analyticsConsentManager.markFirstValueDelivered() }
}
// 2. Show prompt on next app foreground (1.5s delay)
.onChange(of: scenePhase) { _, phase in
if phase == .active && analyticsConsentManager.shouldShowPrompt {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
showAnalyticsConsent = true
}
}
}The 1.5s delay prevents the sheet appearing on top of the active agent response.
Telemetry Payload
Sent once, on grant(), to POST /analytics/telemetry. Guarded by analyticsTelemetrySent UserDefaults flag — will not re-send on reinstall or if the endpoint previously returned a non-200.
| Field | Source | Example |
|---|---|---|
deviceId | DeviceIdentityService.deviceId (Keychain UUID) | "A1B2C3D4-..." |
deviceModel | sysctlbyname("hw.machine") | "iPhone17,2" |
osVersion | UIDevice.current.systemVersion | "iOS 18.4" |
appVersion | CFBundleShortVersionString + CFBundleVersion | "1.2.0 (47)" |
country | Locale.current.region?.identifier | "CA" |
locale | Locale.current.identifier | "en_CA" |
What is never collected:
- Conversation content
- Note or calendar data
- GPS location
- Any personally identifiable information
API Endpoint
POST /analytics/telemetry
Auth: None — unauthenticated public endpoint.
Route file: apps/lucidpal-api/src/routes/analytics.ts
Request body:
json
{
"deviceId": "string",
"deviceModel": "string",
"osVersion": "string",
"appVersion": "string",
"country": "string",
"locale": "string"
}Behaviour:
- Checks KV
telemetry:{deviceId}— if already exists, returns{ ok: true }immediately (idempotent, no overwrite). - Writes payload to KV
telemetry:{deviceId}— permanent, no expiry. - Returns
{ ok: true }.
Errors:
400— missing required fields500— KV write failure
KV namespace: SESSIONS (shared with transcription session tokens).
KV Schema
| Key | Value | TTL | Purpose |
|---|---|---|---|
telemetry:{deviceId} | JSON payload (all fields above) | Permanent | One record per device |
Device Identity (DeviceIdentityService)
All telemetry is keyed by a stable device UUID generated on first launch and stored in Keychain.
swift
// Services/DeviceIdentityService.swift
final class DeviceIdentityService {
static var deviceId: String { load() ?? createAndStore() }
private static func createAndStore() -> String {
let id = UUID().uuidString
KeychainHelper.save(id, forKey: key,
accessibility: kSecAttrAccessibleAfterFirstUnlock)
return id
}
}Keychain accessibility: kSecAttrAccessibleAfterFirstUnlock — survives app deletion and reinstallation. Only a device factory reset (or manual Keychain wipe) generates a new UUID.
Uses beyond analytics:
X-Device-IDheader onPOST /transcription/session— server-side trial balance keytrial_secs:{deviceId}
AnalyticsConsentSheet — UI
Sources/Views/AnalyticsConsentSheet.swift
Sheet presented as .presentationDetents([.large]). Two callbacks: onGrant and onDeny.
Structure:
- Header: icon + "Help shape LucidPal" (identity framing)
- Green checkmark rows — what is collected (4 items)
- Divider
- "We will never collect:" label + red X rows (3 items)
- Fine print: "Anonymous. Never sold. Helps us prioritize features for your device."
- Primary CTA (orange, full-width): "Yes, help improve LucidPal"
- Secondary link (grey): "No thanks"
No navigation, no dismissal gesture — user must choose one of the two actions.
Adding New Telemetry Fields
- Add the field to
AnalyticsConsentManager.sendTelemetry()payload dict. - Update the
POST /analytics/telemetryZod schema inanalytics.ts. - Update the KV write to include the new field.
- Update
AnalyticsConsentSheet— add a green checkmark row if the data is visible to the user. - Update this document.
Do not add any field that identifies the user personally, includes content (messages, notes, events), or includes precise location.