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 }