#![allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::similar_names
)]
use std::collections::{HashMap, VecDeque};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::inspector::{
ConsoleEntry, ConsoleLevel, Header, NetworkEntry, NetworkStatus, RequestDetail, RingBuffer,
};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Clone)]
pub struct InspectorSlots {
pub console: Rc<RefCell<RingBuffer<ConsoleEntry>>>,
pub network: Rc<RefCell<RingBuffer<NetworkEntry>>>,
pub details: Rc<RefCell<RequestDetailStore>>,
pub pending: Rc<RefCell<NetworkPending>>,
}
impl InspectorSlots {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
console: Rc::new(RefCell::new(RingBuffer::new(capacity))),
network: Rc::new(RefCell::new(RingBuffer::new(capacity))),
details: Rc::new(RefCell::new(RequestDetailStore::new(capacity))),
pending: Rc::new(RefCell::new(NetworkPending::new(capacity))),
}
}
}
pub struct RequestDetailStore {
by_seq: HashMap<u64, RequestDetail>,
order: VecDeque<u64>,
capacity: usize,
}
#[allow(clippy::map_entry)] impl RequestDetailStore {
#[must_use]
pub fn new(capacity: usize) -> Self {
let capacity = capacity.max(1);
Self {
by_seq: HashMap::with_capacity(capacity),
order: VecDeque::with_capacity(capacity),
capacity,
}
}
pub fn insert(&mut self, seq: u64, detail: RequestDetail) {
if self.by_seq.contains_key(&seq) {
self.by_seq.insert(seq, detail);
return;
}
if self.by_seq.len() == self.capacity {
if let Some(old) = self.order.pop_front() {
self.by_seq.remove(&old);
}
}
self.order.push_back(seq);
self.by_seq.insert(seq, detail);
}
#[cfg(test)]
pub fn remove(&mut self, seq: u64) -> Option<RequestDetail> {
let entry = self.by_seq.remove(&seq)?;
if let Some(pos) = self.order.iter().position(|s| *s == seq) {
self.order.remove(pos);
}
Some(entry)
}
#[must_use]
pub fn get(&self, seq: u64) -> Option<&RequestDetail> {
self.by_seq.get(&seq)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.by_seq.len()
}
#[cfg(test)]
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.by_seq.is_empty()
}
#[cfg(test)]
pub fn capacity(&self) -> usize {
self.capacity
}
}
pub const SCRIPT: &str = include_str!("inspector_capture.js");
pub const CONSOLE_HANDLER: &str = "vsConsole";
pub const NETWORK_HANDLER: &str = "vsNetwork";
fn ts_from_ms(ms: i64) -> SystemTime {
if ms <= 0 {
return UNIX_EPOCH;
}
UNIX_EPOCH + Duration::from_millis(ms as u64)
}
fn parse_level(s: &str) -> ConsoleLevel {
match s {
"error" => ConsoleLevel::Error,
"warn" => ConsoleLevel::Warn,
"info" => ConsoleLevel::Info,
"debug" => ConsoleLevel::Debug,
_ => ConsoleLevel::Log,
}
}
pub fn ingest_console(buf: &mut RingBuffer<ConsoleEntry>, body: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
return;
};
let level = parse_level(v.get("level").and_then(|x| x.as_str()).unwrap_or("log"));
let message = v
.get("message")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let stack = v.get("stack").and_then(|x| x.as_str()).map(str::to_string);
let ts = v
.get("ts_ms")
.and_then(serde_json::Value::as_i64)
.map_or_else(SystemTime::now, ts_from_ms);
buf.push(ConsoleEntry {
timestamp: ts,
level,
message,
stack,
});
}
fn parse_headers(v: Option<&serde_json::Value>) -> Vec<Header> {
let Some(arr) = v.and_then(serde_json::Value::as_array) else {
return Vec::new();
};
arr.iter()
.filter_map(|pair| {
let p = pair.as_array()?;
let name = p.first()?.as_str()?.to_string();
let value = p.get(1)?.as_str()?.to_string();
Some(Header { name, value })
})
.collect()
}
pub struct NetworkPending {
by_seq: HashMap<u64, PendingEntry>,
order: VecDeque<u64>,
capacity: usize,
}
#[derive(Clone)]
pub struct PendingEntry {
pub start_ms: i64,
pub req_headers: Vec<Header>,
pub req_body: Option<String>,
}
#[allow(clippy::map_entry)] impl NetworkPending {
#[must_use]
pub fn new(capacity: usize) -> Self {
let capacity = capacity.max(1);
Self {
by_seq: HashMap::with_capacity(capacity),
order: VecDeque::with_capacity(capacity),
capacity,
}
}
pub fn insert(&mut self, seq: u64, entry: PendingEntry) {
if self.by_seq.contains_key(&seq) {
self.by_seq.insert(seq, entry);
return;
}
if self.by_seq.len() == self.capacity {
if let Some(old) = self.order.pop_front() {
self.by_seq.remove(&old);
}
}
self.order.push_back(seq);
self.by_seq.insert(seq, entry);
}
pub fn take(&mut self, seq: u64) -> Option<PendingEntry> {
let entry = self.by_seq.remove(&seq)?;
if let Some(pos) = self.order.iter().position(|s| *s == seq) {
self.order.remove(pos);
}
Some(entry)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.by_seq.len()
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.by_seq.is_empty()
}
#[cfg(test)]
pub fn capacity(&self) -> usize {
self.capacity
}
}
pub struct NetworkIngestSlot<'a> {
pub entries: &'a mut RingBuffer<NetworkEntry>,
pub details: &'a mut RequestDetailStore,
pub pending: &'a mut NetworkPending,
}
#[allow(clippy::needless_pass_by_value)] pub fn ingest_network(slot: NetworkIngestSlot<'_>, body: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
return;
};
let Some(seq) = v.get("seq").and_then(serde_json::Value::as_u64) else {
return;
};
let phase = v.get("phase").and_then(|x| x.as_str()).unwrap_or("");
if phase == "start" {
let ts = v
.get("ts_ms")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
slot.pending.insert(
seq,
PendingEntry {
start_ms: ts,
req_headers: parse_headers(v.get("req_headers")),
req_body: v
.get("req_body")
.and_then(|x| x.as_str())
.map(str::to_string),
},
);
return;
}
if phase != "end" {
return;
}
let method = v
.get("method")
.and_then(|x| x.as_str())
.unwrap_or("GET")
.to_string();
let url = v
.get("url")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let status_code = v
.get("status")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let status = if status_code == 0 {
NetworkStatus::Abort
} else {
NetworkStatus::Code(status_code as u16)
};
let end_ms = v
.get("ts_ms")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let pending = slot.pending.take(seq);
let start_ms = pending.as_ref().map_or(end_ms, |p| p.start_ms);
let latency = if end_ms >= start_ms {
Some((end_ms - start_ms) as u64)
} else {
None
};
let size = v
.get("size")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let timestamp = ts_from_ms(end_ms);
let _evicted = slot.entries.push(NetworkEntry {
seq,
timestamp,
method: method.clone(),
url: url.clone(),
status: status.clone(),
size,
latency_ms: latency,
});
let (req_headers, req_body) = pending
.map(|p| (p.req_headers, p.req_body))
.unwrap_or_default();
let res_headers = parse_headers(v.get("res_headers"));
let res_body = v
.get("res_body")
.and_then(|x| x.as_str())
.map(str::to_string);
slot.details.insert(
seq,
RequestDetail {
seq,
method,
url,
status,
request_headers: req_headers,
request_body: req_body,
response_headers: res_headers,
response_body: res_body,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inspector::DEFAULT_BUFFER_CAPACITY;
fn make_slots(cap: usize) -> InspectorSlots {
InspectorSlots::new(cap)
}
fn ingest_pair(slots: &InspectorSlots, seq: u64) {
let start = format!(
r#"{{"seq":{seq},"phase":"start","ts_ms":1000,"req_headers":[["X-Test","y"]],"req_body":"hi"}}"#
);
let end = format!(
r#"{{"seq":{seq},"phase":"end","ts_ms":1100,"method":"GET","url":"http://x/{seq}","status":200,"size":42}}"#
);
{
let mut entries = slots.network.borrow_mut();
let mut details = slots.details.borrow_mut();
let mut pending = slots.pending.borrow_mut();
ingest_network(
NetworkIngestSlot {
entries: &mut entries,
details: &mut details,
pending: &mut pending,
},
&start,
);
}
{
let mut entries = slots.network.borrow_mut();
let mut details = slots.details.borrow_mut();
let mut pending = slots.pending.borrow_mut();
ingest_network(
NetworkIngestSlot {
entries: &mut entries,
details: &mut details,
pending: &mut pending,
},
&end,
);
}
}
#[test]
fn details_store_evicts_oldest_at_capacity() {
let slots = make_slots(3);
for seq in 1..=5 {
ingest_pair(&slots, seq);
}
let details = slots.details.borrow();
assert_eq!(details.len(), 3, "details store must stay bounded");
assert_eq!(details.capacity(), 3);
assert!(
details.get(1).is_none(),
"oldest seq 1 should have been evicted"
);
assert!(
details.get(2).is_none(),
"second-oldest seq 2 should have been evicted"
);
assert!(details.get(3).is_some(), "seq 3 still in window");
assert!(details.get(5).is_some(), "seq 5 still in window");
}
#[test]
fn pending_evicts_oldest_when_starts_pile_up_without_ends() {
let slots = make_slots(3);
for seq in 1..=5 {
let body = format!(
r#"{{"seq":{seq},"phase":"start","ts_ms":{},"req_headers":[],"req_body":null}}"#,
seq * 100
);
let mut entries = slots.network.borrow_mut();
let mut details = slots.details.borrow_mut();
let mut pending = slots.pending.borrow_mut();
ingest_network(
NetworkIngestSlot {
entries: &mut entries,
details: &mut details,
pending: &mut pending,
},
&body,
);
}
let pending = slots.pending.borrow();
assert_eq!(pending.len(), 3, "pending must stay bounded by capacity");
assert_eq!(pending.capacity(), 3);
assert!(!pending.is_empty());
}
#[test]
fn end_event_drains_pending_back_to_empty() {
let slots = make_slots(DEFAULT_BUFFER_CAPACITY);
ingest_pair(&slots, 42);
assert!(slots.pending.borrow().is_empty());
let details = slots.details.borrow();
assert_eq!(details.len(), 1);
let detail = details.get(42).expect("seq 42 present");
assert_eq!(detail.method, "GET");
assert_eq!(detail.url, "http://x/42");
}
#[test]
fn details_remove_clears_order_too() {
let mut store = RequestDetailStore::new(4);
for seq in 1..=3 {
store.insert(
seq,
RequestDetail {
seq,
method: "GET".into(),
url: format!("http://x/{seq}"),
status: NetworkStatus::Code(200),
request_headers: vec![],
request_body: None,
response_headers: vec![],
response_body: None,
},
);
}
assert_eq!(store.len(), 3);
store.remove(2);
assert_eq!(store.len(), 2);
for seq in 4..=5 {
store.insert(
seq,
RequestDetail {
seq,
method: "GET".into(),
url: format!("http://x/{seq}"),
status: NetworkStatus::Code(200),
request_headers: vec![],
request_body: None,
response_headers: vec![],
response_body: None,
},
);
}
assert_eq!(store.len(), 4);
assert!(store.get(1).is_some(), "seq 1 should not have been evicted");
assert!(store.get(2).is_none(), "seq 2 was removed");
assert!(store.get(5).is_some());
}
#[test]
fn console_ring_buffer_evicts_at_capacity() {
let slots = make_slots(3);
for i in 0..5 {
let body = format!(r#"{{"level":"log","message":"m{i}","ts_ms":{}}}"#, 1000 + i);
let mut buf = slots.console.borrow_mut();
ingest_console(&mut buf, &body);
}
let buf = slots.console.borrow();
let snap = buf.snapshot();
assert_eq!(snap.len(), 3);
assert_eq!(snap[0].message, "m2", "oldest two should have been evicted");
assert_eq!(snap[2].message, "m4");
}
}