Files
fn_registry/functions/browser/cdp_collect_console.go
T
Egutierrez c4ecf871c8 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>
2026-06-16 20:43:17 +02:00

282 lines
10 KiB
Go

package browser
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// ConsoleEntry es una entrada del log de consola/diagnostico capturada via CDP
// durante una ventana temporal. Type clasifica el origen:
// - "log"/"info"/"warn"/"error"/"debug" — Runtime.consoleAPICalled (console.*)
// - "exception" — Runtime.exceptionThrown (errores JS no capturados)
// - el level de Log.entryAdded ("verbose"/"info"/"warning"/"error") para
// avisos del propio navegador (network, security, deprecaciones...)
type ConsoleEntry struct {
Type string `json:"type"` // log|info|warn|warning|error|debug|exception|verbose
Text string `json:"text"` // mensaje legible (args concatenados / descripcion + stack)
URL string `json:"url"` // URL del script o recurso, si Chrome lo informa
Line int `json:"line"` // numero de linea (1-based), 0 si desconocido
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, 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/...)
// - Runtime.exceptionThrown -> "exception" (texto = descripcion + stack)
// - Log.entryAdded -> el `level` del entry (warning/error del browser)
//
// 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, 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)
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 {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
num := func(m map[string]any, k string) float64 {
if v, ok := m[k]; ok {
if f, ok := v.(float64); ok {
return f
}
}
return 0
}
// argToText convierte un RemoteObject de Runtime a una representacion legible.
// Para primitivas usa `value`; para objetos sin value cae a `description` o
// `unserializableValue`; ultimo recurso, el `type`.
argToText := func(arg map[string]any) string {
if v, ok := arg["value"]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
// objetos/arrays serializados por valor -> JSON real.
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return fmt.Sprintf("%v", v)
}
if d := str(arg, "description"); d != "" {
return d
}
if u := str(arg, "unserializableValue"); u != "" {
return u
}
return str(arg, "type")
}
// --- Runtime.consoleAPICalled: console.log / info / warn / error / ... ---
cancel1 := c.OnEvent("Runtime.consoleAPICalled", func(_ string, p map[string]any) {
entry := ConsoleEntry{
Type: str(p, "type"),
Timestamp: num(p, "timestamp"),
}
// Concatenar los args a un texto legible separado por espacios.
if rawArgs, ok := p["args"].([]any); ok {
parts := make([]string, 0, len(rawArgs))
for _, ra := range rawArgs {
if am, ok := ra.(map[string]any); ok {
parts = append(parts, argToText(am))
}
}
entry.Text = strings.Join(parts, " ")
}
// stackTrace -> primer frame para URL/linea.
if st, ok := p["stackTrace"].(map[string]any); ok {
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
if f0, ok := frames[0].(map[string]any); ok {
entry.URL = str(f0, "url")
// lineNumber es 0-based en CDP; +1 para ser 1-based legible.
if ln := int(num(f0, "lineNumber")); ln >= 0 {
entry.Line = ln + 1
}
}
}
}
add(entry)
})
defer cancel1()
// --- Runtime.exceptionThrown: errores JS no capturados ---
cancel2 := c.OnEvent("Runtime.exceptionThrown", func(_ string, p map[string]any) {
entry := ConsoleEntry{
Type: "exception",
Timestamp: num(p, "timestamp"),
}
ed, _ := p["exceptionDetails"].(map[string]any)
if ed != nil {
// Texto base de la excepcion.
text := str(ed, "text")
// Si hay un objeto de excepcion con descripcion (stack completo), preferirlo.
if exc, ok := ed["exception"].(map[string]any); ok {
if desc := str(exc, "description"); desc != "" {
if text != "" && !strings.Contains(desc, text) {
text = text + ": " + desc
} else {
text = desc
}
}
}
entry.Text = text
entry.URL = str(ed, "url")
// lineNumber 0-based -> 1-based.
if ln := int(num(ed, "lineNumber")); ln >= 0 {
entry.Line = ln + 1
}
// stackTrace top frame como respaldo de URL/linea.
if entry.URL == "" {
if st, ok := ed["stackTrace"].(map[string]any); ok {
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
if f0, ok := frames[0].(map[string]any); ok {
entry.URL = str(f0, "url")
if entry.Line == 0 {
if ln := int(num(f0, "lineNumber")); ln >= 0 {
entry.Line = ln + 1
}
}
}
}
}
}
}
if entry.Text == "" {
entry.Text = "uncaught exception"
}
add(entry)
})
defer cancel2()
// --- Log.entryAdded: avisos del propio navegador (network, security...) ---
cancel3 := c.OnEvent("Log.entryAdded", func(_ string, p map[string]any) {
le, _ := p["entry"].(map[string]any)
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: ts,
}
add(entry)
})
defer cancel3()
// Habilitar dominios. Runtime.enable provoca un flush de consoleAPICalled
// rezagados; Log.enable abre el stream de avisos del navegador.
if _, err := c.sendCDP("Runtime.enable", nil); err != nil {
return nil, fmt.Errorf("cdp collect console: Runtime.enable: %w", err)
}
if _, err := c.sendCDP("Log.enable", nil); err != nil {
// Log.enable puede no estar disponible en algunos targets; no es fatal,
// seguimos capturando Runtime.*. Deshabilitar Runtime no hace falta.
_ = err
}
// No deshabilitamos Runtime al salir: otras funciones (ej. cdp_pick_element_js)
// dependen de consoleAPICalled. Solo cerramos Log que abrimos aqui.
defer c.sendCDP("Log.disable", nil)
// 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
}