Repository navigation
feat(desktop): add opt-out usage telemetry - #84
leoisadev1 wants to merge 4 commits into
Conversation
|
Note 🤖 GPT-6.1 Sol responding on behalf of Leo Review outcome: the independent GPT-6.1 Sol review could not run because the T3 Codex provider status check timed out. No alternate provider or Mac Grok bridge was used. Static review completed locally: telemetry HTTP remains isolated in |
| .timeout_global(Some(Duration::from_secs(3))) | ||
| .build() | ||
| .new_agent(); | ||
| let _ = agent.post(ENDPOINT).send(body.to_string()); |
There was a problem hiding this comment.
🔴 Telemetry batches lack JSON content type
When send_batch posts events, it sends a string without declaring JSON. PostHog can reject the batch, losing every event.
| let _ = agent.post(ENDPOINT).send(body.to_string()); | |
| let _ = agent | |
| .post(ENDPOINT) | |
| .header("content-type", "application/json") | |
| .send(body.to_string()); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| if !was_trial_ended && matches!(self.license, client::State::TrialEnded) { | ||
| self.telemetry | ||
| .capture("trial_expired", self.telemetry.common(&self.license)); |
There was a problem hiding this comment.
🟡 Trial expiry events never fire
When a trial expires, begin_conversion rejects the conversion before trial_expired can run. Expired trials disappear from the telemetry funnel.
Learn more
The licensing check returns an error for an expired trial before the event condition is evaluated. The event is only captured on the success path, where the state cannot transition to TrialEnded. begin_conversion explicitly rejects states that cannot convert. Emit the expiry event on the blocked path, after determining the trial has expired, while guarding against repeated attempts or restarts if the event represents a one-time transition.
Example: A trial began eight days ago. A user starts a conversion; begin_conversion returns Blocked::State(State::TrialEnded), so the conversion returns an error and no expiry event is captured.
Recommended fix: Handle the TrialEnded result before the early return in queue_jobs, and define a persisted or session-level deduplication strategy if trial_expired must occur only once.
Was this helpful? React with 👍 or 👎 to provide feedback.
| let telemetry = Telemetry::new( | ||
| settings.install_id.clone(), | ||
| settings.telemetry, | ||
| licensing.enforced(), | ||
| ); |
There was a problem hiding this comment.
🟡 Install identity changes across launches
On launches without a settings file, install_id gets regenerated but remains unsaved until a settings write. Repeated app_opened events appear as different installations.
Learn more
Missing settings produce a freshly generated identifier through Settings::default. Settings::load returns those defaults without saving them, and this initialization reads the ID before any settings write. The launch event therefore has an ephemeral ID if no other setting is saved that session. This also affects an existing settings file from an older version with no install_id until it is rewritten.
Example: A fresh packaged install starts twice without changing settings or saving an update-check result. Each launch generates a different UUID and records app_opened under a different installation.
Recommended fix: Persist a generated install_id before capturing launch events, including when migrating old settings files, while preserving the existing user preference and avoiding overwriting malformed settings.
Was this helpful? React with 👍 or 👎 to provide feedback.
| self.telemetry | ||
| .capture("conversion_started", self.telemetry.common(&self.license)); |
There was a problem hiding this comment.
| if events.is_empty() && !*worker_stop.lock().unwrap() { | ||
| let (next, _) = wake.wait_timeout(events, Duration::from_secs(60)).unwrap(); | ||
| events = next; | ||
| } | ||
| let stopping = *worker_stop.lock().unwrap(); | ||
| let batch: Vec<_> = events.drain(..).collect(); | ||
| drop(events); | ||
| if !batch.is_empty() { | ||
| send_batch(&batch); |
| .map_err(|e| format!("Signed in, but the sign-in couldn't be saved: {e}")) | ||
| }) { | ||
| Ok(session) => { | ||
| self.telemetry.identify(&session.email); |
| .on_click({ | ||
| let app = app.clone(); | ||
| let on = settings.telemetry; | ||
| move |_, _, cx| app.update(cx, |s, cx| s.update_settings(|s| s.telemetry = !on, cx)) | ||
| }); |
| let telemetry = Telemetry::new( | ||
| settings.install_id.clone(), | ||
| settings.telemetry, | ||
| licensing.enforced(), | ||
| ); |
There was a problem hiding this comment.
|
| Outcome::Cancelled | ||
| } | ||
| }; | ||
| if matches!(entry.status, Status::Done(_) | Status::Failed(_)) { |
There was a problem hiding this comment.
apply moves outputs or e out of entry.status when building outcome, then the new telemetry block uses entry.status again. Neither value is Copy, so the desktop app fails to compile. Build telemetry before consuming the status, or borrow it while building telemetry. Finding confidence: 5/5.
| .map_err(|e| format!("Signed in, but the sign-in couldn't be saved: {e}")) | ||
| }) { | ||
| Ok(session) => { | ||
| self.telemetry.identify(&session.email); |
There was a problem hiding this comment.
Anonymous events contain emails
Successful sign-in sends session.email to PostHog as $identified_id, alongside install_id. Telemetry is enabled by default in packaged builds, so this sends an account email through a feature described as anonymous. This violates the repository requirement that approved telemetry stay anonymous. Remove the email-identifying event. Finding confidence: 5/5.
How this was verified: Successful sign-in passes the email to identify, which adds it to the event sent to the PostHog endpoint.
Context Used: AGENTS.md (source)
| .on_click({ | ||
| let app = app.clone(); | ||
| let on = settings.telemetry; | ||
| move |_, _, cx| app.update(cx, |s, cx| s.update_settings(|s| s.telemetry = !on, cx)) |
There was a problem hiding this comment.
Turning off the checkbox only changes settings.telemetry. update_settings never updates the live Telemetry, whose enabled value was fixed at startup. Events keep being sent until restart; turning telemetry on also has no immediate effect. This violates the repository's opt-out requirement. Update the live sender and discard unsent events when the user opts out. Finding confidence: 5/5.
How this was verified: The checkbox changes only saved settings, while capture continues checking the unchanged startup value of enabled.
Context Used: AGENTS.md (source)
| self.telemetry | ||
| .capture("conversion_started", self.telemetry.common(&self.license)); |
There was a problem hiding this comment.
The Nautilus menu launches convt-app open --to ..., which reaches convert_silently and these same conversion hooks. A fresh process also sends app_opened before inspecting the request. This violates the repository requirement that menu integrations remain silent. Exclude menu-only launches and their jobs from telemetry, including requests forwarded to an already-running app. Finding confidence: 5/5.
Context Used: AGENTS.md (source)
| impl Default for Settings { | ||
| fn default() -> Self { | ||
| Self { | ||
| install_id: crate::telemetry::new_install_id(), |
There was a problem hiding this comment.
Settings test compares random IDs
Adding a random install_id to Settings::default breaks round_trip_and_defaults. The test compares loading a missing file with a second call to Settings::default; each call now generates a different ID, so the assertion fails. Check the default fields and ID shape separately, while retaining the saved-ID round-trip check. Finding confidence: 5/5.
| let telemetry = Telemetry::new( | ||
| settings.install_id.clone(), | ||
| settings.telemetry, | ||
| licensing.enforced(), | ||
| ); |
There was a problem hiding this comment.
Installation IDs change on restart
A legacy settings file gets a new install_id through #[serde(default)], but loading it does not save that ID. An existing user with update checks disabled and no other settings changes gets a different ID on every launch. This counts one installation as several and loses its cross-launch funnel. Save a newly generated ID before sending events, without overwriting settings that failed to load. Finding confidence: 5/5.
| if !was_trial_ended && matches!(self.license, client::State::TrialEnded) { | ||
| self.telemetry | ||
| .capture("trial_expired", self.telemetry.common(&self.license)); |
There was a problem hiding this comment.
trial_expired sits after the early return for a blocked conversion. begin_conversion rejects TrialEnded, so an expired trial returns before reaching this event. Also, was_trial_ended reads today's state rather than the previously observed state, so moving the event above the return alone is not enough. Track the last observed or recorded trial state and record expiry before returning. Finding confidence: 5/5.
| self.telemetry | ||
| .capture("conversion_started", self.telemetry.common(&self.license)); |
There was a problem hiding this comment.
Conversion counts do not match
conversion_started runs once per queue_jobs call, but conversion_completed runs once per file. Converting ten files records one start and ten completions, making the conversion funnel misleading. Record starts per job, or use separate batch events with matching completion rules. Finding confidence: 5/5.
| let t = Telemetry::new("id".into(), true, false); | ||
| t.capture("conversion_completed", p); | ||
| assert_eq!(size_bucket(Some(2_000_000)), "1-10MB"); |
There was a problem hiding this comment.
scrubbed_payload_has_no_path_or_error_text creates a disabled sender, so capture returns immediately. The test then asserts only a size bucket. It would pass even if real events leaked paths or error text. Test the actual event-building code through a queue or injected sender and assert the complete allowed fields. Finding confidence: 5/5.
| if events.is_empty() && !*worker_stop.lock().unwrap() { | ||
| let (next, _) = wake.wait_timeout(events, Duration::from_secs(60)).unwrap(); | ||
| events = next; | ||
| } | ||
| let stopping = *worker_stop.lock().unwrap(); | ||
| let batch: Vec<_> = events.drain(..).collect(); | ||
| drop(events); | ||
| if !batch.is_empty() { | ||
| send_batch(&batch); |
There was a problem hiding this comment.
The worker sends as soon as capture wakes it. The 60-second wait only applies while the queue is empty, so normally spaced events each cause their own request. send_batch also creates a fresh HTTP agent each time. Add a short collection window or a batch-size threshold so routine conversions do not repeatedly open connections for single events. Finding confidence: 5/5.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
6e0854b to
b00a0a4
Compare
|
Note 🤖 GPT-6.1 Sol responding on behalf of Leo Removed all privacy-page edits from #84. The combined crash-reporting and desktop-telemetry disclosure remains owned by #82. The branch is rebased onto current |
| rusqlite.workspace = true | ||
| serde.workspace = true | ||
| serde_json.workspace = true | ||
| sha2.workspace = true |
There was a problem hiding this comment.
| pub fn allowed(setting: bool, enforced: bool) -> bool { | ||
| enabled(setting, enforced) | ||
| } |
There was a problem hiding this comment.
allowed is called only by tests, but it is also compiled into the regular app inside the private telemetry module. Rust reports it as unused, so the desktop Clippy job fails with -D warnings. Mark the wrapper #[cfg(test)], or remove it and test enabled directly.
Finding confidence: 5/5.
| pub fn allowed(setting: bool, enforced: bool) -> bool { | |
| enabled(setting, enforced) | |
| } | |
| #[cfg(test)] | |
| pub fn allowed(setting: bool, enforced: bool) -> bool { | |
| enabled(setting, enforced) | |
| } |
| props.insert( | ||
| "size_bucket".into(), | ||
| serde_json::json!(crate::telemetry::size_bucket( | ||
| std::fs::metadata(&entry.input).ok().map(|m| m.len()) | ||
| )), | ||
| ); |
There was a problem hiding this comment.
apply reads the input file's size on the UI thread before capture checks whether telemetry is enabled. If a network drive becomes slow or unavailable after conversion, this extra std::fs::metadata call can freeze the window, including for users who disabled telemetry. Read the size on a worker thread and pass back the bucket.
Finding confidence: 5/5.
| pub fn shutdown(&self) { | ||
| *self.stop.lock().unwrap() = true; | ||
| self.queue.1.notify_one(); | ||
| if let Some(worker) = self.worker.lock().unwrap().take() { | ||
| let _ = worker.join(); |
There was a problem hiding this comment.
shutdown changes stop and sends its wake-up signal without locking the queue. If the worker has checked stop but has not entered wait_timeout, it misses the signal and sleeps for 60 seconds while Drop waits in join. This can also delay disabled-sender tests.
Keep the stop flag under the queue lock so checking it and entering the wait stay coordinated.
Finding confidence: 5/5.
| if let Ok(ref license) = result { | ||
| self.telemetry.capture( | ||
| "license_activated", | ||
| self.telemetry | ||
| .license_props(&self.license, license.plan.name()), | ||
| ); |
There was a problem hiding this comment.
Sign-in activations are missing
license_activated is recorded only when users enter a key manually. Signing in fetches and saves their Pro key through offer_key, which never reaches this hook. The purchase funnel therefore misses account-based activations.
Record the event when that path first stores a license too, without counting unchanged daily renewals.
Finding confidence: 5/5.
convt is the seller's trading name and Polar (polar.sh) processes every purchase as merchant of record. No postal address. 14-day no-reason refunds. Florida law and courts, with consumers keeping their home protections. Effective October 7, 2026. The privacy page no longer says the app has no analytics or crash reporting; PRs #82 and #84 own those disclosures. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Replace the draft terms and privacy text with final wording Drop the draft banner and bracket placeholders. Terms now cover refunds, termination, governing law and contact; the provider, refund policy, governing law and effective date come from legal in lib/site.ts, marked TODO(CNV-48) until Leo decides them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Match the refund and deletion wording to how billing works Keys are checked offline, so a revoked key is not switched off; account deletion ends subscriptions before removing the account and can take up to a day; the refund window covers Desktop and Pro, not metered API use. The legal test now rejects any bracketed or draft text. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Apply Leo's legal decisions and drop the app's no-analytics claims convt is the seller's trading name and Polar (polar.sh) processes every purchase as merchant of record. No postal address. 14-day no-reason refunds. Florida law and courts, with consumers keeping their home protections. Effective October 7, 2026. The privacy page no longer says the app has no analytics or crash reporting; PRs #82 and #84 own those disclosures. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Test the Polar, refund-revocation and controller wording Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Do not promise a deadline for account deletion Deletion retries failed billing steps with no cutoff, so the terms say it usually takes minutes rather than promising a day. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
The desktop app records opt-out anonymous funnel events through a bounded background queue and PostHog batch endpoint. Install identity and the telemetry preference persist in settings, while paths, filenames, contents, email addresses, and error text stay out of event properties. Privacy-page disclosure is owned by PR #82, which will carry the combined crash-reporting and desktop-telemetry text.
Evidence
telemetry; crash reporting can call publictelemetry::enabled(setting, enforced), which also honorsDO_NOT_TRACK.cargo fmt --all -- --checkandgit diff --checkpass; the UI/model/main sources contain noureqreference.cargo check -p convt-appwas attempted but is blocked locally by missingfontconfig.pc; GitHub CI remains the build proof.main(PR feat(telemetry): report desktop and web exceptions to PostHog #82 is still open, so its future merge is not yet in the base).Merge Danger
Door: one-way. This adds outbound anonymous events to the desktop app; reverting removes the sender, but already-sent events remain in PostHog.
Blast radius: desktop app startup, conversion lifecycle, settings persistence, and upgrade/license UI hooks. The CLI and Finder/Explorer menu paths remain silent.
Fixes CNV-55
Created with GPT-6.1 Sol in T3 Code.