diff --git a/README.md b/README.md index 061e0a4..bccbe30 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shadow DOM + iframes), multi-step plans, structured extraction, visual diff, and guardrails** for payments and bookings. It drives real Chromium, so it reads **Next.js / SPA** pages after hydration — not just static HTML. -> 29 MCP tools · stealth + rotating proxies · virtualized-list scraping · HAR record/replay · pixel visual-diff · human handoff. +> 30 MCP tools · stealth + rotating proxies · virtualized-list scraping · HAR record/replay · pixel visual-diff · human handoff. ## Install @@ -63,7 +63,7 @@ Full reference in **[`docs/`](./docs/README.md)**: [Installation](./docs/installation.md) · [CLI](./docs/cli.md) · -[MCP tools (29)](./docs/mcp-tools.md) · +[MCP tools (30)](./docs/mcp-tools.md) · [Configuration](./docs/configuration.md) · [Sessions](./docs/sessions.md) · [Extraction](./docs/extraction.md) · diff --git a/docs/README.md b/docs/README.md index e4b5f1f..bf87b48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ New here? Start with the root [README](../README.md), then dive in: | --- | --- | | [Installation](./installation.md) | Requirements, install, Chromium, MCP registration, the three ways to get a browser | | [CLI](./cli.md) | `probe` / `fetch` / `serp-batch` / `shots` + every flag | -| [MCP tools](./mcp-tools.md) | All 29 tools with parameters and examples | +| [MCP tools](./mcp-tools.md) | All 30 tools with parameters and examples | | [Configuration](./configuration.md) | `AgentOptions`, `FUSE_*` env vars, identity, retry, output location | | [Sessions](./sessions.md) | Session lifecycle, auto crash recovery, `storageState` auto-save, HAR record/replay, CDP attach | | [Extraction](./extraction.md) | `browser_extract` / `extract_schema` / `collect` + the clean→validate→dedupe→emit pipeline | diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 43ffacb..611b6ce 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP tools -Complete reference for the 28 `browser_*` tools exposed by the fuse-browser MCP server. +Complete reference for the 30 `browser_*` tools exposed by the fuse-browser MCP server. Tools fall into two families: @@ -542,3 +542,21 @@ If neither `url` nor `selector` is given, it resumes on the next navigation. ```json { "sessionId": "s_abc123", "reason": "Solve captcha", "selector": "#dashboard", "timeoutMs": 300000 } ``` + +--- + +## Diagnostics + +### browser_metrics + +Read the process-global scraping metrics: a point-in-time snapshot of probe counts, durations, resilience rejections, the live probe-queue depth, RSS and uptime. No session needed. Pass `reset:true` to zero the counters after reading (e.g. at the start of a new job). + +| Param | Type | Required | Description | +| --- | --- | --- | --- | +| `reset` | boolean | no | Zero all counters **after** returning the snapshot. | + +Returned fields: `uptimeMs`, `probesOk`, `probesFailed`, `avgDurationMs`, `minDurationMs`, `maxDurationMs`, `breakerRejects` (requests blocked by an open [circuit breaker](./configuration.md#circuit-breaker)), `queueRejects` / `budgetRejects` (from the [probe queue](./configuration.md#probe-queue)), `queue` (`{ running, admitted, waiting }`), `rssBytes`. Counters are process-global and persist until reset; the HTTP fast-path is not counted (only browser probes). + +```json +{ "reset": false } +``` diff --git a/src/agent/browser-agent.ts b/src/agent/browser-agent.ts index 36af453..94a3a8e 100644 --- a/src/agent/browser-agent.ts +++ b/src/agent/browser-agent.ts @@ -5,7 +5,14 @@ import { preflight, type PreflightResult } from "../guardrails/preflight.js"; import type { ProbeReport } from "../interfaces/report.js"; import type { AgentOptions, BrowserAction, ProbeOptions } from "../interfaces/types.js"; -import { GuardrailViolation } from "../lib/errors.js"; +import { BudgetExhaustedError, CircuitOpenError, GuardrailViolation, QueueFullError } from "../lib/errors.js"; +import { + recordBreakerReject, + recordBudgetReject, + recordProbeFailed, + recordProbeOk, + recordQueueReject, +} from "../net/metrics.js"; import { withQueue } from "../net/queue-guard.js"; import { resolveConfig, type ResolvedConfig } from "./config.js"; import { tryFastContacts } from "./fast-contacts.js"; @@ -34,8 +41,23 @@ export class BrowserAgent { if (!pf.allowed) throw new GuardrailViolation(pf.reason, pf.blockedActions); const fast = await tryFastContacts(this.config, url, options); if (fast) return fast; - // Only the browser path is gated by the queue/budget (the fast path is HTTP). - return withQueue(this.config.probeQueue, () => runProbe(this.config, url, options)); + return this.runBrowserProbe(url, options); + } + + /** Gate the browser probe by the queue/budget and record metrics. */ + private async runBrowserProbe(url: string, options: ProbeOptions): Promise { + const start = Date.now(); + try { + const report = await withQueue(this.config.probeQueue, () => runProbe(this.config, url, options)); + recordProbeOk(Date.now() - start); + return report; + } catch (err) { + if (err instanceof CircuitOpenError) recordBreakerReject(); + else if (err instanceof QueueFullError) recordQueueReject(); + else if (err instanceof BudgetExhaustedError) recordBudgetReject(); + else recordProbeFailed(Date.now() - start); + throw err; + } } /** Probe an inline HTML fixture via a base64 data URL. */ diff --git a/src/net/metrics.ts b/src/net/metrics.ts new file mode 100644 index 0000000..4eddb2c --- /dev/null +++ b/src/net/metrics.ts @@ -0,0 +1,81 @@ +/** + * Process-global scraping metrics: in-memory counters bumped by the agent and + * read via the `browser_metrics` tool. Single-process, zero-dependency, no + * external telemetry. Reset explicitly at the start of a job. + * @module net/metrics + */ +import { queueStats } from "./probe-queue.js"; + +let startedAt = Date.now(); +let probesOk = 0; +let probesFailed = 0; +let durTotalMs = 0; +let durMinMs = Number.POSITIVE_INFINITY; +let durMaxMs = 0; +let breakerRejects = 0; +let queueRejects = 0; +let budgetRejects = 0; + +function recordDuration(ms: number): void { + durTotalMs += ms; + if (ms < durMinMs) durMinMs = ms; + if (ms > durMaxMs) durMaxMs = ms; +} + +/** Record a successful browser probe and its wall-clock duration (ms). */ +export function recordProbeOk(ms: number): void { + probesOk += 1; + recordDuration(ms); +} + +/** Record a failed browser probe (threw a non-resilience error) and its duration. */ +export function recordProbeFailed(ms: number): void { + probesFailed += 1; + recordDuration(ms); +} + +/** Record a request rejected fast by an open circuit breaker. */ +export function recordBreakerReject(): void { + breakerRejects += 1; +} + +/** Record a request rejected because the probe queue was full. */ +export function recordQueueReject(): void { + queueRejects += 1; +} + +/** Record a request rejected because the per-process probe budget was spent. */ +export function recordBudgetReject(): void { + budgetRejects += 1; +} + +/** Point-in-time snapshot of all metrics (adds live queue depth + RSS). */ +export function metricsSnapshot(): Record { + const completed = probesOk + probesFailed; + return { + uptimeMs: Date.now() - startedAt, + probesOk, + probesFailed, + avgDurationMs: completed ? Math.round(durTotalMs / completed) : 0, + minDurationMs: completed ? durMinMs : 0, + maxDurationMs: durMaxMs, + breakerRejects, + queueRejects, + budgetRejects, + queue: queueStats(), + rssBytes: process.memoryUsage().rss, + }; +} + +/** Reset all counters (call at job start, or via the tool's `reset` flag). */ +export function resetMetrics(): void { + startedAt = Date.now(); + probesOk = 0; + probesFailed = 0; + durTotalMs = 0; + durMinMs = Number.POSITIVE_INFINITY; + durMaxMs = 0; + breakerRejects = 0; + queueRejects = 0; + budgetRejects = 0; +} diff --git a/src/net/probe-queue.ts b/src/net/probe-queue.ts index d316d32..62c5b3a 100644 --- a/src/net/probe-queue.ts +++ b/src/net/probe-queue.ts @@ -50,6 +50,11 @@ export function releaseSlot(): void { else running = Math.max(0, running - 1); } +/** Live queue snapshot: in-flight slots, lifetime admissions, waiting callers. */ +export function queueStats(): { running: number; admitted: number; waiting: number } { + return { running, admitted, waiting: waiters.length }; +} + /** Reset queue and budget state (tests only). */ export function resetQueue(): void { running = 0; diff --git a/src/server/server.ts b/src/server/server.ts index 2c50fcf..05c2ba9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -13,6 +13,7 @@ import { registerExtractTool } from "./tools/extract.js"; import { registerExtractSchemaTool } from "./tools/extract-schema.js"; import { registerHandoffTool } from "./tools/handoff.js"; import { registerInspectTool } from "./tools/inspect.js"; +import { registerMetricsTool } from "./tools/metrics.js"; import { registerNavigateTool } from "./tools/navigate.js"; import { registerFetchTool } from "./tools/fetch.js"; import { registerProbeTools } from "./tools/probe.js"; @@ -51,6 +52,7 @@ export function createServer(): BuiltServer { registerInspectTool(server, sessions); registerVisualDiffTool(server, sessions); registerHandoffTool(server, sessions); + registerMetricsTool(server); registerResources(server); return { server, sessions }; } diff --git a/src/server/tools/metrics.ts b/src/server/tools/metrics.ts new file mode 100644 index 0000000..125d79d --- /dev/null +++ b/src/server/tools/metrics.ts @@ -0,0 +1,28 @@ +/** + * `browser_metrics` tool: read the process-global scraping metrics snapshot + * (probes ok/failed, durations, breaker/queue/budget rejects, live queue depth, + * RSS, uptime). Pass `reset:true` to zero the counters after reading. + * @module server/tools/metrics + */ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { metricsSnapshot, resetMetrics } from "../../net/metrics.js"; +import { jsonResult } from "../result.js"; + +/** Register `browser_metrics`. */ +export function registerMetricsTool(server: McpServer): void { + server.registerTool( + "browser_metrics", + { + title: "Scraping metrics", + description: + "Process-global scraping metrics: probes ok/failed, avg/min/max duration, circuit-breaker/queue/budget rejects, live queue depth, RSS, uptime. Pass reset:true to zero the counters after reading (e.g. at the start of a new job).", + inputSchema: { reset: z.boolean().optional() }, + }, + async (args) => { + const snapshot = metricsSnapshot(); + if ((args as { reset?: boolean }).reset === true) resetMetrics(); + return jsonResult(snapshot); + }, + ); +} diff --git a/tests/integration/mcp.test.ts b/tests/integration/mcp.test.ts index c27f3a8..f88616a 100644 --- a/tests/integration/mcp.test.ts +++ b/tests/integration/mcp.test.ts @@ -48,6 +48,7 @@ const EXPECTED = [ "browser_visual_diff", "browser_handoff", "browser_serp_batch", + "browser_metrics", ]; test("MCP exposes the expected tool set with no duplicates", async () => { diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts new file mode 100644 index 0000000..fd0fe3b --- /dev/null +++ b/tests/unit/metrics.test.ts @@ -0,0 +1,70 @@ +/** + * Unit tests for the process-global scraping metrics counters. + */ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + metricsSnapshot, + recordBreakerReject, + recordBudgetReject, + recordProbeFailed, + recordProbeOk, + recordQueueReject, + resetMetrics, +} from "../../src/net/metrics.js"; + +beforeEach(() => resetMetrics()); + +describe("metrics", () => { + test("counts probes and computes duration stats", () => { + recordProbeOk(100); + recordProbeOk(300); + recordProbeFailed(200); + const s = metricsSnapshot(); + expect(s.probesOk).toBe(2); + expect(s.probesFailed).toBe(1); + expect(s.avgDurationMs).toBe(200); // (100+300+200)/3 + expect(s.minDurationMs).toBe(100); + expect(s.maxDurationMs).toBe(300); + }); + + test("zero completed probes reports zeroed durations (no Infinity leak)", () => { + const s = metricsSnapshot(); + expect(s.avgDurationMs).toBe(0); + expect(s.minDurationMs).toBe(0); + expect(s.maxDurationMs).toBe(0); + }); + + test("tracks reject counters independently of probe counts", () => { + recordBreakerReject(); + recordQueueReject(); + recordQueueReject(); + recordBudgetReject(); + const s = metricsSnapshot(); + expect(s.breakerRejects).toBe(1); + expect(s.queueRejects).toBe(2); + expect(s.budgetRejects).toBe(1); + expect(s.probesFailed).toBe(0); // rejects are not probe failures + }); + + test("exposes live queue depth, rss and uptime", () => { + const s = metricsSnapshot(); + // `admitted` is a lifetime counter shared across test files; only the + // in-flight gauges (running/waiting) are guaranteed zero when idle. + const q = s.queue as { running: number; admitted: number; waiting: number }; + expect(q.running).toBe(0); + expect(q.waiting).toBe(0); + expect(typeof q.admitted).toBe("number"); + expect(typeof s.rssBytes).toBe("number"); + expect(typeof s.uptimeMs).toBe("number"); + }); + + test("reset zeroes every counter", () => { + recordProbeOk(50); + recordBreakerReject(); + resetMetrics(); + const s = metricsSnapshot(); + expect(s.probesOk).toBe(0); + expect(s.breakerRejects).toBe(0); + expect(s.maxDurationMs).toBe(0); + }); +});