fix(cdp_collect_console): cap maxEntries + descarta backlog previo a la ventana

CdpCollectConsole gana un parametro maxEntries (default 200): al alcanzarlo deja
de acumular y marca una ConsoleEntry final '_truncated', evitando reventar la
salida en paginas verbosas. Ademas descarta los eventos console anteriores al
inicio de la captura (backlog acumulado en la conexion CDP viva), capturando solo
lo emitido dentro de la ventana durationMs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-06-16 20:43:17 +02:00
parent 9798aed2cf
commit c4ecf871c8
2 changed files with 102 additions and 27 deletions
+80 -17
View File
@@ -22,11 +22,33 @@ type ConsoleEntry struct {
Timestamp float64 `json:"timestamp"` // CDP timestamp (monotonic seconds) o wall time
}
// consoleCollectDefaultMax es el tope de entradas por defecto cuando el caller
// pasa maxEntries <= 0. Acota la salida en paginas verbosas (setInterval ruidoso,
// SPA que loguea sin parar) para no devolver cientos de entradas y reventar el
// output del tool.
const consoleCollectDefaultMax = 200
// CdpCollectConsole habilita los dominios Runtime y Log en la conexion, se
// suscribe a los eventos de consola/excepcion/log del navegador y acumula todo
// lo que ocurra durante `durationMs` milisegundos. Es un SNAPSHOT temporal:
// captura solo lo emitido dentro de la ventana, no el historico previo de la
// pagina. Si durationMs <= 0 usa 1500ms por defecto.
// lo que ocurra durante `durationMs` milisegundos, hasta un maximo de
// `maxEntries` entradas. Es un SNAPSHOT temporal: captura solo lo emitido dentro
// de la ventana, no el historico previo de la pagina. Si durationMs <= 0 usa
// 1500ms por defecto; si maxEntries <= 0 usa 200 por defecto.
//
// Dos defensas contra el backlog de una conexion del pool que lleva rato abierta
// con Runtime habilitado (donde Runtime.enable flushea consoleAPICalled rezagados
// con timestamps antiguos, y un setInterval verboso puede inundar):
// - Filtro por timestamp: se captura `startMs` (wall time, ms epoch) JUSTO antes
// de habilitar los dominios y solo se acumulan eventos cuyo timestamp sea >=
// startMs. Los eventos `consoleAPICalled`/`exceptionThrown`/`Log.entryAdded`
// traen `timestamp` en ms epoch, asi que los rezagados del flush (anteriores
// a startMs) se descartan. Eventos sin timestamp (0) se aceptan: no hay forma
// de fecharlos y casi siempre son nuevos.
// - Cap por cantidad: alcanzado `maxEntries` se dejan de acumular entradas, pero
// la funcion NO corta la ventana — sigue durmiendo hasta `durationMs` para no
// dejar los dominios CDP en estado raro (handlers a medio drenar). Las entradas
// posteriores al cap simplemente se descartan; el flag de truncamiento se
// refleja como una ConsoleEntry final de Type "_truncated".
//
// Eventos capturados y como se mapean a ConsoleEntry.Type:
// - Runtime.consoleAPICalled -> el `type` del evento (log/info/warning/error/...)
@@ -35,19 +57,45 @@ type ConsoleEntry struct {
//
// Robusta ante silencio: si no llega ningun evento devuelve un slice vacio
// (no nil, no error). La conexion debe estar abierta; la funcion no la cierra.
func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error) {
func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error) {
if c == nil {
return nil, fmt.Errorf("cdp collect console: conexion nula")
}
if durationMs <= 0 {
durationMs = 1500
}
if maxEntries <= 0 {
maxEntries = consoleCollectDefaultMax
}
// startMs marca el inicio de la ventana en ms epoch (mismo dominio que el
// `timestamp` de los eventos CDP). Eventos anteriores = backlog -> se descartan.
startMs := float64(time.Now().UnixMilli())
var (
mu sync.Mutex
entries = make([]ConsoleEntry, 0, 16)
mu sync.Mutex
entries = make([]ConsoleEntry, 0, 16)
truncated bool
)
// add intenta acumular una entrada respetando el filtro por timestamp y el cap.
// Devuelve sin hacer nada si la entrada es backlog o si ya se alcanzo el tope.
add := func(e ConsoleEntry) {
// Descartar backlog: eventos fechados antes del inicio de la ventana.
// Timestamp 0 (sin fecha) se acepta — no se puede clasificar como viejo.
if e.Timestamp != 0 && e.Timestamp < startMs {
return
}
mu.Lock()
if len(entries) >= maxEntries {
truncated = true
mu.Unlock()
return
}
entries = append(entries, e)
mu.Unlock()
}
// Helpers para extraer campos de map[string]any sin pelearse con cast.
str := func(m map[string]any, k string) string {
if v, ok := m[k]; ok {
@@ -117,9 +165,7 @@ func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error) {
}
}
}
mu.Lock()
entries = append(entries, entry)
mu.Unlock()
add(entry)
})
defer cancel1()
@@ -168,9 +214,7 @@ func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error) {
if entry.Text == "" {
entry.Text = "uncaught exception"
}
mu.Lock()
entries = append(entries, entry)
mu.Unlock()
add(entry)
})
defer cancel2()
@@ -180,16 +224,23 @@ func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error) {
if le == nil {
return
}
// Log.entryAdded reporta `timestamp` en segundos epoch (a diferencia de
// consoleAPICalled/exceptionThrown que lo dan en ms). Normalizar a ms para
// que el filtro por startMs compare en el mismo dominio. Heurística: si el
// valor parece segundos (varios órdenes por debajo de un ms epoch actual),
// multiplicar por 1000.
ts := num(le, "timestamp")
if ts > 0 && ts < startMs/100 {
ts *= 1000
}
entry := ConsoleEntry{
Type: str(le, "level"), // verbose|info|warning|error
Text: str(le, "text"),
URL: str(le, "url"),
Line: int(num(le, "lineNumber")),
Timestamp: num(le, "timestamp"),
Timestamp: ts,
}
mu.Lock()
entries = append(entries, entry)
mu.Unlock()
add(entry)
})
defer cancel3()
@@ -207,12 +258,24 @@ func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error) {
// dependen de consoleAPICalled. Solo cerramos Log que abrimos aqui.
defer c.sendCDP("Log.disable", nil)
// Ventana de captura.
// Ventana de captura. No hacemos early-return al alcanzar el cap: seguimos
// durmiendo la ventana completa para no dejar los dominios CDP a medio drenar.
time.Sleep(time.Duration(durationMs) * time.Millisecond)
mu.Lock()
out := make([]ConsoleEntry, len(entries))
copy(out, entries)
wasTruncated := truncated
mu.Unlock()
// Senal de truncamiento limpia: una entrada final que el caller puede detectar
// por Type == "_truncated" sin cambiar la forma del slice.
if wasTruncated {
out = append(out, ConsoleEntry{
Type: "_truncated",
Text: fmt.Sprintf("output truncado al alcanzar maxEntries=%d; entradas posteriores descartadas", maxEntries),
Timestamp: float64(time.Now().UnixMilli()),
})
}
return out, nil
}
+22 -10
View File
@@ -3,10 +3,10 @@ name: cdp_collect_console
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error)"
description: "Captura un snapshot temporal del log de consola y diagnostico de una pagina Chrome via CDP. Habilita los dominios Runtime y Log, se suscribe a Runtime.consoleAPICalled (console.log/info/warn/error con args concatenados), Runtime.exceptionThrown (errores JS no capturados, type=exception con descripcion + stack) y Log.entryAdded (avisos del propio navegador: network, security, deprecaciones) y acumula todo lo que ocurra durante durationMs ms (default 1500). Devuelve un slice de ConsoleEntry (Type, Text, URL, Line, Timestamp). Es un snapshot de la ventana, no historico previo. Robusta ante silencio: devuelve slice vacio si no llega ningun evento."
signature: "func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error)"
description: "Captura un snapshot temporal del log de consola y diagnostico de una pagina Chrome via CDP. Habilita los dominios Runtime y Log, se suscribe a Runtime.consoleAPICalled (console.log/info/warn/error con args concatenados), Runtime.exceptionThrown (errores JS no capturados, type=exception con descripcion + stack) y Log.entryAdded (avisos del propio navegador: network, security, deprecaciones) y acumula todo lo que ocurra durante durationMs ms (default 1500), hasta un maximo de maxEntries entradas (default 200). Devuelve un slice de ConsoleEntry (Type, Text, URL, Line, Timestamp). Es un snapshot de la ventana, no historico previo: filtra por timestamp para descartar el backlog de eventos que una conexion del pool acumulo antes de la llamada. Si se alcanza maxEntries deja de acumular pero no corta la ventana; anade una entrada final con Type=_truncated. Robusta ante silencio: devuelve slice vacio si no llega ningun evento."
tags: [chrome, cdp, browser, automation, console, devtools, debug, diagnostics, logs, errors, exceptions, flow-replay]
uses_functions: []
uses_types: []
@@ -18,8 +18,10 @@ params:
- name: c
desc: "conexión CDP activa (*CDPConn) contra una pestaña Chrome con el target abierto"
- name: durationMs
desc: "ventana de captura en milisegundos; si <=0 usa 1500ms. Es el tiempo durante el cual se acumulan eventos de consola/excepcion/log antes de devolver"
output: "slice de ConsoleEntry (Type, Text, URL, Line, Timestamp) con todo lo emitido en la ventana; slice vacío (no nil, no error) si no hubo eventos; error solo si la conexión es nula o falla Runtime.enable"
desc: "ventana de captura en milisegundos; si <=0 usa 1500ms. Es el tiempo durante el cual se acumulan eventos de consola/excepcion/log antes de devolver. La función duerme la ventana completa aunque se alcance maxEntries antes"
- name: maxEntries
desc: "tope de entradas a acumular; si <=0 usa 200. Al alcanzarlo se descartan las entradas posteriores (no se corta la ventana) y se añade una entrada final con Type=_truncated. Acota la salida en páginas verbosas (setInterval ruidoso, SPA que loguea sin parar)"
output: "slice de ConsoleEntry (Type, Text, URL, Line, Timestamp) con todo lo emitido en la ventana (filtrado de backlog previo a la llamada y acotado a maxEntries); si se truncó, la última entrada tiene Type=_truncated; slice vacío (no nil, no error) si no hubo eventos; error solo si la conexión es nula o falla Runtime.enable"
tested: false
tests: []
test_file_path: ""
@@ -32,19 +34,26 @@ file_path: "functions/browser/cdp_collect_console.go"
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com")
// Captura todo lo que la pagina escriba en consola durante 2 segundos
// mientras se carga / interactua.
entries, err := CdpCollectConsole(conn, 2000)
// Captura todo lo que la pagina escriba en consola durante 2 segundos,
// hasta un maximo de 100 entradas (descarta el backlog previo de la conexion).
entries, err := CdpCollectConsole(conn, 2000, 100)
if err != nil {
log.Fatal(err)
}
for _, e := range entries {
if e.Type == "_truncated" {
fmt.Println("...", e.Text) // se alcanzo el cap de 100 entradas
continue
}
fmt.Printf("[%s] %s (%s:%d)\n", e.Type, e.Text, e.URL, e.Line)
}
// Ejemplo de salida:
// [error] Uncaught TypeError: x is not a function (https://example.com/app.js:42)
// [warning] Mixed Content: requested an insecure resource (https://example.com:0)
// [log] app initialized (https://example.com/app.js:5)
// Cap por defecto (200): pasar maxEntries <= 0.
entries, _ = CdpCollectConsole(conn, 1500, 0)
```
## Cuando usarla
@@ -54,8 +63,11 @@ Cuando necesitas ver qué errores, warnings o mensajes de consola produce una p
## Gotchas
- **Impura: requiere Chrome vivo.** Necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
- **Es un snapshot temporal, no histórico.** Solo captura eventos emitidos DURANTE la ventana `durationMs`. Los mensajes que la página imprimió antes de llamar a la función no se recuperan (excepto los que `Runtime.enable` reenvía al activarse, que Chrome flushea de forma limitada). Si quieres capturar el arranque, conéctate y llama ANTES de navegar, o navega dentro de la ventana.
- **Bloquea durante `durationMs`.** La función duerme la goroutine la ventana completa antes de devolver — no hay early-return aunque ya tengas eventos. Elige `durationMs` acorde a lo que esperas observar (1500ms default suele bastar para el load inicial).
- **Es un snapshot temporal, no histórico — y filtra el backlog.** Solo captura eventos emitidos DURANTE la ventana `durationMs`. La función captura `startMs` (wall time, ms epoch) justo antes de habilitar los dominios y descarta todo evento con `timestamp` anterior a ese inicio. Esto resuelve el problema real con conexiones del pool que llevan rato abiertas con `Runtime` ya habilitado: cuando `Runtime.enable` se reenvía, Chrome flushea `consoleAPICalled` rezagados con timestamps antiguos; esos backlog se descartan por el filtro. Sin el filtro, en una página verbosa o con un `setInterval` la función devolvía cientos de entradas históricas que reventaban el output. **Por qué `OnEvent` no basta:** los handlers de `OnEvent` solo reciben eventos que lleguen al `readLoop` DESPUÉS del registro, pero el flush de `Runtime.enable` llega justo después y arrastra mensajes viejos — de ahí el backlog. El filtro por timestamp es la defensa que lo separa. Si quieres capturar el arranque, conéctate y llama ANTES de navegar, o navega dentro de la ventana.
- **Eventos sin timestamp se aceptan.** Si un evento llega con `timestamp` 0 (sin fechar) no se puede clasificar como backlog, así que se acumula. En la práctica casi siempre son nuevos.
- **`Log.entryAdded` reporta en segundos, no ms.** A diferencia de `consoleAPICalled`/`exceptionThrown` (ms epoch), `Log.entryAdded` da `timestamp` en segundos epoch. La función lo normaliza a ms (heurística: si el valor es varios órdenes menor que un ms epoch actual, lo multiplica por 1000) para que el filtro por `startMs` compare en el mismo dominio.
- **Cap por cantidad (`maxEntries`).** Al alcanzar `maxEntries` entradas (default 200) la función deja de acumular y descarta las posteriores, pero **NO corta la ventana** — sigue durmiendo hasta `durationMs` para no dejar los dominios CDP a medio drenar (handlers a medias) ni el estado de la conexión raro. Si se truncó, la **última** entrada del slice tiene `Type == "_truncated"` y un `Text` con el cap alcanzado; el caller debe filtrarla o tratarla como señal, no como un log real.
- **Bloquea durante `durationMs`.** La función duerme la goroutine la ventana completa antes de devolver — no hay early-return aunque ya tengas eventos o se alcance el cap. Elige `durationMs` acorde a lo que esperas observar (1500ms default suele bastar para el load inicial).
- **`Type` mezcla tres taxonomías.** `consoleAPICalled` usa `log|info|warning|error|debug|...`; `exceptionThrown` siempre marca `exception`; `Log.entryAdded` usa el `level` del navegador (`verbose|info|warning|error`). Filtra por substring (`warn`, `error`) si quieres agrupar severidades; nota que console.warn produce `warning`, no `warn`.
- **`Line` es 1-based.** CDP reporta `lineNumber` 0-based; esta función suma 1 para que coincida con lo que muestran las DevTools. Los `Log.entryAdded` se dejan tal cual los da Chrome.
- **No deshabilita `Runtime` al salir.** Otras funciones del package (ej. `cdp_pick_element_js`) dependen de `Runtime.consoleAPICalled`; deshabilitarlo rompería sus handlers. Sí cierra el dominio `Log` que abre aquí.