use std::borrow::Borrow;
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use rmcp::schemars;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const SYSTEM_TAG: &str = "_system";
macro_rules! arc_str_newtype {
($name:ident) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(Arc<str>);
impl $name {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for $name {
fn from(s: &str) -> Self {
Self(Arc::from(s))
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
Self(Arc::from(s.as_str()))
}
}
impl From<Arc<str>> for $name {
fn from(s: Arc<str>) -> Self {
Self(s)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for $name {
fn borrow(&self) -> &str {
&self.0
}
}
impl PartialEq<str> for $name {
fn eq(&self, other: &str) -> bool {
self.as_str() == other
}
}
impl PartialEq<&str> for $name {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl FromStr for $name {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from(s))
}
}
impl JsonSchema for $name {
fn schema_name() -> std::borrow::Cow<'static, str> {
stringify!($name).into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
String::json_schema(generator)
}
}
};
}
arc_str_newtype!(TabId);
arc_str_newtype!(SessionId);
arc_str_newtype!(DeviceSerial);
arc_str_newtype!(AppName);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DebugAction {
EvalJs,
Screenshot,
InjectCss,
RevertCss,
ListTabs,
GetPerfMetrics,
GetDom,
SetViewport,
ClearViewport,
NetworkConditions,
Navigate,
StorageClear,
StorageInspect,
StorageSet,
ElementAtPoint,
NewTab,
CloseTab,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Trace,
Debug,
Info,
Warn,
Error,
}
impl Severity {
pub fn level(self) -> u8 {
match self {
Self::Trace => 0,
Self::Debug => 1,
Self::Info => 2,
Self::Warn => 3,
Self::Error => 4,
}
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Trace => "trace",
Self::Debug => "debug",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
};
f.write_str(s)
}
}
impl std::str::FromStr for Severity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"trace" => Ok(Self::Trace),
"debug" => Ok(Self::Debug),
"info" => Ok(Self::Info),
"warn" => Ok(Self::Warn),
"error" => Ok(Self::Error),
other => Err(format!("unknown severity: {other}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum Origin {
Application {
name: AppName,
},
Browser {
tab_id: TabId,
url: String,
},
Device {
serial: DeviceSerial,
platform: DevicePlatform,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DevicePlatform {
#[default]
Android,
Vega,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ObservationKindTag {
Log,
Query,
HttpExchange,
Exception,
StateSnapshot,
Metric,
Custom,
JsException,
Lifecycle,
}
impl fmt::Display for ObservationKindTag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Log => "log",
Self::Query => "query",
Self::HttpExchange => "http_exchange",
Self::Exception => "exception",
Self::StateSnapshot => "state_snapshot",
Self::Metric => "metric",
Self::Custom => "custom",
Self::JsException => "js_exception",
Self::Lifecycle => "lifecycle",
};
f.write_str(s)
}
}
impl std::str::FromStr for ObservationKindTag {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"log" => Ok(Self::Log),
"query" => Ok(Self::Query),
"http_exchange" => Ok(Self::HttpExchange),
"exception" => Ok(Self::Exception),
"state_snapshot" => Ok(Self::StateSnapshot),
"metric" => Ok(Self::Metric),
"custom" => Ok(Self::Custom),
"js_exception" => Ok(Self::JsException),
"lifecycle" => Ok(Self::Lifecycle),
other => Err(format!("unknown observation kind: {other}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum ObservationKind {
Log,
Query {
sql: String,
duration_ms: f64,
},
HttpExchange {
method: String,
url: String,
status: Option<u16>,
duration_ms: Option<f64>,
},
Exception {
message: String,
trace: Option<String>,
},
StateSnapshot {
label: String,
},
Metric {
name: String,
value: f64,
},
Custom {
channel: String,
},
JsException {
message: String,
line: Option<u32>,
column: Option<u32>,
},
Lifecycle {
event_name: String,
frame_id: String,
},
}
impl ObservationKind {
pub fn tag(&self) -> ObservationKindTag {
match self {
Self::Log => ObservationKindTag::Log,
Self::Query { .. } => ObservationKindTag::Query,
Self::HttpExchange { .. } => ObservationKindTag::HttpExchange,
Self::Exception { .. } => ObservationKindTag::Exception,
Self::StateSnapshot { .. } => ObservationKindTag::StateSnapshot,
Self::Metric { .. } => ObservationKindTag::Metric,
Self::Custom { .. } => ObservationKindTag::Custom,
Self::JsException { .. } => ObservationKindTag::JsException,
Self::Lifecycle { .. } => ObservationKindTag::Lifecycle,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceLocation {
pub file: String,
pub line: u32,
pub function: Option<String>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
pub struct Checkpoint(pub u64);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Observation {
pub id: u64,
pub origin: Origin,
pub kind: ObservationKind,
pub data: serde_json::Value,
pub severity: Severity,
pub source_location: Option<SourceLocation>,
pub timestamp_ns: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<Arc<str>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<Arc<str>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<Arc<str>>,
}
impl Observation {
pub fn new(
origin: Origin,
kind: ObservationKind,
data: serde_json::Value,
severity: Severity,
source_location: Option<SourceLocation>,
) -> Self {
let timestamp_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_nanos() as u64;
Self {
id: 0,
origin,
kind,
data,
severity,
source_location,
timestamp_ns,
correlation_id: None,
parent_id: None,
tags: None,
session_id: None,
node_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OriginPattern {
AnyApplication,
ApplicationNamed(AppName),
AnyBrowser,
BrowserTab(TabId),
AnyDevice,
DeviceSerial(DeviceSerial),
}
impl OriginPattern {
pub fn parse(s: &str) -> Self {
match s.split_once(':') {
Some(("app", "*")) | None if s == "app" => Self::AnyApplication,
Some(("app", name)) => Self::ApplicationNamed(name.into()),
Some(("browser", "*")) | None if s == "browser" => Self::AnyBrowser,
Some(("browser", tab_id)) => Self::BrowserTab(tab_id.into()),
Some(("device", "*")) | None if s == "device" => Self::AnyDevice,
Some(("device", serial)) => Self::DeviceSerial(serial.into()),
_ => Self::ApplicationNamed(s.into()),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct Filter {
pub kinds: Option<Vec<ObservationKindTag>>,
pub severity_min: Option<Severity>,
pub origins: Option<Vec<OriginPattern>>,
pub text_match: Option<String>,
pub since: Option<Checkpoint>,
pub limit: Option<usize>,
pub correlation_id: Option<String>,
pub tags: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_system: Option<bool>,
}
impl Filter {
pub fn matches(&self, obs: &Observation) -> bool {
if !self.include_system.unwrap_or(false)
&& let Some(ref tags) = obs.tags
&& tags.iter().any(|t| t == SYSTEM_TAG)
{
return false;
}
if let Some(ref cp) = self.since
&& obs.id <= cp.0
{
return false;
}
if let Some(min) = self.severity_min
&& obs.severity.level() < min.level()
{
return false;
}
if let Some(ref kinds) = self.kinds
&& !kinds.is_empty()
&& !kinds.contains(&obs.kind.tag())
{
return false;
}
if let Some(ref origins) = self.origins
&& !origins.is_empty()
&& !origins.iter().any(|p| p.matches(&obs.origin))
{
return false;
}
if let Some(ref text) = self.text_match {
let haystack = obs.data.to_string().to_ascii_lowercase();
if !haystack.contains(&text.to_ascii_lowercase()) {
return false;
}
}
if let Some(ref cid) = self.correlation_id {
match &obs.correlation_id {
Some(obs_cid) if obs_cid.as_ref() == cid.as_str() => {}
_ => return false,
}
}
if let Some(ref required_tags) = self.tags
&& !required_tags.is_empty()
{
match &obs.tags {
Some(obs_tags) => {
if !required_tags.iter().all(|t| obs_tags.contains(t)) {
return false;
}
}
None => return false,
}
}
true
}
pub fn parse_severity(raw: &str) -> Option<Severity> {
raw.trim().parse::<Severity>().ok()
}
pub fn parse_kinds(raw: &str) -> Vec<ObservationKindTag> {
raw.split(',')
.filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
.collect()
}
pub fn parse_origins(raw: &str) -> Vec<OriginPattern> {
raw.split(',')
.map(|s| OriginPattern::parse(s.trim()))
.collect()
}
pub fn parse_tags(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn kinds_from_vec(v: Vec<String>) -> Vec<ObservationKindTag> {
v.into_iter()
.filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
.collect()
}
pub fn origins_from_vec(v: Vec<String>) -> Vec<OriginPattern> {
v.into_iter()
.map(|s| OriginPattern::parse(s.trim()))
.collect()
}
}
impl OriginPattern {
pub fn matches(&self, origin: &Origin) -> bool {
match (self, origin) {
(Self::AnyApplication, Origin::Application { .. }) => true,
(Self::ApplicationNamed(name), Origin::Application { name: n }) => n == name,
(Self::AnyBrowser, Origin::Browser { .. }) => true,
(Self::BrowserTab(tab), Origin::Browser { tab_id, .. }) => tab_id == tab,
(Self::AnyDevice, Origin::Device { .. }) => true,
(Self::DeviceSerial(serial), Origin::Device { serial: s, .. }) => s == serial,
_ => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SliceSummary {
pub total: usize,
pub counts_by_kind: HashMap<String, usize>,
pub counts_by_severity: HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSlice {
pub observations: Vec<Observation>,
pub checkpoint: Checkpoint,
pub summary: SliceSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HealthStatus {
Ok,
ErrorsDetected,
NoSources,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionKind {
Application,
Browser,
Device,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionInfo {
pub id: String,
pub kind: ConnectionKind,
pub name: String,
pub observation_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSummary {
pub observation_count: u64,
pub error_count_last_60s: u64,
pub active_channels: Vec<String>,
pub connections: Vec<ConnectionInfo>,
pub health: HealthStatus,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn obs(origin: Origin, kind: ObservationKind) -> Observation {
Observation {
id: 0,
origin,
kind,
data: json!({"note": "roundtrip fixture"}),
severity: Severity::Info,
source_location: None,
timestamp_ns: 0,
correlation_id: None,
parent_id: None,
tags: None,
session_id: None,
node_id: None,
}
}
fn roundtrip_observation(o: &Observation) {
let text = serde_json::to_string(o).expect("serialize");
let back: Observation = serde_json::from_str(&text).expect("deserialize");
let text_again = serde_json::to_string(&back).expect("reserialize");
assert_eq!(
text, text_again,
"observation should roundtrip identically; first={text} second={text_again}"
);
}
#[test]
fn origin_application_roundtrip() {
roundtrip_observation(&obs(
Origin::Application {
name: AppName::from("test-app"),
},
ObservationKind::Log,
));
}
#[test]
fn origin_browser_roundtrip() {
roundtrip_observation(&obs(
Origin::Browser {
tab_id: TabId::from("tab-abc-123"),
url: "https://example.com/page".into(),
},
ObservationKind::Log,
));
}
#[test]
fn origin_device_roundtrip() {
roundtrip_observation(&obs(
Origin::Device {
serial: DeviceSerial::from("emulator-5554"),
platform: DevicePlatform::Android,
},
ObservationKind::Log,
));
}
#[test]
fn observation_kind_variants_all_roundtrip() {
let cases = vec![
ObservationKind::Log,
ObservationKind::Query {
sql: "SELECT 1".into(),
duration_ms: 3.5,
},
ObservationKind::HttpExchange {
method: "GET".into(),
url: "/api/users".into(),
status: Some(200),
duration_ms: Some(42.0),
},
ObservationKind::Exception {
message: "boom".into(),
trace: Some("stack".into()),
},
ObservationKind::StateSnapshot {
label: "before-migration".into(),
},
ObservationKind::Metric {
name: "cpu".into(),
value: 0.75,
},
ObservationKind::Custom {
channel: "events".into(),
},
ObservationKind::JsException {
message: "undefined".into(),
line: Some(12),
column: Some(5),
},
ObservationKind::Lifecycle {
event_name: "ready".into(),
frame_id: "frame-1".into(),
},
];
for kind in cases {
roundtrip_observation(&obs(Origin::Application { name: "x".into() }, kind));
}
}
#[test]
fn newtypes_serialize_transparent_as_strings() {
let origin = Origin::Browser {
tab_id: TabId::from("abc"),
url: "https://x".into(),
};
let v: Value = serde_json::to_value(&origin).unwrap();
assert_eq!(
v["tab_id"],
json!("abc"),
"TabId must serialize as a bare string, got {v:#?}"
);
let origin = Origin::Device {
serial: DeviceSerial::from("S123"),
platform: DevicePlatform::Vega,
};
let v: Value = serde_json::to_value(&origin).unwrap();
assert_eq!(v["serial"], json!("S123"));
let origin = Origin::Application {
name: AppName::from("my-app"),
};
let v: Value = serde_json::to_value(&origin).unwrap();
assert_eq!(v["name"], json!("my-app"));
}
#[test]
fn newtypes_deserialize_from_bare_strings() {
let v = json!({
"type": "browser",
"tab_id": "tab-42",
"url": "https://example.com"
});
let origin: Origin = serde_json::from_value(v).unwrap();
match origin {
Origin::Browser { tab_id, url } => {
assert_eq!(tab_id.as_str(), "tab-42");
assert_eq!(url, "https://example.com");
}
_ => panic!("expected Browser origin"),
}
}
#[test]
fn origin_pattern_roundtrip_all_variants() {
let patterns = vec![
OriginPattern::AnyApplication,
OriginPattern::ApplicationNamed(AppName::from("svc")),
OriginPattern::AnyBrowser,
OriginPattern::BrowserTab(TabId::from("tab-9")),
OriginPattern::AnyDevice,
OriginPattern::DeviceSerial(DeviceSerial::from("S-42")),
];
for p in patterns {
let text = serde_json::to_string(&p).unwrap();
let back: OriginPattern = serde_json::from_str(&text).unwrap();
let text_again = serde_json::to_string(&back).unwrap();
assert_eq!(text, text_again);
}
}
#[test]
fn filter_matches_honors_newtype_identity() {
let matching = obs(
Origin::Browser {
tab_id: TabId::from("tab-1"),
url: "".into(),
},
ObservationKind::Log,
);
let other = obs(
Origin::Browser {
tab_id: TabId::from("tab-2"),
url: "".into(),
},
ObservationKind::Log,
);
let filter = Filter {
origins: Some(vec![OriginPattern::BrowserTab(TabId::from("tab-1"))]),
..Filter::default()
};
assert!(filter.matches(&matching));
assert!(!filter.matches(&other));
}
#[test]
fn tab_id_equality_regardless_of_construction_path() {
let from_str = TabId::from("abc");
let from_string = TabId::from(String::from("abc"));
let from_arc: TabId = std::sync::Arc::<str>::from("abc").into();
assert_eq!(from_str, from_string);
assert_eq!(from_string, from_arc);
assert_eq!(from_str, "abc");
}
#[test]
fn system_tag_excluded_by_default() {
let mut system_obs = obs(
Origin::Application {
name: AppName::from("hook"),
},
ObservationKind::Log,
);
system_obs.tags = Some(vec![SYSTEM_TAG.to_string()]);
let normal_obs = obs(
Origin::Application {
name: AppName::from("app"),
},
ObservationKind::Log,
);
let default_filter = Filter::default();
assert!(!default_filter.matches(&system_obs));
assert!(default_filter.matches(&normal_obs));
let include_filter = Filter {
include_system: Some(true),
..Filter::default()
};
assert!(include_filter.matches(&system_obs));
assert!(include_filter.matches(&normal_obs));
}
#[test]
fn debug_action_roundtrip_snake_case() {
let cases = vec![
(DebugAction::EvalJs, "eval_js"),
(DebugAction::GetPerfMetrics, "get_perf_metrics"),
(DebugAction::SetViewport, "set_viewport"),
(DebugAction::StorageInspect, "storage_inspect"),
(DebugAction::ElementAtPoint, "element_at_point"),
];
for (variant, wire) in cases {
let text = serde_json::to_string(&variant).unwrap();
assert_eq!(text, format!("\"{wire}\""));
let back: DebugAction = serde_json::from_str(&text).unwrap();
assert_eq!(back, variant);
}
}
}