Skip to main content

tauri_plugin_sentry/
breadcrumbs.rs

1use std::sync::Arc;
2
3use sentry::{protocol::Map, Breadcrumb};
4use serde_json::Value;
5use tauri::{
6    plugin::Builder,
7    webview::{PageLoadEvent, PageLoadPayload},
8    AppHandle, DragDropEvent, Manager, RunEvent, Runtime, Url, Webview, WebviewEvent, Window,
9    WindowEvent,
10};
11
12const DEFAULT_APP_EVENTS: &[&str] = &[
13    "ready",
14    "exit-requested",
15    "exit",
16    "resumed",
17    "opened",
18    "reopen",
19];
20
21const DEFAULT_WINDOW_EVENTS: &[&str] = &[
22    "created",
23    "focus",
24    "blur",
25    "close-requested",
26    "destroyed",
27    "theme-changed",
28    "scale-change",
29    "drag-drop",
30    "suspended",
31    "resumed",
32];
33
34const DEFAULT_WEBVIEW_EVENTS: &[&str] = &[
35    "created",
36    "navigation",
37    "page-load-started",
38    "page-load-finished",
39];
40
41/// Selects which events of a category create breadcrumbs.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub enum EventFilter {
44    /// The default events for the category. These skip events that fire
45    /// at a high rate such as `resize`, `move` and `drag-over`.
46    #[default]
47    Default,
48    /// All events of the category.
49    All,
50    /// No events of the category.
51    None,
52    /// Only the events with these names.
53    Only(Vec<String>),
54}
55
56impl EventFilter {
57    fn matches(&self, name: &str, defaults: &[&str]) -> bool {
58        match self {
59            EventFilter::Default => defaults.contains(&name),
60            EventFilter::All => true,
61            EventFilter::None => false,
62            EventFilter::Only(names) => names.iter().any(|n| n == name),
63        }
64    }
65}
66
67/// Options for breadcrumbs from Tauri app, window and webview events.
68///
69/// Breadcrumbs have the category `tauri` and a message like `app.ready`,
70/// `window.focus` or `webview.page-load-finished`.
71#[derive(Debug, Clone, Default)]
72pub struct BreadcrumbOptions {
73    /// App events: `ready`, `exit-requested`, `exit`, `resumed`, `opened`
74    /// and `reopen`.
75    pub app: EventFilter,
76    /// Window events: `created`, `focus`, `blur`, `close-requested`,
77    /// `destroyed`, `theme-changed`, `scale-change`, `resize`, `move`,
78    /// `drag-enter`, `drag-over`, `drag-drop`, `drag-leave`, `suspended`
79    /// and `resumed`.
80    pub window: EventFilter,
81    /// Webview events: `created`, `navigation`, `page-load-started`,
82    /// `page-load-finished`, `drag-enter`, `drag-over`, `drag-drop` and
83    /// `drag-leave`.
84    pub webview: EventFilter,
85    /// Adds the window title to window and webview breadcrumbs.
86    ///
87    /// Default: `false`
88    pub capture_window_titles: bool,
89}
90
91impl BreadcrumbOptions {
92    /// No breadcrumbs for Tauri events.
93    pub fn disabled() -> Self {
94        Self {
95            app: EventFilter::None,
96            window: EventFilter::None,
97            webview: EventFilter::None,
98            capture_window_titles: false,
99        }
100    }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104enum Category {
105    App,
106    Window,
107    Webview,
108}
109
110impl Category {
111    fn as_str(self) -> &'static str {
112        match self {
113            Category::App => "app",
114            Category::Window => "window",
115            Category::Webview => "webview",
116        }
117    }
118}
119
120#[derive(Debug, PartialEq)]
121struct TauriEvent {
122    category: Category,
123    name: &'static str,
124    label: Option<String>,
125    data: Map<String, Value>,
126}
127
128impl TauriEvent {
129    fn new(category: Category, name: &'static str, label: Option<&str>) -> Self {
130        Self {
131            category,
132            name,
133            label: label.map(str::to_owned),
134            data: Map::new(),
135        }
136    }
137
138    fn with(mut self, key: &str, value: impl Into<Value>) -> Self {
139        self.data.insert(key.to_owned(), value.into());
140        self
141    }
142
143    fn should_capture(&self, options: &BreadcrumbOptions) -> bool {
144        match self.category {
145            Category::App => options.app.matches(self.name, DEFAULT_APP_EVENTS),
146            Category::Window => options.window.matches(self.name, DEFAULT_WINDOW_EVENTS),
147            Category::Webview => options.webview.matches(self.name, DEFAULT_WEBVIEW_EVENTS),
148        }
149    }
150
151    fn into_breadcrumb(self, title: Option<String>) -> Breadcrumb {
152        let mut data = self.data;
153        if let Some(label) = self.label {
154            data.insert("label".into(), label.into());
155        }
156        if let Some(title) = title {
157            data.insert("title".into(), title.into());
158        }
159
160        Breadcrumb {
161            ty: "ui".into(),
162            category: Some("tauri".into()),
163            message: Some(format!("{}.{}", self.category.as_str(), self.name)),
164            data,
165            ..Default::default()
166        }
167    }
168}
169
170fn drag_drop_name(event: &DragDropEvent) -> Option<&'static str> {
171    match event {
172        DragDropEvent::Enter { .. } => Some("drag-enter"),
173        DragDropEvent::Over { .. } => Some("drag-over"),
174        DragDropEvent::Drop { .. } => Some("drag-drop"),
175        DragDropEvent::Leave => Some("drag-leave"),
176        _ => None,
177    }
178}
179
180fn window_event(label: &str, event: &WindowEvent) -> Option<TauriEvent> {
181    let window = |name| Some(TauriEvent::new(Category::Window, name, Some(label)));
182
183    match event {
184        WindowEvent::Resized(_) => window("resize"),
185        WindowEvent::Moved(_) => window("move"),
186        WindowEvent::CloseRequested { .. } => window("close-requested"),
187        WindowEvent::Destroyed => window("destroyed"),
188        WindowEvent::Focused(true) => window("focus"),
189        WindowEvent::Focused(false) => window("blur"),
190        WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
191            window("scale-change").map(|e| e.with("scale_factor", *scale_factor))
192        }
193        WindowEvent::DragDrop(event) => drag_drop_name(event).and_then(window),
194        WindowEvent::ThemeChanged(theme) => {
195            window("theme-changed").map(|e| e.with("theme", theme.to_string()))
196        }
197        #[cfg(mobile)]
198        WindowEvent::Suspended => window("suspended"),
199        #[cfg(mobile)]
200        WindowEvent::Resumed => window("resumed"),
201        _ => None,
202    }
203}
204
205fn run_event(event: &RunEvent) -> Option<TauriEvent> {
206    let app = |name| Some(TauriEvent::new(Category::App, name, None));
207
208    match event {
209        RunEvent::Ready => app("ready"),
210        RunEvent::ExitRequested { code, .. } => app("exit-requested").map(|e| match code {
211            Some(code) => e.with("code", *code),
212            None => e,
213        }),
214        RunEvent::Exit => app("exit"),
215        RunEvent::Resumed => app("resumed"),
216        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
217        RunEvent::Opened { .. } => app("opened"),
218        #[cfg(target_os = "macos")]
219        RunEvent::Reopen { .. } => app("reopen"),
220        RunEvent::WindowEvent { label, event, .. } => window_event(label, event),
221        RunEvent::WebviewEvent {
222            label,
223            event: WebviewEvent::DragDrop(event),
224            ..
225        } => {
226            drag_drop_name(event).map(|name| TauriEvent::new(Category::Webview, name, Some(label)))
227        }
228        _ => None,
229    }
230}
231
232fn page_load_name(event: PageLoadEvent) -> &'static str {
233    match event {
234        PageLoadEvent::Started => "page-load-started",
235        PageLoadEvent::Finished => "page-load-finished",
236    }
237}
238
239fn add(
240    event: Option<TauriEvent>,
241    options: &BreadcrumbOptions,
242    title: impl FnOnce() -> Option<String>,
243) {
244    let Some(event) = event.filter(|e| e.should_capture(options)) else {
245        return;
246    };
247
248    let title = if options.capture_window_titles && event.category != Category::App {
249        title()
250    } else {
251        None
252    };
253
254    sentry::add_breadcrumb(event.into_breadcrumb(title));
255}
256
257pub(crate) fn on_event<R: Runtime>(
258    options: &BreadcrumbOptions,
259    app: &AppHandle<R>,
260    event: &RunEvent,
261) {
262    let event = run_event(event);
263    let label = event.as_ref().and_then(|e| e.label.clone());
264    add(event, options, || {
265        app.get_webview_window(&label?).and_then(|w| w.title().ok())
266    });
267}
268
269pub(crate) fn on_window_created<R: Runtime>(options: &BreadcrumbOptions, window: &Window<R>) {
270    let event = TauriEvent::new(Category::Window, "created", Some(window.label()));
271    add(Some(event), options, || window.title().ok());
272}
273
274pub(crate) fn on_webview_created<R: Runtime>(options: &BreadcrumbOptions, webview: &Webview<R>) {
275    let event = TauriEvent::new(Category::Webview, "created", Some(webview.label()));
276    add(Some(event), options, || webview.window().title().ok());
277}
278
279pub(crate) fn on_navigation<R: Runtime>(
280    options: &BreadcrumbOptions,
281    webview: &Webview<R>,
282    url: &Url,
283) {
284    let event = TauriEvent::new(Category::Webview, "navigation", Some(webview.label()))
285        .with("url", url.as_str());
286    add(Some(event), options, || webview.window().title().ok());
287}
288
289pub(crate) fn on_page_load<R: Runtime>(
290    options: &BreadcrumbOptions,
291    webview: &Webview<R>,
292    payload: &PageLoadPayload<'_>,
293) {
294    let event = TauriEvent::new(
295        Category::Webview,
296        page_load_name(payload.event()),
297        Some(webview.label()),
298    )
299    .with("url", payload.url().as_str());
300    add(Some(event), options, || webview.window().title().ok());
301}
302
303pub(crate) fn attach<R: Runtime>(
304    builder: Builder<R>,
305    options: Arc<BreadcrumbOptions>,
306) -> Builder<R> {
307    let (o1, o2, o3, o4, o5) = (
308        options.clone(),
309        options.clone(),
310        options.clone(),
311        options.clone(),
312        options,
313    );
314    builder
315        .on_window_ready(move |window| on_window_created(&o1, &window))
316        .on_webview_ready(move |webview| {
317            on_webview_created(&o2, &webview);
318            #[cfg(feature = "webview-crashes")]
319            crate::webview_crash::install(&webview);
320        })
321        .on_navigation(move |webview, url| {
322            on_navigation(&o3, webview, url);
323            true
324        })
325        .on_page_load(move |webview, payload| on_page_load(&o4, webview, payload))
326        .on_event(move |app, event| on_event(&o5, app, event))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use tauri::{PhysicalPosition, PhysicalSize, Theme};
333
334    fn captured(event: Option<TauriEvent>, options: &BreadcrumbOptions) -> Option<Breadcrumb> {
335        event
336            .filter(|e| e.should_capture(options))
337            .map(|e| e.into_breadcrumb(None))
338    }
339
340    #[test]
341    fn maps_app_events() {
342        let options = BreadcrumbOptions::default();
343
344        let crumb = captured(run_event(&RunEvent::Ready), &options).unwrap();
345        assert_eq!(crumb.category.as_deref(), Some("tauri"));
346        assert_eq!(crumb.ty, "ui");
347        assert_eq!(crumb.message.as_deref(), Some("app.ready"));
348        assert!(crumb.data.is_empty());
349
350        let crumb = captured(run_event(&RunEvent::Exit), &options).unwrap();
351        assert_eq!(crumb.message.as_deref(), Some("app.exit"));
352
353        let crumb = captured(run_event(&RunEvent::Resumed), &options).unwrap();
354        assert_eq!(crumb.message.as_deref(), Some("app.resumed"));
355
356        assert!(run_event(&RunEvent::MainEventsCleared).is_none());
357    }
358
359    #[test]
360    fn maps_window_events() {
361        let options = BreadcrumbOptions::default();
362
363        let crumb = captured(window_event("main", &WindowEvent::Focused(true)), &options).unwrap();
364        assert_eq!(crumb.message.as_deref(), Some("window.focus"));
365        assert_eq!(crumb.data.get("label"), Some(&Value::from("main")));
366
367        let crumb = captured(window_event("main", &WindowEvent::Focused(false)), &options).unwrap();
368        assert_eq!(crumb.message.as_deref(), Some("window.blur"));
369
370        let crumb = captured(window_event("main", &WindowEvent::Destroyed), &options).unwrap();
371        assert_eq!(crumb.message.as_deref(), Some("window.destroyed"));
372
373        let crumb = captured(
374            window_event("main", &WindowEvent::ThemeChanged(Theme::Dark)),
375            &options,
376        )
377        .unwrap();
378        assert_eq!(crumb.message.as_deref(), Some("window.theme-changed"));
379        assert_eq!(crumb.data.get("theme"), Some(&Value::from("dark")));
380    }
381
382    #[test]
383    fn skips_noisy_events_by_default() {
384        let options = BreadcrumbOptions::default();
385
386        let resized = WindowEvent::Resized(PhysicalSize::new(1, 1));
387        let moved = WindowEvent::Moved(PhysicalPosition::new(1, 1));
388        let drag_over = WindowEvent::DragDrop(DragDropEvent::Over {
389            position: PhysicalPosition::new(1.0, 1.0),
390        });
391
392        for event in [&resized, &moved, &drag_over] {
393            assert!(window_event("main", event).is_some());
394            assert!(captured(window_event("main", event), &options).is_none());
395        }
396
397        let options = BreadcrumbOptions {
398            window: EventFilter::All,
399            ..Default::default()
400        };
401        let crumb = captured(window_event("main", &resized), &options).unwrap();
402        assert_eq!(crumb.message.as_deref(), Some("window.resize"));
403    }
404
405    #[test]
406    fn filters_by_category() {
407        let options = BreadcrumbOptions {
408            app: EventFilter::None,
409            window: EventFilter::Only(vec!["blur".into()]),
410            ..Default::default()
411        };
412
413        assert!(captured(run_event(&RunEvent::Ready), &options).is_none());
414        assert!(captured(window_event("main", &WindowEvent::Focused(true)), &options).is_none());
415        assert!(captured(window_event("main", &WindowEvent::Focused(false)), &options).is_some());
416
417        let options = BreadcrumbOptions::disabled();
418        let event = TauriEvent::new(Category::Webview, "created", Some("main"));
419        assert!(captured(Some(event), &options).is_none());
420    }
421
422    #[test]
423    fn maps_webview_events() {
424        let options = BreadcrumbOptions::default();
425
426        let event = TauriEvent::new(
427            Category::Webview,
428            page_load_name(PageLoadEvent::Finished),
429            Some("main"),
430        )
431        .with("url", "tauri://localhost/");
432        let crumb = captured(Some(event), &options).unwrap();
433        assert_eq!(crumb.message.as_deref(), Some("webview.page-load-finished"));
434        assert_eq!(crumb.data.get("label"), Some(&Value::from("main")));
435        assert_eq!(
436            crumb.data.get("url"),
437            Some(&Value::from("tauri://localhost/"))
438        );
439    }
440
441    #[test]
442    fn adds_title_only_when_enabled() {
443        let capture = |capture_window_titles| {
444            let options = BreadcrumbOptions {
445                capture_window_titles,
446                ..Default::default()
447            };
448            let events = sentry::test::with_captured_events(|| {
449                let event = TauriEvent::new(Category::Window, "focus", Some("main"));
450                add(Some(event), &options, || Some("My App".into()));
451                sentry::capture_message("test", sentry::Level::Info);
452            });
453            events[0].breadcrumbs[0].data.get("title").cloned()
454        };
455
456        assert_eq!(capture(false), None);
457        assert_eq!(capture(true), Some(Value::from("My App")));
458    }
459}