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.
An investigation, start to finish
Section titled “An investigation, start to finish”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 | 02026-10-20T00:00:34.652468Z | pass | 0 | completed | 02026-10-19T23:59:23.827831Z | pass | 0 | completed | 0
3 rowsThe 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 rowPayment 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.
What is recorded
Section titled “What is recorded”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_bytesof 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.
Settings
Section titled “Settings”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 exitsdiagnostics_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 = falseRecall 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.
Output limit
Section titled “Output limit”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.
Retention
Section titled “Retention”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.
When the store cannot be written
Section titled “When the store cannot be written”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.
Sharing history in ClickHouse
Section titled “Sharing history in ClickHouse”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 onlyINSERTandSELECTon 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 toSELECTandINSERT. - 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 (
FINALonReplacingMergeTreetables), 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 inconflicts. - 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’sretentiondiffers from the tables’ TTL, Recall warns once a day with theALTER TABLE … MODIFY TTLand view statements that would apply it, and never changes the TTL itself. - Reading.
sqb recall query,schemaandoutputread the ClickHouse store, so queries use ClickHouse SQL.--jsonoutput includes the server’srows_read,bytes_readandquery_id.
Use a single ClickHouse server or ClickHouse Cloud; ON CLUSTER stores are not supported yet.
Filtering what Recall stores
Section titled “Filtering what Recall stores”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 RecallWriterfrom sqlbuild.sinks import CommandOutputRecord, command_output_sink
@command_output_sinkdef 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_sinkreplacement receives each output chunk with its exact bytes inrecord.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_sinkreplacement receives each event itsevent_kindsandmin_severityaccept and keeps it withwriter.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 asdropped. 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 asfailed, and Recall never falls back to storing the unfiltered record. The command’s exit code and output are unaffected.output_invocations.filter_activeis1for invocations that ran a replacement. - There is at most one replacement per stream; discovery fails with
D015naming eachpath:functionotherwise. A replacement takes only its record and the writer, not providers, and the provider namerecall_writeris reserved. - Other sinks keep receiving every record. With
enabled = false, no replacement runs.
Querying history
Section titled “Querying history”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.
Relationship to compute logs
Section titled “Relationship to compute logs”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.