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:
shivammittal274
2026-03-16 22:20:40 +05:30
committed by GitHub
parent 41c9b1547c
commit d1d2074abc
7 changed files with 388 additions and 3 deletions

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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(' ')
}

View 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,
})
},
})

View File

@@ -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,

View File

@@ -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