diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4b7d0..5f79c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.3.0] - 2026-10-16 -### Fixed - -- Use the system proxy settings on macOS and Windows again. Turning off reqwest's default features to add the TLS feature flags (#34) also turned off its `system-proxy`, `http2` and `charset` features; they're back on, and the TLS backend is still chosen with `default-tls`, `rustls-tls` or `native-tls` -- `rust-version` is 1.85.0: reqwest 0.13 and rand 0.10 don't build on 1.81 +JavaScript bindings: `@aptabase/tauri` 0.7.0. ### Added +- Error reporting (beta), to the error endpoint (`/api/v0/error`); reports appear on the Errors page of the dashboard +- `ErrorTracker` trait on `App`, `AppHandle` and `Window`: `track_error(&err)` reports any `std::error::Error` with its type name and causes, and `track_error_report(ErrorReport)` reports one described field by field (`ErrorReport::with_fatal` for severity `fatal`) +- `trackError(error, { fatal })` in the JavaScript bindings, with the `aptabase:allow-track-error` permission +- `Builder::with_crash_reporting()`: a panic on the main thread is reported as a fatal `crash` (the app waits up to three seconds for the report before it exits), one in an async task as a `taskException` and one on another thread as `unhandled`; the webview's uncaught errors and unhandled promise rejections are reported too +- Reports are sent right away, one per request, with the same retry rules as the other SDKs: network errors, 408, 429 and 5xx are retried, a 403 (monthly error quota exhausted) and other 4xx are dropped; at most 25 wait for delivery - Tests for event delivery (batches of 25, what's kept for the next flush and what's dropped, the event format, polling) and for App Key validation, run in CI - README sections on delivery and on the TLS backend features +### Fixed + +- Use the system proxy settings on macOS and Windows again. Turning off reqwest's default features to add the TLS feature flags (#34) also turned off its `system-proxy`, `http2` and `charset` features; they're back on, and the TLS backend is still chosen with `default-tls`, `rustls-tls` or `native-tls` +- `rust-version` is 1.85.0: reqwest 0.13 and rand 0.10 don't build on 1.81 +- `flush_events_blocking()`, and the flush after a custom panic hook, panicked with "there is no reactor running" when events were queued and the calling thread was outside Tauri's async runtime (the main thread handling `RunEvent::Exit`, as the README shows); the flush now runs on the runtime + ## [1.2.0] - 2026-08-11 ### Changed diff --git a/Cargo.toml b/Cargo.toml index 36936c5..d371e0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-aptabase" -version = "1.2.0" +version = "1.3.0" license = "MIT" description = "Tauri Plugin for Aptabase: Open Source, Privacy-First and Simple Analytics for Mobile, Desktop and Web Apps" authors = ["Guilherme Oenning"] @@ -13,7 +13,7 @@ links = "tauri-plugin-aptabase" [dependencies] tauri = "2.11.5" -tokio = "1.53.1" +tokio = { version = "1.53.1", features = ["rt", "time", "net"] } futures = "0.3.31" serde = "1.0.229" serde_json = "1.0.151" @@ -26,6 +26,7 @@ log = "0.4.27" sys-locale = "0.3.2" [dev-dependencies] +tauri = { version = "2.11.5", features = ["test"] } time = { version = "0.3.41", features = ["parsing"] } [build-dependencies] diff --git a/README.md b/README.md index dff185e..84627ca 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-aptabase = "1.0.0" +tauri-plugin-aptabase = "1.3.0" ``` ```toml @@ -94,6 +94,59 @@ A few important notes: 3. You do not need to await for the `trackEvent` function, it'll run in the background. 3. Only strings and numbers values are allowed on custom properties +## Error Reporting + +> Error reporting is in beta. Reports appear on the `Errors` page of your Aptabase dashboard. + +Use `track_error` to report errors you've caught and handled in Rust, through the `tauri_plugin_aptabase::ErrorTracker` trait on `App`, `AppHandle` or `Window`: + +```rust +use tauri_plugin_aptabase::ErrorTracker; + +if let Err(err) = std::fs::read_to_string("settings.json") { + app.track_error(&err); // severity: error +} +``` + +The `trackError` function does the same from JavaScript. Add `aptabase:allow-track-error` to your Access Control List first: + +```js +import { trackError } from "@aptabase/tauri"; + +try { + await saveSettings(); +} catch (error) { + trackError(error); // severity: error +} +``` + +For errors your app can't recover from, mark the report as fatal: + +```js +trackError(error, { fatal: true }); // severity: fatal +``` + +```rust +use tauri_plugin_aptabase::{ErrorReport, ErrorTracker}; + +app.track_error_report(ErrorReport::from_error(&err).with_fatal(true)); // severity: fatal +``` + +To also report crashes and uncaught errors automatically, enable crash reporting when you register the plugin: + +```rust +tauri::Builder::default() + .plugin(tauri_plugin_aptabase::Builder::new("").with_crash_reporting().build()) +``` + +A few important notes about error reporting: + +1. A panic on the main thread ends your app: it's reported as a fatal `crash`, and the app waits up to three seconds for the report to be sent before it exits. A panic in an async command or a task on Tauri's async runtime ends only that task (`taskException`), and one on a thread of your own ends only that thread (`unhandled`): both are reported with severity `error`. A hook set with `with_panic_hook` still runs. +2. With crash reporting on, the webview's uncaught errors (`unhandled`) and unhandled promise rejections (`taskException`) are reported too. This needs the `aptabase:allow-track-error` permission. +3. Each report includes the error type, message, stack trace, severity (`error` or `fatal`) and how it was captured (`handled`, `unhandled`, `taskException` or `crash`). A Rust error is reported with its type name and the errors that caused it; a panic with its location and a backtrace. +4. Errors count against a separate monthly error quota. When it is exhausted, the server rejects new reports until it resets, and the plugin drops them instead of retrying. +5. Crashes that don't go through Rust's panic handler (a segfault or an abort in native code) aren't captured. + ## Providing the APTABASE_KEY via .env It's possible to load the APTABASE_KEY from a .env file at compile time using the `dotenvy_macro` crate. The `.env` file needs to be @@ -151,7 +204,7 @@ To switch backend, turn off the default features: ```toml [dependencies] -tauri-plugin-aptabase = { version = "1.2", default-features = false, features = ["native-tls"] } +tauri-plugin-aptabase = { version = "1.3", default-features = false, features = ["native-tls"] } ``` Neither `default-tls` nor `rustls-tls` needs OpenSSL, so you can cross-compile for Android and Linux without it. diff --git a/build.rs b/build.rs index b6b7b1a..61e6ec7 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -const COMMANDS: &[&str] = &["track_event"]; +const COMMANDS: &[&str] = &["track_event", "track_error"]; fn main() { tauri_plugin::Builder::new(COMMANDS).build(); diff --git a/examples/helloworld/src-tauri/capabilities/desktop.json b/examples/helloworld/src-tauri/capabilities/desktop.json index 8182109..4e0e02c 100644 --- a/examples/helloworld/src-tauri/capabilities/desktop.json +++ b/examples/helloworld/src-tauri/capabilities/desktop.json @@ -3,6 +3,7 @@ "description": "Capability for the main window", "windows": ["main"], "permissions": [ - "aptabase:allow-track-event" + "aptabase:allow-track-event", + "aptabase:allow-track-error" ] } \ No newline at end of file diff --git a/examples/helloworld/src-tauri/src/lib.rs b/examples/helloworld/src-tauri/src/lib.rs index f520eb8..d76773c 100644 --- a/examples/helloworld/src-tauri/src/lib.rs +++ b/examples/helloworld/src-tauri/src/lib.rs @@ -20,7 +20,7 @@ pub fn run() { client.track_event("panic", Some(json!({ "info": format!("{} ({})", msg, location), }))); - })).build()) + })).with_crash_reporting().build()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/examples/helloworld/src/lib/Greet.svelte b/examples/helloworld/src/lib/Greet.svelte index 91b9989..2142cd0 100644 --- a/examples/helloworld/src/lib/Greet.svelte +++ b/examples/helloworld/src/lib/Greet.svelte @@ -1,6 +1,6 @@
@@ -20,5 +33,9 @@

{greetMsg}

+
+ + +
diff --git a/llms.txt b/llms.txt index 504674e..b8ef9a2 100644 --- a/llms.txt +++ b/llms.txt @@ -36,7 +36,7 @@ In `src-tauri/Cargo.toml`: ```toml [dependencies] -tauri-plugin-aptabase = "1.0.0" +tauri-plugin-aptabase = "1.3.0" ``` ### 2. Add the JS package (optional, for webview tracking) @@ -61,11 +61,11 @@ async fn main() { ### 4. Add ACL permission -Add `aptabase:allow-track-event` to your Tauri capabilities to allow the JS API to send events. In your `src-tauri/capabilities/*.json`: +Add `aptabase:allow-track-event` to your Tauri capabilities to allow the JS API to send events, and `aptabase:allow-track-error` to report errors. In your `src-tauri/capabilities/*.json`: ```json { - "permissions": ["aptabase:allow-track-event"] + "permissions": ["aptabase:allow-track-event", "aptabase:allow-track-error"] } ``` @@ -125,6 +125,45 @@ trackEvent("screen_view", { name: "Settings" }); trackEvent("purchase", { plan: "pro", price: 9.99 }); ``` +## Error Reporting (beta) + +Reports appear on the `Errors` page of the Aptabase dashboard. Errors count against a separate monthly error quota. + +Report a handled error from Rust with the `ErrorTracker` trait (implemented for `App`, `AppHandle`, and `Window`): + +```rust +use tauri_plugin_aptabase::{ErrorReport, ErrorTracker}; + +app.track_error(&err); // any std::error::Error: its type name, message and causes +app.track_error_report(ErrorReport::from_error(&err).with_fatal(true)); // severity: fatal +app.track_error_report(ErrorReport::new("SyncConflict", "two devices edited the same note")); +``` + +Report a handled error from JavaScript (needs the `aptabase:allow-track-error` permission): + +```js +import { trackError } from "@aptabase/tauri"; + +try { + await saveSettings(); +} catch (error) { + trackError(error); // severity: error + // trackError(error, { fatal: true }) for errors the app can't recover from +} +``` + +Report crashes and uncaught errors automatically: + +```rust +tauri::Builder::default() + .plugin(tauri_plugin_aptabase::Builder::new("").with_crash_reporting().build()) +``` + +- A panic on the main thread is a fatal `crash`; the app waits up to 3 seconds for the report before it exits +- A panic in an async command or a task on Tauri's async runtime is a `taskException`; one on another thread is `unhandled` (both severity `error`) +- The webview's uncaught errors (`unhandled`) and unhandled promise rejections (`taskException`) are reported too, with the `aptabase:allow-track-error` permission +- Reports are sent immediately, one per request; network errors, 408, 429 and 5xx are retried, a 403 (quota exhausted) is dropped + ## Configuration The `Builder` accepts optional `InitOptions`: @@ -155,7 +194,7 @@ tauri::Builder::default() Cargo features choose the TLS backend: `default-tls` (default, rustls), `rustls-tls`, or `native-tls` (OpenSSL on Linux and Android). To switch, turn off the default features: ```toml -tauri-plugin-aptabase = { version = "1.2", default-features = false, features = ["native-tls"] } +tauri-plugin-aptabase = { version = "1.3", default-features = false, features = ["native-tls"] } ``` ## Platform Notes @@ -164,7 +203,7 @@ tauri-plugin-aptabase = { version = "1.2", default-features = false, features = - Rust edition 2021, MSRV 1.85 - The `EventTracker` trait is implemented for `App`, `AppHandle`, and `Window` - The JS `trackEvent` is async but you don't need to await it -- A custom panic hook can be set via `Builder::with_panic_hook()` to track crashes +- `Builder::with_crash_reporting()` reports panics and the webview's uncaught errors; a custom panic hook can still be set via `Builder::with_panic_hook()` ## Cross-Discovery diff --git a/package.json b/package.json index f8e502f..50870c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aptabase/tauri", - "version": "0.6.0", + "version": "0.7.0", "private": false, "type": "module", "description": "Tauri Plugin for Aptabase: Open Source, Privacy-First and Simple Analytics for Mobile, Desktop and Web Apps", diff --git a/permissions/autogenerated/commands/track_error.toml b/permissions/autogenerated/commands/track_error.toml new file mode 100644 index 0000000..f638e43 --- /dev/null +++ b/permissions/autogenerated/commands/track_error.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-track-error" +description = "Enables the track_error command without any pre-configured scope." +commands.allow = ["track_error"] + +[[permission]] +identifier = "deny-track-error" +description = "Denies the track_error command without any pre-configured scope." +commands.deny = ["track_error"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 652ec58..fe1dfb9 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -7,6 +7,32 @@ + + + +`aptabase:allow-track-error` + + + + +Enables the track_error command without any pre-configured scope. + + + + + + + +`aptabase:deny-track-error` + + + + +Denies the track_error command without any pre-configured scope. + + + + diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 4c0ac2b..e0b2329 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -294,6 +294,18 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the track_error command without any pre-configured scope.", + "type": "string", + "const": "allow-track-error", + "markdownDescription": "Enables the track_error command without any pre-configured scope." + }, + { + "description": "Denies the track_error command without any pre-configured scope.", + "type": "string", + "const": "deny-track-error", + "markdownDescription": "Denies the track_error command without any pre-configured scope." + }, { "description": "Enables the track_event command without any pre-configured scope.", "type": "string", diff --git a/src/client.rs b/src/client.rs index d1cb149..01a4c7a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2,14 +2,15 @@ use rand::RngExt; use serde_json::{json, Value}; use std::time::{SystemTime, UNIX_EPOCH}; use std::{ - sync::{Arc, Mutex as SyncMutex}, + sync::{mpsc, Arc, Mutex as SyncMutex}, time::Duration, }; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use crate::{ config::Config, - dispatcher::EventDispatcher, + dispatcher::{self, EventDispatcher}, + errors::{self, ErrorDispatcher, ErrorKind, ErrorReport}, sys::{self, SystemProperties}, }; @@ -50,6 +51,7 @@ pub struct AptabaseClient { is_enabled: bool, session: SyncMutex, dispatcher: Arc, + errors: Arc, app_version: String, sys_info: SystemProperties, } @@ -60,11 +62,21 @@ impl AptabaseClient { let sys_info = sys::get_info(); let is_enabled = !config.app_key.is_empty(); - let dispatcher = Arc::new(EventDispatcher::new(config, &sys_info)); + let build = |b: reqwest::ClientBuilder| b.build().expect("could not build http client"); + let http_client = build(dispatcher::http_client(config, &sys_info)); + let crash_client = + build(dispatcher::http_client(config, &sys_info).pool_max_idle_per_host(0)); + let errors = Arc::new(ErrorDispatcher::new( + config.error_api_url.clone(), + http_client.clone(), + crash_client, + )); + let dispatcher = Arc::new(EventDispatcher::new(config, http_client)); Self { is_enabled, dispatcher, + errors, session: SyncMutex::new(TrackingSession::new()), app_version, sys_info, @@ -74,18 +86,21 @@ impl AptabaseClient { /// Starts the event dispatcher loop. pub(crate) fn start_polling(&self, interval: Duration) { let dispatcher = self.dispatcher.clone(); + let errors = self.errors.clone(); tauri::async_runtime::spawn(async move { loop { tokio::time::sleep(interval).await; dispatcher.flush().await; + errors.flush().await; } }); } /// Returns the current session ID, creating a new one if necessary. pub(crate) fn eval_session_id(&self) -> String { - let mut session = self.session.lock().expect("could not lock events"); + // Also called from panic hooks, where a lock poisoned by another panic must not panic again. + let mut session = self.session.lock().unwrap_or_else(|e| e.into_inner()); let now = OffsetDateTime::now_utc(); if (now - session.last_touch_ts) > SESSION_TIMEOUT { @@ -134,15 +149,146 @@ impl AptabaseClient { Ok(()) } - /// Flushes the event queue. + /// Reports an error to Aptabase. It is sent right away, in the background. + pub fn track_error(&self, report: ErrorReport) { + let kind = if report.fatal { + ErrorKind::Crash + } else { + ErrorKind::Handled + }; + self.track_error_as(report, kind); + } + + /// Reports an error captured as `kind`. + pub(crate) fn track_error_as(&self, report: ErrorReport, kind: ErrorKind) { + if !self.is_enabled { + return; + } + + // Errors are worth more than events: they are sent now rather than at the next flush. + if self.errors.enqueue(self.error_body(&report, kind)) { + let errors = self.errors.clone(); + tauri::async_runtime::spawn(async move { errors.flush().await }); + } + } + + /// Reports a crash, straight away and not through the queue, then sends the reports queued before it, waiting at + /// most `timeout`: what the app allows before it exits. + pub(crate) fn report_crash(&self, report: ErrorReport, timeout: Duration) { + if !self.is_enabled { + return; + } + + let body = self.error_body(&report, ErrorKind::Crash); + errors::send_crash_blocking(&self.errors, body, timeout); + } + + /// Flushes the events and the error reports, waiting at most `timeout`, from a thread and a runtime of their own: + /// what a panic hook can afford. + pub(crate) fn flush_within(&self, timeout: Duration) { + let dispatcher = self.dispatcher.clone(); + let errors = self.errors.clone(); + errors::block_on_own_thread( + async move { + dispatcher.flush().await; + errors.flush().await; + }, + timeout, + ); + } + + fn error_body(&self, report: &ErrorReport, kind: ErrorKind) -> Value { + let timestamp = OffsetDateTime::now_utc().format(&Rfc3339).unwrap(); + errors::report_body( + report, + kind, + &self.eval_session_id(), + ×tamp, + &self.sys_info, + &self.app_version, + ) + } + + /// Flushes the event queue and the error reports waiting to be sent again. pub async fn flush(&self) { self.dispatcher.flush().await; + self.errors.flush().await; } /// Flushes the event queue, blocking the current thread. + /// + /// A request needs an async runtime's reactor. A thread outside the runtime (the main thread handling + /// `RunEvent::Exit`, a panic hook there) has none, so the flush runs on Tauri's runtime while this thread waits; + /// a thread inside a runtime flushes in place, as it always has. pub fn flush_blocking(&self) { - futures::executor::block_on(async { - self.flush().await; + if tokio::runtime::Handle::try_current().is_ok() { + futures::executor::block_on(self.flush()); + return; + } + let dispatcher = self.dispatcher.clone(); + let errors = self.errors.clone(); + let (done, wait) = mpsc::channel(); + tauri::async_runtime::spawn(async move { + dispatcher.flush().await; + errors.flush().await; + let _ = done.send(()); }); + let _ = wait.recv(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_server::server; + + fn client(base: &str) -> AptabaseClient { + let config = Config { + app_key: "A-DEV-0000000000".into(), + ingest_api_url: format!("{base}/api/v0/events").parse().unwrap(), + error_api_url: format!("{base}/api/v0/error").parse().unwrap(), + flush_interval: Duration::from_secs(60), + }; + AptabaseClient::new(&config, "1.4.0".into()) + } + + // The main thread handling RunEvent::Exit is outside the async runtime: flushing there used to panic with + // "there is no reactor running". + #[test] + fn a_blocking_flush_sends_the_events_from_a_thread_outside_the_runtime() { + let server = server(&[200]); + let client = Arc::new(client(&server.base)); + client.track_event("app_exited", None).unwrap(); + let flushing = client.clone(); + std::thread::spawn(move || flushing.flush_blocking()) + .join() + .expect("the flush doesn't panic"); + let sent = server.requests.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].1[0]["eventName"], "app_exited"); + } + + #[test] + fn an_error_is_sent_right_away_stamped_with_the_session() { + let server = server(&[202]); + let client = client(&server.base); + let session = client.eval_session_id(); + client.track_error(ErrorReport::new( + "std::io::error::Error", + "settings.json is missing", + )); + let started = std::time::Instant::now(); + while server.requests.lock().unwrap().is_empty() + && started.elapsed() < Duration::from_secs(5) + { + std::thread::sleep(Duration::from_millis(20)); + } + let sent = server.requests.lock().unwrap(); + assert_eq!(sent.len(), 1, "sent without waiting for the next flush"); + let report = &sent[0].1; + assert_eq!(report["errorType"], "std::io::error::Error"); + assert_eq!(report["kind"], "handled"); + assert_eq!(report["sessionId"], session.as_str()); + assert_eq!(report["appVersion"], "1.4.0"); } } diff --git a/src/commands.rs b/src/commands.rs index 21839a9..dd7f6ae 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,7 +2,10 @@ use serde_json::Value; use std::sync::Arc; use tauri::{command, State}; -use crate::client::AptabaseClient; +use crate::{ + client::AptabaseClient, + errors::{ErrorKind, ErrorReport}, +}; #[command] pub fn track_event( @@ -12,3 +15,30 @@ pub fn track_event( ) -> Result<(), String> { client.track_event(name, props) } + +/// An error from the webview: one `trackError` reports (handled, or fatal), or one crash reporting caught +/// (`kind` "unhandled" for an uncaught error, "taskException" for an unhandled promise rejection). +#[command] +pub fn track_error( + client: State<'_, Arc>, + error_type: String, + message: String, + stack_trace: Option, + fatal: Option, + kind: Option, +) { + let fatal = fatal.unwrap_or(false); + let kind = match kind.as_deref() { + Some("unhandled") => ErrorKind::Unhandled, + Some("taskException") => ErrorKind::TaskException, + _ if fatal => ErrorKind::Crash, + _ => ErrorKind::Handled, + }; + let report = ErrorReport { + error_type, + message, + stack_trace, + fatal, + }; + client.track_error_as(report, kind); +} diff --git a/src/config.rs b/src/config.rs index 6b82204..e4c3c60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ use crate::InitOptions; pub struct Config { pub app_key: String, pub ingest_api_url: Url, + pub error_api_url: Url, pub flush_interval: Duration, } @@ -53,6 +54,7 @@ impl Config { Self { app_key, ingest_api_url: format!("{}/api/v0/events", base_url).parse().unwrap(), + error_api_url: format!("{}/api/v0/error", base_url).parse().unwrap(), flush_interval: opts.flush_interval.unwrap_or(DEFAULT_FLUSH_INTERVAL), } } @@ -63,6 +65,7 @@ impl Default for Config { Self { app_key: String::new(), ingest_api_url: Url::parse(LOCAL).unwrap(), + error_api_url: Url::parse(LOCAL).unwrap(), flush_interval: DEFAULT_FLUSH_INTERVAL, } } diff --git a/src/crash_reporting.js b/src/crash_reporting.js new file mode 100644 index 0000000..2490ceb --- /dev/null +++ b/src/crash_reporting.js @@ -0,0 +1,44 @@ +// Added to every webview by `Builder::with_crash_reporting`: reports uncaught errors and unhandled promise rejections +// to Aptabase through the plugin's `track_error` command (the `aptabase:allow-track-error` permission). +(function () { + var internals = window.__TAURI_INTERNALS__; + if (!internals || typeof internals.invoke !== "function") return; + + function report(error, kind) { + try { + var name = "Error"; + var message; + var stack; + if (error instanceof Error) { + name = error.name || "Error"; + message = error.message; + stack = error.stack; + } else { + message = String(error); + } + // Reporting never throws or rejects: a failure here must not become an error of its own. + Promise.resolve( + internals.invoke("plugin:aptabase|track_error", { + errorType: name, + message: name + ": " + message, + stackTrace: stack || null, + kind: kind, + }) + ).catch(function () {}); + } catch (_) {} + } + + window.addEventListener("error", function (event) { + if (!event.error) { + // A script from another origin reports only "Script error.", without the error: nothing to act on. A + // ResizeObserver that couldn't deliver every notification in one frame is harmless, and browsers report it often. + var text = String(event.message || ""); + if (text === "Script error." || text.indexOf("ResizeObserver loop") !== -1) return; + } + report(event.error || event.message, "unhandled"); + }); + + window.addEventListener("unhandledrejection", function (event) { + report(event.reason, "taskException"); + }); +})(); diff --git a/src/dispatcher.rs b/src/dispatcher.rs index a94b017..66c66a5 100644 --- a/src/dispatcher.rs +++ b/src/dispatcher.rs @@ -22,25 +22,26 @@ pub(crate) struct EventDispatcher { http_client: reqwest::Client, } -impl EventDispatcher { - pub fn new(config: &Config, sys: &SystemProperties) -> Self { - let mut headers = HeaderMap::new(); - let app_key_header = HeaderValue::from_str(config.app_key.as_str()) - .expect("failed to define App Key header value"); - headers.insert("App-Key", app_key_header); - headers.insert("Content-Type", HeaderValue::from_static("application/json")); - - let user_agent = format!( - "{}/{} {}/{} {}", - sys.os_name, sys.os_version, sys.engine_name, sys.engine_version, sys.locale - ); - let http_client = reqwest::Client::builder() - .timeout(HTTP_REQUEST_TIMEOUT) - .default_headers(headers) - .user_agent(user_agent) - .build() - .expect("could not build http client"); +/// How events and error reports are sent: the App Key, JSON, and a User-Agent describing the system. +pub(crate) fn http_client(config: &Config, sys: &SystemProperties) -> reqwest::ClientBuilder { + let mut headers = HeaderMap::new(); + let app_key_header = HeaderValue::from_str(config.app_key.as_str()) + .expect("failed to define App Key header value"); + headers.insert("App-Key", app_key_header); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + + let user_agent = format!( + "{}/{} {}/{} {}", + sys.os_name, sys.os_version, sys.engine_name, sys.engine_version, sys.locale + ); + reqwest::Client::builder() + .timeout(HTTP_REQUEST_TIMEOUT) + .default_headers(headers) + .user_agent(user_agent) +} +impl EventDispatcher { + pub fn new(config: &Config, http_client: reqwest::Client) -> Self { let queue = Arc::new(RwLock::new(VecDeque::new())); Self { diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..4c0254e --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,692 @@ +//! Error reports: what the plugin sends to Aptabase's error endpoint (`/api/v0/error`), one report per request. + +use std::{ + backtrace::{Backtrace, BacktraceStatus}, + collections::VecDeque, + panic::PanicHookInfo, + sync::{mpsc, Arc, RwLock, RwLockWriteGuard}, + time::Duration, +}; + +use futures::lock::Mutex as AsyncMutex; +use log::debug; +use reqwest::{StatusCode, Url}; +use serde_json::{json, Value}; + +use crate::sys::SystemProperties; + +/// The platform every report names, as the other SDKs name theirs ("React Native", ".NET MAUI"). +const PLATFORM: &str = "Tauri"; + +/// The most reports kept waiting for delivery: a crash loop shouldn't grow memory without bound. +const MAX_QUEUED_REPORTS: usize = 25; + +// The error endpoint rejects a whole report when a field is longer than these. They count UTF-16 code units, as .NET +// measures a string's length, so a report is truncated the same way before it is sent. +const MAX_ERROR_MESSAGE: usize = 5000; +const MAX_ERROR_TYPE: usize = 100; +const MAX_STACK_TRACE: usize = 10000; +const MAX_OS_NAME: usize = 30; +const MAX_OS_VERSION: usize = 100; +const MAX_APP_VERSION: usize = 50; +const MAX_SDK_VERSION: usize = 40; + +/// An error to report to Aptabase. It appears on the Errors page of the dashboard. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorReport { + /// The error's type, e.g. `std::io::error::Error` or `TypeError`. Reports are grouped by it. + pub error_type: String, + /// What went wrong. + pub message: String, + /// Where it happened, when known. + pub stack_trace: Option, + /// Whether the app can't recover from it (severity `fatal`, rather than `error`). + pub fatal: bool, +} + +impl ErrorReport { + /// A report of an error of `error_type` with `message`. + pub fn new(error_type: impl Into, message: impl Into) -> Self { + Self { + error_type: error_type.into(), + message: message.into(), + stack_trace: None, + fatal: false, + } + } + + /// A report of `error`: its type name, its message followed by the errors that caused it, and a backtrace of + /// where it was reported when `RUST_BACKTRACE` or `RUST_LIB_BACKTRACE` enables backtraces. + pub fn from_error(error: &E) -> Self { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + message.push_str("\nCaused by: "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + // A trait object's type name is "dyn core::error::Error", which says nothing about the error. + let type_name = std::any::type_name::(); + let error_type = if type_name.starts_with("dyn ") { + "Error" + } else { + type_name + }; + let backtrace = Backtrace::capture(); + Self { + error_type: error_type.to_string(), + message, + stack_trace: (backtrace.status() == BacktraceStatus::Captured) + .then(|| backtrace.to_string()), + fatal: false, + } + } + + /// The same report with `stack_trace`. + pub fn with_stack_trace(mut self, stack_trace: impl Into) -> Self { + self.stack_trace = Some(stack_trace.into()); + self + } + + /// The same report, fatal or not. + pub fn with_fatal(mut self, fatal: bool) -> Self { + self.fatal = fatal; + self + } +} + +/// How an error was captured, as the error endpoint records it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ErrorKind { + /// It ended the app. + Crash, + /// Nothing caught it, but the app carries on: a webview's uncaught error, a panic on a thread of its own. + Unhandled, + /// An asynchronous task failed: a panic in a task on Tauri's async runtime, a webview's unhandled rejection. + TaskException, + /// The app caught it and reported it. + Handled, +} + +impl ErrorKind { + fn as_str(self) -> &'static str { + match self { + ErrorKind::Crash => "crash", + ErrorKind::Unhandled => "unhandled", + ErrorKind::TaskException => "taskException", + ErrorKind::Handled => "handled", + } + } +} + +/// What a panic ends. In a task spawned on an async runtime (an async command, `tauri::async_runtime::spawn`), only +/// that task, which the runtime catches, on whatever thread it runs (a single-thread runtime runs its tasks on the +/// main thread); otherwise on the main thread (Rust names it "main"), the app; on any other thread, only that thread. +/// With `panic = "abort"`, every panic ends the app. +pub(crate) fn panic_kind(in_spawned_task: bool, on_main_thread: bool, aborts: bool) -> ErrorKind { + if aborts { + ErrorKind::Crash + } else if in_spawned_task { + ErrorKind::TaskException + } else if on_main_thread { + ErrorKind::Crash + } else { + ErrorKind::Unhandled + } +} + +/// The report of a panic, and how it was captured. The location is part of the message, so the same message from +/// two places (`called \`Option::unwrap()\` on a \`None\` value`) is reported as two errors. +pub(crate) fn panic_report(info: &PanicHookInfo<'_>, message: &str) -> (ErrorReport, ErrorKind) { + let thread = std::thread::current(); + let kind = panic_kind( + tokio::task::try_id().is_some(), + thread.name() == Some("main"), + cfg!(panic = "abort"), + ); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())); + let message = match &location { + Some(at) => format!("{message} (at {at})"), + None => message.to_string(), + }; + let stack_trace = format!( + "thread '{}' panicked at {}\n{}", + thread.name().unwrap_or(""), + location.as_deref().unwrap_or("an unknown location"), + Backtrace::force_capture() + ); + let report = ErrorReport { + error_type: "panic".into(), + message, + stack_trace: Some(stack_trace), + fatal: kind == ErrorKind::Crash, + }; + (report, kind) +} + +/// The body the error endpoint takes for `report`, stamped with the time and session it was captured in. +pub(crate) fn report_body( + report: &ErrorReport, + kind: ErrorKind, + session_id: &str, + timestamp: &str, + sys: &SystemProperties, + app_version: &str, +) -> Value { + // The endpoint refuses a report without a type or a message, so neither is ever sent empty. + let error_type = match report.error_type.trim() { + "" => "Error", + t => t, + }; + let message = match report.message.trim() { + "" => error_type, + m => m, + }; + json!({ + "errorMessage": truncate(message, MAX_ERROR_MESSAGE), + "errorType": truncate(error_type, MAX_ERROR_TYPE), + "stackTrace": report.stack_trace.as_deref().map(|s| truncate(s, MAX_STACK_TRACE)), + "timestamp": timestamp, + "sessionId": session_id, + "platform": PLATFORM, + // Unlike the events endpoint, the error endpoint doesn't read the OS from the User-Agent. + "osName": truncate(&sys.os_name, MAX_OS_NAME), + "osVersion": truncate(&sys.os_version, MAX_OS_VERSION), + "appVersion": truncate(app_version, MAX_APP_VERSION), + "sdkVersion": truncate(concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION")), MAX_SDK_VERSION), + "severity": if report.fatal { "fatal" } else { "error" }, + "kind": kind.as_str(), + "isDebug": sys.is_debug, + }) +} + +/// `value`, cut to at most `max` UTF-16 code units on a character boundary. +fn truncate(value: &str, max: usize) -> String { + let mut units = 0; + for (at, c) in value.char_indices() { + units += c.len_utf16(); + if units > max { + return value[..at].to_string(); + } + } + value.to_string() +} + +/// Whether a report the server answered with `status` (or didn't answer) can be delivered by sending it again. +/// No connection, a timeout (408), the per-IP rate limit (429) and server errors pass; 403 (the month's error quota +/// is used up, until the 1st) and any other refusal never will. +fn retry_later(status: Option) -> bool { + match status { + None => true, + Some(s) => { + s == StatusCode::REQUEST_TIMEOUT + || s == StatusCode::TOO_MANY_REQUESTS + || s.is_server_error() + } + } +} + +/// Sends error reports one per request, right away, keeping the ones that can be delivered later for the next flush. +pub(crate) struct ErrorDispatcher { + url: Url, + queue: RwLock>, + flushing: AsyncMutex<()>, + http_client: reqwest::Client, + /// The same client without idle connections, for a crash's report on a runtime of its own: a kept connection is + /// served by the runtime that opened it, which may be the one the crashing thread is blocking. + crash_client: reqwest::Client, +} + +impl ErrorDispatcher { + pub fn new(url: Url, http_client: reqwest::Client, crash_client: reqwest::Client) -> Self { + Self { + url, + queue: RwLock::new(VecDeque::new()), + flushing: AsyncMutex::new(()), + http_client, + crash_client, + } + } + + // Reports are queued from panic hooks too, where a lock poisoned by another panic must not panic again. + fn queue(&self) -> RwLockWriteGuard<'_, VecDeque> { + self.queue.write().unwrap_or_else(|e| e.into_inner()) + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.queue().len() + } + + /// Queues a report; false when the queue is full and the report was dropped. A fatal report finding the queue full + /// takes the place of the oldest report that isn't. + pub fn enqueue(&self, report: Value) -> bool { + let mut queue = self.queue(); + if queue.len() >= MAX_QUEUED_REPORTS { + let fatal = |r: &Value| r["severity"] == "fatal"; + match queue + .iter() + .position(|r| !fatal(r)) + .filter(|_| fatal(&report)) + { + Some(oldest) => { + queue.remove(oldest); + debug!("error report queue is full, dropping an older report for a fatal one"); + } + None => { + debug!("error report queue is full, dropping the report"); + return false; + } + } + } + queue.push_back(report); + true + } + + /// Sends the queued reports, oldest first. One that can't be delivered now stays queued, with those behind it. + pub async fn flush(&self) { + let _flushing = self.flushing.lock().await; + loop { + let Some(report) = self.queue().pop_front() else { + return; + }; + if !self.send(&self.http_client, &report).await { + self.queue().push_front(report); + return; + } + } + } + + /// Sends one report: true once it is settled (delivered, or refused for good), false when it should be sent + /// again later. + async fn send(&self, client: &reqwest::Client, report: &Value) -> bool { + let response = client.post(self.url.clone()).json(report).send().await; + let status = match response { + Ok(response) if response.status().is_success() => return true, + Ok(response) => Some(response.status()), + Err(err) => { + debug!("failed to send error report: {err}"); + None + } + }; + let retry = retry_later(status); + if let Some(status) = status { + debug!( + "error report refused with status {status}{}", + if retry { ", will retry" } else { ", dropped" } + ); + } + !retry + } +} + +/// Sends a crash's report, then the reports queued before it, waiting at most `timeout` (the app is about to exit). +/// +/// The crash goes straight out, never through the queue: a full queue can't drop it, and it doesn't wait for a flush +/// in progress, which may be a send on the very runtime the crashing thread is stuck on. For the same reason it is +/// sent from a thread and a runtime of its own, on a connection of its own: the crashing thread may be the only one of +/// the runtime Tauri uses (an app can hand it a single-thread runtime), and that runtime runs nothing while it waits. +pub(crate) fn send_crash_blocking( + dispatcher: &Arc, + crash: Value, + timeout: Duration, +) { + let dispatcher = dispatcher.clone(); + block_on_own_thread( + async move { + let client = &dispatcher.crash_client; + dispatcher.send(client, &crash).await; + loop { + let Some(report) = dispatcher.queue().pop_front() else { + break; + }; + if !dispatcher.send(client, &report).await { + dispatcher.queue().push_front(report); + break; + } + } + }, + timeout, + ); +} + +/// Runs `work` on a thread and a runtime of its own, waiting for it at most `timeout`: what a panic hook can do, since +/// it may run on the only thread of the runtime Tauri uses, which runs nothing while the hook waits. +pub(crate) fn block_on_own_thread(work: F, timeout: Duration) +where + F: std::future::Future + Send + 'static, +{ + let (done, wait) = mpsc::channel(); + let spawned = std::thread::Builder::new() + .name("aptabase-flush".into()) + .spawn(move || { + if let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + runtime.block_on(work); + } + let _ = done.send(()); + }); + if spawned.is_ok() { + let _ = wait.recv_timeout(timeout); + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::test_server::server; + + fn sys() -> SystemProperties { + SystemProperties { + is_debug: true, + os_name: "macOS".into(), + os_version: "15.4".into(), + locale: "en-US".into(), + engine_name: "WebKit".into(), + engine_version: "620.1".into(), + } + } + + fn body(report: &ErrorReport, kind: ErrorKind) -> Value { + report_body(report, kind, "123", "2026-10-16T12:00:00Z", &sys(), "1.4.0") + } + + #[test] + fn a_report_has_every_field_the_endpoint_takes() { + let b = body( + &ErrorReport::new("TypeError", "TypeError: x is undefined"), + ErrorKind::Handled, + ); + assert_eq!(b["errorType"], "TypeError"); + assert_eq!(b["errorMessage"], "TypeError: x is undefined"); + assert_eq!(b["stackTrace"], Value::Null); + assert_eq!(b["platform"], "Tauri"); + assert_eq!(b["osName"], "macOS"); + assert_eq!(b["osVersion"], "15.4"); + assert_eq!(b["appVersion"], "1.4.0"); + assert_eq!(b["sessionId"], "123"); + assert_eq!(b["severity"], "error"); + assert_eq!(b["kind"], "handled"); + assert_eq!(b["isDebug"], true); + assert!(b["sdkVersion"] + .as_str() + .unwrap() + .starts_with("tauri-plugin-aptabase@")); + } + + #[test] + fn severity_and_kind_follow_how_it_was_captured() { + let fatal = ErrorReport::new("panic", "boom").with_fatal(true); + let b = body(&fatal, ErrorKind::Crash); + assert_eq!( + (b["severity"].as_str(), b["kind"].as_str()), + (Some("fatal"), Some("crash")) + ); + let b = body(&ErrorReport::new("Error", "no"), ErrorKind::TaskException); + assert_eq!( + (b["severity"].as_str(), b["kind"].as_str()), + (Some("error"), Some("taskException")) + ); + let b = body(&ErrorReport::new("Error", "no"), ErrorKind::Unhandled); + assert_eq!(b["kind"], "unhandled"); + } + + #[test] + fn a_report_is_never_sent_without_a_type_or_a_message() { + let b = body(&ErrorReport::new(" ", ""), ErrorKind::Handled); + assert_eq!( + (b["errorType"].as_str(), b["errorMessage"].as_str()), + (Some("Error"), Some("Error")) + ); + let b = body(&ErrorReport::new("io::Error", " "), ErrorKind::Handled); + assert_eq!(b["errorMessage"], "io::Error"); + } + + #[test] + fn fields_are_cut_to_the_endpoints_limits_in_utf16_units() { + // An emoji is two UTF-16 units: 2,600 of them are 5,200 units, past the message's 5,000. + let long = "😀".repeat(2600); + let b = body( + &ErrorReport::new("E".repeat(150), long).with_stack_trace("s".repeat(12_000)), + ErrorKind::Handled, + ); + let message = b["errorMessage"].as_str().unwrap(); + assert_eq!(message.encode_utf16().count(), 5000); + assert!(message.chars().all(|c| c == '😀')); + assert_eq!(b["errorType"].as_str().unwrap().len(), 100); + assert_eq!(b["stackTrace"].as_str().unwrap().len(), 10_000); + assert_eq!(truncate("a😀", 2), "a", "a character is never split"); + } + + #[derive(Debug)] + struct Outer(std::io::Error); + + impl std::fmt::Display for Outer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "could not read settings") + } + } + + impl std::error::Error for Outer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + + #[test] + fn an_error_is_reported_with_its_type_and_its_causes() { + let err = Outer(std::io::Error::new( + std::io::ErrorKind::NotFound, + "settings.json is missing", + )); + let report = ErrorReport::from_error(&err); + assert!( + report.error_type.ends_with("Outer"), + "{}", + report.error_type + ); + assert_eq!( + report.message, + "could not read settings\nCaused by: settings.json is missing" + ); + assert!(!report.fatal); + let as_dyn: &dyn std::error::Error = &err; + assert_eq!(ErrorReport::from_error(as_dyn).error_type, "Error"); + } + + #[test] + fn a_panic_is_a_crash_only_where_it_ends_the_app() { + // (in a spawned task, on the main thread, panic = "abort") + assert_eq!(panic_kind(false, true, false), ErrorKind::Crash); + assert_eq!(panic_kind(true, false, false), ErrorKind::TaskException); + assert_eq!( + panic_kind(true, true, false), + ErrorKind::TaskException, + "a single-thread runtime runs its tasks on the main thread" + ); + assert_eq!(panic_kind(false, false, false), ErrorKind::Unhandled); + assert_eq!(panic_kind(true, false, true), ErrorKind::Crash); + } + + #[test] + fn only_what_can_be_delivered_later_is_retried() { + for status in [408, 429, 500, 502, 503] { + assert!(retry_later(StatusCode::from_u16(status).ok()), "{status}"); + } + for status in [400, 401, 403, 404, 413] { + assert!(!retry_later(StatusCode::from_u16(status).ok()), "{status}"); + } + assert!(retry_later(None), "no connection"); + } + + fn dispatcher(url: Url) -> Arc { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "App-Key", + reqwest::header::HeaderValue::from_static("A-DEV-0000000000"), + ); + let client = |idle: usize| { + reqwest::Client::builder() + .default_headers(headers.clone()) + .pool_max_idle_per_host(idle) + .build() + .unwrap() + }; + Arc::new(ErrorDispatcher::new(url, client(usize::MAX), client(0))) + } + + fn report(n: u32) -> Value { + json!({ "errorType": "Error", "errorMessage": format!("error {n}") }) + } + + #[test] + fn reports_are_sent_one_per_request_with_the_app_key() { + let server = server(&[202, 202]); + let errors = dispatcher(server.url.clone()); + errors.enqueue(report(1)); + errors.enqueue(report(2)); + tauri::async_runtime::block_on(errors.flush()); + assert_eq!(errors.len(), 0); + let sent = server.requests.lock().unwrap(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0], ("A-DEV-0000000000".to_string(), report(1))); + assert_eq!(sent[1].1, report(2)); + } + + #[test] + fn a_report_the_server_can_take_later_is_kept_and_sent_on_the_next_flush() { + let server = server(&[503, 202, 202]); + let errors = dispatcher(server.url.clone()); + errors.enqueue(report(1)); + errors.enqueue(report(2)); + tauri::async_runtime::block_on(errors.flush()); + assert_eq!( + errors.len(), + 2, + "the failed report and the one behind it wait" + ); + tauri::async_runtime::block_on(errors.flush()); + assert_eq!(errors.len(), 0); + let sent: Vec = server + .requests + .lock() + .unwrap() + .iter() + .map(|(_, b)| b.clone()) + .collect(); + assert_eq!( + sent, + [report(1), report(1), report(2)], + "in order, the first one twice" + ); + } + + #[test] + fn a_report_over_quota_is_dropped_not_retried() { + let server = server(&[403, 202]); + let errors = dispatcher(server.url.clone()); + errors.enqueue(report(1)); + errors.enqueue(report(2)); + tauri::async_runtime::block_on(errors.flush()); + assert_eq!(errors.len(), 0); + assert_eq!(server.requests.lock().unwrap().len(), 2); + } + + #[test] + fn the_queue_holds_at_most_25_reports() { + let errors = dispatcher("http://127.0.0.1:9/api/v0/error".parse().unwrap()); + for n in 0..30 { + assert_eq!(errors.enqueue(report(n)), n < 25, "{n}"); + } + assert_eq!(errors.len(), 25); + } + + fn fatal(n: u32) -> Value { + json!({ "errorType": "panic", "errorMessage": format!("crash {n}"), "severity": "fatal" }) + } + + #[test] + fn a_fatal_report_finding_the_queue_full_takes_the_oldest_others_place() { + let errors = dispatcher("http://127.0.0.1:9/api/v0/error".parse().unwrap()); + errors.enqueue(fatal(0)); + for n in 1..25 { + errors.enqueue(report(n)); + } + assert!( + !errors.enqueue(report(25)), + "a report that isn't fatal is dropped" + ); + assert!(errors.enqueue(fatal(26))); + let queue = errors.queue(); + assert_eq!(queue.len(), 25); + assert_eq!(queue[0], fatal(0), "fatal reports are kept"); + assert_eq!(queue[1], report(2), "the oldest other report made room"); + assert_eq!(queue[24], fatal(26)); + } + + #[test] + fn a_full_queue_of_fatal_reports_drops_the_next() { + let errors = dispatcher("http://127.0.0.1:9/api/v0/error".parse().unwrap()); + for n in 0..25 { + errors.enqueue(fatal(n)); + } + assert!(!errors.enqueue(fatal(25))); + assert_eq!(errors.len(), 25); + } + + // A crash's report goes out first, from outside any runtime as a panic hook on the main thread is, and even when + // the queue is full; the reports queued before it follow. + #[test] + fn a_crash_is_sent_first_even_with_the_queue_full() { + let server = server(&[202; 26]); + let errors = dispatcher(server.url.clone()); + for n in 0..25 { + errors.enqueue(report(n)); + } + let sending = errors.clone(); + std::thread::spawn(move || send_crash_blocking(&sending, fatal(0), Duration::from_secs(5))) + .join() + .unwrap(); + let sent = server.requests.lock().unwrap(); + assert_eq!(sent.len(), 26); + assert_eq!(sent[0].1, fatal(0)); + assert_eq!(sent[1].1, report(0)); + assert_eq!(errors.len(), 0); + } + + // A flush in progress may be a send on the very runtime the crashing thread is stuck on: it can't finish, so the + // crash doesn't wait for it. + #[test] + fn a_crash_does_not_wait_for_a_flush_in_progress() { + let server = server(&[202]); + let errors = dispatcher(server.url.clone()); + let _in_progress = futures::executor::block_on(errors.flushing.lock()); + let started = std::time::Instant::now(); + send_crash_blocking(&errors, fatal(0), Duration::from_secs(3)); + assert!( + started.elapsed() < Duration::from_secs(2), + "{:?}", + started.elapsed() + ); + assert_eq!(server.requests.lock().unwrap()[0].1, fatal(0)); + } + + #[test] + fn a_crash_waits_at_most_its_timeout() { + // Answered never: the send is still waiting when the timeout returns. + let server = server(&[0]); + let errors = dispatcher(server.url.clone()); + let started = std::time::Instant::now(); + send_crash_blocking(&errors, fatal(0), Duration::from_millis(300)); + assert!( + started.elapsed() < Duration::from_secs(2), + "{:?}", + started.elapsed() + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6c20b53..c6a07ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,14 +2,21 @@ mod client; mod commands; mod config; mod dispatcher; +mod errors; mod sys; #[cfg(test)] mod test_server; -use std::{panic::PanicHookInfo, sync::Arc, time::Duration}; +use std::{ + panic::PanicHookInfo, + sync::Arc, + time::{Duration, Instant}, +}; use client::AptabaseClient; use config::Config; +use errors::ErrorKind; +pub use errors::ErrorReport; use serde_json::Value; use tauri::{ plugin::{self, TauriPlugin}, @@ -22,10 +29,17 @@ pub struct InitOptions { pub flush_interval: Option, } +/// How long a crash waits for its report to be sent before the app exits. +const CRASH_FLUSH_TIMEOUT: Duration = Duration::from_secs(3); + +/// Reports the webview's uncaught errors and unhandled promise rejections, with crash reporting on. +const CRASH_REPORTING_SCRIPT: &str = include_str!("crash_reporting.js"); + /// The Aptabase Plugin builder pub struct Builder { app_key: String, panic_hook: Option, + crash_reporting: bool, options: InitOptions, } @@ -49,6 +63,7 @@ impl Builder { Self { app_key: app_key.into(), panic_hook: None, + crash_reporting: false, options: Default::default(), } } @@ -65,24 +80,64 @@ impl Builder { self } + /// Reports crashes and uncaught errors to Aptabase, on the Errors page of the dashboard. + /// + /// - A panic on the main thread ends the app: it is reported as a fatal crash, and the app waits up to three + /// seconds for the report to be sent before it exits. + /// - A panic in a task on an async runtime (an async command, `tauri::async_runtime::spawn`) ends only that task, + /// and one on a thread of your own ends only that thread: both are reported as errors. + /// - The webview's uncaught errors and unhandled promise rejections are reported as errors. This needs the + /// `aptabase:allow-track-error` permission in the window's capabilities. + /// + /// A hook set with [`Builder::with_panic_hook`] still runs, before the panic is reported. + pub fn with_crash_reporting(mut self) -> Self { + self.crash_reporting = true; + self + } + /// Builds and initializes the plugin pub fn build(self) -> TauriPlugin { - plugin::Builder::new("aptabase") - .invoke_handler(tauri::generate_handler![commands::track_event]) - .setup(|app, _api| { + let crash_reporting = self.crash_reporting; + let mut builder = + plugin::Builder::new("aptabase").invoke_handler(tauri::generate_handler![ + commands::track_event, + commands::track_error + ]); + if crash_reporting { + builder = builder.js_init_script(CRASH_REPORTING_SCRIPT.to_string()); + } + builder + .setup(move |app, _api| { let cfg = Config::new(self.app_key, self.options); let app_version = app.package_info().version.to_string(); let client = Arc::new(AptabaseClient::new(&cfg, app_version)); client.start_polling(cfg.flush_interval); - if let Some(hook) = self.panic_hook { + if self.panic_hook.is_some() || crash_reporting { + let custom_hook = self.panic_hook; let default_panic = std::panic::take_hook(); let hook_client = client.clone(); std::panic::set_hook(Box::new(move |info| { let msg = get_panic_message(info); - hook(&hook_client, info, msg); - hook_client.flush_blocking(); + if let Some(hook) = &custom_hook { + hook(&hook_client, info, msg.clone()); + } + // One budget for everything the hook sends, so the app never waits longer than that in all. + let deadline = Instant::now() + CRASH_FLUSH_TIMEOUT; + if crash_reporting { + let (report, kind) = errors::panic_report(info, &msg); + if kind == ErrorKind::Crash { + hook_client.report_crash(report, CRASH_FLUSH_TIMEOUT); + } else { + hook_client.track_error_as(report, kind); + } + } + // What the custom hook tracked, after the crash's report so that nothing holds it up. + let left = deadline.saturating_duration_since(Instant::now()); + if custom_hook.is_some() && !left.is_zero() { + hook_client.flush_within(left); + } default_panic(info); })); } @@ -109,6 +164,19 @@ pub trait EventTracker { fn flush_events_blocking(&self); } +/// Trait implemented by Tauri handlers to report errors +pub trait ErrorTracker { + /// Reports an error your code handled: its type name, its message followed by the errors that caused it, and a + /// backtrace of where it was reported when `RUST_BACKTRACE` enables backtraces. It is sent right away, in the + /// background. + fn track_error(&self, error: &E) { + self.track_error_report(ErrorReport::from_error(error)); + } + + /// Reports an error described field by field; [`ErrorReport::with_fatal`] marks one the app can't recover from. + fn track_error_report(&self, report: ErrorReport); +} + impl EventTracker for App { fn track_event(&self, name: &str, props: Option) -> Result<(), String> { let client = self.state::>(); @@ -144,3 +212,24 @@ impl EventTracker for Window { client.flush_blocking(); } } + +impl ErrorTracker for App { + fn track_error_report(&self, report: ErrorReport) { + let client = self.state::>(); + client.track_error(report); + } +} + +impl ErrorTracker for AppHandle { + fn track_error_report(&self, report: ErrorReport) { + let client = self.state::>(); + client.track_error(report); + } +} + +impl ErrorTracker for Window { + fn track_error_report(&self, report: ErrorReport) { + let client = self.state::>(); + client.track_error(report); + } +} diff --git a/src/test_server.rs b/src/test_server.rs index 5c91635..c505648 100644 --- a/src/test_server.rs +++ b/src/test_server.rs @@ -1,7 +1,7 @@ //! A local HTTP server for tests: it answers each request with the next status it was given and keeps what it was //! sent (the App-Key header and the JSON body). A status of 0 never answers: the request stays in flight. -// Not every test uses all of it. +// Shared with the integration tests, which don't use all of it. #![allow(dead_code)] use std::{ @@ -10,12 +10,15 @@ use std::{ sync::{Arc, Mutex}, }; +use reqwest::Url; use serde_json::Value; /// A server that answers each request with the next status in `statuses`, keeping what it was sent. pub(crate) struct Server { /// Where it listens, e.g. `http://127.0.0.1:50211`. pub base: String, + /// Its error endpoint. + pub url: Url, pub requests: Arc>>, } @@ -26,6 +29,7 @@ pub(crate) fn server(statuses: &[u16]) -> Server { /// [`server`] on a listener the test bound itself, e.g. on a port an earlier request found closed. pub(crate) fn server_on(listener: TcpListener, statuses: &[u16]) -> Server { let base = format!("http://{}", listener.local_addr().unwrap()); + let url = format!("{base}/api/v0/error").parse().unwrap(); let requests = Arc::new(Mutex::new(Vec::new())); let seen = requests.clone(); let statuses = statuses.to_vec(); @@ -66,5 +70,9 @@ pub(crate) fn server_on(listener: TcpListener, statuses: &[u16]) -> Server { std::thread::sleep(std::time::Duration::from_secs(60)); drop(unanswered); }); - Server { base, requests } + Server { + base, + url, + requests, + } } diff --git a/tests/crash_on_single_thread_runtime.rs b/tests/crash_on_single_thread_runtime.rs new file mode 100644 index 0000000..5a59e36 --- /dev/null +++ b/tests/crash_on_single_thread_runtime.rs @@ -0,0 +1,82 @@ +//! A crash on the only thread of the runtime Tauri uses: an app can hand Tauri a single-thread runtime +//! (`tauri::async_runtime::set`), and a panic on that runtime's thread stops the one thread that could run anything +//! spawned on it, a send already in flight included. The crash report must still be delivered before the hook +//! returns. (A test binary of its own: the runtime Tauri uses is set once per process.) + +#[path = "../src/test_server.rs"] +mod test_server; + +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use serde_json::Value; +use tauri_plugin_aptabase::{Builder, InitOptions}; + +#[test] +fn a_crash_on_the_runtimes_only_thread_is_still_delivered() { + let runtime = Arc::new( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(), + ); + tauri::async_runtime::set(runtime.handle().clone()); + + // The first report is never answered: its send is still in flight when the app crashes. + let server = test_server::server(&[0, 202]); + let _app = tauri::test::mock_builder() + .plugin( + Builder::new("A-SH-0000000000") + .with_options(InitOptions { + host: Some(server.base.clone()), + ..Default::default() + }) + .with_crash_reporting() + .build(), + ) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + + // The main thread (Rust names it "main") running the runtime, as `#[tokio::main(flavor = "current_thread")]` does. + let on_runtime = runtime.clone(); + let started = Instant::now(); + let crashed = std::thread::Builder::new() + .name("main".into()) + .spawn(move || { + on_runtime.block_on(async { + // A task's panic runs on this thread too, but only the task dies; its report starts on its way. + let _ = tokio::spawn(async { panic!("sync failed") }).await; + tokio::time::sleep(Duration::from_millis(300)).await; + panic!("settings are corrupt"); + }) + }) + .unwrap(); + assert!(crashed.join().is_err()); + let elapsed = started.elapsed(); + + let sent: Vec = server + .requests + .lock() + .unwrap() + .iter() + .map(|(_, b)| b.clone()) + .collect(); + assert_eq!(sent.len(), 2, "{sent:?}"); + let (task, crash) = (&sent[0], &sent[1]); + assert_eq!( + (task["severity"].as_str(), task["kind"].as_str()), + (Some("error"), Some("taskException")) + ); + assert_eq!( + (crash["severity"].as_str(), crash["kind"].as_str()), + (Some("fatal"), Some("crash")) + ); + assert!(crash["errorMessage"] + .as_str() + .unwrap() + .starts_with("settings are corrupt")); + // 300 ms of sleep, then a crash delivered without waiting out the 3 s timeout for the send in flight. + assert!(elapsed < Duration::from_secs(2), "{elapsed:?}"); +} diff --git a/tests/crash_reporting.rs b/tests/crash_reporting.rs new file mode 100644 index 0000000..d0aa3a7 --- /dev/null +++ b/tests/crash_reporting.rs @@ -0,0 +1,92 @@ +//! Crash reporting end to end: the plugin, built into a (mock) Tauri app, installs its panic hook, and each panic +//! reaches the error endpoint reported as what it ended. + +#[path = "../src/test_server.rs"] +mod test_server; + +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tauri_plugin_aptabase::{Builder, InitOptions}; + +fn wait_for(server: &test_server::Server, count: usize) -> Vec { + let started = Instant::now(); + while server.requests.lock().unwrap().len() < count + && started.elapsed() < Duration::from_secs(5) + { + std::thread::sleep(Duration::from_millis(20)); + } + server + .requests + .lock() + .unwrap() + .iter() + .map(|(_, body)| body.clone()) + .collect() +} + +#[test] +fn panics_are_reported_as_what_they_ended() { + let server = test_server::server(&[202, 202]); + let _app = tauri::test::mock_builder() + .plugin( + Builder::new("A-SH-0000000000") + .with_options(InitOptions { + host: Some(server.base.clone()), + ..Default::default() + }) + .with_crash_reporting() + .build(), + ) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + + // The main thread's panic ends the app (Rust names that thread "main"; one of the same name stands in for it). + // The hook waits for the report before it returns, so the report has arrived by the time the thread is joined. + let crashed = std::thread::Builder::new() + .name("main".into()) + .spawn(|| panic!("settings are corrupt")) + .unwrap() + .join(); + assert!(crashed.is_err()); + let sent: Vec = server + .requests + .lock() + .unwrap() + .iter() + .map(|(_, body)| body.clone()) + .collect(); + assert_eq!(sent.len(), 1, "delivered before the hook returned"); + let crash = &sent[0]; + assert_eq!(crash["errorType"], "panic"); + assert_eq!( + (crash["severity"].as_str(), crash["kind"].as_str()), + (Some("fatal"), Some("crash")) + ); + let message = crash["errorMessage"].as_str().unwrap(); + assert!( + message.starts_with("settings are corrupt (at tests/crash_reporting.rs:"), + "{message}" + ); + let stack = crash["stackTrace"].as_str().unwrap(); + assert!( + stack.starts_with("thread 'main' panicked at tests/crash_reporting.rs:"), + "{stack}" + ); + assert_eq!(crash["platform"], "Tauri"); + + // A panic in a task on Tauri's async runtime ends only that task. + let task = tauri::async_runtime::spawn(async { panic!("sync failed") }); + assert!(tauri::async_runtime::block_on(task).is_err()); + let sent = wait_for(&server, 2); + assert_eq!(sent.len(), 2); + let task = &sent[1]; + assert!(task["errorMessage"] + .as_str() + .unwrap() + .starts_with("sync failed (at ")); + assert_eq!( + (task["severity"].as_str(), task["kind"].as_str()), + (Some("error"), Some("taskException")) + ); +} diff --git a/tests/crash_with_custom_hook.rs b/tests/crash_with_custom_hook.rs new file mode 100644 index 0000000..9f71f83 --- /dev/null +++ b/tests/crash_with_custom_hook.rs @@ -0,0 +1,44 @@ +//! A crash with a custom panic hook: the crash's report and what the hook tracked share the one wait the app can afford, +//! so an endpoint that never answers holds the app up for that wait once, not once for each. (A test binary of its +//! own: the panic hook is set once per process.) + +#[path = "../src/test_server.rs"] +mod test_server; + +use std::time::{Duration, Instant}; + +use tauri_plugin_aptabase::{Builder, InitOptions}; + +#[test] +fn a_crash_waits_for_its_reports_once() { + // Nothing is ever answered: the crash's report and the hook's event both stay in flight. + let server = test_server::server(&[0, 0]); + let _app = tauri::test::mock_builder() + .plugin( + Builder::new("A-SH-0000000000") + .with_options(InitOptions { + host: Some(server.base.clone()), + // As in a release build: the hook's event is still queued when the app crashes. + flush_interval: Some(Duration::from_secs(60)), + }) + .with_panic_hook(Box::new(|client, _info, msg| { + let _ = + client.track_event("panic", Some(serde_json::json!({ "message": msg }))); + })) + .with_crash_reporting() + .build(), + ) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + + let started = Instant::now(); + let crashed = std::thread::Builder::new() + .name("main".into()) + .spawn(|| panic!("settings are corrupt")) + .unwrap() + .join(); + assert!(crashed.is_err()); + let elapsed = started.elapsed(); + assert!(elapsed >= Duration::from_millis(2900), "{elapsed:?}"); + assert!(elapsed < Duration::from_millis(3800), "{elapsed:?}"); +} diff --git a/tests/ipc.rs b/tests/ipc.rs new file mode 100644 index 0000000..51f1aca --- /dev/null +++ b/tests/ipc.rs @@ -0,0 +1,107 @@ +//! The webview's side of error reporting: `trackError` and the crash reporting script call the plugin's +//! `track_error` command with these arguments, as JavaScript names them. + +#[path = "../src/test_server.rs"] +mod test_server; + +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tauri::{ + ipc::{CallbackFn, InvokeBody}, + test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}, + webview::InvokeRequest, + WebviewWindowBuilder, +}; +use tauri_plugin_aptabase::{Builder, InitOptions}; + +fn invoke( + webview: &tauri::WebviewWindow, + args: Value, +) -> Result<(), Value> { + get_ipc_response( + webview, + InvokeRequest { + cmd: "plugin:aptabase|track_error".into(), + callback: CallbackFn(0), + error: CallbackFn(1), + url: if cfg!(any(windows, target_os = "android")) { + "http://tauri.localhost" + } else { + "tauri://localhost" + } + .parse() + .unwrap(), + body: InvokeBody::Json(args), + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) + .map(|_| ()) +} + +#[test] +fn the_webview_reports_errors_through_track_error() { + let server = test_server::server(&[202, 202]); + // An app's build script tells it the plugins' permissions, as `aptabase:allow-track-error` grants this command; + // a mock app has no build script, so the command is allowed directly. + let mut context = mock_context(noop_assets()); + context.runtime_authority_mut().__allow_command( + "plugin:aptabase|track_error".into(), + tauri::utils::acl::ExecutionContext::Local, + ); + let app = mock_builder() + .plugin( + Builder::new("A-SH-0000000000") + .with_options(InitOptions { + host: Some(server.base.clone()), + ..Default::default() + }) + .build(), + ) + .build(context) + .unwrap(); + let webview = WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + + // trackError(error, { fatal: true }) + invoke( + &webview, + json!({ "errorType": "TypeError", "message": "TypeError: x is undefined", "stackTrace": "at save (app.js:3:9)", "fatal": true }), + ) + .unwrap(); + // The crash reporting script, for an unhandled rejection without a stack. + invoke( + &webview, + json!({ "errorType": "Error", "message": "Error: offline", "stackTrace": null, "kind": "taskException" }), + ) + .unwrap(); + + let started = Instant::now(); + while server.requests.lock().unwrap().len() < 2 && started.elapsed() < Duration::from_secs(5) { + std::thread::sleep(Duration::from_millis(20)); + } + let mut sent: Vec = server + .requests + .lock() + .unwrap() + .iter() + .map(|(_, body)| body.clone()) + .collect(); + sent.sort_by_key(|r| r["errorType"].as_str().unwrap().to_string()); + assert_eq!(sent.len(), 2); + let (rejection, fatal) = (&sent[0], &sent[1]); + assert_eq!(fatal["errorType"], "TypeError"); + assert_eq!(fatal["stackTrace"], "at save (app.js:3:9)"); + assert_eq!( + (fatal["severity"].as_str(), fatal["kind"].as_str()), + (Some("fatal"), Some("crash")) + ); + assert_eq!(rejection["errorMessage"], "Error: offline"); + assert_eq!(rejection["stackTrace"], Value::Null); + assert_eq!( + (rejection["severity"].as_str(), rejection["kind"].as_str()), + (Some("error"), Some("taskException")) + ); +} diff --git a/webview-dist/index.d.ts b/webview-dist/index.d.ts index dab0a4e..c1fcfad 100644 --- a/webview-dist/index.d.ts +++ b/webview-dist/index.d.ts @@ -1,5 +1,16 @@ type Props = { [key: string]: string | number; }; +/** + * Optional parameters for the trackError function. + */ +export type TrackErrorOptions = { + fatal?: boolean; +}; export declare function trackEvent(name: string, props?: Props): Promise; +/** + * Reports an error to Aptabase: its type, message and stack trace. It appears on the Errors page of the dashboard. + * Needs the `aptabase:allow-track-error` permission. + */ +export declare function trackError(error: unknown, options?: TrackErrorOptions): Promise; export {}; diff --git a/webview-dist/index.js b/webview-dist/index.js index b957d7f..048cc13 100644 --- a/webview-dist/index.js +++ b/webview-dist/index.js @@ -1 +1 @@ -function n(n,e,r,t){return new(r||(r=Promise))(function(o,u){function i(n){try{p(t.next(n))}catch(n){u(n)}}function c(n){try{p(t.throw(n))}catch(n){u(n)}}function p(n){var e;n.done?o(n.value):(e=n.value,e instanceof r?e:new r(function(n){n(e)})).then(i,c)}p((t=t.apply(n,e||[])).next())})}function e(e,r){return n(this,void 0,void 0,function*(){yield async function(n,e={},r){return window.__TAURI_INTERNALS__.invoke(n,e,r)}("plugin:aptabase|track_event",{name:e,props:r})})}"function"==typeof SuppressedError&&SuppressedError,"function"==typeof SuppressedError&&SuppressedError;export{e as trackEvent}; +function r(r,n,e,t){return new(e||(e=Promise))(function(o,a){function s(r){try{u(t.next(r))}catch(r){a(r)}}function i(r){try{u(t.throw(r))}catch(r){a(r)}}function u(r){var n;r.done?o(r.value):(n=r.value,n instanceof e?n:new e(function(r){r(n)})).then(s,i)}u((t=t.apply(r,n||[])).next())})}async function n(r,n={},e){return window.__TAURI_INTERNALS__.invoke(r,n,e)}function e(e,t){return r(this,void 0,void 0,function*(){yield n("plugin:aptabase|track_event",{name:e,props:t})})}function t(e,t){return r(this,void 0,void 0,function*(){const{name:r,message:o,stack:a}=function(r){if(r instanceof Error)return{name:r.name||"Error",message:r.message,stack:r.stack};try{return{name:"Error",message:String(r)}}catch(r){return{name:"Error",message:"[unserializable error]"}}}(e);yield n("plugin:aptabase|track_error",{errorType:r,message:`${r}: ${o}`,stackTrace:null!=a?a:null,fatal:!0===(null==t?void 0:t.fatal)})})}"function"==typeof SuppressedError&&SuppressedError,"function"==typeof SuppressedError&&SuppressedError;export{t as trackError,e as trackEvent}; diff --git a/webview-src/index.ts b/webview-src/index.ts index f98960f..66c9dc5 100644 --- a/webview-src/index.ts +++ b/webview-src/index.ts @@ -4,6 +4,40 @@ type Props = { [key: string]: string | number; }; +/** + * Optional parameters for the trackError function. + */ +export type TrackErrorOptions = { + // Report the error as fatal (severity "fatal", kind "crash"). Defaults to false. + fatal?: boolean; +}; + export async function trackEvent(name: string, props?: Props): Promise { await invoke('plugin:aptabase|track_event', { name, props }); } + +/** + * Reports an error to Aptabase: its type, message and stack trace. It appears on the Errors page of the dashboard. + * Needs the `aptabase:allow-track-error` permission. + */ +export async function trackError(error: unknown, options?: TrackErrorOptions): Promise { + const { name, message, stack } = normalizeError(error); + await invoke('plugin:aptabase|track_error', { + errorType: name, + message: `${name}: ${message}`, + stackTrace: stack ?? null, + fatal: options?.fatal === true, + }); +} + +// Anything can be thrown: a value that isn't an Error is reported by its string form. +function normalizeError(error: unknown): { name: string; message: string; stack?: string } { + if (error instanceof Error) { + return { name: error.name || 'Error', message: error.message, stack: error.stack }; + } + try { + return { name: 'Error', message: String(error) }; + } catch { + return { name: 'Error', message: '[unserializable error]' }; + } +}