
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
Quick answer. SQLite has no query log because it has no server: your application calls a library, so there is no daemon in between that could hold one. The ecosystem's answer is
sqlite3_trace_v2, a callback you register from inside the process, and SQLite's own documentation notes that each database connection may have at most one trace callback. On Linux there is a second place to stand. The sharedlibsqlite3.so.0is one file that every caller shares, so a uprobe on it covers every process on the box at once:yeet run gh:yeet-src/sqlitefeed.
I build eBPF tools for a living and sqlitefeed is one of them, which means my bias here is structural and worth declaring up front. I am not a SQLite committer and I have never shipped a patch to it; the people who wrote the documentation quoted throughout this post understand the engine far better than I do. What I have watched, repeatedly, is engineers hit the same wall in the same order. They want to know what SQL a running process is issuing, they find sqlite3_trace_v2, they discover it requires editing and restarting the thing they were trying to observe, and they give up and go read strace output instead. The interesting part is that SQLite's documentation is completely honest about all of this. The gap is in what the ecosystem concluded from it.
Watch the library, not the ORM. Every query builder, every lazy-loading relation and every migration helper eventually calls sqlite3_prepare_v2 with a string, and that string is the ground truth after all the abstraction layers have had their say. Attaching a uprobe to that function in the shared libsqlite3.so.0 gives you the statement as the engine received it, which is a different artifact from what your ORM logs: the ORM logs its intent, and the library receives its output.
The practical difference shows up when the two disagree. An ORM echo flag prints the SQL the ORM believes it generated, before the driver has finished with it, and it prints nothing at all for statements the ORM did not originate. Statements it did not originate are more common than most people expect: the Python sqlite3 module's own transaction management issues its own statements, and so do triggers defined in the database, which fire without any application code asking them to.
yeet run gh:yeet-src/sqlitefeed
That clones, builds and runs, and needs no sudo because the yeet daemon performs the privileged BPF load on its behalf. Rows land as soon as any process on the host prepares or executes a statement. The feed identifies each one by comm/pid, so a Python service, a Go sidecar and someone's interactive sqlite3 shell appear in the same stream, distinguishable but undivided.
What you get per statement is the SQL text, the values bound to each placeholder, the number of rows the execution returned, the worst single sqlite3_step latency in that execution, and the final result code. That last set is the part an ORM log usually cannot give you at all, because by the time the ORM sees an error it has been translated into an exception two layers up.
Because a query log is a feature of a server, and SQLite deliberately does not have one. SQLite's documentation puts the architecture plainly: "Most SQL database engines are implemented as a separate server process. Programs that want to access the database communicate with the server using some kind of interprocess communication (typically TCP/IP) to send requests to the server and to receive back results. SQLite does not work this way." Instead, "the process that wants to access the database reads and writes directly from the database files on disk. There is no intermediary server process."
Every operator-facing observability feature you are used to lives in that missing process. log_min_duration_statement in Postgres works because a Postgres backend is a long-lived process that owns the connection, parses the query, and can therefore write a line about it. MySQL's slow query log is the same idea. Both are configuration, applied by someone with access to the server, taking effect without touching a single application. The server is the shared chokepoint, which is exactly why it can be instrumented once and cover everything.
SQLite trades that chokepoint away on purpose, and the documentation is explicit about what it buys: "The main advantage is that there is no separate server process to install, setup, configure, initialize, manage, and troubleshoot." That trade is why SQLite is, by its own accounting, "likely used more than all other database engines combined," with "billions and billions of copies" in the wild and probably "over one trillion (1e12) SQLite databases in active use." You cannot have zero configuration and a configurable log. The absence of the daemon is the feature.
So the question is not how to enable a log that was never there. It is where else a shared chokepoint exists. For a networked datastore the answer is usually the wire, which is where reading Redis traffic happens, and for HTTP it is the kernel's view of the socket, which is what monitoring HTTP traffic on Linux exploits. SQLite has no wire. What it has instead is a file.
Yes, on Linux, if the application links the shared libsqlite3 instead of bundling its own copy: uprobes attach to the library file on disk, which needs no rebuild and no restart. But the in-process route deserves its due first, because for a class of problems it is the right one. sqlite3_trace_v2 is a supported, documented, stable interface for exactly this job. It gives you the prepared statement pointer, it can report timing, and because it runs inside the process it knows things no external observer can know, including which connection and therefore which database file a statement belongs to. If you are building the application and you want tracing as a shipped feature, this is what you should use, and nothing below argues otherwise.
The limits are in the shape of the interface, not its quality. SQLite's documentation states that the call "registers a trace callback function X against database connection D," and then, decisively for operators: "Each database connection may have at most one trace callback." Three consequences follow, and each one bites in practice:
The event codes carry a subtler catch. An SQLITE_TRACE_STMT callback receives "a pointer to a string which is the unexpanded SQL text of the prepared statement." Unexpanded means the placeholders are still placeholders. To get values you have to call sqlite3_expanded_sql(P) yourself inside the callback, then free the result with sqlite3_free, and that function "always return[s] NULL" if the library was built with SQLITE_OMIT_TRACE. Timing has a similar shape: SQLITE_TRACE_PROFILE reports "approximately the number of nanoseconds that the prepared statement took to run" and is "invoked when the statement finishes," so it is a whole-statement number rather than per-step.
The other in-process route is worse for operators, not better. SQLITE_ENABLE_SQLLOG sounds like the query log the name promises, but SQLite's compile-options page describes what using it involves: "In order for the SQLITE_ENABLE_SQLLOG option to be useful, some extra code is required," specifically appending the test_sqllog.c source file "to the end of an 'sqlite3.c' amalgamation," recompiling the application with -DSQLITE_ENABLE_SQLLOG, and controlling logging through environment variables. That is a build-system change to get visibility into a running system.
Language bindings inherit these limits and narrow them further. Python's sqlite3 module exposes set_trace_callback, which registers "for each SQL statement that is actually executed by the SQLite backend," and the documentation is clear about what arrives: "The only argument passed to the callback is the statement (as str) that is being executed." One string. No bound values, no timing, no result code, and it is a method on a Connection object, so it carries the per-connection scope with it.
The values are usually the entire bug, and a statement log without them is half a log. INSERT INTO users (username, email) VALUES (?, ?) failing with a constraint violation tells you nothing you did not already know. ?1='alice5866' next to it tells you someone is retrying a signup, or that a uniqueness assumption you made about email addresses is wrong, or that two workers picked up the same job. SQLite's own documentation frames the distinction with an example worth internalizing: a statement prepared as SELECT $abc,:xyz with $abc bound to 2345 gives you SELECT $abc,:xyz from sqlite3_sql and SELECT 2345,NULL from sqlite3_expanded_sql. Same statement, two very different debugging artifacts.
Reading them from outside the process means hooking the bind calls, because the values never appear in the SQL text at all. sqlite3_bind_int, _int64, _text and _null each take the sqlite3_stmt* pointer, a one-based parameter index, and the value, so a uprobe on each one gets all three. Correlating them back to a statement works because the statement pointer is the identity: sqlite3_prepare_v2 mints it, and every subsequent bind and step names it. sqlitefeed keys on that pointer to reassemble one execution from a dozen separate function calls, and it does that reassembly in userspace, not in the kernel, which keeps the BPF programs simple enough to pass the verifier on older kernels.
Two value types do not survive the trip, and both are register-level facts instead of design choices. A bound REAL is passed in an SSE register, xmm0, which is not part of the pt_regs structure a uprobe receives, so the value cannot be read and only its type is known; sqlitefeed renders it as «real». Blobs are not hooked at all, on the grounds that arbitrary binary of arbitrary length does not belong in a scrolling terminal feed. Integers, text and NULLs come through in full, capped at 512 bytes because that cap is applied at the kernel boundary before the event is ever queued.
python3/34412 INSERT INTO users (username, email) VALUES (?, ?) 0r 412µs CONSTRAINT
↳ ?1='alice5866' ?2='[email protected]'
That is the shape of the answer to "which values broke it", and the result code on the right is SQLite's own: CONSTRAINT, UNIQUE, NOTNULL, BUSY, MISMATCH. An application that catches the exception and logs "database error" has discarded that code on its way to your log file. It never left the library.
Latency in SQLite attaches to sqlite3_step, not to the statement as a whole, and the difference is not pedantic. One statement usually means many step calls: step returns SQLITE_ROW once per result row and SQLITE_DONE when it is finished, so a SELECT returning 400 rows involves 401 calls. A total duration blends the expensive call with the cheap ones and hides the shape. Pairing a uprobe on entry with a uretprobe on return gives you each call separately, which is what makes "the first step took 36ms and the remaining 400 took 4µs each" a visible fact instead of an average of 90µs.
Per-step latency separates three failure shapes that a single duration blends together, and they have different fixes:
ORDER BY, or materializing a subquery. This is the shape an index usually fixes.Distinguishing them needs a distribution, not a single number, which is why sqlitefeed's detail overlay aggregates p50, p95, p99 and max across every logged run of that exact SQL, with a sparkline of the most recent 48 runs. Consistently slow and occasionally slow look completely different there.
The other performance shape a statement feed exposes almost for free is the N+1 query. Its signature is the same SQL repeating with a different bound id on every row, dozens of times in a burst, each execution individually fast. No latency graph can show you that, because nothing is slow; the total is slow. Watching statements scroll makes the repetition obvious in a way that aggregated metrics structurally cannot, which is why this is worth doing even when your monitoring says the database is healthy. If the question is aggregate database health across a fleet instead of one host right now, an APM answers it and this does not, for reasons covered in the kernel metrics an APM agent misses.
Yes, and this is the case in-process tracing structurally cannot serve, because a callback you register now cannot report a statement compiled an hour ago. It is also harder than it looks, for a reason built into how applications use SQLite: they prepare a statement once and reuse the handle for the life of the process. Python's sqlite3 module caches 128 statements per connection by default. Attach in the middle of that and every step you see belongs to a statement whose prepare happened long before you arrived, leaving you a bare pointer and no SQL text.
The recovery is a small piece of applied disassembly. sqlite3_sql(stmt) is, in effect, return ((Vdbe*)stmt)->zSql, which compiles down to a single mov OFFSET(%rdi),%rax. So at build time sqlitefeed disassembles that one function in the host's own copy of libsqlite3, reads the structure offset directly out of the instruction, and bakes it in as a compile-time define. On the first step or bind of a statement it has not seen prepared, the probe reads Vdbe.zSql at that offset and emits a synthetic prepare event, so a cached statement lights up with its real SQL and then correlates exactly like one that was watched compiling. No DWARF, no debug symbols, and no per-version offset table to maintain.
Two limits come with that trick and belong next to it. It is x86-64 only, because the instruction being read is x86-64. If the library cannot be located at build time, or the architecture is different, the offset is zero, the recovery path compiles out, and statements prepared before you attached render as «unknown» until the application re-prepares them. A restart of the traced application gives you a complete feed either way, which is often available even when a restart-to-add-tracing is not.
It is almost certainly linked statically, and this is the largest limitation of the entire library-boundary approach. A uprobe attaches to a file on disk. If a program bundles its own copy of SQLite into its executable instead of calling the system one, there is no libsqlite3.so.0 in that process to attach to, and its statements never appear in the feed. This is not an edge case: it is the normal build mode for single-binary Go and Rust tools, which is most of the modern ecosystem's CLI and service tooling.
Check before you conclude the tool is broken. Two commands, in order:
ldconfig -p | grep libsqlite3
ldd $(which myapp) | grep -i sqlite
The first asks whether a shared libsqlite3 exists on this host at all. If it returns nothing, there is no seam here and nothing to attach to. The second asks whether the specific program you care about actually uses it. ldconfig -p lists the linker cache, and ldd prints the shared objects a binary needs; a binary with SQLite compiled in shows no SQLite line in ldd output, and that absence is your answer. An empty feed and a broken feed look identical from the outside, so running these first saves an hour of debugging the wrong thing.
Containers have a version of the same question with a different answer. Attaching from the host covers container processes that use the host's shared library, because it is the same file. A container carrying its own libsqlite3 in its image is a different file on disk and is not covered by that attach, exactly like a static binary. The rule underneath both cases is that the unit of instrumentation is the library file, not the process and not the machine.
Where does that leave a statically linked application? With the in-process routes. If your Go service compiles SQLite in, sqlite3_trace_v2 through whatever your driver exposes is the tool, and its per-connection scope is a much smaller problem when you only have one application to instrument and you control its source. This is the case where the criticism in this post does not apply.
Six routes, and the choice comes down to two questions: can you change and restart the application, and does it link the shared libsqlite3 or bundle its own copy?
| Tool or route | Where it stands | Needs a code change | Covers other processes | Gives bound values |
|---|---|---|---|---|
| sqlitefeed with yeet | uprobes on shared libsqlite3.so.0 | no | yes, all dynamic linkers | yes, except REAL and blobs |
sqlite3_trace_v2 | inside the process, per connection | yes | no | via sqlite3_expanded_sql |
SQLITE_ENABLE_SQLLOG | compiled into the library | yes, a rebuild | no | yes, in the logs it writes |
| ORM echo flags | the query builder, above the driver | config or code | no | usually yes |
strace | syscalls, below the library | no | yes, per process | no |
| Your own probe on yeet | wherever you attach it | no | yes | whatever you capture |
Best when you did not write the application, cannot restart it, or need several processes in one view. One attach covers every current and future caller of that library file, which includes the cron job somebody adds next quarter and the interactive sqlite3 shell a colleague runs by hand. What you give up: no retention, no query language, no alerting, no view wider than this host, and a 2000-execution in-memory log that is gone when you quit. It also cannot tell you which database file a statement hit, because the probes key on the statement pointer and the filename lives on the connection.
sqlite3_trace_v2: the interface SQLite providesBest when you are building the application and want tracing as a shipped feature instead of an intervention. It is structured, supported, and knows the connection, which means it knows the database file. It is the only route that works for statically linked SQLite. The costs are the ones the documentation states: one callback per connection, a code change to register it, and unexpanded SQL unless you call sqlite3_expanded_sql and free the result yourself.
SQLITE_ENABLE_SQLLOG: logs for offline analysisBest for reproducible offline analysis of an application you build, which is what the documentation describes it as being for: logs "useful in doing off-line analysis of the behavior of an application, and especially for performance analysis." The cost is the build procedure, which involves appending test_sqllog.c to an amalgamation and recompiling. Nobody does this to a production incident.
Best in development, on one application, when the question is what your code intends to send. It is the fastest route to an answer when it applies, and it is the only one that shows you the SQL alongside the application code that generated it. It cannot see statements the ORM did not originate, including trigger-fired statements and the transaction management your driver performs on its own, and it covers exactly one process.
strace: the syscall floorBest when you suspect the problem is I/O and not SQL. It sees pread64 and pwrite64 against the database file, so it tells you SQLite did I/O, how much, and how long it blocked. It cannot tell you which statement caused that I/O, because by the time a syscall happens the SQL has become page offsets. It also has real overhead, since ptrace stops the process on every syscall.
Best when you need one specific thing sqlitefeed does not do, such as emitting statements as JSON to a collector, or filtering on a table name in the kernel before events ever reach userspace. Attaching uprobes from JavaScript on the yeet runtime is a smaller job than it sounds, and sqlitefeed's own probe module is roughly one file. If you would rather work in a tracing DSL, the tradeoffs between bpftrace and BCC apply here unchanged.
Not measurably, and the reason is worth understanding because it determines what happens when it does become a problem. A uprobe is a passive breakpoint: it costs a trap into the kernel, a few register reads, and a bounded write into a ring buffer, on a code path that was already executing a database operation. There is no formatting, no I/O, and no lock in the traced process, and nothing is injected into the application's address space.
What matters more than the per-call cost is the backpressure behavior. sqlitefeed's ring buffer is 512 KB, and every step and exec event carries its SQL text, so a process running tens of thousands of statements per second can outrun it. When that happens events are dropped rather than queued, which means the feed becomes lossy while the traced application continues at full speed. That is the correct trade for a debugging tool and the opposite of the trade a metrics pipeline makes: a tool that blocked your application to guarantee its own completeness would have changed the thing you were measuring.
The privilege story is separate from the overhead story and is worth separating explicitly. Loading a BPF program and attaching uprobes is privileged work, but the privilege does not have to live in your shell. yeet runs a daemon that performs the load, so yeet run never takes sudo, and the script itself runs unprivileged. If uprobes and BPF are new territory, what eBPF is and how it works covers the mechanism underneath all of this.
Use sqlite3_trace_v2 when you are building the application, when you need to know which database file or connection a statement belongs to, or when SQLite is statically linked, which is the case where library-level probing has nothing to attach to. Use an ORM echo flag in development when the question is what your own code intends to send and you want it in your own logs. Use your APM when the question is aggregate and historical: which service regressed last Tuesday, across a fleet, with retention.
Use sqlitefeed when the question is what a process on this box is doing right now, and you did not write it, cannot restart it, or need several processes in one view. That framing is narrow on purpose. There is no retention, no alerting, no fleet view, and no persistence of any kind; the log holds 2000 executions in memory and forgets them on exit. It is a live-debugging instrument, which is a different job from monitoring, and the two are complementary and not competing.
And if the database in question is not SQLite, this shape does not transfer. The library boundary works here specifically because SQLite is a library. A datastore reached over a socket puts its traffic on the wire, which is a different and generally easier place to stand, whether that is Redis or anything else speaking a protocol you can parse.
sqlite3_trace_v2 for apps you build, uprobes for hosts you inheritIf you own the source and can restart the process, register a trace callback: it is supported, it knows the connection, and it is the only option for a statically linked build. If you inherited the box and the process is misbehaving now, attach to the library instead, because libsqlite3.so.0 is the one chokepoint every dynamic caller shares. If SQLite is compiled into the binary, check ldd first and then stop reading about uprobes, because there is nothing there to attach to. If you need history, retention or a fleet view, that is an APM's job and nothing on this page substitutes for it. And if the statement text alone is not answering your question, the values bound to the placeholders probably are, which is the argument the whole post rests on: WHERE score > ? is a shape, and the bug is usually in the ?.
The mistake worth avoiding is the one that starts this whole sequence, which is reading "SQLite has no query log" as a gap in SQLite. It is a consequence of a design decision the documentation states outright, taken deliberately, and it bought the ubiquity that makes the question worth asking in the first place. The daemon that would have held your log is the same daemon nobody had to install.
No, and not in a way any configuration file can turn on. A query log in Postgres or MySQL is a feature of a server process that sits between your application and the data. SQLite has no server process, so there is nothing running that could hold a log. The closest equivalents are sqlite3_trace_v2, a callback you register from inside the application, and the SQLITE_ENABLE_SQLLOG compile-time option, which requires rebuilding the library. Both are things the application does to itself rather than something an operator turns on from outside.
Two levels, and they answer different questions. In-process, the SQLITE_TRACE_PROFILE event passes what SQLite documents as approximately the number of nanoseconds that the prepared statement took to run, invoked when the statement finishes, so it is one figure for the whole statement. From outside, pairing a uprobe on sqlite3_step entry with a uretprobe on its return gives you each step call separately, which matters because one statement is usually many step calls and the expensive one is often the first.
sqlite3_sql returns the SQL text as it was compiled, with the placeholders still in it. sqlite3_expanded_sql returns the same statement with bound parameters substituted. SQLite's own documentation gives the example: a statement prepared as SELECT $abc,:xyz with $abc bound to 2345 returns SELECT $abc,:xyz from sqlite3_sql and SELECT 2345,NULL from sqlite3_expanded_sql. The distinction matters because a trace that only gives you the unexpanded text has told you the shape of the query and not the values that made it fail.
Because that is what the interface does. SQLite's documentation states that sqlite3_trace_v2 registers a trace callback against a database connection, and that each database connection may have at most one trace callback. A process with several open connections needs the callback registered on each, and a library in your dependency tree that opens its own connection is not covered by a callback you registered on yours.
A kprobe attaches to a function inside the kernel; a uprobe attaches to a function in a userspace executable or shared library, specified as a path plus an offset. The Linux uprobe-tracer documentation describes the target as an executable or a library, which is what makes library-level tracing possible: the probe lives on the file, so every process that maps that file is covered by one attach. Both have return-probe forms that fire when the function exits, which is how you get a duration rather than just a call.
Run ldd against the binary and look for a libsqlite3 line. If one appears, the program calls the shared library and library-level tracing can see it; if nothing appears, SQLite is compiled into the executable and there is no shared object to attach a probe to. Checking ldconfig -p separately tells you whether a shared libsqlite3 exists on the host at all, which is worth confirming first, because an empty trace and a broken trace look identical from the outside.
Not from statement-level probes. The filename lives on the connection handle, and probes that key on the prepared-statement pointer never see it, so a process with several open databases appears as one undifferentiated stream. If distinguishing databases is the whole question, an in-process trace callback registered per connection knows which connection it is on and is the better tool.
It is the compiled form of one SQL statement. sqlite3_prepare_v2 takes SQL text and returns a sqlite3_stmt pointer to a bytecode program, sqlite3_bind_int and its siblings attach concrete values to the placeholders, and sqlite3_step executes the bytecode, returning SQLITE_ROW once per result row and SQLITE_DONE at the end. Applications typically prepare a statement once and reuse the handle for the life of the process, which is why the SQL text is often long gone by the time you start watching.
By its own accounting, yes. SQLite's documentation states that it is likely used more than all other database engines combined, that billions and billions of copies exist in the wild, and that there are probably over one trillion SQLite databases in active use, counting every Android device, iPhone, Mac, Windows 10 and 11 installation, and every Firefox, Chrome and Safari browser.
Uprobes on the shared libsqlite3, because uninteresting work is never done rather than being filtered later, and nothing is added to the traced process. sqlitefeed on the yeet runtime is a ready-made version of that. The comparison is not entirely fair to the alternatives though: an in-process trace callback that formats and writes a log line is doing strictly more work, and an ORM echo flag is doing more still.
Loading a BPF program is privileged, but the privilege can live somewhere other than your shell. yeet runs a daemon that performs the load, so yeet run itself never takes sudo. Without that arrangement, attaching uprobes and loading BPF objects requires CAP_BPF and CAP_PERFMON, or root on older kernels.
Integers, text and NULLs, yes, because they arrive in general-purpose registers that a uprobe can read from pt_regs. Doubles are the exception: a bound REAL is passed in an SSE register that is not part of pt_regs, so the value cannot be recovered and only its type is known. Blobs are arbitrary binary of arbitrary length and are generally not worth capturing into a terminal feed.
(T,C,P,X) where T is one of the SQLITE_TRACE constants indicating why it fired.SQLITE_TRACE_STMT fires when a statement "first begins running" and passes "the unexpanded SQL text of the prepared statement", so recovering values requires invoking sqlite3_expanded_sql(P) inside the callback. SQLITE_TRACE_PROFILE passes "approximately the number of nanoseconds that the prepared statement took to run" and is "invoked when the statement finishes", making it whole-statement rather than per-step. SQLITE_TRACE_ROW fires per result row; SQLITE_TRACE_CLOSE on connection close.SELECT $abc,:xyz with $abc bound to 2345 yields SELECT $abc,:xyz from sqlite3_sql and SELECT 2345,NULL from sqlite3_expanded_sql. Returns NULL on insufficient memory or past SQLITE_LIMIT_LENGTH, is capped by SQLITE_TRACE_SIZE_LIMIT, "always return[s] NULL" under SQLITE_OMIT_TRACE, and its result "must be freed by the application by passing it to sqlite3_free()". sqlite3_normalized_sql needs SQLITE_ENABLE_NORMALIZE.SQLITE_ENABLE_SQLLOG produces logs "useful in doing off-line analysis of the behavior of an application, and especially for performance analysis", but "some extra code is required": append test_sqllog.c "to the end of an 'sqlite3.c' amalgamation, recompile the application using the -DSQLITE_ENABLE_SQLLOG option, then control logging using environment variables."Connection.set_trace_callback registers a callable "to be invoked for each SQL statement that is actually executed by the SQLite backend", and "The only argument passed to the callback is the statement (as str) that is being executed": no values, no timing, no result code. Notes that the backend runs statements beyond those passed to Cursor.execute(), including the module's own transaction management and trigger execution. connect() caches 128 statements by default via cached_statements.PATH is "an executable or a library" specified with an offset, and that return probes are supported alongside entry probes. This is the primitive sqlitefeed binds to concrete libsqlite3 symbols at attach time.REAL values arrive as a type because xmm0 is not in pt_regs, blobs are not hooked, SQL and text values are capped at 512 bytes at the kernel boundary, the log holds 2000 executions in memory with no persistence, and events are dropped rather than queued when a 512 KB ring buffer is outrun.sqlite3_sql(stmt) compiles to a single mov OFFSET(%rdi),%rax, so the build disassembles that one function in the host's own libsqlite3 and bakes the resulting Vdbe.zSql offset in as a define, with no DWARF and no per-version offset table. x86-64 only; elsewhere the path compiles out and unseen statements render as «unknown».ptrace, so a SQLite workload appears as pread64 and pwrite64 against the database file: evidence that I/O happened, with no path back to the statement that caused it.Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.