diff --git a/README.md b/README.md index 3102bff..fa9b8f8 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ ### manage\_\* tools -- `manage_browsers` - Create, list, get, and delete browser sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling. +- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling. - `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins. - `manage_projects` - Create, list, get, update, and delete organization projects. Inspect and update per-project resource limits. - `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. Create returns the plaintext key once. diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index ac347e4..513e9fe 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { TELEMETRY_EVENT_CATALOG } from "@/lib/mcp/telemetry"; export function registerKernelPrompts(server: McpServer) { // MCP Prompt explaining Kernel concepts @@ -128,7 +129,19 @@ kernel browsers process --help kernel browsers playwright --help \`\`\` -**MCP Exception:** The \`computer_action\` MCP tool with action "screenshot" is useful since it returns images directly to the agent. +**MCP Exceptions:** The \`computer_action\` MCP tool with action "screenshot" is useful since it returns images directly to the agent, and \`manage_browsers\` with action "get_telemetry" reads structured telemetry events (see below). + +--- + +## Telemetry Events (structured signal — works even after the session is deleted) + +When telemetry was captured, it's usually the fastest way to pinpoint a failure — read it before reaching for screenshots or logs. If the session has been deleted, it's the only signal still available: every CLI command in this guide needs a live session. + +Start broad: call \`manage_browsers\` with action "get_telemetry", session_id "${session_id}", and no filters. That starts at session creation and returns the first page (up to 100 events); page with \`next_offset\` as \`offset\` while \`has_more\` is true, preserving \`categories\`, \`until\`, and \`order\`. An empty unfiltered read is definitive: nothing was archived. Narrow when the output is too large to scan or you already know where to look: \`categories\` to isolate a signal you've spotted, \`order\` "desc" when the end of the session matters most, \`since\`/\`until\` to bracket a known failing step. Correlate event timestamps with the failing automation step. + +**Gotcha: telemetry is opt-in and only covers activity that happened while capture was on.** Archived events survive telemetry being disabled and the session being deleted, so the archive — not the current config — is the ground truth: \`manage_browsers\` action "get" showing no enabled \`telemetry\` categories means capture is off now, not that nothing was recorded. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. To capture new evidence on an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue; recreate the browser only if the session has ended. + +${TELEMETRY_EVENT_CATALOG} --- @@ -224,6 +237,7 @@ These are **normal** and don't indicate problems: ## Debugging Checklist - [ ] Session exists and is active +- [ ] Telemetry events reviewed (if any were captured) - [ ] Screenshot shows expected content (or reveals error) - [ ] Current URL is as expected - [ ] Supervisor logs show all services running @@ -237,11 +251,12 @@ These are **normal** and don't indicate problems: Based on your issue "${issue_description}", start with: -1. **Get browser info** to confirm session is active -2. **Take screenshot** to see current state -3. **Check page URL** to see if on error page -4. **Test network** if seeing connection errors -5. **Review logs** for specific error patterns`; +1. **Read telemetry events** — works whether or not the session still exists; if the archive is empty and the session is active, enable the debug categories and reproduce +2. **Get browser info** to confirm the session is active before using the CLI commands +3. **Take screenshot** to see current state +4. **Check page URL** to see if on error page +5. **Test network** if seeing connection errors +6. **Review logs** for specific error patterns`; return { messages: [ diff --git a/src/lib/mcp/telemetry.ts b/src/lib/mcp/telemetry.ts new file mode 100644 index 0000000..761ada7 --- /dev/null +++ b/src/lib/mcp/telemetry.ts @@ -0,0 +1,14 @@ +export const telemetryEventCategories = [ + "console", + "network", + "page", + "interaction", + "control", + "connection", + "system", + "screenshot", + "captcha", + "monitor", +] as const; + +export const TELEMETRY_EVENT_CATALOG = `Event categories: console (console output and uncaught exceptions), network (request/response metadata), page (navigation and lifecycle), interaction (clicks, keys, scrolls), control (agent-driven API calls), connection (CDP/live-view attach/detach), system (VM health), screenshot (periodic monitor screenshots), captcha (captcha detection and solve outcomes), monitor (telemetry collector health; captured automatically with any CDP category). High-signal event types: console_error, network_loading_failed, network_response with non-2xx status, captcha_solve_result, system_oom_kill, service_crashed, monitor_disconnected (telemetry gap — treat following events as incomplete).`; diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 42040f1..6865fe2 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -1,4 +1,5 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { NotFoundError } from "@onkernel/sdk"; import { z } from "zod"; import { buildBrowserCreateConfig, @@ -15,11 +16,18 @@ import { toolErrorResponse, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; +import { + TELEMETRY_EVENT_CATALOG, + telemetryEventCategories, +} from "@/lib/mcp/telemetry"; type BrowserCreateParams = NonNullable< Parameters[0] >; type BrowserUpdateParams = Parameters[1]; +type TelemetryEventsQuery = NonNullable< + Parameters[1] +>; type TelemetryParams = { telemetry_enabled?: boolean; @@ -79,6 +87,183 @@ function buildTelemetry( }; } +type TelemetryEnvelope = Awaited< + ReturnType +>["items"][number]; + +// Payload fields that are always omitted, even when small. The size limit +// catches new high-volume fields that are added to telemetry later. +const omittedTelemetryDataFields: ReadonlySet = new Set([ + "body", + "headers", + "post_data", + "png", +]); +const maxTelemetryDataFieldBytes = 8 * 1024; + +async function summarizeEmptyTelemetryResult( + client: KernelClient, + { + sessionId, + hasMore, + fullSessionRead, + soleSince, + }: { + sessionId: string; + hasMore: boolean; + fullSessionRead: boolean; + soleSince?: string; + }, +) { + if (hasMore) { + return "No matching events on this page; continue with next_offset."; + } + + const browser = await client.browsers + .retrieve(sessionId) + .catch((error: unknown) => { + if (error instanceof NotFoundError) return null; + throw error; + }); + const telemetryDisabled = + browser !== null && + !Object.values(browser.telemetry?.browser ?? {}).some( + (category) => category?.enabled, + ); + + // An explicit since at or before the session's creation also covers the + // whole archive. Duration-style values ("10m") fail Date.parse and stay on + // the windowed wording, as do deleted sessions (null browser). + const coversFullSession = + fullSessionRead || + (soleSince !== undefined && + browser !== null && + Date.parse(soleSince) <= Date.parse(browser.created_at)); + + if (coversFullSession) { + return telemetryDisabled + ? "No telemetry events are archived for this session. Telemetry is currently disabled." + : "No telemetry events are archived for this session."; + } + return telemetryDisabled + ? "No events matched this query. Broaden the categories or time window before changing capture settings; capture is currently disabled, so no new events are being archived." + : "No events matched this query."; +} + +function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { + const { ts, category, type, source, truncated } = event; + const data = "data" in event ? event.data : undefined; + + let compactData: Record | undefined; + let omittedFields: string[] | undefined; + if (data) { + compactData = { ...(data as Record) }; + for (const [field, value] of Object.entries(compactData)) { + const alwaysOmit = omittedTelemetryDataFields.has(field); + const serialized = alwaysOmit ? undefined : JSON.stringify(value); + const oversized = + serialized !== undefined && + Buffer.byteLength(serialized, "utf8") > maxTelemetryDataFieldBytes; + if (alwaysOmit || oversized) { + delete compactData[field]; + (omittedFields ??= []).push(field); + } + } + } + + return { + seq, + // Raw ts (Unix microseconds) is kept alongside the readable time so exact + // event boundaries can be fed back as since/until. + ts, + time: new Date(ts / 1000).toISOString(), + category, + type, + source, + ...(compactData && { data: compactData }), + ...(truncated && { truncated }), + ...(omittedFields && { omitted_fields: omittedFields }), + }; +} + +type BrowserTelemetryReadParams = { + session_id: string; + categories?: TelemetryEventsQuery["category"]; + limit?: number; + offset?: number; + since?: string; + until?: string; + order?: "asc" | "desc"; +}; + +async function readBrowserTelemetry( + client: KernelClient, + params: BrowserTelemetryReadParams, +) { + const query: TelemetryEventsQuery = { limit: params.limit ?? 100 }; + if (params.categories) query.category = params.categories; + if (params.offset !== undefined) query.offset = params.offset; + if (params.since !== undefined) query.since = params.since; + if (params.until !== undefined) query.until = params.until; + if (params.order !== undefined) query.order = params.order; + + // Avoid the API's five-minute default. The archive can't predate the + // session, so the epoch reads the full session without a browser lookup; + // until-only reads already start at the stream head and desc reads anchor + // at the stream tail. + if ( + query.offset === undefined && + query.since === undefined && + query.until === undefined && + query.order !== "desc" + ) { + query.since = "1970-01-01T00:00:00Z"; + } + const unfilteredExceptSince = + params.offset === undefined && + params.until === undefined && + params.categories === undefined; + const fullSessionRead = unfilteredExceptSince && params.since === undefined; + + const page = await client.browsers.telemetry.events(params.session_id, query); + const items = page.getPaginatedItems().map(compactTelemetryEvent); + + // Counter-steer the pagination reflex: an unfiltered ascending read starts + // at session creation, so an agent chasing a recent failure should flip to + // desc rather than page through the whole archive oldest-first. Applies to + // offset-continuation pages too — that's the agent already deep in a page + // walk. Category, since, and until filters signal a deliberate bracket. + const ascPagingNote = + page.has_more && + params.categories === undefined && + params.since === undefined && + params.until === undefined && + query.order !== "desc" + ? 'Reading oldest-first from session start. If the end of the session matters most, use order "desc" instead of paging.' + : undefined; + + const note = + items.length === 0 + ? await summarizeEmptyTelemetryResult(client, { + sessionId: params.session_id, + hasMore: Boolean(page.has_more), + fullSessionRead, + soleSince: unfilteredExceptSince ? params.since : undefined, + }) + : ascPagingNote; + + // Single-line JSON rather than the pretty-printed house helpers: a page + // carries up to 100 events and indentation would inflate the token cost. + return textResponse( + JSON.stringify({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + ...(note && { note }), + }), + ); +} + function browserSessionNextActions(sessionId: string) { return [ `Use computer_action with session_id "${sessionId}" to inspect or control the browser.`, @@ -157,18 +342,18 @@ export function registerBrowserCapabilities(server: McpServer) { read: (client, sessionId) => client.browsers.retrieve(sessionId), }); - // manage_browsers -- Create, update, list, get, and delete browser sessions + // manage_browsers -- Manage browser sessions and read archived telemetry server.tool( "manage_browsers", - 'Manage browser sessions when an agent needs a live browser to inspect, automate, or debug web state. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, and "delete" when finished.', + 'Manage browser sessions and their archived telemetry. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished.', { action: z - .enum(["create", "update", "list", "get", "delete"]) + .enum(["create", "update", "list", "get", "get_telemetry", "delete"]) .describe("Operation to perform."), session_id: z .string() .describe( - "Browser session ID. Required for update, get, and delete actions.", + "Browser session ID. Required for update, get, get_telemetry, and delete actions.", ) .optional(), start_url: z @@ -295,31 +480,65 @@ export function registerBrowserCapabilities(server: McpServer) { .enum(["active", "deleted", "all"]) .describe('(list) Filter by status. Default "active".') .optional(), - ...paginationParams, + limit: paginationParams.limit.describe( + "(list, get_telemetry) Max results per page (1-100). get_telemetry defaults to 100; the list default is set by the API.", + ), + offset: paginationParams.offset.describe( + "(list) Numeric pagination offset. (get_telemetry) Opaque cursor: pass next_offset from the previous response and preserve categories, until, and order. Do not derive it from event seq values.", + ), + categories: z + .array(z.enum(telemetryEventCategories)) + .min(1) + .describe( + `(get_telemetry) Restrict results to these event categories. A filtered page can be empty while has_more is true. ${TELEMETRY_EVENT_CATALOG}`, + ) + .optional(), + since: z + .string() + .describe( + "(get_telemetry) Start of the window: an RFC-3339 timestamp or a duration like '30m' meaning that long ago. Defaults to session creation. Ignored when offset is set; cannot be combined with order=desc.", + ) + .optional(), + until: z + .string() + .describe( + "(get_telemetry) End of the window (exclusive): an RFC-3339 timestamp or a duration like '5m'. Preserve it while paging.", + ) + .optional(), + order: z + .enum(["asc", "desc"]) + .describe( + "(get_telemetry) Read direction. asc (default) reads oldest first from session start; desc reads newest first. Prefer desc when diagnosing a recent failure in a long session — it reaches the end without paging. Preserve it while paging.", + ) + .optional(), telemetry_enabled: z .boolean() .describe( - "(create, update) Enable telemetry with VM defaults, or disable telemetry when false.", + "(create, update) Enable telemetry, or disable telemetry when false. Telemetry is off unless requested. The default category set is the lightweight operational bundle (control, connection, system, captcha) and does NOT include console, network, or page — enable those explicitly when you intend to debug page behavior.", ) .optional(), telemetry_console: z .boolean() - .describe("(create, update) Enable or disable console telemetry.") + .describe( + "(create, update) Enable or disable console telemetry (console output and uncaught exceptions). Off by default; enable for debugging.", + ) .optional(), telemetry_network: z .boolean() - .describe("(create, update) Enable or disable network telemetry.") + .describe( + "(create, update) Enable or disable network telemetry (request/response metadata). Off by default; enable for debugging.", + ) .optional(), telemetry_page: z .boolean() .describe( - "(create, update) Enable or disable page lifecycle telemetry.", + "(create, update) Enable or disable page lifecycle telemetry (navigation, load, layout shifts, LCP). Off by default; enable for debugging.", ) .optional(), telemetry_interaction: z .boolean() .describe( - "(create, update) Enable or disable user interaction telemetry.", + "(create, update) Enable or disable user interaction telemetry (clicks, keys, scrolls). Off by default; enable for debugging.", ) .optional(), }, @@ -446,6 +665,26 @@ export function registerBrowserCapabilities(server: McpServer) { ); return jsonResponse(browser); } + case "get_telemetry": { + if (!params.session_id) + return errorResponse( + "Error: session_id is required for get_telemetry action.", + ); + if (params.since !== undefined && params.order === "desc") { + return errorResponse( + "Error: since cannot be combined with order=desc. Use until to bound a newest-first read, or order=asc with since.", + ); + } + return await readBrowserTelemetry(client, { + session_id: params.session_id, + categories: params.categories, + limit: params.limit, + offset: params.offset, + since: params.since, + until: params.until, + order: params.order, + }); + } case "delete": { if (!params.session_id) return errorResponse(