Privacy-safe PostHog instrumentation for the macOS integration connect lifecycle
(Calendar, Gmail, Apple Notes, Local Files, X, and the ChatGPT/Claude memory-log
connectors). Closes the macOS half of a gap the Flutter app already covers:
Flutter emits Integration Connect Attempted/Succeeded/Failed
(app/lib/utils/analytics/analytics_manager.dart) but the macOS Swift app
previously emitted nothing for any connector connect/import outcome, so the
connect funnel was unmeasurable by build cohort on macOS.
- Value-type namespace:
Sources/Integrations/IntegrationConnectTelemetry.swift - Facade + test seam:
AnalyticsManager.integrationConnectAttempted/Succeeded/Failed - This doc + the namespace are the single source of truth for event names and dimensions. Do not add a parallel convention.
| Event | Emitted at | Owner |
|---|---|---|
Integration Connect Attempted |
Start of a user-initiated connect | ConnectorImportRunner.start (Apps tab, all 7 connectors); onboarding connectContext (Calendar/Gmail verify probe) |
Integration Connect Succeeded |
Terminal success | ConnectorImportRunner.finish(.success); onboarding verify probe .connected |
Integration Connect Failed |
Terminal failure | ConnectorImportRunner.finish(.failure); onboarding verify probe .needsSignIn/.error |
Event-name strings are stable and MUST stay byte-identical with Flutter so PostHog aggregates the connect funnel across iOS/Android/macOS.
Every payload is built by IntegrationConnectTelemetry.*Payload(...) and passed
through an allow-list filter, so a caller cannot leak an undeclared key.
| Key | Type | Source |
|---|---|---|
integration_name |
String | Display name matching Flutter (Google Calendar, Gmail, …) — cross-platform aggregation key |
connector_id |
String | macOS internal id (calendar, email, apple-notes, …) |
surface |
closed enum | apps · onboarding |
stage |
closed set | import (Apps runner) · verify (onboarding probe) |
error_class |
closed enum (Failed only) | Union of GmailFailureClass/CalendarFailureClass raw values + PostHogManager.diagnosticErrorClass outputs |
reconnect_required |
Bool (Failed only) | Derived from error_class — true for reauth-needed classes |
duration_bucket |
closed enum (terminal, optional) | 0_1s 1_3s 3_10s 10_30s 30_60s 60s_plus |
source_count_bucket |
closed enum (Succeeded, optional) | 0 1_10 11_50 51_200 201_500 500_plus |
memory_count_bucket |
closed enum (Succeeded, optional) | as above |
was_first_sync |
Bool | true when no prior successful sync existed for this connector+user |
Context dims are NOT in the payload. platform, app_version,
app_build, and update_channel are attached automatically to every event by
PostHog super-properties registered once at PostHogManager.initialize(). Do
not re-add them (the Auth Flow helper's per-event re-addition is redundant).
Payloads carry only the bounded dimensions above. Never emit tokens,
cookies, OAuth credentials, email/calendar/note content, account IDs, URL query
strings, raw exception text, local file paths, or browser profile names.
error_class is always a closed-enum value derived from the connector-native
failure enum or PostHogManager.diagnosticErrorClass — never the raw
message/errorDescription/localizedDescription (those stay in RunState
for the UI). The allow-list filter is defense-in-depth, mirroring the
content-key denylist in DesktopDiagnosticsManager.
- attempts =
count(Integration Connect Attempted) - initial-sync successes =
count(Integration Connect Succeeded where surface=apps) - verified connections =
count(Integration Connect Succeeded where surface=onboarding, stage=verify) - terminal failures =
count(Integration Connect Failed where error_class not in {no_content})— excludeno_content(a memory-log parse that produced nothing durable is a successful no-op, not a connect failure; surfaced as Failed only to carry UI guidance) - reconnect-required users =
countDistinct(Integration Connect Failed where reconnect_required=true) - degraded (transient) failures =
count(Integration Connect Failed where error_class in {network, unknown}) - configuration failures =
count(Integration Connect Failed where error_class=configuration)(Calendar API key missing/invalid — previously silent) - connect-success rate =
succeeded / (attempted)(per surface) - distinct users = PostHog
uniqueDistinctId()overAttempted - latency =
duration_bucketdistribution
These are product-funnel events routed AnalyticsManager →
PostHogManager.track. They are DISTINCT from
DesktopDiagnosticsManager.recordFallback (desktop_health_event /
fallback_triggered), which is reserved for fail-open resilience. A connect
MODE change (e.g. OAuth↔cookie fallback) should additionally call
recordFallback; the two streams are complementary, not duplicative. Do not
route connect outcomes through recordFallback (they are user-initiated actions
with explicit terminal outcomes, matching the Auth Flow * channel, not
fail-open resilience).
- Every real attempt resolves to exactly one terminal (Succeeded or
Failed). No orphan Attempted, no double-terminal —
ConnectorImportRunnerdeduplicates starts and run-token-guards finishes. - No-op ≠ failure: a background reverify that succeeds never emits Failed.
reconnect_requiredis derived from the classifiederror_class, so reauth-needed users are a queryable subset of Failed, not a separate event.- Cancelled/deduplicated starts emit nothing (the
tasks[connectorID] == nilguard instartsuppresses duplicate attempts).
Integration Disconnected(Flutter has it) — no disconnect action exists for the cookie-based connectors on macOS today; deferred until a real disconnect owner exists.- Notion / Memory-bank MCP connectors (
NotionMCPConnector,MemoryBankConnector) — separate OAuth/MCP owners; each needs its own bounded event. Deferred. - The Settings → Gmail Reader surface and the legacy onboarding background
insights path (
OnboardingPagedIntroCoordinator) — separate call sites for the same providers; deferred to avoid double-counting the same funnel.