Repository navigation
Statement log: the ferro.sql logger and the Rust emit path, proven on migration DDL #764
Description
Parent
What to build
The statement log exists and Ferro's loggers reach the application. After this ticket a developer who sets ferro.sql to DEBUG sees every DDL statement the reconciliation pass or a migration run sends, as it finishes:
logging.getLogger("ferro.sql").setLevel(logging.DEBUG)
report = await ferro.migrate()
# ferro.sql DEBUG CREATE TABLE "post" (...) params=() 1.2ms
# ferro.sql DEBUG ALTER TABLE "author" ADD COLUMN "bio" varchar params=() 0.8msEach record is a logging.LogRecord on ferro.sql with the message <sql> params=<params> <duration>ms (plus failed: <error> when the statement raised) and structured attributes sql, params (tuple), dialect, duration_ms, operation, subject, connection, rows, error. DDL from the pass and from a run carries operation="migrate"; the pass's subject is the table or enum type (the same string PassReport records), a run's is NNNN_name/<step file>.
The log costs nothing when it is off: Rust asks Python once per FFI entry whether ferro.sql is enabled for DEBUG (it holds the GIL at entry) and opens a task-local statement context only when it is; the engine's execute/fetch layer, the one place every statement passes through, measures and emits only inside such a context, taking the GIL once per statement. Rust formats nothing: it hands the record's data to one Python callable that builds the message and the LogRecord.
The ferro logger stops installing a StreamHandler with propagate = False at import; it installs a NullHandler and propagates. The ferro migrate CLI installs its own stream handler on ferro so a data step's ctx.log.info(...) lines print as they do today. The free-text ferro migrate: <file>: <sql> and ferro auto-migrate: <subject>: <sql> debug lines are removed in favour of the records; PassReport is unchanged. The auto-migrate guide's sentence about those debug lines now points readers at the ferro.sql records.
The emitter is the primitive the next tickets use: the operation ticket opens the same context at every operation entry with its own operation/connection and a set of secret values; the rendering ticket compares .sql() against these records. This ticket's records never mask anything (DDL has no parameters) but the record carries params and the emitter accepts a secret-value set so the operation ticket adds no second emit path.
Acceptance criteria
-
ferro.migrate()andferro.create_tables()on SQLite and Postgres leave oneferro.sqlrecord per statement inreport.statements, in order, withsqlequal to the report's,operation == "migrate",subjectequal to the report's,params == (),duration_ms >= 0,error is None. - A migration run (the migration test harness applying one migration) leaves one record per DDL/SQL-step statement with
subject == "NNNN_name/<step file>"; the lock-timeout test asserts on records, not onferrotext lines. - A statement the database rejects leaves a record with
errorset and the message endingfailed: <error>, and the exception still propagates. - With
ferro.sqlnot enabled forDEBUG, the Python emit callable is never called during a pass (asserted with a spy on it). - pytest's
caplogatDEBUGcapturesferro.sqlrecords andferrorecords with no handler setup; afterimport ferro,logging.getLogger("ferro").handlersis exactly oneNullHandlerandpropagateisTrue. -
ferro migrate upon a project whose data step callsctx.log.info("backfilled 3 rows")prints that line, as today. - No
ferro migrate:/ferro auto-migrate:statement line is logged anywhere;grep -rn "ferro migrate: \|ferro auto-migrate: " srcfinds only the error/refusal texts that are not statement logs. - The
ferro.sqllogger, the record attributes and theferro-logger change are documented in the Connections guide's logging section (one short section; the full guide page is the docs ticket) and the auto-migrate page's sentence is updated.
Files owned
- Create:
src/sql_log.rs,src/ferro/_sql_log.py,tests/test_statement_log.py - Modify:
src/lib.rs,src/backend.rs,src/ddl_exec.rs,src/run.rs,src/migrate.rs,src/migrations_ffi.rs,src/ferro/__init__.py,src/ferro/cli/__init__.py,tests/conftest.py,docs/pages/guide/schema/auto-migrate.md,docs/pages/guide/connections.md - Test:
tests/test_ddl_lock_timeout.py(itsferro_logfixture and theferro migrate: 0003_…line assertions become record assertions)
Interfaces
- Consumes:
PassReport.statements(subject,sql,role) and the DDL executor'sExecutedrecording, unchanged (ADR-0049). - Consumes:
ferro.migrations.testingharness and thebackend_matrix/postgres_onlymarkers for the tests. - Produces (Rust,
src/sql_log.rs):enabled(py: Python) -> PyResult<bool>(readslogging.getLogger("ferro.sql").isEnabledFor(DEBUG)through a cached logger);StatementContext { operation: &'static str, connection: String, subject: Option<String>, secret_values: Vec<EngineBindValue> };scope<F>(ctx: Option<StatementContext>, fut: F) -> impl Future(tokio task-local,Nonewhen disabled);observe(sql: &str, binds: &[EngineBindValue], dialect: Dialect, started: Instant, outcome: Result<Option<u64>, &sqlx::Error>)called by everyEngineHandle/EngineConnectionexecute and fetch method after the statement finishes, a no-op outside a scope;with_subject(subject: &str, fut)to override the subject for one statement group (a run's step). Used by the operation ticket. - Produces (Python,
src/ferro/_sql_log.py):LOGGER = logging.getLogger("ferro.sql");REDACTED(a singleton whosereprandstrare***);emit(record: dict[str, object]) -> None(builds the message and callsLOGGER.debug(msg, extra=...); theparamsentry arrives as a tuple withNonein masked positions andmask: tuple[bool, ...], andemitsubstitutesREDACTED). Used by the operation and rendering tickets. - Produces (tests,
tests/conftest.py): fixturestatement_recordsyielding the list ofLogRecords emitted onferro.sqlduring the test, withferro.sqlset toDEBUGfor its duration. Used by every later ticket's tests. - Produces:
ferrologger with aNullHandler, propagating;ferro.cli.maininstalls aStreamHandleronferroatINFObefore dispatching a verb.
Test scenarios
- Happy path:
ferro.create_tables()after dropping a table → one record perreport.statementsentry, same order,sql/subjectequal,operation == "migrate",params == (),dialectmatches the backend. - Happy path: the migration harness applies a migration with a DDL step and a SQL step → records with
subject == "0001_<name>/01_<step>.up.sql"(the step file name), one per statement. - Happy path:
caplog.set_level(logging.DEBUG)alone captures the records;logging.getLogger("ferro")has oneNullHandlerandpropagate is True. - Happy path:
ferro migrate upthrough the CLI entry point with a data step callingctx.log.info("backfilled 3 rows")→ the line appears on stderr. - Edge case:
ferro.sqlatWARNING→ a spy onferro._sql_log.emitis never called duringferro.migrate(). - Edge case: duration is measured around the send only: a record's
duration_msis a non-negative float. - Error path: a migration SQL step with invalid SQL → a record with
errornaming the database error and the message endingfailed: …; the run's failure propagates as today. - Integration:
tests/test_ddl_lock_timeout.py's executed-statement assertions readstatement_recordsand still pass on Postgres.
Verify
uv run maturin develop
uv run --no-sync pytest tests/test_statement_log.py tests/test_ddl_lock_timeout.py tests/test_migrations_testing_harness.py -q --no-cov -p no:cacheprovider
uv run --no-sync pytest tests -q --no-cov -p no:cacheprovider -k "cli or auto_migrate or create_tables"
cargo test --no-default-features --features testing
uv run --no-sync ruff check src/ferro testsSet FERRO_POSTGRES_URL and pass --db-backends=sqlite,postgres so the Postgres cases run rather than skip.
Blocked by
None (can start immediately).
Activity
0x054 commentedon Oct 21, 2026
Status: verify-passed
Worker: fix-764
Branch:
sdd/764-fix-panel-round-1PR: #773
Verify: passed 62b07fe (reviewer re-run, sqlite+postgres)
Panel round 1 on PR #771 (F3, F9, F8). The run-lock statements now go through the engine's fetch layer, so they are records under
migrate.tests/test_statement_log_run.pypins this on Postgres for both a run and the pass, and it fails onbcb9931.F9: decision (b). Pool hooks are excluded from the log, and the Connections guide and an ADR-0057 amendment say so. F8: every
operationvalue is documented.Review of #773: Spec PASS and Quality PASS. F3, F9 and F8 are addressed. One Minor: the docs say
SET search_pathruns on each new connection, but it runs only when a search_path is configured. #773 (review)Earlier: #768 merged; reviewer-764 gave Spec PASS and Quality PASS.
feat(log): statement log records on ferro.sql for migration DDL (#764) (
0x054 commentedon Oct 21, 2026
Merged in #768 into
feat/700-sql-inspection(2d1cf15).fix(log): panel round 1 for #764 — run-lock statements in the log, po…
feat: SQL inspection and logging (#700) (#771)