diff --git a/.gitignore b/.gitignore index 35193f354..aed90f1e9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ eval-summary.json package-lock.json evals/deterministic/tests/BrowserContext/tmp-test.har lib/version.ts +*.log diff --git a/examples/test-api-logger.ts b/examples/test-api-logger.ts new file mode 100644 index 000000000..29562beb1 --- /dev/null +++ b/examples/test-api-logger.ts @@ -0,0 +1,44 @@ +import { Stagehand } from "../lib/index"; +import { createStagehandApiLogger } from "../lib/stagehandApiLogger"; + +async function testApiLogger() { + console.log("Starting test with custom sh:api logger...\n"); + + const stagehand = new Stagehand({ + env: "LOCAL", + logger: createStagehandApiLogger(), + localBrowserLaunchOptions: { + headless: false, + }, + }); + + try { + await stagehand.init(); + const page = stagehand.page; + + console.log("\nNavigating to example.com..."); + await page.goto("https://example.com"); + + console.log("\nExtracting page title..."); + const title = await page.extract({ + instruction: "Extract the main heading of the page", + }); + console.log("Extracted title:", title); + + console.log("\nPerforming a simple action..."); + await page.act({ + action: "click on the 'More information' link", + }); + + console.log("\nObserving the page..."); + const observation = await page.observe(); + console.log("Observation result:", observation); + } catch (error) { + console.error("Error during test:", error); + } finally { + await stagehand.close(); + } +} + +// Run the test +testApiLogger().catch(console.error); diff --git a/lib/StagehandContext.ts b/lib/StagehandContext.ts index 18978f1eb..765645c19 100644 --- a/lib/StagehandContext.ts +++ b/lib/StagehandContext.ts @@ -1,3 +1,4 @@ +import "./debug"; import type { BrowserContext as PlaywrightContext, CDPSession, diff --git a/lib/StagehandPage.ts b/lib/StagehandPage.ts index e4fd45dfb..33aa8b712 100644 --- a/lib/StagehandPage.ts +++ b/lib/StagehandPage.ts @@ -1,3 +1,4 @@ +import "./debug"; import type { CDPSession, Page as PlaywrightPage, Frame } from "playwright"; import { selectors } from "playwright"; import { z } from "zod/v3"; @@ -30,6 +31,7 @@ import { import { StagehandAPIError } from "@/types/stagehandApiErrors"; import { scriptContent } from "@/lib/dom/build/scriptContent"; import type { Protocol } from "devtools-protocol"; +import { markStagehandCDPCall } from "./debug"; async function getCurrentRootFrameId(session: CDPSession): Promise { const { frameTree } = (await session.send( @@ -447,6 +449,7 @@ ${scriptContent} \ const rawGoto: typeof target.goto = Object.getPrototypeOf(target).goto.bind(target); return async (url: string, options: GotoOptions) => { + const result = this.api ? await this.api.goto(url, { ...options, @@ -464,6 +467,7 @@ ${scriptContent} \ } } + if (this.stagehand.debugDom) { this.stagehand.log({ category: "deprecation", @@ -585,8 +589,13 @@ ${scriptContent} \ const hasDoc = !!(await this.page.title().catch(() => false)); if (!hasDoc) await this.page.waitForLoadState("domcontentloaded"); + markStagehandCDPCall("Network.enable"); await client.send("Network.enable"); + + markStagehandCDPCall("Page.enable"); await client.send("Page.enable"); + + markStagehandCDPCall("Target.setAutoAttach"); await client.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, @@ -1060,7 +1069,9 @@ ${scriptContent} \ target: PlaywrightPage | Frame = this.page, ): Promise { const cached = this.cdpClients.get(target); - if (cached) return cached; + if (cached) { + return cached; + } try { const session = await this.context.newCDPSession(target); @@ -1096,10 +1107,15 @@ ${scriptContent} \ ): Promise { const client = await this.getCDPClient(target ?? this.page); - return client.send( + // Mark this as a Stagehand CDP call + markStagehandCDPCall(method); + + const result = (await client.send( method as Parameters[0], params as Parameters[1], - ) as Promise; + )) as T; + + return result; } /** Enable a CDP domain (e.g. `"Network"` or `"DOM"`) on the chosen target. */ diff --git a/lib/a11y/utils.ts b/lib/a11y/utils.ts index 49c9d8ab5..df25fd8a3 100644 --- a/lib/a11y/utils.ts +++ b/lib/a11y/utils.ts @@ -15,6 +15,7 @@ import { } from "../../types/context"; import { StagehandPage } from "../StagehandPage"; import { LogLine } from "../../types/log"; +import { markStagehandCDPCall } from "../debug"; import { ContentFrameNotFoundError, StagehandDomProcessError, @@ -149,10 +150,15 @@ export async function buildBackendIdMaps( try { // 1. full DOM tree - const { root } = (await session.send("DOM.getDocument", { + markStagehandCDPCall("DOM.getDocument"); + const result = (await session.send("DOM.getDocument", { depth: -1, pierce: true, - })) as { root: DOMNode }; + })) as { + root: DOMNode; + }; + + const { root } = result; // 2. pick start node + root frame-id let startNode: DOMNode = root; @@ -499,7 +505,9 @@ export async function getCDPFrameId( try { const sess = await sp.context.newCDPSession(frame); // throws if detached + markStagehandCDPCall("Page.getFrameTree"); const ownResp = (await sess.send("Page.getFrameTree")) as unknown; + const { frameTree } = ownResp as { frameTree: CdpFrameTree }; return frameTree.frame.id; // root of OOPIF @@ -723,10 +731,13 @@ export async function getFrameRootBackendNodeId( } // Retrieve the DOM node that owns the frame via CDP - const { backendNodeId } = (await cdp.send("DOM.getFrameOwner", { + markStagehandCDPCall("DOM.getFrameOwner"); + const frameOwnerResult = (await cdp.send("DOM.getFrameOwner", { frameId: fid, })) as FrameOwnerResult; + const { backendNodeId } = frameOwnerResult; + return backendNodeId ?? null; } diff --git a/lib/debug.ts b/lib/debug.ts new file mode 100644 index 000000000..c69591749 --- /dev/null +++ b/lib/debug.ts @@ -0,0 +1,150 @@ +/** + * Debug utility for Stagehand that intercepts Playwright's CDP protocol logs + * and marks calls that originated from Stagehand code + */ + +const DEBUG = process.env.DEBUG || ""; +const debugNamespaces = DEBUG.split(",").map((ns) => ns.trim()); + +// If sh:protocol is enabled, automatically enable pw:protocol +// This must happen before any Playwright imports +if ( + debugNamespaces.includes("sh:protocol") && + !debugNamespaces.some((ns) => ns.includes("pw:protocol")) +) { + debugNamespaces.push("pw:protocol"); + // Update process.env.DEBUG to include pw:protocol + process.env.DEBUG = debugNamespaces.join(","); +} + +// Track pending Stagehand CDP calls by method name and timestamp +interface PendingCall { + method: string; + timestamp: number; + sessionId?: string; +} + +const pendingStagehandCalls: PendingCall[] = []; +const CALL_TIMEOUT_MS = 100; // Clear old pending calls after 100ms + +// Clean up old pending calls +function cleanupOldCalls() { + const now = Date.now(); + const index = pendingStagehandCalls.findIndex( + (call) => now - call.timestamp > CALL_TIMEOUT_MS, + ); + if (index > 0) { + pendingStagehandCalls.splice(0, index); + } +} + +// Intercept stderr to rewrite Playwright protocol logs for Stagehand-originated calls +if ( + debugNamespaces.includes("sh:protocol") && + debugNamespaces.some((ns) => ns.includes("pw:protocol")) +) { + const originalStderrWrite = process.stderr.write; + + // Track message IDs that we've identified as Stagehand calls + const stagehandMessageIds = new Set(); + + process.stderr.write = function ( + chunk: unknown, + ...args: unknown[] + ): boolean { + const str = chunk?.toString(); + + if (str && str.includes("pw:protocol")) { + // Check if this is a SEND + const sendMatch = str.match(/pw:protocol SEND ► ({.*})/); + if (sendMatch) { + try { + const message = JSON.parse(sendMatch[1]); + + // Check if this matches a pending Stagehand call + cleanupOldCalls(); + const pendingIndex = pendingStagehandCalls.findIndex( + (call) => + call.method === message.method && + (!call.sessionId || call.sessionId === message.sessionId), + ); + + if (pendingIndex >= 0) { + // This is a Stagehand call - mark it and rewrite the log + stagehandMessageIds.add(message.id); + pendingStagehandCalls.splice(pendingIndex, 1); + chunk = str.replace("pw:protocol", "sh:protocol"); + } + } catch { + // Ignore JSON parse errors + } + } + + // Check if this is a RECV for a Stagehand request + const recvMatch = str.match(/pw:protocol ◀ RECV ({.*})/); + if (recvMatch) { + try { + const message = JSON.parse(recvMatch[1]); + if (message.id && stagehandMessageIds.has(message.id)) { + // This is a response to a Stagehand call + chunk = str.replace("pw:protocol", "sh:protocol"); + stagehandMessageIds.delete(message.id); + } + } catch { + // Ignore JSON parse errors + } + } + } + + return originalStderrWrite.apply(process.stderr, [chunk, ...args]); + }; +} + +// Mark that a CDP call is about to be made from Stagehand +export function markStagehandCDPCall(method: string, sessionId?: string) { + if (debugNamespaces.includes("sh:protocol")) { + pendingStagehandCalls.push({ + method, + timestamp: Date.now(), + sessionId, + }); + } +} + +// Simple logger for non-CDP messages +export function createDebugLogger(namespace: string) { + const enabled = debugNamespaces.some((ns) => { + if (ns.endsWith("*")) { + return namespace.startsWith(ns.slice(0, -1)); + } + return ns === namespace; + }); + + return { + enabled, + log: (...args: unknown[]) => { + if (!enabled) return; + + const timestamp = new Date().toISOString(); + const prefix = `${timestamp} ${namespace} `; + + const message = args + .map((arg) => { + if (typeof arg === "object") { + try { + return JSON.stringify(arg, null, 2); + } catch { + // Fallback to String for circular objects + return String(arg); + } + } + return String(arg); + }) + .join(" "); + + process.stderr.write(`${prefix}${message}\n`); + }, + }; +} + +export const shProtocolDebug = createDebugLogger("sh:protocol"); diff --git a/lib/index.ts b/lib/index.ts index 37d90730f..5b0f41fc2 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ +import "./debug"; import { Browserbase } from "@browserbasehq/sdk"; import { Browser, chromium } from "playwright"; import dotenv from "dotenv"; @@ -27,6 +28,7 @@ import { StagehandAPI } from "./api"; import { scriptContent } from "./dom/build/scriptContent"; import { LLMClient } from "./llm/LLMClient"; import { LLMProvider } from "./llm/LLMProvider"; +import { markStagehandCDPCall } from "./debug"; import { ClientOptions } from "../types/model"; import { isRunningInBun, loadApiKeyFromEnv } from "./utils"; import { ApiResponse, ErrorResponse } from "@/types/api"; @@ -828,8 +830,12 @@ export class Stagehand { }); const session = await this.context.newCDPSession(this.page); + + // Mark this as a Stagehand CDP call + markStagehandCDPCall("Browser.setDownloadBehavior"); + await session.send("Browser.setDownloadBehavior", { - behavior: "allow", + behavior: "allow" as const, downloadPath: this.downloadsPath, eventsEnabled: true, }); diff --git a/lib/stagehandApiLogger.ts b/lib/stagehandApiLogger.ts new file mode 100644 index 000000000..3ba19a662 --- /dev/null +++ b/lib/stagehandApiLogger.ts @@ -0,0 +1,77 @@ +import { LogLine, Logger } from "../types/log"; + +/** + * Creates a custom logger for Stagehand that formats logs to match + * the debug log format used by DEBUG=pw:api,pw:browser*,sh:protocol + * and labels entries with sh:api + * + * This logger writes to stderr to match the behavior of the debug module + * + * @returns {Logger} A logger function that can be passed to Stagehand's logger option + */ +export function createStagehandApiLogger(): Logger { + return (logLine: LogLine): void => { + // Generate timestamp in ISO format to match debug log format + const timestamp = new Date().toISOString(); + + // Use sh:api as the namespace + const namespace = "sh:api"; + + // Format the message with category if provided + const categoryPrefix = logLine.category ? `[${logLine.category}] ` : ""; + const formattedMessage = `${categoryPrefix}${logLine.message}`; + + // Format auxiliary data if present + let auxiliaryInfo = ""; + if (logLine.auxiliary) { + const auxData: Record = {}; + for (const [key, { value, type }] of Object.entries(logLine.auxiliary)) { + // Convert values based on their type + switch (type) { + case "integer": + auxData[key] = parseInt(value, 10); + break; + case "float": + auxData[key] = parseFloat(value); + break; + case "boolean": + auxData[key] = value === "true"; + break; + case "object": + try { + auxData[key] = JSON.parse(value); + } catch { + auxData[key] = value; + } + break; + default: + auxData[key] = value; + } + } + // Only add auxiliary info if there's actual data + if (Object.keys(auxData).length > 0) { + auxiliaryInfo = ` ${JSON.stringify(auxData)}`; + } + } + + // Construct the final log line in debug format: timestamp namespace message + const logOutput = `${timestamp} ${namespace} ${formattedMessage}${auxiliaryInfo}\n`; + + // Write to stderr to match debug module behavior + process.stderr.write(logOutput); + }; +} + +/** + * Example usage: + * + * ```typescript + * import { Stagehand } from "@browserbasehq/stagehand"; + * import { createStagehandApiLogger } from "./stagehandApiLogger"; + * + * const stagehand = new Stagehand({ + * logger: createStagehandApiLogger(), + * // other options... + * }); + * ``` + */