Skip to content

feat(desktop): add opt-out usage telemetry - #84

Open
leoisadev1 wants to merge 4 commits into
mainfrom
cnv-55-desktop-telemetry
Open

leoisadev1 wants to merge 4 commits into
mainfrom
cnv-55-desktop-telemetry

Conversation

@leoisadev1

@leoisadev1 leoisadev1 commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

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.

AppState/UI hooks -> telemetry queue -> background batch flush -> PostHog US
                         | Settings.telemetry + telemetry::enabled(setting, enforced)
                         | DO_NOT_TRACK

Evidence

  • Identity: sign-in identify uses an opaque SHA-256 account identifier; the email is never placed in the payload.
  • Shared opt-out: the setting key is telemetry; crash reporting can call public telemetry::enabled(setting, enforced), which also honors DO_NOT_TRACK.
  • Static verification: cargo fmt --all -- --check and git diff --check pass; the UI/model/main sources contain no ureq reference.
  • Build check: cargo check -p convt-app was attempted but is blocked locally by missing fontconfig.pc; GitHub CI remains the build proof.
  • Rebase: rebased onto current 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.

@leoisadev1

Copy link
Copy Markdown
Member Author

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 telemetry.rs, event properties use coarse size buckets and error kinds, and the ureq guard surface is unchanged. Full compile/tests remain blocked by missing fontconfig.pc.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 8 potential issues.

Devin Review

.timeout_global(Some(Duration::from_secs(3)))
.build()
.new_agent();
let _ = agent.post(ENDPOINT).send(body.to_string());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
let _ = agent.post(ENDPOINT).send(body.to_string());
let _ = agent
.post(ENDPOINT)
.header("content-type", "application/json")
.send(body.to_string());

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +424 to +426
if !was_trial_ended && matches!(self.license, client::State::TrialEnded) {
self.telemetry
.capture("trial_expired", self.telemetry.common(&self.license));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +317 to +321
let telemetry = Telemetry::new(
settings.install_id.clone(),
settings.telemetry,
licensing.enforced(),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +418 to +419
self.telemetry
.capture("conversion_started", self.telemetry.common(&self.license));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Conversion funnel mixes batches and files

When queue_jobs receives multiple files, it emits one conversion_started but one conversion_completed per finished job. Completion ratios then count different units.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +63 to +71
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Each spaced event triggers a request

capture wakes the worker for every event, which immediately drains and sends the queue. The 60-second wait never coalesces spaced events.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread crates/convt-app/src/account.rs Outdated
.map_err(|e| format!("Signed in, but the sign-in couldn't be saved: {e}"))
}) {
Ok(session) => {
self.telemetry.identify(&session.email);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Sign-in exposes email in anonymous telemetry

After sign-in, identify sends the account email and links it to earlier installation events. PostHog can associate conversion activity with a person.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +303 to +307
.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))
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Opt-out keeps transmitting until restart

When a user disables telemetry, enabled retains its startup value. Further conversions still send events despite the opt-out.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +317 to +321
let telemetry = Telemetry::new(
settings.install_id.clone(),
settings.telemetry,
licensing.enforced(),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Source builds transmit telemetry when licensing is enabled

With CONVT_LICENSE_ENFORCE=1, a source build passes the enforced telemetry gate. Its default preference then sends events, despite the source-build default-off policy.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 0/5 Tier: plus

[High risk] Adds network telemetry collection to the desktop app.

The PR is not safe to merge while build failures, ineffective live opt-out, and account-linked telemetry remain.

Fix All in Claude CodeFindings

  1. P1 Releases reject the lock file ▶
  2. P1 Unused helper fails Clippy ▶
  3. P1 File reads freeze the window ▶
  4. P1 Desktop build fails ▶
  5. P1 Security Anonymous events contain emails ▶
  6. P1 Security Opt-out does not stop events ▶
  7. P1 Menu conversions send events ▶
  8. P1 Settings test compares random IDs ▶
  9. P2 Shutdown waits a minute ▶
  10. P2 Sign-in activations are missing ▶
  11. P2 Installation IDs change on restart ▶
  12. P2 Trial expiry is not recorded ▶
  13. P2 Conversion counts do not match ▶
  14. P2 Privacy test checks no events ▶
  15. P2 Events make separate requests ▶

Summary

Adds desktop usage events, a bounded background queue, a saved installation ID, and a telemetry checkbox. Sign-in sends a hashed account identifier, and first run explains the default setting.

  • New findings cover release checks, an unused helper, a UI-thread file read, shutdown timing, and missing account-based activation events.
  • No build or UI run was performed during this review.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    Settings["Saved settings at startup"] --> Gate["Setting + license enforcement + DO_NOT_TRACK"]
    Hooks["Startup and conversion hooks"] --> Capture["capture"]
    Account["Sign-in → hashed account ID"] --> Capture
    License["Manual license activation"] --> Capture
    Gate --> Capture
    Capture --> Queue["Queue: at most 128 events"]
    Queue --> Worker["Background worker"]
    Worker --> PostHog["PostHog batch endpoint"]
Loading

Reviews (2) · Last reviewed commit: "chore: leave privacy disclosure to error..." · Reviewed by Greptile

Outcome::Cancelled
}
};
if matches!(entry.status, Status::Done(_) | Status::Failed(_)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Desktop build fails

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.

Fix in Claude Code

Comment thread crates/convt-app/src/account.rs Outdated
.map_err(|e| format!("Signed in, but the sign-in couldn't be saved: {e}"))
}) {
Ok(session) => {
self.telemetry.identify(&session.email);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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)

Fix in Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Opt-out does not stop events

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)

Fix in Claude Code

Comment on lines +418 to +419
self.telemetry
.capture("conversion_started", self.telemetry.common(&self.license));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Menu conversions send events

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)

Fix in Claude Code

impl Default for Settings {
fn default() -> Self {
Self {
install_id: crate::telemetry::new_install_id(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix in Claude Code

Comment on lines +317 to +321
let telemetry = Telemetry::new(
settings.install_id.clone(),
settings.telemetry,
licensing.enforced(),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix in Claude Code

Comment on lines +424 to +426
if !was_trial_ended && matches!(self.license, client::State::TrialEnded) {
self.telemetry
.capture("trial_expired", self.telemetry.common(&self.license));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Trial expiry is not recorded

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.

Fix in Claude Code

Comment on lines +418 to +419
self.telemetry
.capture("conversion_started", self.telemetry.common(&self.license));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix in Claude Code

Comment on lines +209 to +211
let t = Telemetry::new("id".into(), true, false);
t.capture("conversion_completed", p);
assert_eq!(size_bucket(Some(2_000_000)), "1-10MB");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Privacy test checks no events

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.

Fix in Claude Code

Comment on lines +63 to +71
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Events make separate requests

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!

Fix in Claude Code

@leoisadev1
leoisadev1 force-pushed the cnv-55-desktop-telemetry branch from 6e0854b to b00a0a4 Compare October 7, 2026 20:32
@leoisadev1

Copy link
Copy Markdown
Member Author

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 main; #82 is still open, so its future merge is not yet part of this base. The shared telemetry setting and telemetry::enabled(setting, enforced) helper remain in #84 for #82 to reuse. No merge or auto-merge was enabled.

rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Releases reject the lock file

The new sha2 dependency is missing from the convt-app entry in Cargo.lock. Both release workflows run cargo fetch --locked, which refuses to update that entry and stops the release before building. Regenerate and commit the lock file.

Finding confidence: 5/5.

Fix in Claude Code

Comment on lines +186 to +188
pub fn allowed(setting: bool, enforced: bool) -> bool {
enabled(setting, enforced)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unused helper fails Clippy

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.

Suggested change
pub fn allowed(setting: bool, enforced: bool) -> bool {
enabled(setting, enforced)
}
#[cfg(test)]
pub fn allowed(setting: bool, enforced: bool) -> bool {
enabled(setting, enforced)
}

Fix in Claude Code

Comment on lines +834 to +839
props.insert(
"size_bucket".into(),
serde_json::json!(crate::telemetry::size_bucket(
std::fs::metadata(&entry.input).ok().map(|m| m.len())
)),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 File reads freeze the window

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.

Fix in Claude Code

Comment on lines +137 to +141
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Shutdown waits a minute

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.

Fix in Claude Code

Comment on lines +740 to +745
if let Ok(ref license) = result {
self.telemetry.capture(
"license_activated",
self.telemetry
.license_props(&self.license, license.plan.name()),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix in Claude Code

leoisadev1 added a commit that referenced this pull request Oct 7, 2026
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>
leoisadev1 added a commit that referenced this pull request Oct 7, 2026
* 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>

This branch has not been deployed

No deployments
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