Fecha: 2026-08-13 Estado: Aceptado Bloque: L — log de sentencias del motor. Complementa: ADR-0012 (audit log del gateway MCP). ADR-0014 (logs JSON + /metrics del server). Respeta: ADR-0001 (cero deps runtime).

Contexto

Todo motor de base de datos serio persiste dos cosas que gabysql no persistía: qué se ejecutó y qué falló. Lo que había era esto:

Capa Qué captura Dónde Cobertura
gabysql-mcp --audit-log (ADR-0012) SQL + reason + clientInfo + ok/error archivo JSONL sólo tráfico MCP
gabysql-server -log-json (ADR-0014) method, path, status, latency stdout sin SQL ni código de error
gabysql CLI / REPL — — nada
Uso embebido (lib) — — nada
.msi de escritorio — — nada

El agujero concreto: DbError (src/lib.rs) es un struct con un solo String. Se propaga hacia arriba y muere en quien lo llame — el CLI lo imprime a stderr, el server lo serializa a JSON y responde 400. Nadie lo escribe a ningún lado. Un POST /exec que revienta con [GBY-3001] aparece en el log JSON del server como {"status":400}, sin decir qué sentencia fue ni qué código dio.

Traducido al vocabulario de PostgreSQL: existía el log del transporte y faltaba el equivalente a log_statement / log_min_error_statement, que es el log de la base.

Restricciones:

Decisión

Módulo nuevo src/dblog.rs — sink JSONL append-only con rotación por tamaño — enganchado en un solo punto: Engine::exec.

1. Por qué Engine::exec y no los frontends

Engine::exec es un match de dispatch único por el que pasa toda sentencia, venga de donde venga. Instrumentarlo cubre de una sola vez el CLI, el REPL, el server, el gateway y cualquier embebido futuro, sin que ninguno de ellos tenga que enterarse.

El costo de esa decisión es un detalle no obvio: exec se llama recursivamente a sí mismo desde 12 sitios de sql.rs (bodies de trigger, procedure, function, WHILE/LOOP/FOR, handlers de EXCEPTION). Sin guard, un CALL con un loop de 1000 iteraciones escribiría 1000 líneas por una sentencia del usuario. Por eso el Engine lleva un exec_depth y sólo se loguea el nivel 0. Hay dos tests E2E dedicados a esto (nested_exec_from_a_procedure_loop_... y nested_exec_from_a_trigger_...).

2. El texto SQL lo aporta el caller

Engine::exec recibe un Statement ya parseado: para cuando llega, el texto original se perdió. El caller que sí lo tiene lo declara con Engine::set_log_source(&sql), y cada entrada queda con el texto del batch completo más un stmt_index que desambigua cuál de las sentencias del batch la generó.

Consecuencia directa: los errores del parser no pasan por el hook. Un SELEKT oops falla en parse(), antes de que exista un Statement. Se loguean explícitamente desde el frontend con kind: "PARSE" — en src/server.rs (log_parse_error) y en src/bin/gabysql.rs. Es asimétrico y feo, pero la alternativa (que parse devuelva spans por sentencia) es una cirugía al parser que no se justifica por esto.

3. Niveles

Espejan log_statement de PostgreSQL, con error agregado abajo:

Nivel Qué entra
none nada
error (default) sólo sentencias que fallaron
mod errores + sentencias que cambian estado
all todo, incluidos los SELECT

error es el default porque el caso de uso dominante (“quiero ver qué falló”) no debería costar el volumen de all.

Qué cuenta como “cambia estado” para el corte de mod — la función statement_kind en sql.rs es la fuente de verdad, y clasifica tres cosas, no sólo escrituras de datos:

  1. Estado durable: DDL, DML, y ANALYZE (persiste un record TableStats en el catálogo desde P3b).
  2. Estado transaccional: BEGIN/COMMIT/ROLLBACK/savepoints. PostgreSQL los excluye de log_statement=mod; acá se incluyen a propósito, porque sin el boundary de commit el log no permite reconstruir qué quedó aplicado — que es exactamente lo que se le pide a un log de auditoría.
  3. Contexto de seguridad: SET SESSION AUTHORIZATION no toca un byte del disco, pero cambia bajo qué identidad se evalúan los privilegios de todo lo que sigue.

Las sentencias de control de flujo (CALL, BEGIN…END, IF, loops, CASE) se marcan como cambio de estado de forma conservadora: su body puede contener DML y desde afuera no se sabe. Un falso positivo cuesta una línea de log de más; un falso negativo pierde una escritura del log de auditoría.

EXPLAIN pelado es de sólo lectura, pero EXPLAIN ANALYZE ejecuta el statement interno y por lo tanto hereda su clasificación.

4. Rotación por tamaño

ADR-0014 descartó la rotación con el argumento “stdout + logrotate resuelve”. Ese argumento no aplica acá y es la diferencia operativa central de esta ADR: en el uso embebido y en el .msi de escritorio no hay supervisor, no hay logrotate y no hay nadie rotando nada — el archivo crecería sin techo en la máquina de un usuario final.

Default: 8 MiB por archivo, 3 rotados (.log.1, .log.2, .log.3) → techo de ~32 MiB. max_bytes = 0 desactiva la rotación.

El handle se cierra antes de renombrar: Windows rechaza el rename de un archivo con handles abiertos. El tamaño se siembra desde el metadata() real al abrir, para que un reinicio no reinicie el conteo.

5. Formato

Una línea JSON por sentencia, con \n final:

{"v":1,"ts_unix":1786655967,"kind":"INSERT","mutating":true,"stmt_index":0,"ok":false,"rows":0,"duration_us":136,"sql":"INSERT INTO users (id,name) VALUES (1,'Duplicada');","code":3001,"error":"[GBY-3001] PRIMARY KEY duplicada: la clave 1 ya existe en la tabla"}

El campo v va desde el día uno. ADR-0012 anotó como consecuencia negativa el no haber versionado el shape del audit log del gateway; esa deuda no se repite acá.

code se extrae del prefijo [GBY-NNNN] y se omite cuando el error no lo trae — todavía quedan errores construidos con DbError::new pelado (el PARSE del smoke test de abajo es uno).

6. Configuración

  gabysql-server gabysql CLI
Archivo -log-file P / GABYSQL_LOG_FILE GABYSQL_LOG_FILE
Nivel -log-level L / GABYSQL_LOG_LEVEL GABYSQL_LOG_LEVEL
Rotación GABYSQL_LOG_MAX_BYTES, GABYSQL_LOG_MAX_FILES idem

El CLI usa argumentos posicionales (gabysql exec <db> <sql...>), así que flags ahí obligarían a distinguirlas del SQL. Por env queda limpio, y además es la vía natural para el .msi y para wrappers que lanzan el binario.

Sin configuración, el Engine no tiene logger y no paga ni un Instant::now() — el camino sin log es una rama antes de cualquier trabajo.

Verificación

Smoke E2E con el binario real, GABYSQL_LOG_LEVEL=mod:

--- intento de PK duplicada (debe fallar) ---
error: [GBY-3001] PRIMARY KEY duplicada: la clave 1 ya existe en la tabla
--- SELECT (no debe loguearse en level=mod) ---
--- SQL que no parsea ---
error: sentencia no soportada (solo CREATE/INSERT/SELECT/...)
===== contenido de gabysql.log =====
{"v":1,...,"kind":"CREATE TABLE","mutating":true,"stmt_index":0,"ok":true,"rows":0,"duration_us":101,"sql":"CREATE TABLE users (id INT PRIMARY KEY, name TEXT);"}
{"v":1,...,"kind":"INSERT","mutating":true,"stmt_index":0,"ok":true,"rows":0,"duration_us":126,"sql":"INSERT INTO users (id,name) VALUES (1,'Ana');"}
{"v":1,...,"kind":"INSERT","mutating":true,"stmt_index":0,"ok":false,"rows":0,"duration_us":136,"sql":"INSERT INTO users (id,name) VALUES (1,'Duplicada');","code":3001,"error":"[GBY-3001] PRIMARY KEY duplicada: la clave 1 ya existe en la tabla"}
{"v":1,...,"kind":"PARSE","mutating":false,"stmt_index":0,"ok":false,"rows":0,"duration_us":0,"sql":"SELEKT oops;","error":"sentencia no soportada (solo CREATE/INSERT/SELECT/...)"}

El SELECT no aparece (correcto para mod), el fallo trae code y SQL completo, y el error de parseo quedó capturado con kind: "PARSE".

Tests

Alternativas descartadas

Tabla de sistema __gabysql_log dentro de la DB. Coherencia transaccional con la escritura que registra. Pero: bump de formato on-disk, y el problema fatal — si la transacción hace rollback, el log del error se va con ella. Justo el caso que más importa es el que se pierde.

Extender -log-json de ADR-0014 con el SQL. Cubre sólo el server. Deja fuera CLI, embebido y desktop, que es donde más falta hace.

Sink a stdout en vez de archivo. Es lo que ya hace ADR-0014 y funciona para el server bajo supervisor. No funciona para el .msi ni para el uso embebido, donde no hay nadie escuchando stdout.

Crate tracing. El ecosistema canónico, y 10-20 deps transitivas. Viola ADR-0001 por una línea JSON por sentencia.

Consecuencias

Positivas

Negativas / a vigilar

Referencias