diff --git a/crates/app/src/app_menus.rs b/crates/app/src/app_menus.rs index ecaeac1f..0ad94249 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -24,7 +24,9 @@ actions!( ReloadPlugins, Quit, CopyDebugInfo, - ReportIssue, + ReportBug, + RequestFeature, + ReportUxIssue, OpenLogsFolder, OpenAbout, /// What every greyed-out menu item below dispatches. Deliberately unhandled: the label is @@ -225,7 +227,15 @@ fn app_menus(cx: &gpui::App) -> Vec { // Unit actions, not `OpenBrowser { url }`: this runs once at startup, while the // debug info those items carry changes with every project opened and plugin loaded. MenuItem::action("Copy Debug Info", CopyDebugInfo), - MenuItem::action("Report an Issue", ReportIssue), + MenuItem::submenu(Menu { + name: "Send Feedback".into(), + disabled: false, + items: vec![ + MenuItem::action("Report a Bug…", ReportBug), + MenuItem::action("Request a Feature…", RequestFeature), + MenuItem::action("Report a UI/UX Issue…", ReportUxIssue), + ], + }), MenuItem::action("Open Logs Folder", OpenLogsFolder), MenuItem::Separator, MenuItem::action(format!("Version {}", env!("CARGO_PKG_VERSION")), OpenAbout), diff --git a/crates/app/src/logging.rs b/crates/app/src/logging.rs index 4aa2c77f..0879eb90 100644 --- a/crates/app/src/logging.rs +++ b/crates/app/src/logging.rs @@ -1,4 +1,4 @@ -//! The session log, the panic hook, and the debug-info dump behind Help ▸ Copy Debug Info. +//! The session log, panic hook, and reports behind Help ▸ Copy Debug Info and Send Feedback. //! //! Everything here shares one thing — the path to the current session's log file — which is why //! the logger, the panic hook, the log tail, and the dump that embeds it live in one module. @@ -7,9 +7,9 @@ //! `qrate.old.log` is the one before it. That is the whole retention policy, and it is what makes //! "reproduce it, then send me the log" work — the reproduction is always the current file. //! -//! Nothing is uploaded. `qrate.dvnl.work` is a static site and cannot receive a report, so the -//! dump travels by clipboard or by a prefilled GitHub issue URL, both of which put the user in -//! front of the text before it leaves the machine. +//! Reports open on the qrate site with diagnostics and a short, redacted log tail in the fragment. +//! The fragment is not sent in the page request, and the user reviews what to include before the +//! site uploads anything. use std::fmt::Write as _; use std::fs::{self, File}; @@ -22,6 +22,17 @@ use simplelog::{ ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode, WriteLogger, }; +pub enum FeedbackKind { + Bug, + Feature, + Ux, +} + +#[cfg(debug_assertions)] +const FEEDBACK_URL: &str = "http://localhost:4321/feedback"; +#[cfg(not(debug_assertions))] +const FEEDBACK_URL: &str = "https://qrate.dvnl.work/feedback"; + /// Where the current session writes. `None` only if the OS has no local data dir, in which case /// there is nowhere to log and the terminal sink is all there is. pub fn log_path() -> Option { @@ -195,8 +206,7 @@ pub fn debug_info(cx: &App, log_lines: usize) -> String { Some(project) => { let _ = writeln!( out, - "\nProject: {} ({} rows x {} columns)", - project.file.display(), + "\nProject: open ({} rows x {} columns)", project.data.rows.len(), project.data.headers.len() ); @@ -240,6 +250,18 @@ fn log_tail(lines: usize) -> String { all[all.len().saturating_sub(lines)..].join("\n") } +fn feedback_log_tail() -> String { + let log = redact(&log_tail(30)); + if log.chars().count() <= 1_500 { + return log; + } + let tail: String = log.chars().rev().take(1_500).collect(); + format!( + "(earlier lines omitted)\n{}", + tail.chars().rev().collect::() + ) +} + /// Replace the user's home directory with `~` throughout. /// /// The dump carries project paths and plugin file names, which on a real machine means a real @@ -268,6 +290,42 @@ pub fn urlencode(text: &str) -> String { }) } +/// Open the hosted form with diagnostics and a bounded log tail in the fragment. +/// +/// The page removes the fragment after reading it and shows both values before submission. +pub fn feedback_url(cx: &App, kind: Option) -> String { + let kind = match kind { + Some(FeedbackKind::Bug) => "?type=bug", + Some(FeedbackKind::Feature) => "?type=feature", + Some(FeedbackKind::Ux) => "?type=ui_ux", + None => "", + }; + let plugins = plugin_host::status(cx); + let project = cx + .try_global::() + .map(|project| { + serde_json::json!({ + "rows": project.data.rows.len(), + "columns": project.data.headers.len(), + }) + }); + let diagnostics = serde_json::json!({ + "schema": 1, + "version": version(), + "commit": env!("QRATE_GIT_SHA"), + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "project": project, + "plugins": plugins.len(), + "plugin_failures": plugins.iter().filter(|(_, error)| error.is_some()).count(), + }); + format!( + "{FEEDBACK_URL}{kind}#diagnostics={}&logs={}", + urlencode(&diagnostics.to_string()), + urlencode(&feedback_log_tail()) + ) +} + /// The path Help ▸ Open Logs Folder reveals, or the folder itself if this session never opened a /// file to reveal. pub fn reveal_target() -> Option { @@ -294,6 +352,27 @@ mod tests { assert_eq!(super::urlencode("qrate-0.1_x.y~z"), "qrate-0.1_x.y~z"); } + #[gpui::test] + fn feedback_links_select_the_form_and_keep_diagnostics_in_the_fragment( + cx: &mut gpui::TestAppContext, + ) { + let (bug, feature, ux) = cx.update(|cx| { + ( + super::feedback_url(cx, Some(super::FeedbackKind::Bug)), + super::feedback_url(cx, Some(super::FeedbackKind::Feature)), + super::feedback_url(cx, Some(super::FeedbackKind::Ux)), + ) + }); + + assert!(bug.starts_with(&format!("{}?type=bug#diagnostics=", super::FEEDBACK_URL))); + assert!(feature.contains("?type=feature#diagnostics=")); + assert!(ux.contains("?type=ui_ux#diagnostics=")); + assert!(bug.contains("%22schema%22%3A1")); + assert!(bug.contains("&logs=")); + assert!(!bug.contains("CPU")); + assert!(bug.len() < 7_000); + } + /// The demotion has to be narrow: it must catch gpui's two teardown messages and nothing else, /// or a real failure quietly stops being an error. #[test] diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index e13da789..affddf33 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -40,7 +40,7 @@ use crate::{ actions::{NewProject, ToggleBottomDock, ToggleLeftDock, ToggleRightDock}, app_menus::{ CopyDebugInfo, OpenAbout, OpenColumnSettings, OpenLogsFolder, OpenPluginsFolder, - OpenProjects, OpenSettings, Quit, REPO_URL, ReloadPlugins, ReportIssue, + OpenProjects, OpenSettings, Quit, ReloadPlugins, ReportBug, ReportUxIssue, RequestFeature, }, status_items::build_status_bar_registry, title_items::build_title_bar_registry, @@ -515,11 +515,17 @@ fn main() { cx.on_action(|_: &CopyDebugInfo, cx| { cx.write_to_clipboard(ClipboardItem::new_string(logging::debug_info(cx, 200))); }); - cx.on_action(|_: &ReportIssue, cx| { - // A shorter tail than the clipboard gets: GitHub stops honouring `?body=` somewhere - // past 8 KB, and a silently truncated report is worse than a short one. - let body = logging::urlencode(&logging::debug_info(cx, 30)); - cx.open_url(&format!("{REPO_URL}/issues/new?body={body}")); + cx.on_action(|_: &ReportBug, cx| { + cx.open_url(&logging::feedback_url(cx, Some(logging::FeedbackKind::Bug))); + }); + cx.on_action(|_: &RequestFeature, cx| { + cx.open_url(&logging::feedback_url( + cx, + Some(logging::FeedbackKind::Feature), + )); + }); + cx.on_action(|_: &ReportUxIssue, cx| { + cx.open_url(&logging::feedback_url(cx, Some(logging::FeedbackKind::Ux))); }); cx.on_action(|_: &OpenLogsFolder, _| match logging::reveal_target() { Some(target) => { diff --git a/crates/app/src/title_items/launcher_bar.rs b/crates/app/src/title_items/launcher_bar.rs index 62ef9f87..028bebb5 100644 --- a/crates/app/src/title_items/launcher_bar.rs +++ b/crates/app/src/title_items/launcher_bar.rs @@ -15,7 +15,7 @@ use window_wrapper::OpenBrowser; use crate::actions::NewProject; use crate::app_menus::{ CopyDebugInfo, OpenAbout, OpenLogsFolder, OpenPluginsFolder, OpenSettings, REPO_URL, - ReloadPlugins, ReportIssue, + ReloadPlugins, ReportBug, ReportUxIssue, RequestFeature, }; use crate::title_items::update_notice::UpdateNotice; @@ -62,7 +62,7 @@ impl Render for LauncherBar { menu.menu("Plugins Folder", Box::new(OpenPluginsFolder)) .menu("Reload Plugins", Box::new(ReloadPlugins)) }) - .submenu("Help", window, cx, |menu, _, _| { + .submenu("Help", window, cx, |menu, window, cx| { menu.menu( "Repository", Box::new(OpenBrowser { @@ -77,7 +77,11 @@ impl Render for LauncherBar { ) .separator() .menu("Copy Debug Info", Box::new(CopyDebugInfo)) - .menu("Report an Issue", Box::new(ReportIssue)) + .submenu("Send Feedback", window, cx, |menu, _, _| { + menu.menu("Report a Bug…", Box::new(ReportBug)) + .menu("Request a Feature…", Box::new(RequestFeature)) + .menu("Report a UI/UX Issue…", Box::new(ReportUxIssue)) + }) .menu("Open Logs Folder", Box::new(OpenLogsFolder)) }) .separator() @@ -91,6 +95,21 @@ impl Render for LauncherBar { ) }), ) - .child(self.update.clone()) + .child( + h_flex() + .gap_2() + .child( + Button::new("launcher-feedback") + .small() + .compact() + .ghost() + .label("Feedback") + .occlude() + .on_click(|_, _, cx| { + cx.open_url(&crate::logging::feedback_url(cx, None)) + }), + ) + .child(self.update.clone()), + ) } } diff --git a/crates/app/src/title_items/mod.rs b/crates/app/src/title_items/mod.rs index 0af2fc86..8625b738 100644 --- a/crates/app/src/title_items/mod.rs +++ b/crates/app/src/title_items/mod.rs @@ -3,7 +3,9 @@ mod update_notice; use gpui::*; use gpui_component::{ + ActiveTheme, dock::{DockArea, DockPlacement}, + h_flex, menu::AppMenuBar, }; use plugin_api::{Bar, Side}; @@ -14,6 +16,25 @@ use update_notice::UpdateNotice; use window_wrapper::{BarRegistry, title_bar::TitleBarRegistry}; use workspace::DockToggleButton; +struct FeedbackButton; + +impl Render for FeedbackButton { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .id("feedback") + .px(px(6.)) + .py(px(2.)) + .rounded_md() + .text_sm() + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .hover(|this| this.bg(cx.theme().secondary_hover)) + .occlude() + .child("Feedback") + .on_click(|_, _, cx| cx.open_url(&crate::logging::feedback_url(cx, None))) + } +} + /// Populate the title bar registry. The app menus are the one always-present item, so they /// are registered on the left by default. Right: generic open/close buttons for each dock. pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> TitleBarRegistry { @@ -30,6 +51,8 @@ pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> Tit let plugins = cx.new(|cx| PluginBar::new(Bar::Title, Side::Right, cx)); registry.items_mut().add_right(plugins); + registry.items_mut().add_right(cx.new(|_| FeedbackButton)); + // Download progress and the explicit restart action from the signed updater. // Text before buttons, on the same reasoning as the plugin bar above. let update_notice = cx.new(UpdateNotice::new); diff --git a/docs/dev/feedback-email-receipts.md b/docs/dev/feedback-email-receipts.md new file mode 100644 index 00000000..c138d75e --- /dev/null +++ b/docs/dev/feedback-email-receipts.md @@ -0,0 +1,147 @@ +# Feedback email receipts + +## Status + +This document records a future requirement. The beta feedback flow does not send email receipts. + +## Recommendation + +Use Resend Transactional Email after Linear confirms issue creation. + +Resend fits the current Cloudflare Worker design: + +- The Worker can use one HTTPS request. +- The free tier allows 3,000 emails each month and 100 each day. +- Resend supports idempotency keys for 24 hours. +- An API key can have access to one sender domain. +- Resend publishes a data processing agreement and a subprocessor list. + +Do not send a private Linear URL. Send only the public ticket identifier, such as `TSGB-123`. + +Do not include the report text, diagnostics, logs, attachments, or user environment in the email. + +Use plain text. Disable open tracking and click tracking. + +## Proposed flow + +1. Validate the optional email address. +2. Validate Turnstile and the feedback report. +3. Create the Linear issue. +4. Return feedback success even if the email later fails. +5. If the user supplied an email address, send one receipt. +6. Use the Linear issue ID and a recipient digest in the idempotency key. +7. Retry only temporary network and provider errors. + +Use an idempotency key with this form: + +```text +feedback-receipt// +``` + +The receipt can use this content: + +```text +Subject: qrate received your feedback: TSGB-123 + +qrate received your feedback. + +Ticket: TSGB-123 + +Keep this ticket number if you need to contact the qrate team. +``` + +Do not add a resend endpoint during the beta. + +## Failure behavior + +Email delivery is a secondary action. A receipt failure must not change a successful feedback result. + +Use `waitUntil()` for a best-effort beta receipt. Do not describe this method as durable delivery. + +If receipts become a contractual requirement, add a queue and explicit delivery state. Do not add this system before it is necessary. + +Log only these values: + +- Linear issue ID. +- Provider response status. +- Provider message ID. +- A one-way digest of the recipient address. + +Do not log the email address or email body. + +## Provider comparison + +| Provider | Cost at beta volume | Idempotency | Main tradeoff | +|---|---:|---|---| +| Resend | 3,000 emails each month and 100 each day at no cost | 24-hour request key | Adds one processor with 30-day free-plan retention | +| Cloudflare Email Sending | 3,000 each month with Workers Paid | No documented request key | Fewer vendors, but the outbound service is in beta | +| Postmark | 100 each month at no cost | No provider key | Mature delivery, but higher cost and 45-day message retention | +| MailChannels | 100 each day at no cost, with account restrictions | No documented request key | Pricing and verified-recipient rules need confirmation | +| Amazon SES | Low unit cost | No provider key | Adds AWS, IAM, sandbox approval, and more operations | + +Cloudflare Email Routing does not provide general outbound transactional email. Recheck Cloudflare Email Sending after it leaves beta. + +## Privacy requirements + +The email provider receives personal data: + +- Recipient email address. +- Sender address. +- Ticket identifier. +- Delivery and bounce metadata. + +Before release: + +1. Add the provider and receipt purpose to the privacy notice. +2. Review the provider data processing agreement. +3. Review the provider subprocessor list and retention policy. +4. Use a dedicated sender subdomain. +5. Add SPF, DKIM, return-path, and DMARC records. +6. Disable marketing, open, and click tracking. +7. Configure bounce and suppression handling. + +Do not use a user value for the subject, sender, reply-to address, header, or recipient list. + +## Abuse controls + +Turnstile does not prevent all email abuse. A user can submit a third party's email address. + +Keep these controls: + +- One receipt for each confirmed issue and recipient. +- Existing IP rate limits. +- A fresh Turnstile check before any report. +- No receipt attachments. +- No user-controlled email content. +- No public resend action. + +## Launch checklist + +- [ ] Create a dedicated receipt sender subdomain. +- [ ] Configure SPF, DKIM, return-path, and DMARC records. +- [ ] Add a restricted Resend API key as a Worker secret. +- [ ] Send only after Linear returns a confirmed issue. +- [ ] Use a deterministic idempotency key. +- [ ] Keep email failure non-fatal. +- [ ] Disable open and click tracking. +- [ ] Update the privacy notice. +- [ ] Test success, timeout, retry, suppression, bounce, and duplicate submission. +- [ ] Test a submission without an email address. + +## Sources + +- [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) +- [Cloudflare Email Service pricing](https://developers.cloudflare.com/email-service/platform/pricing/) +- [Cloudflare outbound email setup](https://developers.cloudflare.com/email-service/get-started/send-emails/) +- [Resend pricing](https://resend.com/pricing) +- [Resend domain setup](https://resend.com/docs/add-a-domain) +- [Resend API keys](https://resend.com/docs/dashboard/api-keys/introduction) +- [Resend idempotency keys](https://resend.com/docs/dashboard/emails/idempotency-keys) +- [Resend data processing agreement](https://resend.com/legal/dpa) +- [Postmark pricing](https://postmarkapp.com/pricing) +- [Postmark email API](https://postmarkapp.com/developer/api/email-api) +- [Postmark idempotency guidance](https://postmarkapp.com/support/article/what-is-an-idempotency-key) +- [MailChannels Email API pricing](https://docs.mailchannels.com/email-api/billing/pricing) +- [MailChannels authentication](https://docs.mailchannels.com/email-api/authentication) +- [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) +- [Amazon SES sandbox requirements](https://aws.amazon.com/ses/faqs/) diff --git a/docs/dev/feedback-reporting-options.md b/docs/dev/feedback-reporting-options.md new file mode 100644 index 00000000..2eccbde8 --- /dev/null +++ b/docs/dev/feedback-reporting-options.md @@ -0,0 +1,329 @@ +# In-app feedback reporting options + +## Decision summary + +Use a hosted feedback form and send reports to a Cloudflare Worker. The Worker creates an issue in Grass Labs, project qrate, milestone Beta Intake & Stabilization, state Backlog. + +Add a persistent **Feedback** button to the main title bar and launcher title bar. Keep **Help > Send Feedback** as a second entry point. + +The app should open the browser form with a small diagnostic payload in the URL fragment. The form should remove the fragment after it reads the data. + +This design keeps temporary form code out of the desktop app. It also keeps reports private and does not require a reporter account. + +The beta does not depend on `qrate://`. Website-to-app attachment can follow later through the plugin branch's shared protocol handler. + +Do not send diagnostics inside a `qrate://` link. Operating systems can expose protocol arguments in process lists and logs. + +## Final implementation audit + +- Keep one hosted form and one Worker submission endpoint. +- Store reports and attachments in Linear. Do not add Notion synchronization or a separate upload bucket. +- Keep a structured summary and a bounded current-session log tail in the URL fragment. +- Treat the fragment as visible to browser extensions and page scripts, not as encrypted storage. +- Remove the fragment immediately. Do not load analytics on the feedback page. +- Let users review and edit diagnostics and logs before submission. Require an explicit choice to include logs. +- Use `ui_ux` consistently in desktop links, the form, and the Worker. +- Keep Turnstile, bounded input, rate limiting, and duplicate-submit protection. Do not require a spoofable client header. +- Use one description field with category-specific guidance, rather than six separate forms. +- Crash is a manual report category. Automatic crash uploads and session recording are out of scope. +- Keep provider credentials in Worker secrets. Deployment and a live submission need operator configuration and verification. + +## Current qrate features + +qrate already has these local support features: + +- A current session log and one rotated session log. +- Panic messages and backtraces in the session log. +- App version, commit, operating system, hardware, project size, plugin status, and a short log tail. +- Home directory redaction. +- **Copy Debug Info**, **Open Logs Folder**, and **Report an Issue** actions. +- A GitHub issue link that lets the user review the report before submission. + +The current workflow has four limits: + +1. GitHub requires an account. +2. Issues in the public qrate repository are public. +3. One generic form handles all report types. +4. The app has no persistent feedback button or hosted report form. + +The work on `feat/in-app-feedback` adds bug, feature, and UI/UX shortcuts to the complete hosted form. It also removes the project filename from the diagnostic summary. + +## Recommended beta design + +```mermaid +flowchart TD + A["Feedback button"] --> B["Browser form with diagnostic fragment"] + B --> C["User reviews the report"] + C --> D["HTTPS POST"] + D --> E["Cloudflare Worker"] + E --> F["Grass Labs qrate Backlog"] + F --> G["Manual triage"] + G --> H["Accepted engineering or product issue"] +``` + +### Browser feedback form + +The form should contain: + +- Category: **Bug**, **UI / UX**, **Feature Request**, **Crash**, **Performance**, or **Improvement**. +- Summary. +- A type-specific description field. +- Optional contact email. +- An **Include diagnostics** checkbox. Select it by default for bugs and UI/UX issues. +- An expandable diagnostic preview. +- Optional screenshot or recording selection. +- A privacy notice and a required review checkbox. +- **Send feedback** and **Cancel** buttons. + +The form should keep entered text after a failed request. A successful request should show a public receipt ID, not a private Linear URL. + +### App-to-browser data transfer + +Use a URL fragment such as: + +```text +https://qrate.dvnl.work/feedback#qrate= +``` + +Browsers do not send a URL fragment in the HTTP request. Page JavaScript can read the payload locally and then call `history.replaceState` to remove it. + +Keep the payload small. Include a structured diagnostic summary and a bounded, home-path-redacted log tail. + +The form shows the exact log tail and does not include it until the user selects the log option. + +The future `qrate://` handler works in the other direction. This is not a beta dependency: + +```text +qrate://feedback/new?return=https%3A%2F%2Fqrate.dvnl.work%2Ffeedback +``` + +qrate must allowlist the return origin. It then opens the browser form with a new diagnostic fragment. + +### Data contract + +Send only fields from an explicit allowlist. + +Send these report fields: + +- Report category. +- Summary and description. +- Reproduction steps and expected result for bugs. +- Optional contact email. +- Submission time from the Worker. +- A random request ID for safe retries. + +The default diagnostic summary can contain: + +- qrate version and commit. +- Operating system version and architecture. +- CPU model and logical core count. +- Used and total memory. +- Project row and column counts. +- Plugin counts and load-failure counts, without plugin names or error messages. + +Never add these fields automatically: + +- Project name or path. +- Cell values, notes, or column names. +- Linked file paths or file contents. +- Google tokens or account details. +- Local user name or home directory. +- The complete current or previous session log. + +Log files can contain workplace details and secrets. The browser must show diagnostic text for review and require an explicit choice for file attachments. + +Screenshots must be optional. The window must tell users to check screenshots for collection data. + +### Worker controls + +The Worker should: + +1. Accept only `POST` requests with JSON or bounded multipart data. +2. Reject unknown fields. +3. Limit text and attachment sizes. +4. Apply an IP-based rate limit. +5. Validate the Turnstile token and expected hostname and action. +6. Use the request ID as an idempotency key. +7. Keep the Linear token in a Worker secret. +8. Avoid logging request bodies. +9. Return a generic error without Linear details. + +Do not require a custom client header. It provides no authentication for a public browser form. + +The hosted form can use Cloudflare Turnstile. The Worker must validate the Turnstile token before it creates a Linear issue. + +### Storage and privacy + +Create all reports in the existing **Grass Labs** team and **qrate** project. Assign the active **Beta Intake & Stabilization** milestone. + +New reports start in **Backlog**. Triage moves accepted reports to **Todo**. + +The complete workflow is: + +- **Backlog**: raw intake. +- **Todo**: accepted work. +- **In Progress**: active work. +- **In Review**: testing and verification. +- **Done**: shipped work. +- **Canceled**: invalid, obsolete, or rejected reports. +- **Duplicate**: redundant reports. + +Every report gets one category label. Reports opened from qrate also get **Windows**, **macOS**, or **Linux**. + +Add **Logs Attached** when the report includes an inline log or an uploaded log. + +Do not add **Reproducible** during intake. A reviewer adds it after a successful reproduction. + +The Worker can upload screenshots to Linear private storage. Linear provides a `fileUpload` mutation and a server-side upload flow. + +After triage, remove Needs Triage and move accepted reports to Todo in the same project. Add Reproducible only after verification. + +### Linear configuration + +These IDs are public configuration values. Keep only the Linear API key in a Worker secret. + +| Item | ID | +|---|---| +| Grass Labs team | `d14f1e07-93ce-4cfe-968c-c6372c6f58a6` | +| qrate project | `aa8cfc24-95f6-461e-aac4-46437d89459e` | +| Beta Intake & Stabilization milestone | `7cdb86ec-c9a9-4d58-b46b-2e382566f1f5` | +| Backlog state | `4b7df4af-a498-4656-8d0a-c87e1c1d28aa` | +| Todo state | `8509604b-9991-4692-8b85-a083e0445f59` | +| In Progress state | `2758444f-10ab-4e6b-a3a6-c13e5326aa23` | +| In Review state | `8560a37f-8749-466e-ba8f-b622795ba3d4` | +| Done state | `ebed7fcf-e471-42c4-8d68-00c93ac41c4a` | +| Canceled state | `671784d6-b381-4624-9cc6-71dc96fecc73` | +| Duplicate state | `f0973e0d-201f-4904-bc49-bcb1cb0204dc` | +| Reproducible label | `d721d1ca-6213-4bda-8928-21ca37671d96` | +| Bug label | `9c1c54ef-3835-4b07-b7fe-c02952216f74` | +| UI / UX label | `8ac21715-4a1e-4a00-a4bb-06276a4ff7ca` | +| Crash label | `ed742751-05de-42db-a428-e52864a8a907` | +| Performance label | `154aeee1-f247-4bc6-b797-3b06051f1a7b` | +| Improvement label | `486d0f47-7520-42d5-88a5-c1fde5dcf769` | +| Logs Attached label | `0429eef3-39f3-4f67-a303-a9f985122a61` | +| Windows label | `951df0ef-4d13-4eaa-97cc-b7621702126c` | +| macOS label | `6ad3615e-c1ad-4be6-91bf-9ce6a9d6b093` | +| Linux label | `2dae9fe2-a98c-43bd-90d5-3a786923d8af` | +| Feature Request label | `bf455f57-e866-46ae-8a73-5dcdd4f1cb23` | + +Configure these values as Worker variables. Configure `LINEAR_API_KEY` with `wrangler secret put LINEAR_API_KEY`. + +The issue creation mutation must set `teamId`, `projectId`, `projectMilestoneId`, `stateId`, and `labelIds`. The Worker should reject a successful HTTP response that contains GraphQL errors. + +The Worker category mapping is: + +| Form value | Linear label | +|---|---| +| `bug` | Bug | +| `ui_ux` | UI / UX | +| `feature` | Feature Request | +| `crash` | Crash | +| `performance` | Performance | +| `improvement` | Improvement | + +Reject unknown categories instead of creating an issue without a category. + +### Feedback Dashboard + +Linear does not let the Worker create a saved custom view. Create **Feedback Dashboard** in the Linear UI with these filters: + +- Project is **qrate**. +- Milestone is **Beta Intake & Stabilization**. +- State is **Backlog**, **Todo**, **In Progress**, or **In Review**. +This view shows active intake and accepted work. It excludes shipped, canceled, and duplicate reports. + +Update the website privacy policy before release. It currently states that qrate sends no reports and operates no data server. + +The policy must name: + +- The exact report data. +- Cloudflare and Linear as processors. +- The purpose of collection. +- The retention period. +- The deletion contact. +- The fact that submission is optional. + +## Options + +| Option | Beta effort | Reporter account | Privacy | Attachments | Tracker fit | Result | +|---|---:|---|---|---|---|---| +| Prefilled public GitHub issue | 1 day | GitHub required | Poor for workplace logs | Yes | Direct GitHub issue | Keep as fallback | +| Browser form, Worker, Linear | 3–5 days | None | Private with user preview | Yes | Separate feedback intake | **Recommended** | +| Browser form, Worker, Notion | 3–5 days | None | Private with user preview | Yes | Mixes raw feedback with planned work | Good alternative | +| Worker writes Notion and Linear | 5–7 days | None | Private with user preview | More complex | Creates duplicate state | Avoid | +| Email through the default mail app | 1–2 days | Email required | Depends on local email | Yes | Manual entry | Poor reliability | +| Sentry user feedback | 2–4 days | None | New external processor | Crash-focused | Separate tracker | Consider later for crashes | + +### GitHub + +GitHub issue forms support structured fields, labels, required answers, and file uploads. They still create issues in the target repository. + +The public qrate repository cannot safely receive workplace logs by default. GitHub also adds an account requirement and takes users out of the app. + +### Cloudflare Worker and Linear + +The qrate website already runs on Cloudflare Workers. Static assets bypass the Worker, and the site already has one dynamic route. + +The free Workers plan allows 100,000 requests each day. A feedback endpoint will use a very small part of that limit. + +Linear supports issue creation through its GraphQL API. A restricted key can have issue creation access for one team. + +Linear also supports private file storage through its server-side upload flow. This lets the Worker attach screenshots without a second storage service. + +This pattern matches common product operations. Raw feedback goes to a separate intake queue. Reviewed work moves to the product or engineering backlog. + +The separate team prevents unreviewed reports and spam from distorting planned work. It also gives the Worker token the smallest useful permission scope. + +### Cloudflare Worker and Notion + +Notion remains useful for planned product work and larger specifications. A separate feedback database could also hold raw reports. + +This option offers flexible views and page content. It adds a promotion step before the team sees an issue in Linear. + +Use Notion if the team wants research synthesis before engineering triage. Use Linear if the team wants one operational feedback queue. + +### Sentry + +Sentry can pair Rust error events with user comments. Its standard Rust feedback flow uses a JavaScript crash report modal. + +Sentry fits automatic crash reporting better than feature requests or general UI/UX feedback. It also changes qrate from local logs to continuous third-party error collection. + +Do not add Sentry for the beta feedback form. Review it as a separate opt-in crash reporting project. + +## Minimal delivery plan + +### Phase 1: browser intake + +1. Add the persistent **Feedback** button. +2. Add the hosted form and six report categories on the `site` branch. +3. Open the form with an allowlisted diagnostic fragment. +4. Keep the existing GitHub action as a fallback. + +### Phase 2: private delivery + +1. Add the Worker endpoint on the `site` branch. +2. Add schema validation, Turnstile, size limits, rate limits, and idempotency. +3. Create reports in the separate Linear feedback team. +4. Upload screenshots through Linear after text reports work. +5. Update the privacy policy. + +### Phase 3: beta operations + +1. Add Linear views for new, accepted, and duplicate reports. +2. Define the retention and deletion process. +3. Test failures with no network and invalid server responses. +4. Test a packaged build on Windows, macOS, and Linux. + +## External references + +- [Cloudflare Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) +- [Cloudflare Workers rate limits](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) +- [Cloudflare Turnstile server validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) +- [Notion page creation](https://developers.notion.com/reference/post-page) +- [Notion API limits](https://developers.notion.com/reference/request-limits) +- [Notion file uploads](https://developers.notion.com/guides/data-apis/working-with-files-and-media) +- [Linear GraphQL API](https://linear.app/developers/graphql) +- [Linear API controls](https://linear.app/docs/api-and-webhooks) +- [Linear file uploads](https://linear.app/developers/how-to-upload-a-file-to-linear) +- [GitHub issue forms](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms) +- [Sentry feedback for Rust](https://docs.sentry.io/platforms/rust/user-feedback/)