Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude/rules/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ For operations with real duration (like syncs), use the `activeSyncSpans` patter
- `service.name` = synctray
- `service.namespace` = synctray
- `service.instance.id` = random UUID per installation (changes on reinstall)
- `synctray.process.kind` = `app` for the menu-bar app, `cli` for a headless `synctray` run
(`TelemetryService.processKind`, set by `SyncTrayCLI.dispatch`). Same `service.name` for
both — filter on this to separate CLI-originated events (a `cache move`, a `mount`) from
the app's
- `enduser.id` = HMAC-SHA256 of hardware UUID (stable across reinstalls, not reversible)
- `service.version` = deployment-precise version (see below)
- `deployment.environment.name` = `development` for DEBUG builds, `production` for Release
Expand Down Expand Up @@ -230,7 +234,7 @@ rollout. No event is emitted on a fresh install.
| `synctray.mount.reinstall_detach` | Counter | Pre-uninstall volume detach attempts for mount-mode profiles, reached on every `SyncSetupService.uninstall` (including the settings-save reinstall path) — `mount.result`: `not_mounted`/`success`/`success_forced`/`failure` |
| `synctray.mount.reinstall_detach.duration` | Histogram | Time taken to gracefully detach a mounted volume before reinstall (seconds) — regression signal for the "settings change freezes an active Stream mount" failure mode; only recorded when the profile was actually mounted |
| `synctray.mount.cache_only_fallback` | Counter | Cache Only was requested (`streamCacheOnly`) but the mount settled on streaming — the silent script fallback when the partial-file exclude list can't be built. The "I enabled Cache Only but it's still streaming/downloading" case; invisible without this. Emitted once at the mode-change edge by `SyncManager.reconcileMountStatesOffMain`, guarded by `resumingFromCacheOnly` so a legitimate drain-and-resume flip isn't counted |
| `synctray.cli.invoked` | Counter | Headless `synctray` CLI invocations (`cli.command`: bounded verb — doctor/status/offline-status/profiles/logs/test-remote/listremotes/sync/mount/unmount/reinstall/install/cache-move/profile-create/profile-show/profile-set/profile-enable/profile-disable/profile-delete/profile-list/help, or `(other)`; `cli.result`: ok/error). Never carries args, paths, profile names, or remotes. Recorded once per run by `runMeasured`, then flushed via `flushForExit` before the process exits |
| `synctray.cli.invoked` | Counter | Headless `synctray` CLI invocations (`cli.command`: bounded verb — doctor/status/offline-status/profiles/logs/test-remote/listremotes/remote-folders/telemetry-status/telemetry-on/telemetry-off/sync/mount/unmount/reinstall/install/cache-move/profile-create/profile-show/profile-set/profile-enable/profile-disable/profile-delete/profile-list/help, or `(other)`; `cli.result`: ok/error). Never carries args, paths, profile names, or remotes. Recorded once per run by `runMeasured`, then flushed via `flushForExit` before the process exits |
| `synctray.offline.warm.duration` | Histogram | Duration of an offline-file warming run, seconds (`warm.trigger`: manual/startup/finder_pin; `warm.outcome`: completed/cancelled) |
| `synctray.offline.warm.throughput` | Histogram | Average read throughput of a warming run, MB/s — the primary signal for slow-fallback diagnosis (the SFTP fallback caps aggregate throughput) |
| `synctray.offline.warm.files` | Counter | Files warmed into the VFS content cache (`warm.outcome`: completed/cancelled) |
Expand Down
29 changes: 23 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,8 @@ Covered by `ConfigSelfTest` AC-CLI4 and AC-SEC3.
| `synctray logs <name\|shortId> [--follow]` | Print (or `tail -f`) that profile's sync log. |
| `synctray test-remote <name\|shortId>` | Probe one profile's remote with `rclone lsd` under a hard timeout; prints `reachable: <remote>` or the real rclone stderr. |
| `synctray listremotes` | `rclone listremotes`, passthrough. |
| `synctray remote folders <remote>[:path] [--json]` | List a remote's folders exactly as the profile editor's folder chooser and the setup wizard show them — same `rclone lsf --dirs-only` arguments (`RcloneConfigService.folderListingArguments`, including the remote's `no_check_certificate`) and the same parser (`parseDirectoryListing`), so names with spaces come out verbatim. Needs no profile. `--json` prints a JSON array, which makes leading/trailing spaces visible. Covered by `ConfigSelfTest` AC-CLI12 (and AC-RF1 for the parser). |
| `synctray telemetry [status\|on\|off] [--json]` | Show or change whether usage data is shared — the same setting as the app's telemetry toggle (see "Privacy and consent" below). `status --json` prints `{"asked":…,"enabled":…}`. `on`/`off` count as answering the first-run question. |

**Configure** (mutating — headless-capable, no running app required):

Expand Down Expand Up @@ -1175,7 +1177,8 @@ counter + `CLI invoked` structured log (bounded command verb + `ok`/`error` +
exit code + duration — never args, paths, profile names, or remotes) and flushes
before exit (`runMeasured` → `TelemetryService.recordCLIInvocation` +
`flushForExit`). It is gated on the user's telemetry opt-in, so a disabled CLI
does no setup, no network, and prints nothing extra. The self-test path
does no setup and no network; its only extra output is the one-time consent
question on an interactive terminal (see "Privacy and consent" below). The self-test path
(`execute` with a fake `CLIEnvironment`) never touches telemetry.

**Pure core / impure shell.** `SyncTrayCLI.parse`/`execute`/`run`/`doctorChecks`
Expand All @@ -1187,10 +1190,24 @@ process/filesystem/launchd touched. Every real `rclone` invocation runs through
hard-timeout watchdog (mirrors `RcloneLocator`'s login-shell probe), since
SMB/WebDAV remotes can hang past their own timeouts.

**Privacy.** The CLI's output goes to the invoking terminal, not telemetry —
`test-remote`/`profiles` printing a remote name to stdout is fine; CLI mode
never calls `TelemetryService.configure()`, so nothing from a CLI invocation
is ever sent anywhere.
**Privacy and consent — one setting for the app and the CLI.** The CLI's output
goes to the invoking terminal, not telemetry — `test-remote`/`profiles` printing a
remote name to stdout is fine. What the CLI *sends* is only the bounded
`synctray.cli.invoked` event above (plus the ordinary events of the operations it
drives, e.g. a `cache move`), and only when usage data is on. That is the SAME
`telemetryEnabled` setting the app's banner and Settings toggle use — the CLI is the
app's own binary, so it reads the same `UserDefaults`, and `synctray telemetry on|off`
also rewrites `telemetryEnabled` in `settings.json` (`AppSettingsFileStore.updateSettingsFile`,
which changes only that key and never `launchAtLogin`), so a running app applies it live.
A first run that has never been answered — in the app or the CLI — asks once
(`promptForTelemetryIfNeeded`, before the command runs, on stderr, with the banner's own
copy `SyncTraySettings.telemetryConsentSummary`), defaulting to **No** (`[y/N]`; an empty
line or EOF is No, Ctrl-C leaves it unanswered). It only asks when stdin AND stderr are a
terminal and `CI` is unset, never for `help` or `telemetry` itself, so scripts and agents
are never blocked. CLI telemetry carries the resource attribute `synctray.process.kind=cli`
(the app sends `app`); `service.name` stays `synctray` for both, so existing queries keep
working. Exporter diagnostics are silenced in CLI mode so they never clutter a terminal.
Covered by `ConfigSelfTest` AC-CLI13.

## Data Flow

Expand Down Expand Up @@ -1641,7 +1658,7 @@ open ~/Library/Developer/Xcode/DerivedData/SyncTray-*/Build/Products/Debug/SyncT
| `SettingsView.swift` | Main settings UI with profile editing |
| `ProfileStore.swift` | File-backed profile persistence — authoritative `{shortId}.profile.json` per profile, write-only blob mirror (see "File-Backed Configuration") |
| `ConfigFileWatcher.swift` | Live-apply watcher for `~/.config/synctray` (profiles + settings); routes an unknown-id `.profile.json` to create-via-file |
| `SyncTrayCLI.swift` | Headless `synctray` CLI: inspect (`doctor`/`status`/`profiles`/`profile show`/`logs`/`test-remote`/`listremotes`), configure (`profile create`/`set`/`enable`/`disable`/`delete`, `install`/`reinstall`), operate (`sync`/`mount`/`unmount`/`cache move`); dispatched from `SyncTrayApp.init` (see "Agent-Editable Configuration & CLI") |
| `SyncTrayCLI.swift` | Headless `synctray` CLI: inspect (`doctor`/`status`/`profiles`/`profile show`/`logs`/`test-remote`/`listremotes`/`remote folders`/`telemetry`), configure (`profile create`/`set`/`enable`/`disable`/`delete`, `install`/`reinstall`), operate (`sync`/`mount`/`unmount`/`cache move`); dispatched from `SyncTrayApp.init` (see "Agent-Editable Configuration & CLI") |
| `CLIShimInstaller.swift` | Installs the `~/.local/bin/synctray` shim (`~/.local/bin` must be on `PATH`) |
| `SyncLogPatterns` | Centralized log message pattern matching (includes `isOutOfSyncError`) |
| `TelemetryService.swift` | OTel singleton — traces, metrics, logs via OTLP/HTTP |
Expand Down
Loading
Loading