feat: [US-015] - Add browser console and network monitoring endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-17 05:50:14 -07:00
parent 5d52010c5e
commit 47312b2a4e
5 changed files with 1014 additions and 1 deletions

View file

@ -288,7 +288,170 @@ impl BrowserRuntime {
// Connect CDP client
match CdpClient::connect().await {
Ok(client) => {
state.cdp_client = Some(Arc::new(client));
let cdp = Arc::new(client);
state.cdp_client = Some(cdp.clone());
// Enable Runtime and Network domains for event monitoring
let _ = cdp.send("Runtime.enable", None).await;
let _ = cdp.send("Network.enable", None).await;
// Subscribe to console events and populate ring buffer
let console_rx = cdp.subscribe("Runtime.consoleAPICalled").await;
let inner_clone = self.inner.clone();
tokio::spawn(async move {
let mut rx = console_rx;
while let Some(params) = rx.recv().await {
let level = params
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("log")
.to_string();
// CDP uses "warning" as type but we normalize to "warning"
let args = params
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let text = args
.iter()
.filter_map(|a| {
a.get("value")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
a.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
})
.collect::<Vec<_>>()
.join(" ");
let stack_trace = params.get("stackTrace");
let call_frame = stack_trace
.and_then(|st| st.get("callFrames"))
.and_then(|cf| cf.as_array())
.and_then(|cf| cf.first());
let url = call_frame
.and_then(|f| f.get("url"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let line = call_frame
.and_then(|f| f.get("lineNumber"))
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let timestamp = params
.get("timestamp")
.and_then(|v| v.as_f64())
.map(|ts| {
chrono::DateTime::from_timestamp_millis((ts * 1000.0) as i64)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
})
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
let msg = BrowserConsoleMessage {
level,
text,
url,
line,
timestamp,
};
let mut state = inner_clone.lock().await;
if state.console_messages.len() >= MAX_CONSOLE_MESSAGES {
state.console_messages.pop_front();
}
state.console_messages.push_back(msg);
}
});
// Subscribe to network request events and populate ring buffer
let request_rx = cdp.subscribe("Network.requestWillBeSent").await;
let response_rx = cdp.subscribe("Network.responseReceived").await;
let inner_clone2 = self.inner.clone();
tokio::spawn(async move {
let mut rx = request_rx;
while let Some(params) = rx.recv().await {
let request_id = params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let request = params.get("request");
let url = request
.and_then(|r| r.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let method = request
.and_then(|r| r.get("method"))
.and_then(|v| v.as_str())
.unwrap_or("GET")
.to_string();
let timestamp = params
.get("timestamp")
.and_then(|v| v.as_f64())
.map(|ts| {
chrono::DateTime::from_timestamp_millis((ts * 1000.0) as i64)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
})
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
let net_req = BrowserNetworkRequest {
request_id: Some(request_id),
url,
method,
status: None,
mime_type: None,
response_size: None,
duration: None,
timestamp,
};
let mut state = inner_clone2.lock().await;
if state.network_requests.len() >= MAX_NETWORK_REQUESTS {
state.network_requests.pop_front();
}
state.network_requests.push_back(net_req);
}
});
// Subscribe to network response events to update existing requests
let inner_clone3 = self.inner.clone();
tokio::spawn(async move {
let mut rx = response_rx;
while let Some(params) = rx.recv().await {
let request_id = params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("");
let response = params.get("response");
let status = response
.and_then(|r| r.get("status"))
.and_then(|v| v.as_u64())
.map(|s| s as u16);
let mime_type = response
.and_then(|r| r.get("mimeType"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let response_size = response
.and_then(|r| r.get("encodedDataLength"))
.and_then(|v| v.as_u64());
let mut state = inner_clone3.lock().await;
// Find the matching request and update it
if let Some(req) = state
.network_requests
.iter_mut()
.rev()
.find(|r| r.request_id.as_deref() == Some(request_id))
{
req.status = status;
req.mime_type = mime_type;
req.response_size = response_size;
}
}
});
}
Err(problem) => {
return Err(self.fail_start_locked(&mut state, problem).await);

View file

@ -426,6 +426,9 @@ pub struct BrowserNetworkQuery {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct BrowserNetworkRequest {
/// Internal CDP request ID for correlating request/response events.
#[serde(default, skip_serializing)]
pub request_id: Option<String>,
pub url: String,
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -305,6 +305,8 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
.route("/browser/scroll", post(post_v1_browser_scroll))
.route("/browser/upload", post(post_v1_browser_upload))
.route("/browser/dialog", post(post_v1_browser_dialog))
.route("/browser/console", get(get_v1_browser_console))
.route("/browser/network", get(get_v1_browser_network))
.route("/agents", get(get_v1_agents))
.route("/agents/:agent", get(get_v1_agent))
.route("/agents/:agent/install", post(post_v1_agent_install))
@ -520,6 +522,8 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
post_v1_browser_scroll,
post_v1_browser_upload,
post_v1_browser_dialog,
get_v1_browser_console,
get_v1_browser_network,
get_v1_agents,
get_v1_agent,
post_v1_agent_install,
@ -625,6 +629,12 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
BrowserScrollRequest,
BrowserUploadRequest,
BrowserDialogRequest,
BrowserConsoleQuery,
BrowserConsoleMessage,
BrowserConsoleResponse,
BrowserNetworkQuery,
BrowserNetworkRequest,
BrowserNetworkResponse,
DesktopClipboardResponse,
DesktopClipboardQuery,
DesktopClipboardWriteRequest,
@ -2620,6 +2630,60 @@ async fn post_v1_browser_dialog(
Ok(Json(BrowserActionResponse { ok: true }))
}
/// Get browser console messages.
///
/// Returns console messages captured from the browser, optionally filtered by
/// level (log, debug, info, warning, error) and limited in count.
#[utoipa::path(
get,
path = "/v1/browser/console",
tag = "v1",
params(BrowserConsoleQuery),
responses(
(status = 200, description = "Console messages retrieved", body = BrowserConsoleResponse),
(status = 409, description = "Browser not active", body = ProblemDetails),
(status = 500, description = "Internal error", body = ProblemDetails)
)
)]
async fn get_v1_browser_console(
State(state): State<Arc<AppState>>,
Query(query): Query<BrowserConsoleQuery>,
) -> Result<Json<BrowserConsoleResponse>, ApiError> {
state.browser_runtime().ensure_active().await?;
let messages = state
.browser_runtime()
.console_messages(query.level.as_deref(), query.limit)
.await;
Ok(Json(BrowserConsoleResponse { messages }))
}
/// Get browser network requests.
///
/// Returns network requests captured from the browser, optionally filtered by
/// URL pattern and limited in count.
#[utoipa::path(
get,
path = "/v1/browser/network",
tag = "v1",
params(BrowserNetworkQuery),
responses(
(status = 200, description = "Network requests retrieved", body = BrowserNetworkResponse),
(status = 409, description = "Browser not active", body = ProblemDetails),
(status = 500, description = "Internal error", body = ProblemDetails)
)
)]
async fn get_v1_browser_network(
State(state): State<Arc<AppState>>,
Query(query): Query<BrowserNetworkQuery>,
) -> Result<Json<BrowserNetworkResponse>, ApiError> {
state.browser_runtime().ensure_active().await?;
let requests = state
.browser_runtime()
.network_requests(query.url_pattern.as_deref(), query.limit)
.await;
Ok(Json(BrowserNetworkResponse { requests }))
}
/// Helper: get the current page URL and title via CDP Runtime.evaluate.
async fn get_page_info_via_cdp(
cdp: &crate::browser_cdp::CdpClient,