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#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub enum EventFilter {
44 #[default]
47 Default,
48 All,
50 None,
52 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#[derive(Debug, Clone, Default)]
72pub struct BreadcrumbOptions {
73 pub app: EventFilter,
76 pub window: EventFilter,
81 pub webview: EventFilter,
85 pub capture_window_titles: bool,
89}
90
91impl BreadcrumbOptions {
92 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}