4. Look inside
Use tatolab streams, graph, tap, logs and exchange to see what the two streams are doing while they run, and find each stream's JSON Lines log.
When a stream misbehaves, you want to see what it’s actually doing, not what the code says it should do. Tatolab gives you five commands for that, and each stream keeps a log you can read with any tool. Leave both tatolab dev commands running, and open another terminal in the motion-watcher folder.
What’s running: tatolab streams
Section titled “What’s running: tatolab streams”tatolab streamsNAME STATE NODES PROJECTmotion_watcher attached 3 /home/you/motion-watchermotion_log attached 1 /home/you/motion-logattached means a stream lives as long as the command that started it, here your two tatolab dev commands. In step 6 you’ll make them stay.
Its shape and health: tatolab graph
Section titled “Its shape and health: tatolab graph”tatolab graph prints the live graph as JSON: every node and link, their states, and their counters. It’s long, so use jq to pick out what you need (install it with your package manager, or brew install jq). List the motion watcher’s nodes and their states:
tatolab graph --stream motion_watcher | jq -r '.nodes[] | "\(.name) \(.components.state)"'camera Runningdetector Runningdisplaywindow RunningAnd its links:
tatolab graph --stream motion_watcher | jq -r '.links[] | "\(.source.node)/\(.source.port) -> \(.target.node)/\(.target.port) \(.state)"'A healthy link reads wired. A link that reads error carries an error_reason saying why, and one that reads awaiting_remote is waiting for another stream, with an awaiting_remote_reason. If a reader drops bags because it fell behind, the count shows up under that node’s components.metrics, so a loss is never silent.
The window kept its default name, displaywindow, because it has no name=.
What a port carries: tatolab tap
Section titled “What a port carries: tatolab tap”tatolab tap samples the bags an output sends, addressed as <stream>/<node>/<port>. Sample the motion reports motion_log reads:
tatolab tap motion_watcher/detector/motion --count 3The answer is JSON describing the sample: how many bags were asked for and received, how many were dropped, and each bag’s length and bytes in hex. A tap never slows the stream down; on a quiet port, it returns what arrived within a short window rather than waiting. It can sample any output that a link reads. An output nothing reads has nothing flowing, so tapping it fails, saying so.
The hex is the bag exactly as it travelled: a 76-byte header (a 64-byte port key, the timestamp as 8 bytes, the length as 4), then the bag as msgpack. To read it, save this as decode_tap.py in your home folder, outside the projects: tatolab dev reloads a stream when any .py file in its project changes.
"""Print each bag of a `tatolab tap` sample: python decode_tap.py < sample.json"""
import jsonimport sys
import msgpack
for bag in json.load(sys.stdin)["bags"]: raw = bytes.fromhex(bag["hex_preview"]) length = int.from_bytes(raw[72:76], "little") print(msgpack.unpackb(raw[76 : 76 + length]))and pipe a sample into it:
tatolab tap motion_watcher/detector/motion --count 3 | uv run --with msgpack python ~/decode_tap.py{'changed': 0.0016, 'moving': False, 'timestamp_ns': 893417260215}{'changed': 0.0021, 'moving': False, 'timestamp_ns': 893517248113}{'changed': 0.1391, 'moving': True, 'timestamp_ns': 893617265840}That’s your detector’s output, exactly as motion_log receives it. Your numbers will differ; the timestamps are nanoseconds on the machine’s monotonic clock.
What it logged: tatolab logs
Section titled “What it logged: tatolab logs”tatolab logs --stream motion_watcher -fThis follows the stream’s log as it’s written: the runtime’s records about the stream, the built-in nodes’, and your nodes’ log.info(...) calls, with anything a node prints. Filter it with --level warn for problems only, or --source python for your nodes alone. Press Ctrl-C to stop following.
Underneath, each stream writes its log to a file in its own project, one JSON object per line:
ls .streamlib/logs/Rk3x9q2m7v1c8b4n6p0w5z2ty-motion_watcher-1791727378117.jsonlThe name is the runtime’s ID, the stream’s name, and when the stream loaded, in milliseconds. Each line is one record, with fields such as level, source (python for your nodes, rust for the runtime and built-ins), message and attrs, which holds the fields you passed to log.info. Pick out your detector’s records with jq:
jq -c 'select(.source == "python") | {level, message, attrs}' .streamlib/logs/*-motion_watcher-*.jsonl{"level":"info","message":"motion started","attrs":{"changed":0.1391}}{"level":"info","message":"motion stopped","attrs":{"changed":0.0018}}Because it’s plain JSON Lines, any tool can read it: jq, a log shipper, or an OpenTelemetry Collector. Logs covers the fields and how to ship them.
What a frame looks like: tatolab exchange
Section titled “What a frame looks like: tatolab exchange”A video bag doesn’t carry pixels; it names a frame held in GPU memory. tatolab exchange fetches the frame itself and saves it as a PNG. Its --channel option takes the same address as tap:
tatolab exchange --channel motion_watcher/camera/video --count 1 --out frames/It prints the path of each file it writes. Open it: it’s the exact, full-resolution frame the camera produced. That works without any window, which makes it the way to see the picture on a machine with no screen.
The next step is optional: it puts the motion on the picture with a GPU effect. If your streams don’t need to process video, go straight to Keep it running. Otherwise, continue to Show motion on the picture.