Skip to content

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.


Managed by AnalyticsConsentManager (@MainActor final class).

.unknown  ──(grant)──▶  .granted  ──▶  sendTelemetry()
          ──(deny)───▶  .denied

Persisted 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.

FieldSourceExample
deviceIdDeviceIdentityService.deviceId (Keychain UUID)"A1B2C3D4-..."
deviceModelsysctlbyname("hw.machine")"iPhone17,2"
osVersionUIDevice.current.systemVersion"iOS 18.4"
appVersionCFBundleShortVersionString + CFBundleVersion"1.2.0 (47)"
countryLocale.current.region?.identifier"CA"
localeLocale.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:

  1. Checks KV telemetry:{deviceId} — if already exists, returns { ok: true } immediately (idempotent, no overwrite).
  2. Writes payload to KV telemetry:{deviceId} — permanent, no expiry.
  3. Returns { ok: true }.

Errors:

  • 400 — missing required fields
  • 500 — KV write failure

KV namespace: SESSIONS (shared with transcription session tokens).


KV Schema

KeyValueTTLPurpose
telemetry:{deviceId}JSON payload (all fields above)PermanentOne 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-ID header on POST /transcription/session — server-side trial balance key trial_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

  1. Add the field to AnalyticsConsentManager.sendTelemetry() payload dict.
  2. Update the POST /analytics/telemetry Zod schema in analytics.ts.
  3. Update the KV write to include the new field.
  4. Update AnalyticsConsentSheet — add a green checkmark row if the data is visible to the user.
  5. Update this document.

Do not add any field that identifies the user personally, includes content (messages, notes, events), or includes precise location.

Internal — not for distribution