mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
feat: add get_console_logs tool for browser console output (#454)
* feat: add get_console_logs tool to surface browser console output Captures Runtime.consoleAPICalled, Runtime.exceptionThrown, and Log.entryAdded CDP events per page with a FIFO ring buffer (500 entries). - ConsoleCollector: per-page buffers with O(1) session routing via Map lookup - Session-aware CDP event dispatching (onSessionEvent) in CdpBackend - Log.enable() added alongside Runtime.enable() in attachToPage - Single tool with level hierarchy, text search, limit, and clear params - Buffer clears on main-frame navigation, cleaned up on page close * fix: address review — handle session re-attach, remove dead code - ConsoleCollector.attach() now updates session mapping on re-attach instead of early-returning, preventing silent event drops after target detach/re-attach (e.g. tab crash, cross-process navigation) - Remove unused clearConsoleLogs() and ConsoleCollector.clear()
This commit is contained in:
@@ -36,6 +36,10 @@ class CdpBackend implements ICdpBackend {
|
||||
private reconnecting = false
|
||||
private reconnectRequested = false
|
||||
private eventHandlers = new Map<string, ((params: unknown) => void)[]>()
|
||||
private sessionEventHandlers = new Map<
|
||||
string,
|
||||
((params: unknown, sessionId: string) => void)[]
|
||||
>()
|
||||
private sessionCache = new Map<string, ProtocolApi>()
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null
|
||||
private preferredDiscoveryHost: LoopbackDiscoveryHost | null = null
|
||||
@@ -432,6 +436,26 @@ class CdpBackend implements ICdpBackend {
|
||||
}
|
||||
}
|
||||
|
||||
onSessionEvent(
|
||||
event: string,
|
||||
handler: (params: unknown, sessionId: string) => void,
|
||||
): () => void {
|
||||
if (!this.sessionEventHandlers.has(event)) {
|
||||
this.sessionEventHandlers.set(event, [])
|
||||
}
|
||||
const handlers = this.sessionEventHandlers.get(event)
|
||||
if (handlers) {
|
||||
handlers.push(handler)
|
||||
}
|
||||
return () => {
|
||||
const list = this.sessionEventHandlers.get(event)
|
||||
if (list) {
|
||||
const idx = list.indexOf(handler)
|
||||
if (idx !== -1) list.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
const message = JSON.parse(data) as {
|
||||
id?: number
|
||||
@@ -439,8 +463,10 @@ class CdpBackend implements ICdpBackend {
|
||||
params?: unknown
|
||||
result?: unknown
|
||||
error?: { message: string; code: number }
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
// Route responses to pending requests
|
||||
if (message.id !== undefined) {
|
||||
const pending = this.pending.get(message.id)
|
||||
if (pending) {
|
||||
@@ -453,12 +479,23 @@ class CdpBackend implements ICdpBackend {
|
||||
}
|
||||
}
|
||||
} else if (message.method) {
|
||||
// Dispatch to global event handlers
|
||||
const handlers = this.eventHandlers.get(message.method)
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
handler(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch to session-aware handlers when sessionId is present
|
||||
if (message.sessionId) {
|
||||
const sessionHandlers = this.sessionEventHandlers.get(message.method)
|
||||
if (sessionHandlers) {
|
||||
for (const handler of sessionHandlers) {
|
||||
handler(message.params, message.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ export interface CdpBackend extends ProtocolApi {
|
||||
isConnected(): boolean
|
||||
getTargets(): Promise<CdpTarget[]>
|
||||
session(sessionId: string): ProtocolApi
|
||||
onSessionEvent(
|
||||
event: string,
|
||||
handler: (params: unknown, sessionId: string) => void,
|
||||
): () => void
|
||||
}
|
||||
|
||||
export interface ControllerBackend {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { logger } from '../lib/logger'
|
||||
import type { CdpBackend, ControllerBackend } from './backends/types'
|
||||
import type { BookmarkNode } from './bookmarks'
|
||||
import * as bookmarks from './bookmarks'
|
||||
import {
|
||||
ConsoleCollector,
|
||||
type GetConsoleLogsOptions,
|
||||
type GetConsoleLogsResult,
|
||||
} from './console-collector'
|
||||
import {
|
||||
buildContentMarkdownExpression,
|
||||
type ContentMarkdownOptions,
|
||||
@@ -84,6 +89,7 @@ export class Browser {
|
||||
private cdp: CdpBackend
|
||||
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: kept for later removal
|
||||
private controller: ControllerBackend
|
||||
private consoleCollector: ConsoleCollector
|
||||
private pages = new Map<number, PageInfo>()
|
||||
private sessions = new Map<string, string>()
|
||||
private nextPageId = 1
|
||||
@@ -91,6 +97,7 @@ export class Browser {
|
||||
constructor(cdp: CdpBackend, controller: ControllerBackend) {
|
||||
this.cdp = cdp
|
||||
this.controller = controller
|
||||
this.consoleCollector = new ConsoleCollector(cdp)
|
||||
this.setupEventHandlers()
|
||||
}
|
||||
|
||||
@@ -123,11 +130,14 @@ export class Browser {
|
||||
throw new Error(
|
||||
`Unknown page ${page}. Use list_pages to see available pages.`,
|
||||
)
|
||||
const sessionId = await this.attachToPage(info.targetId)
|
||||
const sessionId = await this.attachToPage(info.targetId, page)
|
||||
return this.cdp.session(sessionId)
|
||||
}
|
||||
|
||||
private async attachToPage(targetId: string): Promise<string> {
|
||||
private async attachToPage(
|
||||
targetId: string,
|
||||
pageId: number,
|
||||
): Promise<string> {
|
||||
const cached = this.sessions.get(targetId)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -143,10 +153,13 @@ export class Browser {
|
||||
session.Page.enable(),
|
||||
session.DOM.enable(),
|
||||
session.Runtime.enable(),
|
||||
session.Log.enable(),
|
||||
session.Accessibility.enable(),
|
||||
])
|
||||
|
||||
this.sessions.set(targetId, sessionId)
|
||||
this.consoleCollector.attach(pageId, sessionId)
|
||||
|
||||
return sessionId
|
||||
}
|
||||
|
||||
@@ -204,6 +217,7 @@ export class Browser {
|
||||
|
||||
for (const [pageId, info] of this.pages) {
|
||||
if (!seenTargetIds.has(info.targetId)) {
|
||||
this.consoleCollector.detach(pageId)
|
||||
this.pages.delete(pageId)
|
||||
}
|
||||
}
|
||||
@@ -290,6 +304,7 @@ export class Browser {
|
||||
`Unknown page ${page}. Use list_pages to see available pages.`,
|
||||
)
|
||||
await this.cdp.Browser.closeTab({ tabId: info.tabId })
|
||||
this.consoleCollector.detach(page)
|
||||
this.pages.delete(page)
|
||||
this.sessions.delete(info.targetId)
|
||||
}
|
||||
@@ -1236,4 +1251,14 @@ export class Browser {
|
||||
async closeTabGroup(groupId: string): Promise<void> {
|
||||
return tabGroups.closeTabGroup(this.cdp, groupId)
|
||||
}
|
||||
|
||||
// --- Console ---
|
||||
|
||||
async getConsoleLogs(
|
||||
page: number,
|
||||
opts?: GetConsoleLogsOptions,
|
||||
): Promise<GetConsoleLogsResult> {
|
||||
await this.resolveSession(page)
|
||||
return this.consoleCollector.getLogs(page, opts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { EntryAddedEvent } from '@browseros/cdp-protocol/domains/log'
|
||||
import type {
|
||||
ConsoleAPICalledEvent,
|
||||
ExceptionThrownEvent,
|
||||
RemoteObject,
|
||||
} from '@browseros/cdp-protocol/domains/runtime'
|
||||
import { CONTENT_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import type { CdpBackend } from './backends/types'
|
||||
|
||||
export type ConsoleLevel = 'error' | 'warning' | 'info' | 'debug'
|
||||
|
||||
export interface ConsoleEntry {
|
||||
source: 'console' | 'exception' | 'browser'
|
||||
level: ConsoleLevel
|
||||
text: string
|
||||
url?: string
|
||||
lineNumber?: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface GetConsoleLogsOptions {
|
||||
level?: ConsoleLevel
|
||||
search?: string
|
||||
limit?: number
|
||||
clear?: boolean
|
||||
}
|
||||
|
||||
export interface GetConsoleLogsResult {
|
||||
entries: ConsoleEntry[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
// Lower number = higher severity
|
||||
const LEVEL_PRIORITY: Record<ConsoleLevel, number> = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
}
|
||||
|
||||
const CONSOLE_TYPE_TO_LEVEL: Record<string, ConsoleLevel> = {
|
||||
error: 'error',
|
||||
assert: 'error',
|
||||
warning: 'warning',
|
||||
log: 'info',
|
||||
info: 'info',
|
||||
dir: 'info',
|
||||
dirxml: 'info',
|
||||
table: 'info',
|
||||
count: 'info',
|
||||
timeEnd: 'info',
|
||||
debug: 'debug',
|
||||
trace: 'debug',
|
||||
clear: 'debug',
|
||||
startGroup: 'debug',
|
||||
startGroupCollapsed: 'debug',
|
||||
endGroup: 'debug',
|
||||
profile: 'debug',
|
||||
profileEnd: 'debug',
|
||||
}
|
||||
|
||||
const LOG_LEVEL_MAP: Record<string, ConsoleLevel> = {
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
verbose: 'debug',
|
||||
}
|
||||
|
||||
export class ConsoleCollector {
|
||||
private readonly buffers = new Map<number, ConsoleEntry[]>()
|
||||
private readonly sessionToPage = new Map<string, number>()
|
||||
private readonly pageToSession = new Map<number, string>()
|
||||
private readonly maxEntries = CONTENT_LIMITS.CONSOLE_BUFFER_MAX_ENTRIES
|
||||
|
||||
constructor(cdp: CdpBackend) {
|
||||
// Single handler per event type — O(1) routing via sessionToPage lookup
|
||||
cdp.onSessionEvent('Runtime.consoleAPICalled', (params, sessionId) => {
|
||||
const pageId = this.sessionToPage.get(sessionId)
|
||||
if (pageId === undefined) return
|
||||
this.handleConsoleAPI(pageId, params as ConsoleAPICalledEvent)
|
||||
})
|
||||
|
||||
cdp.onSessionEvent('Runtime.exceptionThrown', (params, sessionId) => {
|
||||
const pageId = this.sessionToPage.get(sessionId)
|
||||
if (pageId === undefined) return
|
||||
this.handleException(pageId, params as ExceptionThrownEvent)
|
||||
})
|
||||
|
||||
cdp.onSessionEvent('Log.entryAdded', (params, sessionId) => {
|
||||
const pageId = this.sessionToPage.get(sessionId)
|
||||
if (pageId === undefined) return
|
||||
this.handleLogEntry(pageId, params as EntryAddedEvent)
|
||||
})
|
||||
|
||||
// Clear buffer on main-frame navigation
|
||||
cdp.onSessionEvent('Page.frameNavigated', (params, sessionId) => {
|
||||
const pageId = this.sessionToPage.get(sessionId)
|
||||
if (pageId === undefined) return
|
||||
const frame = (params as { frame: { parentId?: string } }).frame
|
||||
if (!frame.parentId) {
|
||||
this.buffers.set(pageId, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
attach(pageId: number, sessionId: string): void {
|
||||
if (!this.buffers.has(pageId)) {
|
||||
this.buffers.set(pageId, [])
|
||||
}
|
||||
// Clean up old session mapping if session changed (re-attach after detach)
|
||||
const oldSession = this.pageToSession.get(pageId)
|
||||
if (oldSession && oldSession !== sessionId) {
|
||||
this.sessionToPage.delete(oldSession)
|
||||
}
|
||||
this.sessionToPage.set(sessionId, pageId)
|
||||
this.pageToSession.set(pageId, sessionId)
|
||||
}
|
||||
|
||||
detach(pageId: number): void {
|
||||
const sessionId = this.pageToSession.get(pageId)
|
||||
if (sessionId) this.sessionToPage.delete(sessionId)
|
||||
this.pageToSession.delete(pageId)
|
||||
this.buffers.delete(pageId)
|
||||
}
|
||||
|
||||
getLogs(pageId: number, opts?: GetConsoleLogsOptions): GetConsoleLogsResult {
|
||||
const buffer = this.buffers.get(pageId) ?? []
|
||||
const levelThreshold = LEVEL_PRIORITY[opts?.level ?? 'info']
|
||||
|
||||
// Filter by level
|
||||
let filtered = buffer.filter(
|
||||
(e) => LEVEL_PRIORITY[e.level] <= levelThreshold,
|
||||
)
|
||||
|
||||
// Filter by search text
|
||||
if (opts?.search) {
|
||||
const term = opts.search.toLowerCase()
|
||||
filtered = filtered.filter((e) => e.text.toLowerCase().includes(term))
|
||||
}
|
||||
|
||||
// Return most recent entries up to limit
|
||||
const totalCount = filtered.length
|
||||
const limit = Math.min(
|
||||
opts?.limit ?? CONTENT_LIMITS.CONSOLE_DEFAULT_LIMIT,
|
||||
CONTENT_LIMITS.CONSOLE_MAX_LIMIT,
|
||||
)
|
||||
const entries = filtered.slice(-limit)
|
||||
|
||||
if (opts?.clear) {
|
||||
this.buffers.set(pageId, [])
|
||||
}
|
||||
|
||||
return { entries, totalCount }
|
||||
}
|
||||
|
||||
private addEntry(pageId: number, entry: ConsoleEntry): void {
|
||||
const buffer = this.buffers.get(pageId)
|
||||
if (!buffer) return
|
||||
|
||||
// FIFO eviction when buffer is full
|
||||
if (buffer.length >= this.maxEntries) {
|
||||
buffer.shift()
|
||||
}
|
||||
buffer.push(entry)
|
||||
}
|
||||
|
||||
private handleConsoleAPI(pageId: number, event: ConsoleAPICalledEvent): void {
|
||||
const level = CONSOLE_TYPE_TO_LEVEL[event.type] ?? 'info'
|
||||
const text = serializeArgs(event.args)
|
||||
const frame = event.stackTrace?.callFrames[0]
|
||||
|
||||
this.addEntry(pageId, {
|
||||
source: 'console',
|
||||
level,
|
||||
text,
|
||||
url: frame?.url,
|
||||
lineNumber: frame?.lineNumber,
|
||||
timestamp: event.timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
private handleException(pageId: number, event: ExceptionThrownEvent): void {
|
||||
const details = event.exceptionDetails
|
||||
const text = details.exception?.description ?? details.text
|
||||
|
||||
this.addEntry(pageId, {
|
||||
source: 'exception',
|
||||
level: 'error',
|
||||
text,
|
||||
url: details.url ?? details.stackTrace?.callFrames[0]?.url,
|
||||
lineNumber: details.lineNumber,
|
||||
timestamp: event.timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
private handleLogEntry(pageId: number, event: EntryAddedEvent): void {
|
||||
const entry = event.entry
|
||||
const level = LOG_LEVEL_MAP[entry.level] ?? 'info'
|
||||
|
||||
this.addEntry(pageId, {
|
||||
source: 'browser',
|
||||
level,
|
||||
text: entry.text,
|
||||
url: entry.url,
|
||||
lineNumber: entry.lineNumber,
|
||||
timestamp: entry.timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function serializeArgs(args: RemoteObject[]): string {
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (arg.type === 'string') return arg.value as string
|
||||
if (arg.value !== undefined) return String(arg.value)
|
||||
return arg.description ?? `[${arg.type}]`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
95
packages/browseros-agent/apps/server/src/tools/console.ts
Normal file
95
packages/browseros-agent/apps/server/src/tools/console.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { CONTENT_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import { z } from 'zod'
|
||||
import type { ConsoleLevel } from '../browser/console-collector'
|
||||
import { defineTool } from './framework'
|
||||
|
||||
const pageParam = z.number().describe('Page ID (from list_pages)')
|
||||
|
||||
export const get_console_logs = defineTool({
|
||||
name: 'get_console_logs',
|
||||
description:
|
||||
'Get browser console output (logs, warnings, errors, exceptions) for a page. Use to debug JavaScript errors, failed network requests, or unexpected page behavior.',
|
||||
input: z.object({
|
||||
page: pageParam,
|
||||
level: z
|
||||
.enum(['error', 'warning', 'info', 'debug'])
|
||||
.default('info')
|
||||
.describe(
|
||||
'Minimum severity level. "error" = errors only, "warning" = errors + warnings, "info" = errors + warnings + logs (default), "debug" = everything',
|
||||
),
|
||||
search: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Filter entries containing this text (case-insensitive)'),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(CONTENT_LIMITS.CONSOLE_MAX_LIMIT)
|
||||
.optional()
|
||||
.describe(
|
||||
`Max entries to return (default ${CONTENT_LIMITS.CONSOLE_DEFAULT_LIMIT}, max ${CONTENT_LIMITS.CONSOLE_MAX_LIMIT}). Returns most recent entries.`,
|
||||
),
|
||||
clear: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Clear the console buffer after reading'),
|
||||
}),
|
||||
output: z.object({
|
||||
entries: z.array(
|
||||
z.object({
|
||||
source: z.enum(['console', 'exception', 'browser']),
|
||||
level: z.enum(['error', 'warning', 'info', 'debug']),
|
||||
text: z.string(),
|
||||
url: z.string().optional(),
|
||||
lineNumber: z.number().optional(),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
),
|
||||
totalCount: z.number(),
|
||||
returnedCount: z.number(),
|
||||
}),
|
||||
handler: async (args, ctx, response) => {
|
||||
const result = await ctx.browser.getConsoleLogs(args.page, {
|
||||
level: args.level as ConsoleLevel,
|
||||
search: args.search,
|
||||
limit: args.limit,
|
||||
clear: args.clear,
|
||||
})
|
||||
|
||||
// Empty results
|
||||
if (result.entries.length === 0) {
|
||||
response.text(
|
||||
result.totalCount === 0
|
||||
? `No console output for page ${args.page}.`
|
||||
: `No entries match the filter (${result.totalCount} total entries in buffer).`,
|
||||
)
|
||||
response.data({
|
||||
entries: [],
|
||||
totalCount: result.totalCount,
|
||||
returnedCount: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Format each entry as [level] text — url:line
|
||||
const lines = result.entries.map((e) => {
|
||||
const location = e.url
|
||||
? ` — ${e.url}${e.lineNumber !== undefined ? `:${e.lineNumber}` : ''}`
|
||||
: ''
|
||||
return `[${e.level}] ${e.text}${location}`
|
||||
})
|
||||
|
||||
// Build header with count info
|
||||
const header =
|
||||
result.entries.length < result.totalCount
|
||||
? `Console logs for page ${args.page} (showing ${result.entries.length} of ${result.totalCount}, level ≥ ${args.level}):`
|
||||
: `Console logs for page ${args.page} (${result.entries.length} entries, level ≥ ${args.level}):`
|
||||
|
||||
response.text(`${header}\n\n${lines.join('\n')}`)
|
||||
response.data({
|
||||
entries: result.entries,
|
||||
totalCount: result.totalCount,
|
||||
returnedCount: result.entries.length,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
update_bookmark,
|
||||
} from './bookmarks'
|
||||
import { browseros_info } from './browseros-info'
|
||||
import { get_console_logs } from './console'
|
||||
import { get_dom, search_dom } from './dom'
|
||||
import {
|
||||
delete_history_range,
|
||||
@@ -80,7 +81,7 @@ export const registry = createRegistry([
|
||||
close_page,
|
||||
// wait_for, // temporarily disabled
|
||||
|
||||
// Observation (8)
|
||||
// Observation (9)
|
||||
take_snapshot,
|
||||
take_enhanced_snapshot,
|
||||
get_page_content,
|
||||
@@ -89,6 +90,7 @@ export const registry = createRegistry([
|
||||
search_dom,
|
||||
take_screenshot,
|
||||
evaluate_script,
|
||||
get_console_logs,
|
||||
|
||||
// Input (14)
|
||||
click,
|
||||
|
||||
@@ -82,4 +82,7 @@ export const CONTENT_LIMITS = {
|
||||
BODY_CONTEXT_SIZE: 10_000,
|
||||
MAX_QUEUE_SIZE: 1_000,
|
||||
CONSOLE_META_CHAR: 1_000,
|
||||
CONSOLE_BUFFER_MAX_ENTRIES: 500,
|
||||
CONSOLE_DEFAULT_LIMIT: 50,
|
||||
CONSOLE_MAX_LIMIT: 200,
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user