Repository navigation
feat: SQL inspection and logging — the ferro.sql statement log and .sql() on every Query terminal (#700) - #771
Conversation
#768) The statement log exists: every statement the reconciliation pass or a migration run sends is one ferro.sql DEBUG record with sql, params, dialect, duration_ms, operation, subject, connection, rows and error. Rust asks Python once per FFI entry whether the logger is on, opens a task-local statement context only then, and hands each record to ferro._sql_log.emit; the ferro logger installs a NullHandler and propagates, and the CLI installs its own handler. Interfaces changed: scope<F, T>(ctx, fut) -> PyResult<T> where F: Future<Output = PyResult<T>> (the ticket had no output bound; an emitter error must come back as the operation's result). Closes #764 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
#765) (#769) Every FFI operation entry opens the statement context with its operation and connection: queries, mutations, bulk and many-to-many operations, raw SQL, transaction control, the session-settings batch and the catalog probe each leave one ferro.sql record per statement. Redaction is a secret-value set per operation: Python collects the values it unwraps from secret types (compile_query, the bind-payload helpers, raw), Rust adds the values bound to declared secret columns, and a bind equal to any of them is emitted as ***. Interfaces changed: count_filtered takes operation="count"|"exists" (exists() runs through it); close_session requires connection_name (the settings reset on close is logged under its connection). Closes #765 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
…) (#770) Every Query terminal (all, first, count, exists, update, delete) carries a .sql() with the terminal's own signature that returns a RenderedSql (sql, params, dialect; str() is the SQL alone) instead of running. Each terminal's build step is one function shared by the executing and rendering FFI entries, so the rendered statement is the statement sent; rendering routes like the call, reads only the cached catalog, opens no transaction and writes nothing. params are masked with the operation's secret-value set. The parity pin (tests/test_rendered_statement.py) holds .sql() equal to the executed call's ferro.sql record for every terminal on both dialects, listed under I-1's enforcement. Interfaces changed: render_count(operation="count"|"exists") stands in for render_exists; terminal() returns TerminalMethod[S, P, R] binding to Terminal[P, R]; update keeps its two forms through _update_terminal (overloads on the call and on .sql). Closes #766 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
Verdict: FIX FIRSTOne Critical (a plain keyset cursor on a secret order column reaches the log and Spec: FAIL
Deferred to #767/#725, not counted as Missing: the Inspecting SQL guide page; the migration-guide note on data-step Cannot verify: Coherence
Every other shared interface agrees, producer against consumer: FindingsCritical
Important
Minor
Invariants and ADRsChecked: CoveragePersonas: correctness, testing, maintainability, standards-and-invariants, history, depth, security (secrets reach the log; redaction rule), adversarial (≥300 executable lines; data-mutation paths wrapped), api-contract (exported types and Residual risk
|
…et set, one log-off seam (#772) Panel findings F1, F2, F4, F5, F7 and F11 on PR #771: a plain keyset position on a secret order key (root or traversed) is redacted in the log and in .sql(); the secret-value set is a hashed SecretValues with the mask computed before the GIL; one collector (secret_values_in) and the engine as the only reader of the log switch; one query_secret_values for the four query entries and their render twins; ADR-0057 amended to the value-set rule. Interfaces changed: the secret_values FFI keyword accepts any iterable; secret_values_of/secret_values replaced by secret_values_in, deferred_secret_values, field_values in _bind_payload. Part of #700 (panel round 1 on PR #771) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
…ol hooks excluded, operation values documented (#773) Panel findings F3, F9 and F8 on PR #771: the run lock's pg_try_advisory_lock, held-by-session probe and pg_advisory_unlock go through a logged detached fetch in the engine layer and are migrate records; pool hooks (after_connect search_path, the after_release guard) are stated as excluded from the statement log in the Connections guide and a dated ADR-0057 paragraph, since logging them would take the GIL per connection open/release and sqlx runs the release hook on a spawned task; the Connections guide lists every operation value and subject. Interfaces changed: none Part of #700 (panel round 1 on PR #771) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
Summary
Epic: #700 (SQL inspection and logging). Three of its four slices; the guide page (#767) follows once the docs rewrite (#698) lands, so this PR does not close the epic.
A developer can now see what Ferro sends, two ways, both from the one function that builds and sends each statement:
BEGIN/COMMIT/savepoints, the session-settings batch, the catalog probe, migration DDL) is oneferro.sqlrecord. The logger is the only switch: Rust asks once per FFI entry whether it is enabled, opens a task-local context only then, and hands each record's data to one Python callable that formats it. Off costs nothing. Theferrologger now installs aNullHandlerand propagates; theferro migrateCLI installs its own handler.SecretStr/SecretBytes, is emitted as***(a sentinel,REDACTED, so tests can tell it from the string).Queryterminal (all,first,count,exists,update,delete) carries.sql()with the terminal's own signature, returningRenderedSql; rendering routes like the call, reads only the cached catalog, opens no transaction and writes nothing.tests/test_rendered_statement.pypins.sql()equal to the executed call's record for every terminal on both dialects (listed under I-1's enforcement in AGENTS.md).Design: ADR-0057; glossary terms Statement log and Rendered statement. Accepted deviations are on #700's plan comment: redaction as a secret-value set rather than a positional flag;
scope'sPyResultbound;count_filtered(operation=);close_session(connection_name);render_countstanding in forrender_exists.Architecture
The design rests on one rule: a statement is built in one place, and both doors read from it. Everything else follows from keeping that rule cheap.
One build function per terminal. Each
Queryterminal used to build and send its statement in one body. The build step is now its own function (build_fetch,build_count,build_update,build_deletein the Rust core), and two entry points call it: the executing one sends the result, the rendering one returns it. There is no second renderer, soawait query.all.sql()cannot drift from whatawait query.all()sends, and a test pins the two equal for every terminal on both dialects. This is I-1 applied to queries: the same reason the migration doors share one planner.The engine chokepoint, not the call sites. Every statement Ferro sends passes through a handful of execute and fetch methods on the engine connection. Those methods take the time and emit the record after the statement finishes, so one place covers queries, mutations, raw SQL, transaction control, session settings, the catalog probe and migration DDL. A statement that fails emits a record with its error before the exception propagates. The fix round closes the gap the panel found: the Postgres run-lock statements used the raw driver and bypassed this layer.
The switch is the logger, checked once per operation. Each FFI entry already holds the GIL when Python calls it, so it asks
logging.getLogger("ferro.sql").isEnabledFor(DEBUG)once and opens a tokio task-local statement context only when the answer is yes. The chokepoint reads that context; outside one it does nothing. Off costs nothing. On, the GIL is taken once per statement to hand the record's data to one Python callable,ferro._sql_log.emit, which builds the message and theLogRecord. Rust formats nothing, so the record's shape lives in one Python function. There is noecho=flag or setting to disagree with the logger.Redaction is a set of values, matched when the record is written. Rust builds the binds from the query plan, so Python never sees them one at a time, and a positional mask was not available. Instead each operation carries a secret-value set: Python adds every value it unwrapped from a
SecretStrorSecretBytes(predicate literals, field values on save and update, raw arguments), and Rust adds every value bound to a column the model declares secret, for the root model and every joined one. A bind equal to a value in the set is emitted as theREDACTEDsentinel. Matching by value over-masks in one case, a plain value that happens to equal a secret, and never under-masks. The rendered statement'sparamsgo through the same set, so.sql()and the log show the same***. (The panel's F1 and F2 are this design's two gaps: keyset cursors were not added to the set, and the set was a list; the fix round adds them and hashes it.)Terminals carry
.sql()through a decorator.all,first,count,exists,updateanddeletestayasync defmethods; a decorator makes each a callable with a.sqlattribute of the same signature. A decorator rather than a class-level descriptor so the API reference still renders each as a method, and a typed descriptor forupdateso its two forms stay distinct toty. The same decorator can extend.sql()tosave,bulk_createand the many-to-many operations later without a new API.The library stops owning a handler.
ferroinstalls aNullHandlerand propagates, the standard posture for a library, so the records reach whatever the application configured:caplog, structlog, a JSON handler. Theferro migrateCLI is an application and installs its own stream handler, so a data step'sctx.loglines print as before.The decisions and their rejected alternatives (an offline renderer from the model, an
echo=flag, a string-kindquery.sql("update"), acapture_sql()context manager) are recorded in ADR-0057; the glossary names the two artifacts Statement log and Rendered statement.Evidence
ferro's ownStreamHandlerwithpropagate=Falsekept its debug lines out ofcaplogand application handlers.After: on
bcb9931, full suite on SQLite and Postgres: 4875 passed, 77 skipped, 2 xfailed;cargo test --no-default-features --features testing382 passed;tyandruffclean. Each slice's Verify block ran green on its PR head (fresh reviewer) and again on the integration branch after merge.Merge Danger
Door: two-way for the code; one-way for the logging behaviour users observe.
Reverting the branch restores the old logger setup. The behaviour change itself: anyone who relied on Ferro's own stderr handler printing
ferroINFO lines (data-stepctx.logoutput when calling the migration API from Python) now sees them only once they configure logging, which the Connections guide's new Logging section says. Release notes should carry that line.Blast Radius: medium
Every FFI operation entry is now wrapped in the statement-log scope (bodies unchanged, verified by review);
count_filteredandclose_sessiongained parameters (internal_coreAPI, callers updated);Query.updateis now a typed descriptor with the same two overloaded forms (pinned bytyfixtures). No wire-format change: golden vectors are byte-identical.🤖 Generated with Claude Code
https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP