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, Serialize, Deserialize)]
526pub struct SliceSummary {
527 pub total: usize,
528 pub counts_by_kind: HashMap<String, usize>,
529 pub counts_by_severity: HashMap<String, usize>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct StateSlice {
534 pub observations: Vec<Observation>,
535 pub checkpoint: Checkpoint,
536 pub summary: SliceSummary,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum HealthStatus {
542 Ok,
543 ErrorsDetected,
544 NoSources,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum ConnectionKind {
550 Application,
551 Browser,
552 Device,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct ConnectionInfo {
557 pub id: String,
558 pub kind: ConnectionKind,
559 pub name: String,
560 pub observation_count: u64,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct RuntimeSummary {
565 pub observation_count: u64,
566 pub error_count_last_60s: u64,
567 pub active_channels: Vec<String>,
568 pub connections: Vec<ConnectionInfo>,
569 pub health: HealthStatus,
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use serde_json::{Value, json};
576
577 fn obs(origin: Origin, kind: ObservationKind) -> Observation {
578 Observation {
579 id: 0,
580 origin,
581 kind,
582 data: json!({"note": "roundtrip fixture"}),
583 severity: Severity::Info,
584 source_location: None,
585 timestamp_ns: 0,
586 correlation_id: None,
587 parent_id: None,
588 tags: None,
589 session_id: None,
590 node_id: None,
591 }
592 }
593
594 fn roundtrip_observation(o: &Observation) {
595 let text = serde_json::to_string(o).expect("serialize");
596 let back: Observation = serde_json::from_str(&text).expect("deserialize");
597 let text_again = serde_json::to_string(&back).expect("reserialize");
598 assert_eq!(
599 text, text_again,
600 "observation should roundtrip identically; first={text} second={text_again}"
601 );
602 }
603
604 #[test]
605 fn origin_application_roundtrip() {
606 roundtrip_observation(&obs(
607 Origin::Application {
608 name: AppName::from("test-app"),
609 },
610 ObservationKind::Log,
611 ));
612 }
613
614 #[test]
615 fn origin_browser_roundtrip() {
616 roundtrip_observation(&obs(
617 Origin::Browser {
618 tab_id: TabId::from("tab-abc-123"),
619 url: "https://example.com/page".into(),
620 },
621 ObservationKind::Log,
622 ));
623 }
624
625 #[test]
626 fn origin_device_roundtrip() {
627 roundtrip_observation(&obs(
628 Origin::Device {
629 serial: DeviceSerial::from("emulator-5554"),
630 platform: DevicePlatform::Android,
631 },
632 ObservationKind::Log,
633 ));
634 }
635
636 #[test]
637 fn observation_kind_variants_all_roundtrip() {
638 let cases = vec![
639 ObservationKind::Log,
640 ObservationKind::Query {
641 sql: "SELECT 1".into(),
642 duration_ms: 3.5,
643 },
644 ObservationKind::HttpExchange {
645 method: "GET".into(),
646 url: "/api/users".into(),
647 status: Some(200),
648 duration_ms: Some(42.0),
649 },
650 ObservationKind::Exception {
651 message: "boom".into(),
652 trace: Some("stack".into()),
653 },
654 ObservationKind::StateSnapshot {
655 label: "before-migration".into(),
656 },
657 ObservationKind::Metric {
658 name: "cpu".into(),
659 value: 0.75,
660 },
661 ObservationKind::Custom {
662 channel: "events".into(),
663 },
664 ObservationKind::JsException {
665 message: "undefined".into(),
666 line: Some(12),
667 column: Some(5),
668 },
669 ObservationKind::Lifecycle {
670 event_name: "ready".into(),
671 frame_id: "frame-1".into(),
672 },
673 ];
674
675 for kind in cases {
676 roundtrip_observation(&obs(Origin::Application { name: "x".into() }, kind));
677 }
678 }
679
680 #[test]
681 fn newtypes_serialize_transparent_as_strings() {
682 let origin = Origin::Browser {
683 tab_id: TabId::from("abc"),
684 url: "https://x".into(),
685 };
686 let v: Value = serde_json::to_value(&origin).unwrap();
687 assert_eq!(
688 v["tab_id"],
689 json!("abc"),
690 "TabId must serialize as a bare string, got {v:#?}"
691 );
692
693 let origin = Origin::Device {
694 serial: DeviceSerial::from("S123"),
695 platform: DevicePlatform::Vega,
696 };
697 let v: Value = serde_json::to_value(&origin).unwrap();
698 assert_eq!(v["serial"], json!("S123"));
699
700 let origin = Origin::Application {
701 name: AppName::from("my-app"),
702 };
703 let v: Value = serde_json::to_value(&origin).unwrap();
704 assert_eq!(v["name"], json!("my-app"));
705 }
706
707 #[test]
708 fn newtypes_deserialize_from_bare_strings() {
709 let v = json!({
710 "type": "browser",
711 "tab_id": "tab-42",
712 "url": "https://example.com"
713 });
714 let origin: Origin = serde_json::from_value(v).unwrap();
715 match origin {
716 Origin::Browser { tab_id, url } => {
717 assert_eq!(tab_id.as_str(), "tab-42");
718 assert_eq!(url, "https://example.com");
719 }
720 _ => panic!("expected Browser origin"),
721 }
722 }
723
724 #[test]
725 fn origin_pattern_roundtrip_all_variants() {
726 let patterns = vec![
727 OriginPattern::AnyApplication,
728 OriginPattern::ApplicationNamed(AppName::from("svc")),
729 OriginPattern::AnyBrowser,
730 OriginPattern::BrowserTab(TabId::from("tab-9")),
731 OriginPattern::AnyDevice,
732 OriginPattern::DeviceSerial(DeviceSerial::from("S-42")),
733 ];
734 for p in patterns {
735 let text = serde_json::to_string(&p).unwrap();
736 let back: OriginPattern = serde_json::from_str(&text).unwrap();
737 let text_again = serde_json::to_string(&back).unwrap();
738 assert_eq!(text, text_again);
739 }
740 }
741
742 #[test]
743 fn filter_matches_honors_newtype_identity() {
744 let matching = obs(
745 Origin::Browser {
746 tab_id: TabId::from("tab-1"),
747 url: "".into(),
748 },
749 ObservationKind::Log,
750 );
751 let other = obs(
752 Origin::Browser {
753 tab_id: TabId::from("tab-2"),
754 url: "".into(),
755 },
756 ObservationKind::Log,
757 );
758 let filter = Filter {
759 origins: Some(vec![OriginPattern::BrowserTab(TabId::from("tab-1"))]),
760 ..Filter::default()
761 };
762 assert!(filter.matches(&matching));
763 assert!(!filter.matches(&other));
764 }
765
766 #[test]
767 fn tab_id_equality_regardless_of_construction_path() {
768 let from_str = TabId::from("abc");
769 let from_string = TabId::from(String::from("abc"));
770 let from_arc: TabId = std::sync::Arc::<str>::from("abc").into();
771 assert_eq!(from_str, from_string);
772 assert_eq!(from_string, from_arc);
773 assert_eq!(from_str, "abc");
774 }
775
776 #[test]
777 fn system_tag_excluded_by_default() {
778 let mut system_obs = obs(
779 Origin::Application {
780 name: AppName::from("hook"),
781 },
782 ObservationKind::Log,
783 );
784 system_obs.tags = Some(vec![SYSTEM_TAG.to_string()]);
785
786 let normal_obs = obs(
787 Origin::Application {
788 name: AppName::from("app"),
789 },
790 ObservationKind::Log,
791 );
792
793 let default_filter = Filter::default();
794 assert!(!default_filter.matches(&system_obs));
795 assert!(default_filter.matches(&normal_obs));
796
797 let include_filter = Filter {
798 include_system: Some(true),
799 ..Filter::default()
800 };
801 assert!(include_filter.matches(&system_obs));
802 assert!(include_filter.matches(&normal_obs));
803 }
804
805 #[test]
806 fn debug_action_roundtrip_snake_case() {
807 let cases = vec![
808 (DebugAction::EvalJs, "eval_js"),
809 (DebugAction::GetPerfMetrics, "get_perf_metrics"),
810 (DebugAction::SetViewport, "set_viewport"),
811 (DebugAction::StorageInspect, "storage_inspect"),
812 (DebugAction::ElementAtPoint, "element_at_point"),
813 ];
814 for (variant, wire) in cases {
815 let text = serde_json::to_string(&variant).unwrap();
816 assert_eq!(text, format!("\"{wire}\""));
817 let back: DebugAction = serde_json::from_str(&text).unwrap();
818 assert_eq!(back, variant);
819 }
820 }
821}