Skip to content

feat(telemetry): wire up the analytics catalog, identify users, fix opt-out precedence - #367

Merged
AprilNEA merged 5 commits into
masterfrom
feat/telemetry-events
Aug 11, 2026
Merged

feat(telemetry): wire up the analytics catalog, identify users, fix opt-out precedence#367
AprilNEA merged 5 commits into
masterfrom
feat/telemetry-events

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Aug 7, 2026

Copy link
Copy Markdown
Member

Why

PostHog has been connected and sending since the Info.plist injection fix (#246) — the shipped 1.34.0 bundle carries a real PostHogAPIKey, and the SDK's local queue drains against us.i.posthog.com. But what it was sending was close to useless:

  • 10 of the 17 events in Analytics.Event had no call site. Only startup, error, slow-call, and diagnostic events ever fired. No container, image, Kubernetes, terminal, or settings usage was visible at all.
  • Every event was anonymous. identify() was never called, so with personProfiles = .identifiedOnly PostHog built no person profiles.
  • Nothing could be segmented. No super properties, so error_occurred volume couldn't be attributed to a hypervisor backend, release channel, or environment.

What changed

1. Instrument the dead events (0390d10)

Every event in the catalog now has a call site.

Event Where
container_started / stopped / removed gRPC single, Docker single, Docker batch
container_created ContainersViewModel+GRPC (Docker API path)
image_pulled / image_removed ImagesViewModel+Docker
k8s_enabled / k8s_disabled KubernetesState
terminal_opened Docker (container/image), Machine, Sandbox, external launcher
settings_opened ApplicationCoordinator.showSettings

Conventions:

  • Success only — failures already emit error_occurred via ErrorReporting, so double-reporting would make success rates uncomputable.
  • Container events carry backend (grpc/docker) and batch, keeping the two engine paths and bulk actions separable.
  • terminal_opened fires where the session actually reaches .connected, not at the open request — otherwise "Docker CLI not found" would count as a terminal open.
  • Properties stay low-cardinality: no IDs, names, image references, or file paths. Image references in particular can encode private registry paths.

app_launched is dropped: the SDK's captureApplicationLifecycleEvents already emits Application Installed/Opened/Backgrounded on macOS (ApplicationLifecyclePublisher.swift:78).

2. Identity and super properties (f1f9b83)

Identity mirrors AuthSession from the coordinator via withObservationTracking, following the existing daemon/startup idiom — ArcBoxAuth stays free of any analytics dependency. distinct_id is the OIDC subject; email, name, and email_verified become person properties. Sign-out calls reset(). Re-identify is keyed on the whole AuthIdentity because loadUserInfo() enriches it after sign-in.

Super properties: arcbox_profile and update_channel at setup, system_vm_backend from SystemVmBackendModel (funnelled through one setter so the three assignment sites can't drift), k8s_active from KubernetesState. $app_version, $os_version, and $device_type come from the SDK already.

This also fixes a race: the Privacy toggle's opt-in/opt-out moves from GeneralSettingsView into defaultsDidChange. Both used to be driven by the same UserDefaults write with no ordering guarantee, and the SDK drops identify while opted out — so a user who signed in before enabling telemetry would have stayed anonymous forever.

3. Opt-out precedence (5647d0d)

setup() lets the SDK's own persisted .optOut flag override config.optOut (PostHogSDK.swift:128-130), so the value derived from telemetryEnabled was only a fallback for a fresh install. Once the two diverged, PostHog's copy silently won on every launch. The preference is now restated right after setup.

reset() also deletes the registered-properties key (PostHogStorage.swift:403), which dropped all four super properties from every event for the rest of a session after sign-out. They describe the install, not the person, so Analytics keeps its own mirror and re-registers.

Privacy — needs a second opinion

The toggle previously read "Share anonymous usage data … No personal data is collected." That is no longer true once an account is linked, so it now reads:

Share usage data
Help improve ArcBox by sharing feature usage statistics. While you are signed in, this is linked to your account.

The wording should be reviewed, and the privacy policy needs PostHog listed as a processor along with the email / name fields. That part is outside this PR.

Worth stating explicitly since it's easy to misread: signing in was never a gate on collection. capture does not require person processing (PostHogSDK.swift:906-912), so events from users who never sign in were always collected as anonymous events. Signing in only adds a person profile.

One migration note: users who turned telemetry off in an older build but whose telemetryEnabled default reads true will flip back to opted in. The two should never have diverged — the toggle always wrote both — but the alternative (letting the SDK's persisted value win and back-filling UserDefaults) is a real option if stale state is a concern.

Verification

  • make lint — 0 violations in 285 files
  • make test — 227 tests, 0 failures
  • make build in both Debug and Release. This mattered: the branch that actually sends telemetry sits behind #if !DEBUG, so a Debug-only build never type-checks it.
  • Confirmed every catalog case now has a call site outside Analytics.swift.

No files added or removed, so project.pbxproj is untouched and xcodegen does not need to re-run.

No tests

These are straight-line capture/register calls with no branching or transformation, against the PostHogSDK.shared singleton with no injection seam — tests would only restate the implementation. The one piece with real logic, syncAnalyticsIdentity's dedupe, depends on a live @MainActor AuthSession; making it testable means giving AuthSession a test double, which is an auth-package refactor rather than part of this change. Happy to do that separately if it's wanted.

Ten of the seventeen events in `Analytics.Event` had no call site, so
PostHog only ever received startup, error, slow-call, and diagnostic
events. Wire up the container, image, Kubernetes, terminal, and settings
events at their real success points.

Conventions applied throughout:
- fire on success only; failures already emit `error_occurred`
- container events carry `backend` (grpc/docker) and `batch`, so the two
  engine paths and bulk actions stay separable
- terminal events carry `surface`, captured where the session actually
  reaches `.connected` rather than at the open request
- properties stay low-cardinality: no IDs, names, image references, or paths

Drop `app_launched`: the SDK's `captureApplicationLifecycleEvents` already
emits Application Installed/Opened/Backgrounded on macOS.
Events were entirely anonymous: `identify` was never called, so with
`personProfiles = .identifiedOnly` PostHog built no person profiles, and
no super properties meant nothing could be segmented by environment.

Identity mirrors `AuthSession` from the coordinator via
`withObservationTracking`, following the existing daemon/startup idiom —
`ArcBoxAuth` stays free of any analytics dependency. distinct_id is the
OIDC subject; email, name, and email_verified go in as person properties.
Sign-out calls `reset()` so a second account on the same Mac is not
merged into the first. Re-identify is keyed on the whole `AuthIdentity`
because `loadUserInfo()` enriches it after sign-in.

Super properties: arcbox_profile and update_channel at setup,
system_vm_backend from `SystemVmBackendModel` (funnelled through one
setter so the three assignment sites cannot drift), k8s_active from
`KubernetesState`. The SDK already supplies $app_version, $os_version,
and $device_type.

The Privacy toggle's opt-in/opt-out moves from `GeneralSettingsView` into
`defaultsDidChange`. Both used to be driven by the same UserDefaults
write with no ordering guarantee, and the SDK drops `identify` while
opted out — so a user who signed in before enabling telemetry would have
stayed anonymous forever. Opting back in now re-runs identify.

The toggle's caption claimed "No personal data is collected", which is no
longer true once an account is linked; it now says so.
`setup()` lets the SDK's own persisted `.optOut` flag override
`config.optOut` (PostHogSDK.swift:128), so the value derived from
`telemetryEnabled` was only a fallback for a fresh install. Once the two
diverged, PostHog's copy silently won on every launch and Settings >
Privacy no longer described what was happening. Restate the preference
right after setup so the default — opted in — actually holds.

Also restore super properties after `reset()`: it deletes the
registered-properties key along with the identity (PostHogStorage.swift:403),
which dropped arcbox_profile, update_channel, system_vm_backend, and
k8s_active from every event for the rest of a session after sign-out.
They describe the install, not the person, so Analytics now keeps its own
mirror and re-registers.

No behavior change for signed-out users: `capture` never required person
processing, so their events were always collected — only the person
profile depends on signing in. Said so in the doc comment, since the
previous wording invited the opposite reading.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Greptile Summary

The PR connects the analytics event catalog to feature operations, synchronizes PostHog identity with authentication state, registers installation-level dimensions, and makes the ArcBox telemetry preference authoritative.

  • Captures successful container, image, Kubernetes, terminal, and settings activity.
  • Identifies signed-in users and resets analytics identity after sign-out.
  • Re-registers super properties after identity reset and synchronizes opt-in changes centrally.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
ArcBox/Analytics.swift Adds centralized identity, opt-in, reset, and super-property APIs alongside the expanded analytics catalog.
ArcBox/App/AppDelegate+Telemetry.swift Makes the application preference authoritative after PostHog setup and registers initial installation dimensions.
ArcBox/App/ApplicationCoordinator.swift Observes authentication and preference state to synchronize analytics identity, update-channel properties, and telemetry enablement.
ArcBox/Integrations/Terminal/ExternalTerminalLauncher.swift Records external-terminal activity only after the asynchronous launch path returns successfully.
ArcBox/ViewModels/KubernetesState.swift Captures successful Kubernetes transitions and keeps the active-state super property synchronized.
ArcBox/Views/Settings/GeneralSettingsView.swift Updates telemetry disclosure wording and delegates preference side effects to the coordinator.

Sequence Diagram

sequenceDiagram
  participant Defaults as UserDefaults
  participant App as AppDelegate
  participant Coordinator as ApplicationCoordinator
  participant Auth as AuthSession
  participant Analytics
  participant PostHog

  App->>Defaults: Register preference defaults
  App->>PostHog: Setup SDK
  App->>Analytics: Apply opt-in or opt-out
  App->>Analytics: Register install properties
  Coordinator->>Auth: Observe status and identity
  alt Signed in
    Coordinator->>Analytics: identify(subject, profile properties)
    Analytics->>PostHog: Identify person
  else Signed out after prior identity
    Coordinator->>Analytics: reset()
    Analytics->>PostHog: Reset identity
    Analytics->>PostHog: Restore super properties
  end
  Defaults-->>Coordinator: Telemetry preference changed
  Coordinator->>Analytics: Apply new preference
Loading

Reviews (3): Last reviewed commit: "Merge origin/master into feat/telemetry-..." | Re-trigger Greptile

Comment thread ArcBox/App/ApplicationCoordinator.swift Outdated
Comment thread ArcBox/Integrations/Terminal/ExternalTerminalLauncher.swift
Comment thread ArcBox/Analytics.swift
…erminal

Two real defects from review.

The signed-out reset was skipped on launch. `identifiedAs` starts nil, so
the dedupe guard returned before reaching `reset()` — but PostHog's
identity persists across launches, and a session can end while the app is
closed (revoked refresh token, cleared Keychain). Those launches kept
reporting under the previous account. The signed-out branch now runs
before the dedupe and consults `analyticsIdentified` in UserDefaults,
which outlives the process, rather than the in-process cache.

`terminal_opened` for external terminals fired before the launch was
attempted, counting failures as opens. Every path does have a success
signal — `NSWorkspace.open` returns Bool, the completion handler reports
an error, and NSAppleScript fills its error out-param — so the capture is
now threaded through as an `onOpened` callback invoked only by whichever
path actually launches a terminal. This matches how the in-app sessions
already report at `.connected`.

The third comment, reordering `import os` above `import PostHog`, is
declined: OrderedImports in .swift-format sorts one alphabetical block and
rewrites it back, and DockerTerminalSession.swift:3-4 already has
third-party SwiftTerm before os.
Master moved substantially under this branch (#369 in particular), and
the PR went DIRTY — which is why `pull_request` CI stopped running on the
branch entirely: GitHub cannot compute a merge commit for a conflicted PR,
so the last push was never built or tested.

Merged rather than rebased: the drift touches the same lines this branch
changes, so a rebase would have re-resolved the same conflicts once per
commit. Three files conflicted.

ExternalTerminalLauncher — master rewrote it as `async throws` with typed
`ExternalTerminalLaunchError`. This supersedes the `onOpened` callback
added in efe8ff5 for the same review comment: every failure path now
propagates, so a plain capture after the launch returns is both correct
and simpler. The callback threading is gone.

DockerTerminalSession — master funnelled both image paths through
`launchImageSession` and added `imageContainerName`. That already
discriminates the surface, so the `surface` parameter is dropped and the
value derived at the capture site; two parameters encoding the same fact
could only drift.

ImagesViewModel+Docker — master replaced `try response.ok` with
`applyImageDeletion(_:id:)` returning success; `image_removed` moves
inside that branch.

All 15 catalog events still have call sites. pbxproj and project.yml are
byte-identical to master.
@AprilNEA
AprilNEA merged commit 428ead8 into master Aug 11, 2026
6 checks passed
@AprilNEA
AprilNEA deleted the feat/telemetry-events branch August 11, 2026 10:40
@linear-code

linear-code Bot commented Aug 11, 2026

Copy link
Copy Markdown

ABXD-156

KafuChino123 added a commit that referenced this pull request Aug 12, 2026
The 1.35.0 release PR merged with an empty `### Highlights` section, so
`cargo xtask release notes` now fails on every PR against master. That
step runs before Lint, Build and Test, so no PR gets as far as
compiling. The release DMG extracts the same section to feed Sparkle's
update dialog and refuses to build without it, so 1.35.0 cannot be
packaged either.

Write the section from what 1.35.0 actually shipped: the Fleet device
auth, enrollment and runner management in #320, the authoritative
sandbox port reconciliation in #374, and the telemetry identification
and Privacy toggle ordering in #367.
AprilNEA pushed a commit that referenced this pull request Aug 12, 2026
* fix(ui): slide the detail tab indicator instead of morphing glass

The detail tab bar played an exaggerated stretch on every tab switch,
worst on the four-tab container view, and the first switch after a cold
launch was worse still.

GlassEffectContainer's spacing is a blend threshold, not a layout gap:
the higher it is, the sooner shapes start merging as they approach. It
was set to tabCount * detailTabSegment -- 320pt for containers, 480pt
for sandboxes -- against an HStack spacing of 2pt, so the blend field
spanned the whole bar. Apple documents that a container spacing larger
than the interior stack's spacing blends the effects together at rest.

The indicator also toggled glassEffect between .regular and .identity
per segment under one shared glassEffectID. That ID stays constant while
the segment beneath it changes, which is the documented trigger for
GlassEffectTransition.matchedGeometry to apply "additional scale and
offset effects to content" -- on top of the oversized blend.

Render one persistent glass capsule instead and position it with
matchedGeometryEffect against the selected segment. The pill now moves
rather than appearing and disappearing, so there is no morph to
overshoot and no appearance transition to misfire on first mount.
GlassEffectContainer is no longer needed for a single effect.

* chore(release): curate the 1.35.0 highlights

The 1.35.0 release PR merged with an empty `### Highlights` section, so
`cargo xtask release notes` now fails on every PR against master. That
step runs before Lint, Build and Test, so no PR gets as far as
compiling. The release DMG extracts the same section to feed Sparkle's
update dialog and refuses to build without it, so 1.35.0 cannot be
packaged either.

Write the section from what 1.35.0 actually shipped: the Fleet device
auth, enrollment and runner management in #320, the authoritative
sandbox port reconciliation in #374, and the telemetry identification
and Privacy toggle ordering in #367.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant