1use std::borrow::Borrow;
5use std::collections::HashMap;
6use std::fmt;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rmcp::schemars;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub const SYSTEM_TAG: &str = "_system";
16
17macro_rules! arc_str_newtype {
18 ($name:ident) => {
19 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20 #[serde(transparent)]
21 pub struct $name(Arc<str>);
22
23 impl $name {
24 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27 }
28
29 impl fmt::Display for $name {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 f.write_str(&self.0)
32 }
33 }
34
35 impl From<&str> for $name {
36 fn from(s: &str) -> Self {
37 Self(Arc::from(s))
38 }
39 }
40
41 impl From<String> for $name {
42 fn from(s: String) -> Self {
43 Self(Arc::from(s.as_str()))
44 }
45 }
46
47 impl From<Arc<str>> for $name {
48 fn from(s: Arc<str>) -> Self {
49 Self(s)
50 }
51 }
52
53 impl AsRef<str> for $name {
54 fn as_ref(&self) -> &str {
55 &self.0
56 }
57 }
58
59 impl Borrow<str> for $name {
60 fn borrow(&self) -> &str {
61 &self.0
62 }
63 }
64
65 impl PartialEq<str> for $name {
66 fn eq(&self, other: &str) -> bool {
67 self.as_str() == other
68 }
69 }
70
71 impl PartialEq<&str> for $name {
72 fn eq(&self, other: &&str) -> bool {
73 self.as_str() == *other
74 }
75 }
76
77 impl FromStr for $name {
78 type Err = std::convert::Infallible;
79 fn from_str(s: &str) -> Result<Self, Self::Err> {
80 Ok(Self::from(s))
81 }
82 }
83
84 impl JsonSchema for $name {
85 fn schema_name() -> std::borrow::Cow<'static, str> {
86 stringify!($name).into()
87 }
88 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
89 String::json_schema(generator)
90 }
91 }
92 };
93}
94
95arc_str_newtype!(TabId);
96arc_str_newtype!(SessionId);
97arc_str_newtype!(DeviceSerial);
98arc_str_newtype!(AppName);
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "snake_case")]
102pub enum DebugAction {
103 EvalJs,
104 Screenshot,
105 InjectCss,
106 RevertCss,
107 ListTabs,
108 GetPerfMetrics,
109 GetDom,
110 SetViewport,
111 ClearViewport,
112 NetworkConditions,
113 Navigate,
114 StorageClear,
115 StorageInspect,
116 StorageSet,
117 ElementAtPoint,
118 NewTab,
119 CloseTab,
120}
121
122#[derive(
123 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
124)]
125#[serde(rename_all = "snake_case")]
126pub enum Severity {
127 Trace,
128 Debug,
129 Info,
130 Warn,
131 Error,
132}
133
134impl Severity {
135 pub fn level(self) -> u8 {
136 match self {
137 Self::Trace => 0,
138 Self::Debug => 1,
139 Self::Info => 2,
140 Self::Warn => 3,
141 Self::Error => 4,
142 }
143 }
144}
145
146impl fmt::Display for Severity {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 let s = match self {
149 Self::Trace => "trace",
150 Self::Debug => "debug",
151 Self::Info => "info",
152 Self::Warn => "warn",
153 Self::Error => "error",
154 };
155 f.write_str(s)
156 }
157}
158
159impl std::str::FromStr for Severity {
160 type Err = String;
161
162 fn from_str(s: &str) -> Result<Self, Self::Err> {
163 match s.to_ascii_lowercase().as_str() {
164 "trace" => Ok(Self::Trace),
165 "debug" => Ok(Self::Debug),
166 "info" => Ok(Self::Info),
167 "warn" => Ok(Self::Warn),
168 "error" => Ok(Self::Error),
169 other => Err(format!("unknown severity: {other}")),
170 }
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case", tag = "type")]
176pub enum Origin {
177 Application {
178 name: AppName,
179 },
180 Browser {
181 tab_id: TabId,
182 url: String,
183 },
184 Device {
185 serial: DeviceSerial,
186 platform: DevicePlatform,
187 },
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
191#[serde(rename_all = "snake_case")]
192pub enum DevicePlatform {
193 #[default]
194 Android,
195 Vega,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum ObservationKindTag {
202 Log,
203 Query,
204 HttpExchange,
205 Exception,
206 StateSnapshot,
207 Metric,
208 Custom,
209 JsException,
210 Lifecycle,
211}
212
213impl fmt::Display for ObservationKindTag {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 let s = match self {
216 Self::Log => "log",
217 Self::Query => "query",
218 Self::HttpExchange => "http_exchange",
219 Self::Exception => "exception",
220 Self::StateSnapshot => "state_snapshot",
221 Self::Metric => "metric",
222 Self::Custom => "custom",
223 Self::JsException => "js_exception",
224 Self::Lifecycle => "lifecycle",
225 };
226 f.write_str(s)
227 }
228}
229
230impl std::str::FromStr for ObservationKindTag {
231 type Err = String;
232
233 fn from_str(s: &str) -> Result<Self, Self::Err> {
234 match s.to_ascii_lowercase().as_str() {
235 "log" => Ok(Self::Log),
236 "query" => Ok(Self::Query),
237 "http_exchange" => Ok(Self::HttpExchange),
238 "exception" => Ok(Self::Exception),
239 "state_snapshot" => Ok(Self::StateSnapshot),
240 "metric" => Ok(Self::Metric),
241 "custom" => Ok(Self::Custom),
242 "js_exception" => Ok(Self::JsException),
243 "lifecycle" => Ok(Self::Lifecycle),
244 other => Err(format!("unknown observation kind: {other}")),
245 }
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case", tag = "type")]
251pub enum ObservationKind {
252 Log,
253 Query {
254 sql: String,
255 duration_ms: f64,
256 },
257 HttpExchange {
258 method: String,
259 url: String,
260 status: Option<u16>,
261 duration_ms: Option<f64>,
262 },
263 Exception {
264 message: String,
265 trace: Option<String>,
266 },
267 StateSnapshot {
268 label: String,
269 },
270 Metric {
271 name: String,
272 value: f64,
273 },
274 Custom {
275 channel: String,
276 },
277 JsException {
278 message: String,
279 line: Option<u32>,
280 column: Option<u32>,
281 },
282 Lifecycle {
283 event_name: String,
284 frame_id: String,
285 },
286}
287
288impl ObservationKind {
289 pub fn tag(&self) -> ObservationKindTag {
290 match self {
291 Self::Log => ObservationKindTag::Log,
292 Self::Query { .. } => ObservationKindTag::Query,
293 Self::HttpExchange { .. } => ObservationKindTag::HttpExchange,
294 Self::Exception { .. } => ObservationKindTag::Exception,
295 Self::StateSnapshot { .. } => ObservationKindTag::StateSnapshot,
296 Self::Metric { .. } => ObservationKindTag::Metric,
297 Self::Custom { .. } => ObservationKindTag::Custom,
298 Self::JsException { .. } => ObservationKindTag::JsException,
299 Self::Lifecycle { .. } => ObservationKindTag::Lifecycle,
300 }
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct SourceLocation {
306 pub file: String,
307 pub line: u32,
308 pub function: Option<String>,
309}
310
311#[derive(
313 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
314)]
315pub struct Checkpoint(pub u64);
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Observation {
319 pub id: u64,
320 pub origin: Origin,
321 pub kind: ObservationKind,
322 pub data: serde_json::Value,
323 pub severity: Severity,
324 pub source_location: Option<SourceLocation>,
325 pub timestamp_ns: u64,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub correlation_id: Option<Arc<str>>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub parent_id: Option<u64>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub tags: Option<Vec<String>>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub session_id: Option<Arc<str>>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub node_id: Option<Arc<str>>,
336}
337
338impl Observation {
339 pub fn new(
343 origin: Origin,
344 kind: ObservationKind,
345 data: serde_json::Value,
346 severity: Severity,
347 source_location: Option<SourceLocation>,
348 ) -> Self {
349 let timestamp_ns = SystemTime::now()
350 .duration_since(UNIX_EPOCH)
351 .expect("system clock before UNIX epoch")
352 .as_nanos() as u64;
353
354 Self {
355 id: 0,
356 origin,
357 kind,
358 data,
359 severity,
360 source_location,
361 timestamp_ns,
362 correlation_id: None,
363 parent_id: None,
364 tags: None,
365 session_id: None,
366 node_id: None,
367 }
368 }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372#[serde(rename_all = "snake_case")]
373pub enum OriginPattern {
374 AnyApplication,
375 ApplicationNamed(AppName),
376 AnyBrowser,
377 BrowserTab(TabId),
378 AnyDevice,
379 DeviceSerial(DeviceSerial),
380}
381
382impl OriginPattern {
383 pub fn parse(s: &str) -> Self {
384 match s.split_once(':') {
385 Some(("app", "*")) | None if s == "app" => Self::AnyApplication,
386 Some(("app", name)) => Self::ApplicationNamed(name.into()),
387 Some(("browser", "*")) | None if s == "browser" => Self::AnyBrowser,
388 Some(("browser", tab_id)) => Self::BrowserTab(tab_id.into()),
389 Some(("device", "*")) | None if s == "device" => Self::AnyDevice,
390 Some(("device", serial)) => Self::DeviceSerial(serial.into()),
391 _ => Self::ApplicationNamed(s.into()),
392 }
393 }
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
397pub struct Filter {
398 pub kinds: Option<Vec<ObservationKindTag>>,
399 pub severity_min: Option<Severity>,
400 pub origins: Option<Vec<OriginPattern>>,
401 pub text_match: Option<String>,
402 pub since: Option<Checkpoint>,
403 pub limit: Option<usize>,
404 pub correlation_id: Option<String>,
405 pub tags: Option<Vec<String>>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub include_system: Option<bool>,
408}
409
410impl Filter {
411 pub fn matches(&self, obs: &Observation) -> bool {
412 if !self.include_system.unwrap_or(false)
413 && let Some(ref tags) = obs.tags
414 && tags.iter().any(|t| t == SYSTEM_TAG)
415 {
416 return false;
417 }
418
419 if let Some(ref cp) = self.since
420 && obs.id <= cp.0
421 {
422 return false;
423 }
424
425 if let Some(min) = self.severity_min
426 && obs.severity.level() < min.level()
427 {
428 return false;
429 }
430
431 if let Some(ref kinds) = self.kinds
432 && !kinds.is_empty()
433 && !kinds.contains(&obs.kind.tag())
434 {
435 return false;
436 }
437
438 if let Some(ref origins) = self.origins
439 && !origins.is_empty()
440 && !origins.iter().any(|p| p.matches(&obs.origin))
441 {
442 return false;
443 }
444
445 if let Some(ref text) = self.text_match {
446 let haystack = obs.data.to_string().to_ascii_lowercase();
447 if !haystack.contains(&text.to_ascii_lowercase()) {
448 return false;
449 }
450 }
451
452 if let Some(ref cid) = self.correlation_id {
453 match &obs.correlation_id {
454 Some(obs_cid) if obs_cid.as_ref() == cid.as_str() => {}
455 _ => return false,
456 }
457 }
458
459 if let Some(ref required_tags) = self.tags
460 && !required_tags.is_empty()
461 {
462 match &obs.tags {
463 Some(obs_tags) => {
464 if !required_tags.iter().all(|t| obs_tags.contains(t)) {
465 return false;
466 }
467 }
468 None => return false,
469 }
470 }
471
472 true
473 }
474
475 pub fn parse_severity(raw: &str) -> Option<Severity> {
476 raw.trim().parse::<Severity>().ok()
477 }
478
479 pub fn parse_kinds(raw: &str) -> Vec<ObservationKindTag> {
480 raw.split(',')
481 .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
482 .collect()
483 }
484
485 pub fn parse_origins(raw: &str) -> Vec<OriginPattern> {
486 raw.split(',')
487 .map(|s| OriginPattern::parse(s.trim()))
488 .collect()
489 }
490
491 pub fn parse_tags(raw: &str) -> Vec<String> {
492 raw.split(',')
493 .map(|s| s.trim().to_string())
494 .filter(|s| !s.is_empty())
495 .collect()
496 }
497
498 pub fn kinds_from_vec(v: Vec<String>) -> Vec<ObservationKindTag> {
499 v.into_iter()
500 .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
501 .collect()
502 }
503
504 pub fn origins_from_vec(v: Vec<String>) -> Vec<OriginPattern> {
505 v.into_iter()
506 .map(|s| OriginPattern::parse(s.trim()))
507 .collect()
508 }
509}
510
511impl OriginPattern {
512 pub fn matches(&self, origin: &Origin) -> bool {
513 match (self, origin) {
514 (Self::AnyApplication, Origin::Application { .. }) => true,
515 (Self::ApplicationNamed(name), Origin::Application { name: n }) => n == name,
516 (Self::AnyBrowser, Origin::Browser { .. }) => true,
517 (Self::BrowserTab(tab), Origin::Browser { tab_id, .. }) => tab_id == tab,
518 (Self::AnyDevice, Origin::Device { .. }) => true,
519 (Self::DeviceSerial(serial), Origin::Device { serial: s, .. }) => s == serial,
520 _ => false,
521 }
522 }
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
526#[serde(rename_all = "snake_case")]
527pub enum MemoryKind {
528 Pattern,
529 Decision,
530 ErrorSignature,
531 SessionSummary,
532 UserFlagged,
533}
534
535impl fmt::Display for MemoryKind {
536 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537 let s = match self {
538 Self::Pattern => "pattern",
539 Self::Decision => "decision",
540 Self::ErrorSignature => "error_signature",
541 Self::SessionSummary => "session_summary",
542 Self::UserFlagged => "user_flagged",
543 };
544 f.write_str(s)
545 }
546}
547
548impl std::str::FromStr for MemoryKind {
549 type Err = String;
550
551 fn from_str(s: &str) -> Result<Self, Self::Err> {
552 match s.to_ascii_lowercase().as_str() {
553 "pattern" => Ok(Self::Pattern),
554 "decision" => Ok(Self::Decision),
555 "error_signature" => Ok(Self::ErrorSignature),
556 "session_summary" => Ok(Self::SessionSummary),
557 "user_flagged" => Ok(Self::UserFlagged),
558 other => Err(format!("unknown memory kind: {other}")),
559 }
560 }
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct SliceSummary {
565 pub total: usize,
566 pub counts_by_kind: HashMap<String, usize>,
567 pub counts_by_severity: HashMap<String, usize>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct StateSlice {
572 pub observations: Vec<Observation>,
573 pub checkpoint: Checkpoint,
574 pub summary: SliceSummary,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(rename_all = "snake_case")]
579pub enum HealthStatus {
580 Ok,
581 ErrorsDetected,
582 NoSources,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum ConnectionKind {
588 Application,
589 Browser,
590 Device,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct ConnectionInfo {
595 pub id: String,
596 pub kind: ConnectionKind,
597 pub name: String,
598 pub observation_count: u64,
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct RuntimeSummary {
603 pub observation_count: u64,
604 pub error_count_last_60s: u64,
605 pub active_channels: Vec<String>,
606 pub connections: Vec<ConnectionInfo>,
607 pub health: HealthStatus,
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use serde_json::{Value, json};
614
615 fn obs(origin: Origin, kind: ObservationKind) -> Observation {
616 Observation {
617 id: 0,
618 origin,
619 kind,
620 data: json!({"note": "roundtrip fixture"}),
621 severity: Severity::Info,
622 source_location: None,
623 timestamp_ns: 0,
624 correlation_id: None,
625 parent_id: None,
626 tags: None,
627 session_id: None,
628 node_id: None,
629 }
630 }
631
632 fn roundtrip_observation(o: &Observation) {
633 let text = serde_json::to_string(o).expect("serialize");
634 let back: Observation = serde_json::from_str(&text).expect("deserialize");
635 let text_again = serde_json::to_string(&back).expect("reserialize");
636 assert_eq!(
637 text, text_again,
638 "observation should roundtrip identically; first={text} second={text_again}"
639 );
640 }
641
642 #[test]
643 fn origin_application_roundtrip() {
644 roundtrip_observation(&obs(
645 Origin::Application {
646 name: AppName::from("test-app"),
647 },
648 ObservationKind::Log,
649 ));
650 }
651
652 #[test]
653 fn origin_browser_roundtrip() {
654 roundtrip_observation(&obs(
655 Origin::Browser {
656 tab_id: TabId::from("tab-abc-123"),
657 url: "https://example.com/page".into(),
658 },
659 ObservationKind::Log,
660 ));
661 }
662
663 #[test]
664 fn origin_device_roundtrip() {
665 roundtrip_observation(&obs(
666 Origin::Device {
667 serial: DeviceSerial::from("emulator-5554"),
668 platform: DevicePlatform::Android,
669 },
670 ObservationKind::Log,
671 ));
672 }
673
674 #[test]
675 fn observation_kind_variants_all_roundtrip() {
676 let cases = vec![
677 ObservationKind::Log,
678 ObservationKind::Query {
679 sql: "SELECT 1".into(),
680 duration_ms: 3.5,
681 },
682 ObservationKind::HttpExchange {
683 method: "GET".into(),
684 url: "/api/users".into(),
685 status: Some(200),
686 duration_ms: Some(42.0),
687 },
688 ObservationKind::Exception {
689 message: "boom".into(),
690 trace: Some("stack".into()),
691 },
692 ObservationKind::StateSnapshot {
693 label: "before-migration".into(),
694 },
695 ObservationKind::Metric {
696 name: "cpu".into(),
697 value: 0.75,
698 },
699 ObservationKind::Custom {
700 channel: "events".into(),
701 },
702 ObservationKind::JsException {
703 message: "undefined".into(),
704 line: Some(12),
705 column: Some(5),
706 },
707 ObservationKind::Lifecycle {
708 event_name: "ready".into(),
709 frame_id: "frame-1".into(),
710 },
711 ];
712
713 for kind in cases {
714 roundtrip_observation(&obs(Origin::Application { name: "x".into() }, kind));
715 }
716 }
717
718 #[test]
719 fn newtypes_serialize_transparent_as_strings() {
720 let origin = Origin::Browser {
721 tab_id: TabId::from("abc"),
722 url: "https://x".into(),
723 };
724 let v: Value = serde_json::to_value(&origin).unwrap();
725 assert_eq!(
726 v["tab_id"],
727 json!("abc"),
728 "TabId must serialize as a bare string, got {v:#?}"
729 );
730
731 let origin = Origin::Device {
732 serial: DeviceSerial::from("S123"),
733 platform: DevicePlatform::Vega,
734 };
735 let v: Value = serde_json::to_value(&origin).unwrap();
736 assert_eq!(v["serial"], json!("S123"));
737
738 let origin = Origin::Application {
739 name: AppName::from("my-app"),
740 };
741 let v: Value = serde_json::to_value(&origin).unwrap();
742 assert_eq!(v["name"], json!("my-app"));
743 }
744
745 #[test]
746 fn newtypes_deserialize_from_bare_strings() {
747 let v = json!({
748 "type": "browser",
749 "tab_id": "tab-42",
750 "url": "https://example.com"
751 });
752 let origin: Origin = serde_json::from_value(v).unwrap();
753 match origin {
754 Origin::Browser { tab_id, url } => {
755 assert_eq!(tab_id.as_str(), "tab-42");
756 assert_eq!(url, "https://example.com");
757 }
758 _ => panic!("expected Browser origin"),
759 }
760 }
761
762 #[test]
763 fn origin_pattern_roundtrip_all_variants() {
764 let patterns = vec![
765 OriginPattern::AnyApplication,
766 OriginPattern::ApplicationNamed(AppName::from("svc")),
767 OriginPattern::AnyBrowser,
768 OriginPattern::BrowserTab(TabId::from("tab-9")),
769 OriginPattern::AnyDevice,
770 OriginPattern::DeviceSerial(DeviceSerial::from("S-42")),
771 ];
772 for p in patterns {
773 let text = serde_json::to_string(&p).unwrap();
774 let back: OriginPattern = serde_json::from_str(&text).unwrap();
775 let text_again = serde_json::to_string(&back).unwrap();
776 assert_eq!(text, text_again);
777 }
778 }
779
780 #[test]
781 fn filter_matches_honors_newtype_identity() {
782 let matching = obs(
783 Origin::Browser {
784 tab_id: TabId::from("tab-1"),
785 url: "".into(),
786 },
787 ObservationKind::Log,
788 );
789 let other = obs(
790 Origin::Browser {
791 tab_id: TabId::from("tab-2"),
792 url: "".into(),
793 },
794 ObservationKind::Log,
795 );
796 let filter = Filter {
797 origins: Some(vec![OriginPattern::BrowserTab(TabId::from("tab-1"))]),
798 ..Filter::default()
799 };
800 assert!(filter.matches(&matching));
801 assert!(!filter.matches(&other));
802 }
803
804 #[test]
805 fn tab_id_equality_regardless_of_construction_path() {
806 let from_str = TabId::from("abc");
807 let from_string = TabId::from(String::from("abc"));
808 let from_arc: TabId = std::sync::Arc::<str>::from("abc").into();
809 assert_eq!(from_str, from_string);
810 assert_eq!(from_string, from_arc);
811 assert_eq!(from_str, "abc");
812 }
813
814 #[test]
815 fn system_tag_excluded_by_default() {
816 let mut system_obs = obs(
817 Origin::Application {
818 name: AppName::from("hook"),
819 },
820 ObservationKind::Log,
821 );
822 system_obs.tags = Some(vec![SYSTEM_TAG.to_string()]);
823
824 let normal_obs = obs(
825 Origin::Application {
826 name: AppName::from("app"),
827 },
828 ObservationKind::Log,
829 );
830
831 let default_filter = Filter::default();
832 assert!(!default_filter.matches(&system_obs));
833 assert!(default_filter.matches(&normal_obs));
834
835 let include_filter = Filter {
836 include_system: Some(true),
837 ..Filter::default()
838 };
839 assert!(include_filter.matches(&system_obs));
840 assert!(include_filter.matches(&normal_obs));
841 }
842
843 #[test]
844 fn debug_action_roundtrip_snake_case() {
845 let cases = vec![
846 (DebugAction::EvalJs, "eval_js"),
847 (DebugAction::GetPerfMetrics, "get_perf_metrics"),
848 (DebugAction::SetViewport, "set_viewport"),
849 (DebugAction::StorageInspect, "storage_inspect"),
850 (DebugAction::ElementAtPoint, "element_at_point"),
851 ];
852 for (variant, wire) in cases {
853 let text = serde_json::to_string(&variant).unwrap();
854 assert_eq!(text, format!("\"{wire}\""));
855 let back: DebugAction = serde_json::from_str(&text).unwrap();
856 assert_eq!(back, variant);
857 }
858 }
859}