From a8d26880206086aa653d5e22a3d1130ac8e0c252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 06:12:05 -0400 Subject: [PATCH] Record local diagnostics events --- .../internal_pages/site_compatibility.rs | 20 ++- crates/ely_browser_core/src/state.rs | 38 ++--- .../ely_browser_core/src/state/diagnostics.rs | 16 ++ crates/ely_browser_core/src/state/privacy.rs | 28 ++++ .../src/state/tab_lifecycle.rs | 5 +- .../ely_browser_core/tests/crash_recovery.rs | 19 ++- crates/ely_domain/src/diagnostics.rs | 149 ++++++++++++++++++ crates/ely_domain/src/error.rs | 3 + crates/ely_domain/src/lib.rs | 4 + 9 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 crates/ely_browser_core/src/state/diagnostics.rs create mode 100644 crates/ely_browser_core/src/state/privacy.rs create mode 100644 crates/ely_domain/src/diagnostics.rs diff --git a/crates/ely_app/src/shell/internal_pages/site_compatibility.rs b/crates/ely_app/src/shell/internal_pages/site_compatibility.rs index f88506c..78b1824 100644 --- a/crates/ely_app/src/shell/internal_pages/site_compatibility.rs +++ b/crates/ely_app/src/shell/internal_pages/site_compatibility.rs @@ -337,6 +337,8 @@ fn diagnostic_report( format!("Space: {}", snapshot.active_space_name), format!("Profile: {}", snapshot.active_profile_name), format!("Profile kind: {}", profile_kind_label(&snapshot.active_profile_kind)), + format!("Diagnostics reporting: {}", snapshot.diagnostics_reporting_policy.status()), + format!("Local diagnostics: {}", snapshot.diagnostic_events.len()), format!("URL scope: {}", diagnostic_url_scope(active_tab)), format!("Tab title: {}", active_tab.title()), format!("Tab state: {}", tab_state_label(active_tab.state())), @@ -395,9 +397,10 @@ fn tab_state_label(state: &TabState) -> &'static str { #[cfg(test)] mod tests { + use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ProfileId, SpaceId, TabId, UrlText}; - use super::{BrowserTab, diagnostic_url_scope}; + use super::{BrowserTab, active_tab, diagnostic_report, diagnostic_url_scope, origin_for_tab}; #[test] fn diagnostic_url_scope_omits_path_and_query() -> Result<(), Box> { @@ -426,4 +429,19 @@ mod tests { assert_eq!(diagnostic_url_scope(&tab), "ely://settings/advanced"); Ok(()) } + + #[test] + fn diagnostic_report_includes_privacy_and_local_event_count() + -> Result<(), Box> { + let core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let snapshot = core.snapshot()?; + let active_tab = active_tab(&snapshot) + .ok_or_else(|| std::io::Error::other("default browser starts with an active tab"))?; + let origin = origin_for_tab(active_tab); + let report = diagnostic_report(&snapshot, active_tab, origin.as_ref()); + + assert!(report.contains("Diagnostics reporting: Diagnostics reporting is on")); + assert!(report.contains("Local diagnostics: 1")); + Ok(()) + } } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 23f4b74..1d7f0f1 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -2,10 +2,11 @@ use std::{collections::BTreeMap, time::SystemTime}; use ely_domain::{ ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DEFAULT_SIDEBAR_WIDTH_PX, - DiagnosticsReportingPolicy, DomainError, DownloadEntry, DownloadPolicy, FavoriteLimit, - HistoryEntry, HistoryRecordingPolicy, NewTabDestination, NoteEntry, Profile, ProfileId, - ProfileKind, ReadingListEntry, SearchEngine, SitePermissionAuditEvent, SitePermissionEntry, - Space, SpaceId, SplitLayout, SyncStatus, TabGroup, TabId, UpdatePolicy, UrlText, + DiagnosticEvent, DiagnosticsReportingPolicy, DomainError, DownloadEntry, DownloadPolicy, + FavoriteLimit, HistoryEntry, HistoryRecordingPolicy, NewTabDestination, NoteEntry, Profile, + ProfileId, ProfileKind, ReadingListEntry, SearchEngine, SitePermissionAuditEvent, + SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabGroup, TabId, UpdatePolicy, + UrlText, }; use crate::{CoreError, navigation::tab_title}; @@ -13,10 +14,12 @@ use sync::SyncObjectPolicies; mod bookmarks; mod commands; +mod diagnostics; mod downloads; mod history; mod notes; mod plugins; +mod privacy; mod profiles; mod reading_list; mod site_data; @@ -98,6 +101,7 @@ pub struct BrowserSnapshot { pub split_layouts: Vec, pub installed_plugins: Vec, pub plugin_audit_events: Vec, + pub diagnostic_events: Vec, pub spaces: Vec, pub trashed_spaces: Vec, pub profiles: Vec, @@ -137,6 +141,7 @@ pub struct BrowserCore { trashed_spaces: Vec, installed_plugins: Vec, plugin_audit_events: Vec, + diagnostic_events: Vec, active_space_id: SpaceId, active_profile_id: ProfileId, active_tab_id: TabId, @@ -213,6 +218,7 @@ impl BrowserCore { trashed_spaces: Vec::new(), installed_plugins: Vec::new(), plugin_audit_events: Vec::new(), + diagnostic_events: vec![DiagnosticEvent::startup_success(SystemTime::now())], command_query: String::new(), }) } @@ -324,29 +330,6 @@ impl BrowserCore { self.new_tab_destination } - pub fn set_history_recording_policy(&mut self, policy: HistoryRecordingPolicy) { - self.history_recording_policy = policy; - } - - pub fn set_diagnostics_reporting_policy(&mut self, policy: DiagnosticsReportingPolicy) { - self.diagnostics_reporting_policy = policy; - } - - pub fn reset_privacy_settings(&mut self) { - self.set_history_recording_policy(HistoryRecordingPolicy::default()); - self.set_diagnostics_reporting_policy(DiagnosticsReportingPolicy::default()); - } - - #[must_use] - pub fn history_recording_policy(&self) -> HistoryRecordingPolicy { - self.history_recording_policy - } - - #[must_use] - pub fn diagnostics_reporting_policy(&self) -> DiagnosticsReportingPolicy { - self.diagnostics_reporting_policy - } - pub fn set_favorite_limit(&mut self, favorite_limit: FavoriteLimit) { self.favorite_limit = favorite_limit; } @@ -406,6 +389,7 @@ impl BrowserCore { split_layouts: self.visible_split_layouts(), installed_plugins: self.installed_plugins.clone(), plugin_audit_events: self.plugin_audit_events.clone(), + diagnostic_events: self.diagnostic_events.clone(), spaces: self.sorted_spaces(), trashed_spaces: self.trashed_spaces.clone(), profiles: self.profiles.clone(), diff --git a/crates/ely_browser_core/src/state/diagnostics.rs b/crates/ely_browser_core/src/state/diagnostics.rs new file mode 100644 index 0000000..f65dff7 --- /dev/null +++ b/crates/ely_browser_core/src/state/diagnostics.rs @@ -0,0 +1,16 @@ +use std::time::SystemTime; + +use ely_domain::{DiagnosticEvent, DiagnosticEventKind}; + +use super::BrowserCore; + +impl BrowserCore { + pub fn record_diagnostic_event(&mut self, kind: DiagnosticEventKind) { + self.diagnostic_events.push(DiagnosticEvent::new(kind, SystemTime::now())); + } + + #[must_use] + pub fn diagnostic_events(&self) -> &[DiagnosticEvent] { + &self.diagnostic_events + } +} diff --git a/crates/ely_browser_core/src/state/privacy.rs b/crates/ely_browser_core/src/state/privacy.rs new file mode 100644 index 0000000..251f58c --- /dev/null +++ b/crates/ely_browser_core/src/state/privacy.rs @@ -0,0 +1,28 @@ +use ely_domain::{DiagnosticsReportingPolicy, HistoryRecordingPolicy}; + +use super::BrowserCore; + +impl BrowserCore { + pub fn set_history_recording_policy(&mut self, policy: HistoryRecordingPolicy) { + self.history_recording_policy = policy; + } + + pub fn set_diagnostics_reporting_policy(&mut self, policy: DiagnosticsReportingPolicy) { + self.diagnostics_reporting_policy = policy; + } + + pub fn reset_privacy_settings(&mut self) { + self.set_history_recording_policy(HistoryRecordingPolicy::default()); + self.set_diagnostics_reporting_policy(DiagnosticsReportingPolicy::default()); + } + + #[must_use] + pub fn history_recording_policy(&self) -> HistoryRecordingPolicy { + self.history_recording_policy + } + + #[must_use] + pub fn diagnostics_reporting_policy(&self) -> DiagnosticsReportingPolicy { + self.diagnostics_reporting_policy + } +} diff --git a/crates/ely_browser_core/src/state/tab_lifecycle.rs b/crates/ely_browser_core/src/state/tab_lifecycle.rs index 2ab4e7e..219a0fc 100644 --- a/crates/ely_browser_core/src/state/tab_lifecycle.rs +++ b/crates/ely_browser_core/src/state/tab_lifecycle.rs @@ -1,6 +1,6 @@ use std::time::SystemTime; -use ely_domain::TabId; +use ely_domain::{DiagnosticEventKind, TabId, WebViewCrashKind}; use super::BrowserCore; use crate::CoreError; @@ -18,6 +18,9 @@ impl BrowserCore { .find(|tab| tab.id() == tab_id) .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; tab.mark_crashed(); + self.record_diagnostic_event(DiagnosticEventKind::WebViewCrash { + crash_kind: WebViewCrashKind::TabCrashed, + }); Ok(tab_id.clone()) } diff --git a/crates/ely_browser_core/tests/crash_recovery.rs b/crates/ely_browser_core/tests/crash_recovery.rs index 1ba4f03..088f32c 100644 --- a/crates/ely_browser_core/tests/crash_recovery.rs +++ b/crates/ely_browser_core/tests/crash_recovery.rs @@ -1,7 +1,7 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{CommandIntent, TabState, UrlText}; +use ely_domain::{CommandIntent, DiagnosticEventKind, TabState, UrlText, WebViewCrashKind}; #[test] fn crash_active_tab_preserves_tab_metadata() -> Result<(), Box> { @@ -18,6 +18,23 @@ fn crash_active_tab_preserves_tab_metadata() -> Result<(), Box> { assert_eq!(active_tab.url().as_str(), "https://example.com/form"); assert_eq!(active_tab.title(), title); assert_eq!(active_tab.favicon_key(), Some("favicons/example.ico")); + assert_eq!( + core.diagnostic_events().last().map(ely_domain::DiagnosticEvent::kind), + Some(&DiagnosticEventKind::WebViewCrash { crash_kind: WebViewCrashKind::TabCrashed }) + ); + Ok(()) +} + +#[test] +fn new_browser_core_records_startup_success_diagnostic() -> Result<(), Box> { + let core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let events = core.diagnostic_events(); + + assert_eq!(events.len(), 1); + assert!(matches!( + events[0].kind(), + DiagnosticEventKind::AppStartup { outcome: ely_domain::DiagnosticOutcome::Success } + )); Ok(()) } diff --git a/crates/ely_domain/src/diagnostics.rs b/crates/ely_domain/src/diagnostics.rs new file mode 100644 index 0000000..3c2673c --- /dev/null +++ b/crates/ely_domain/src/diagnostics.rs @@ -0,0 +1,149 @@ +use std::time::SystemTime; + +use crate::{DomainError, PluginId}; + +const DIAGNOSTIC_CODE_MAX_LEN: usize = 80; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DiagnosticOutcome { + Success, + Failure, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebViewCrashKind { + TabCrashed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiagnosticCode(String); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DiagnosticEventKind { + AppStartup { outcome: DiagnosticOutcome }, + AppCrash, + WebViewCrash { crash_kind: WebViewCrashKind }, + SyncError { error_code: DiagnosticCode }, + PluginCrash { plugin_id: PluginId }, + UpdateResult { outcome: DiagnosticOutcome }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiagnosticEvent { + kind: DiagnosticEventKind, + occurred_at: SystemTime, +} + +impl DiagnosticOutcome { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::Failure => "failure", + } + } +} + +impl WebViewCrashKind { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::TabCrashed => "tab_crashed", + } + } +} + +impl DiagnosticCode { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.len() > DIAGNOSTIC_CODE_MAX_LEN + || !trimmed.chars().all(is_diagnostic_code_character) + { + return Err(DomainError::InvalidDiagnosticCode { value }); + } + + Ok(Self(trimmed.to_string())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl DiagnosticEventKind { + #[must_use] + pub fn event_type(&self) -> &'static str { + match self { + Self::AppStartup { .. } => "app_startup", + Self::AppCrash => "app_crash", + Self::WebViewCrash { .. } => "webview_crash", + Self::SyncError { .. } => "sync_error", + Self::PluginCrash { .. } => "plugin_crash", + Self::UpdateResult { .. } => "update_result", + } + } + + #[must_use] + pub fn label(&self) -> &'static str { + match self { + Self::AppStartup { .. } => "App startup", + Self::AppCrash => "App crash", + Self::WebViewCrash { .. } => "WebView crash", + Self::SyncError { .. } => "Sync error", + Self::PluginCrash { .. } => "Plugin crash", + Self::UpdateResult { .. } => "Update result", + } + } +} + +impl DiagnosticEvent { + #[must_use] + pub fn new(kind: DiagnosticEventKind, occurred_at: SystemTime) -> Self { + Self { kind, occurred_at } + } + + #[must_use] + pub fn startup_success(occurred_at: SystemTime) -> Self { + Self::new( + DiagnosticEventKind::AppStartup { outcome: DiagnosticOutcome::Success }, + occurred_at, + ) + } + + #[must_use] + pub fn kind(&self) -> &DiagnosticEventKind { + &self.kind + } + + #[must_use] + pub fn occurred_at(&self) -> SystemTime { + self.occurred_at + } +} + +fn is_diagnostic_code_character(character: char) -> bool { + character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | ':' | '-') +} + +#[cfg(test)] +mod tests { + use super::DiagnosticCode; + + #[test] + fn diagnostic_code_accepts_machine_codes() -> Result<(), Box> { + let code = DiagnosticCode::parse("sync.pull.5xx")?; + + assert_eq!(code.as_str(), "sync.pull.5xx"); + Ok(()) + } + + #[test] + fn diagnostic_code_rejects_url_like_values() { + let result = DiagnosticCode::parse("https://example.com/private?q=token"); + + assert!(result.is_err()); + } +} diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index 2841395..f8a544e 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -79,4 +79,7 @@ pub enum DomainError { #[error("invalid plugin signature: {value}")] InvalidPluginSignature { value: String }, + + #[error("invalid diagnostic code: {value}")] + InvalidDiagnosticCode { value: String }, } diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 2a058eb..250353b 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -1,6 +1,7 @@ mod archive; mod bookmark; mod command; +mod diagnostics; mod download; mod error; mod favorite; @@ -25,6 +26,9 @@ mod url_text; pub use archive::{ArchiveSource, ArchivedTab}; pub use bookmark::BookmarkEntry; pub use command::{CommandIntent, CommandScope}; +pub use diagnostics::{ + DiagnosticCode, DiagnosticEvent, DiagnosticEventKind, DiagnosticOutcome, WebViewCrashKind, +}; pub use download::{ DownloadChecksum, DownloadChecksumAlgorithm, DownloadDestination, DownloadEntry, DownloadPolicy, DownloadSecurity, DownloadState,