Skip to main content
A sandbox will happily run a command an agent wrote, but the result only tells you what it printed. Tracing records what it actually did: the programs it started, the files it read and wrote, the hosts it reached. The record is plain data you can print, diff, store or feed back to a model.

Turning it on

Tracing is off unless you ask for it, and it is chosen when the sandbox is created:
Every result carries its own slice of the record, so result.trace is that command and nothing else. sandbox.trace.collect() gives you everything the sandbox has done so far.
result.trace on a sandbox created without trace=True raises RuntimeError, rather than handing back an empty record that would read as “it did nothing”.

What ran

processes() returns the roots of a tree of programs:
A node is one successful exec, not one process. That distinction is what makes the tree readable:
  • A shell that execs its last command reuses its own pid, so make appears nested under the sh that became it. Both nodes carry pid 563 above, and that is correct: one process, two programs.
  • A fork that never execs adds no node. Its children attach to the closest program that did exec, so a pipeline’s plumbing does not show up as empty boxes.
  • Threads are not programs. process.fork events with thread: true are ignored here.

What it touched

files() returns one entry per path, with everything that happened to it folded together:
Paths are recorded where the file actually is, not as the program typed it. A program that does cd /tmp/rel and then writes copy.txt is recorded against /tmp/rel/copy.txt, and a child inherits its parent’s directory, so the resolution survives subprocess and shell pipelines. denied is worth watching on its own. A failed open is not noise, it is the most interesting line in the file:

Where it went

network() returns one entry per address and port:
Request lines appear when vpod handles the connection itself, which covers plain HTTP and the HTTPS path it proxies. A program that brings its own TLS end to end still gets its connection, byte counts and hostname recorded, but not the individual requests.

Following along

watch() hands you an iterator of raw events, useful when a sandbox runs for a long time and you want to react rather than wait:
The iterator ends when the sandbox closes, so the thread above finishes on its own.
Events reach a watcher when the record is drained, which happens at the end of every command and whenever you call collect(). A watcher sees a command’s events as that command finishes, not while it is still running.
clear() forgets what has been recorded so far, which keeps a long-lived sandbox from accumulating a record you are never going to read:

Was anything missed

complete answers the only question that matters before you trust a trace:
It is False in three cases, all of which appear in the raw events: The third is the mild one. vpod learns where the guest kernel keeps process ids by watching the guest tell it, which takes a couple of syscalls from two different processes. Until that settles, events are still recorded, but without pids and with relative paths left unresolved. In practice it settles during the first command.

Choosing what to record

trace=True records everything. Pass a dict to record less:
buffer_bytes caps what the engine holds between drains, and defaults to 64 MB. When it fills, the oldest events survive and new ones are counted into a trace.dropped event, so the record tells you it is short rather than quietly lying:

Noise and vpod’s own plumbing

Two filters are on by default, both of which you can lift per call. Noise is activity that is real but never what you asked about: /proc, /sys, /dev, and shared libraries that were only read. vpod’s own plumbing is the handful of files and processes the SDK uses to run your command at all, marked internal in the raw events.
network() and processes() take internal too.

What a request carried

A method and a URL say an agent called an API. They do not say which model it picked or what it pasted into the prompt. Network tracing records what the guest actually sent, so each request carries its headers and body:
Bodies are cut at 64 KB, so a large upload costs a bounded amount rather than filling the buffer. Ten API calls with 300-byte bodies grow a trace by about 7 KiB, measured.
A credential set with secrets appears here as its stand-in, never the real value, because the trace is taken before the gateway swaps it in. A real key placed in env is recorded as-is.
Responses are not recorded. A body with no declared length, such as a chunked upload, is not recorded either, and shows as body_bytes: None.

Raw events

Every view above is built from one flat list of events, and that list is public:
Each event carries v (schema version), seq, guest_ns (guest time, which is deterministic), wall_ms (host wall clock), and kind. Most carry pid and task, and vpod’s own activity carries internal: true. A path that could not be resolved to an absolute one is left as the program wrote it and flagged with path_unresolved: true.

What a guest can and cannot hide from

Network and mount activity is observed outside the guest, in the emulator’s own device and host filesystem code. Nothing running inside the sandbox can perform that I/O without going through it, so those two sources are not evadable. Process and file activity is read from the guest’s syscalls, and a program that is trying to hide can get around that. io_uring is the practical route, so vpod turns it off when tracing starts. Guest root can turn it back on, which is why the engine also watches for it: the first ring that opens produces a trace.blind event and complete turns False. Writing kernel memory directly or loading a kernel module would evade it too. So a trace is a faithful record of what a program did, and complete tells you when it is not. It is not a security boundary. The boundary is the sandbox itself, which holds whether or not anything is being traced.

Cost

Tracing off is not a mode, it is one predictable branch per syscall, and measures as no change. On, measured on the wasm engine: CPU-bound work is unaffected, because nothing on the hot path changes. What costs is syscall volume.

Resumed sandboxes

Tracing is chosen per sandbox, including when you resume one:
A resumed sandbox starts a fresh record. The events from before the suspend are not carried across, so resumed.trace.collect() covers what has happened since it came back.