Skip to content

Observability

Recall

The local history of every command: what ran, what it executed, and what it printed.

Recall records each sqb invocation in a local SQLite store, or in a shared ClickHouse store, so you can ask what happened after the fact: which command ran, how it ended, which statements it executed and how long they took, and exactly what it printed. Recall is on by default and needs no setup.

The waffle shop example’s daily_revenue model has an audit, revenue_is_non_negative, at warn severity. One night the payments feed starts recording a refund as a negative payment. The unit tests still pass, because they run on mocked data. The build finishes with a warning that nobody reads.

The next morning, ask when the audit started failing, and what each of those builds reported:

sqb recall query --format table \
"SELECT a.invocation_started_at AS run, a.outcome, a.violation_count, i.status, i.exit_code
FROM audit_results a JOIN invocations i USING (invocation_id)
WHERE a.audit_name = 'revenue_is_non_negative' ORDER BY run DESC LIMIT 3"
run | outcome | violation_count | status | exit_code
--------------------------- | ------- | --------------- | --------- | ---------
2026-10-20T00:01:44.784683Z | warn | 1 | completed | 0
2026-10-20T00:00:34.652468Z | pass | 0 | completed | 0
2026-10-19T23:59:23.827831Z | pass | 0 | completed | 0
3 rows

The audit started warning on the latest build, and that build still exited 0, so nothing alerted.

See what that build printed, exactly as the scheduler saw it:

sqb recall output last --tail 4
daily_revenue
audit revenue_is_non_negative returned 1 row
✓ Completed with warnings PASS=89 WARN=1 FAIL=0 INSUFFICIENT=0 SKIP=0 TOTAL=90 (3.08s)

Then check what is true in the warehouse now, with sqb recall query again:

sqb recall query --backend warehouse --format table \
"SELECT payment_id, order_id, amount_cents, paid_at FROM prod.stg_payments WHERE amount_cents < 0"
payment_id | order_id | amount_cents | paid_at
---------- | -------- | ------------ | -------------------
9 | 8 | -2200 | 2026-04-03 15:42:00
1 row

Payment 9 is a refund recorded as a negative payment, and it is still in prod. A coding agent can follow the same steps: the history is SQL over the views, and every query can return --json.

For every recorded invocation, Recall keeps:

  • the invocation itself: command, arguments, target, adapter, status, exit code, and timing;
  • its lifecycle events: runs, resource attempts, statements, retries, and audit results;
  • the SQL text of every executed statement, stored once per distinct statement and shared across invocations;
  • its stdout and stderr, byte for byte, in the order they were written, up to max_output_bytes of each stream;
  • framework diagnostics at or above diagnostics_min_severity.

Writes happen on a background thread and never fail the command. A command that cannot record all of its history still succeeds, prints a warning followed by its own final status, and output_invocations reports the gap. An invocation whose process died without finishing has status abandoned.

Every command is recorded except sqb recall itself, commands that create a project (such as sqb init), and raw-query sqb diff. Commands sqb answers without starting Python are not recorded either: a sqb compile answered from the launcher’s warm cache, and sqb clean, sqb skills, sqb rules list and sqb rules show in a project with no custom rules, rule options or sinks.

The store is logs/recall.sqlite3 in the project directory. Add logs/ to .gitignore; the store can contain sensitive SQL and output.

Configure Recall in [recall] in sqlbuild_project.toml. A [targets.<name>.recall] table overrides individual keys for one target.

[recall]
retention = "30d" # days of history to keep; also accepts hours, such as "720h"
shutdown_timeout = "3s" # longest delay Recall may add when a command exits
diagnostics_min_severity = "info"
max_output_bytes = 2097152 # bytes of each output stream to keep; 0 keeps everything
[targets.ci.recall]
enabled = false # do not record runs against the ci target
Key Default Description
enabled true Set to false to stop recording
connection (none) A sqlite connection whose path names the store, or a clickhouse connection; defaults to logs/recall.sqlite3
retention "30d" How long invocations are kept, in days (d) or hours (h), up to 3650 days
shutdown_timeout "3s" The cap on time spent flushing history at exit, in s or ms, up to 10 minutes
diagnostics_min_severity "info" trace, debug, info, warning, error, or critical
max_output_bytes 2097152 for the local store Bytes of stdout and of stderr kept per invocation, an integer; 0 keeps everything. See Output limit

To turn Recall off for the whole project:

[recall]
enabled = false

Recall never delays the start of a command. At exit it waits at most shutdown_timeout for queued history to reach the store; anything left is counted as dropped. A second Ctrl-C stops waiting at once.

Recall keeps at most max_output_bytes of each output stream per invocation: the first half and the last half, byte for byte. The local SQLite store defaults to 2 MiB, so a command that prints more keeps its first and last MiB of stdout and of stderr. Set max_output_bytes = 0 to keep everything, or a larger number to keep more; a [targets.<name>.recall] table can set it for one target. The command itself prints nothing about the limit.

The middle that was left out is one gap: output_invocations reports gap_reason = 'truncated', the left-out byte count in truncated_bytes, and complete = 0. sqb recall output prints a note in its place:

… 23.6 MB of stdout not recorded (kept the first and last 1 MiB). Raise [recall] max_output_bytes to keep more.

The end of a stream is held in memory until the command exits, so a process that is killed keeps only the first half.

Commands occasionally prune invocations older than retention, with all of their events, output, and diagnostics. SQL text is removed once no remaining invocation references it. Pruning runs in the background within the exit budget and never removes an invocation that is still running.

If the store location is not writable, the command still runs and prints a notice on stderr with the SQL an administrator can use to create the store; the notice comes before the command’s own output whenever the problem is visible from file permissions. The notice repeats at most once a day. If the store file is not a SQLite database, Recall moves it aside to recall.sqlite3.corrupt-<UTC timestamp>, starts a fresh store, and says so once. Commands that find the same corrupt file coordinate through recall.sqlite3.lock, so only one of them moves it.

A connection on any adapter other than sqlite or clickhouse is not supported; Recall prints a notice and records nothing.

Point connection at a ClickHouse connection to keep one history for a whole team. Every machine that writes to it, including CI and scheduled production runs, becomes queryable from anywhere: see what last night’s production build ran and printed without access to the machine, find the first run where an audit started failing across everyone’s runs, or compare a production run with your own by target, git_revision or sqlbuild_version.

The connection needs adapter = "clickhouse" when the project uses another adapter, and its database must already exist; Recall never creates databases.

[connections.history]
adapter = "clickhouse"
host = "${env:CLICKHOUSE_HOST}"
user = "recall_writer"
password = "${env:CLICKHOUSE_PASSWORD}"
database = "observability"
[recall]
connection = "history"
retention = "30d"
  • Setup. On first use Recall creates its recall_* MergeTree tables and the views above in that database, and adds columns that a newer SQLBuild introduces. After that, writing needs only INSERT and SELECT on the tables. If the user may not create them, the command still succeeds and prints the complete DDL for an administrator to run; a store created from it accepts writers limited to SELECT and INSERT.
  • Writes. History is sent in batches of up to 5,000 rows about once a second, one insert per table, with the same queue, shutdown budget and exit-code guarantees as the local store. Many concurrent commands can write one store.
  • Duplicates. Every row has a deterministic key. A retried batch is deduplicated by ClickHouse, and the views read each key once (FINAL on ReplacingMergeTree tables), so a repeated delivery is counted once. Before writing, Recall looks up the batch’s keys: a key already stored with different content keeps the stored row, and the rejected copy is listed in conflicts.
  • Retention. Every table carries a TTL of retention (SQL text a week longer, and refreshed while it is still referenced), and the views hide rows past their TTL at once. ClickHouse drops expired daily parts in the background, so rows may stay on disk for a while after the views stop showing them. Retention belongs to the store: when a project’s retention differs from the tables’ TTL, Recall warns once a day with the ALTER TABLE … MODIFY TTL and view statements that would apply it, and never changes the TTL itself.
  • Reading. sqb recall query, schema and output read the ClickHouse store, so queries use ClickHouse SQL. --json output includes the server’s rows_read, bytes_read and query_id.

Use a single ClickHouse server or ClickHouse Cloud; ON CLUSTER stores are not supported yet.

By default Recall stores every lifecycle event and every output byte. To decide yourself, write a sink with a parameter annotated RecallWriter: it replaces Recall’s default for its stream, and Recall stores only what it publishes.

from sqlbuild.recall import RecallWriter
from sqlbuild.sinks import CommandOutputRecord, command_output_sink
@command_output_sink
def recall_output(record: CommandOutputRecord, writer: RecallWriter) -> None:
redacted: bytes = record.data.replace(b"secret-token", b"[redacted]")
writer.publish_command_output(record.replace(data=redacted))
  • A @command_output_sink replacement receives each output chunk with its exact bytes in record.data (record schema version 2). record.replace(data=…, message=…) returns a copy that keeps the record ID and sequence; publish at most one record per call.
  • A @lifecycle_event_sink replacement receives each event its event_kinds and min_severity accept and keeps it with writer.publish_lifecycle(event).
  • Both replacements run on Recall’s background thread, so a slow one never delays the command or other sinks by more than shutdown_timeout; what it has not handled by then is counted as dropped. Output written before discovery finishes is held until the replacement can see it.
  • A record the sink does not publish is counted as filtered; a sink that raises an exception has its record counted as failed, and Recall never falls back to storing the unfiltered record. The command’s exit code and output are unaffected. output_invocations.filter_active is 1 for invocations that ran a replacement.
  • There is at most one replacement per stream; discovery fails with D015 naming each path:function otherwise. A replacement takes only its record and the writer, not providers, and the provider name recall_writer is reserved.
  • Other sinks keep receiving every record. With enabled = false, no replacement runs.

Use sqb recall to read the store. Queries are read-only SELECT statements, in the store’s SQL dialect, against these views:

View One row per
invocations Invocation, with status, exit code, and timing
statements Executed statement, with resource, kind, purpose, duration, and row counts
audit_results Evaluated audit
events Lifecycle event
diagnostics Retained diagnostic
artifacts Reference from an invocation to captured SQL, with its text
output_invocations Invocation’s output, with completeness
output_transcripts Invocation’s stdout, stderr, and combined output
output_tail Recent output chunk
conflicts Record written twice with different content

Run sqb recall schema for every column.

Compute logs (logs/<date>/<invocation_id>/stdout.log, stderr.log, and diagnostics.jsonl) are still written alongside Recall. Recall stores the same output bytes, captured at the same point, so sqb recall output matches the compute log files.