Module: observability
Observe the app's data flow — frontend↔backend↔external — the way Docker Desktop shows a container's network I/O. The compact "Data flow" widget is part of the seeded Dashboard workspace; the full I/O panel is opened on demand.
Status: implemented — frontend in
packages/core/src/modules/observability/, instrumentation in
backend/modules/telemetry/ plus packages/core/src/{telemetry,ws}.ts.
The instrumentation, not the widget, is the point
A useful observability view can't be per-module logging — it instruments the chokepoints once so every module's traffic shows up for free (like the Docker daemon already seeing all container I/O). Four sources feed one stream:
client— every frontend→backend round-trip, recorded in the API client'srequest<T>(packages/core/src/api.ts).inbound— every request the backend receives, via an ASGI middleware (backend/modules/telemetry/instrument.py).outbound— the backend's calls to external services (Clubhouse, Ollama), viainstrumented_client()wrapping httpx; agent and clubhouse use it. This is the part the frontend can't see on its own.ws— individual frames of the multiplexed/wssocket (agent, collab, network, lobby, terminal…), captured at the two chokepoints: outbound atWsConnection.send_json(via a telemetry-free observer hook the app registers), inbound in the/wsreceive loop. Thetelemetrychannel itself is skipped so the push stream doesn't observe its own output. The frame's JSON payload is the body; the direction (send/recv) is the method.
So a single user action reads end to end: client GET /agent/status →
inbound /api/agent/status → outbound GET …/api/tags (Ollama).
Detail is captured raw — this is a local Wireshark
The panel is the app's built-in protocol analyzer, so it deliberately hides
nothing at the application layer: every event carries metadata (method, target,
status, duration, byte counts) plus the full headers and bodies, unredacted,
for the inspector. Nothing is masked or suppressed — credential headers
(authorization, cookie, tokens) and sensitive bodies (Clubhouse phone
numbers, SMS codes) are shown verbatim. The only limit is size: bodies are
truncated to a configurable cap (observability.maxBodyChars, default 16 KB) and
never read past a 1 MB hard ceiling. Outbound URLs are recorded scheme+host+path
only (query/fragment stripped). Treat the in-memory buffer as sensitive — it is a
local introspection tool, and the data never leaves the machine.
Response bodies are captured on client events and on all outbound calls — so
the request↔response pair to the LLM reads end to end — truncated like request
bodies:
- Non-streaming outbound responses (e.g. the agent's
stream:false/api/chattool-calling round) are read in the response hook; httpx caches the read, so the caller's.json()still works. - Streaming outbound responses (Ollama chat/pull NDJSON, OpenAI SSE — detected
by
content-type) can't be read in the hook without consuming the stream. The hook records the event with no body yet, thentee_stream— wrapping the two streaming call sites (providers.generate_stream,routes._proxy_ndjson) — passes each line through to the caller while accumulating a capped copy, andrecorder.amendfills the body into the same event once the stream finishes (capped at the 1 MB ceiling so a long generation can't grow unbounded).
Inbound response bodies aren't captured (the client event for the same
round-trip shows the payload). Capture and truncation live at the chokepoints
(instrument.py, api.ts).
Transport
The backend keeps a 500-event ring buffer (recorder.py) and streams events to
the frontend over the shared /ws socket on the telemetry channel — the
first real use of that socket. On connect it replays the recent backlog, then
pushes live. GET /api/telemetry/recent exposes the same backlog for polling.
The frontend store (telemetry.ts) merges client events with the streamed
inbound/outbound events into one capped list, upserted by (source, id)
so an amended event (a filled-in streaming body, re-emitted under the same id)
replaces its row in place rather than duplicating it.
Contributions to the layout shell
- Panel group:
observability.monitor—observability.iois the primary (the entry point that appears in the command palette and in the tab-strip picker). Opening Data flow renders aPaneGroupShellwith an⊡ Inspectortoggle in the strip; clicking it opensobservability.logsas a resizable companion pane. See panel groups. - Widgets:
observability.io("Data flow") — compact summary (call/error counts + last few, expandable the same way), opened as a pane. Part of the seeded Dashboard workspace; also addable to any workspace from the palette ("Open widget: Data flow") or the tab-strip picker. - Panels:
observability.logs(singleton,defaultPlacement: bottom) — a Wireshark-style master-detail: a packet-list table (time, source badge, method, target, status, ms, size) over an inspector for the selected row. The inspector shows General metadata, then request/response headers and bodies (for awsframe, a single "Frame payload"); bodies are JSON pretty-printed and syntax-highlighted with a Raw⇄Pretty toggle and Copy. A toolbar filter narrows rows live (client-side): a free-text query over method+target+body, a source selector (all / client / inbound / outbound / ws), and an errors only toggle; the count readsshown / totalwhile a filter is active. A Clear button empties the buffer. Accessible only via the toggle strip on the Data flow widget (use the⊡ Inspectortoggle) — not openable as a standalone command. - Commands:
observability.open(opens the Data flow widget — the primary). - Settings:
observability.recentCount(number, default 5) — how many recent calls the Data flow widget lists; read live viauseSetting.observability.maxBodyChars(number, default 16384) — how many characters of each captured request/response body (andwsframe) to keep for the inspector. UnlikerecentCount, this one is consumed on the backend (the truncation happens at capture, ininstrument.py, which reads the persisted value via the settings module'sget_value); it's still hard-capped at 1 MB. See settings.md.
Backend surface
backend/modules/telemetry/ — GET /api/telemetry/recent (backlog) and the
/ws telemetry stream. The telemetry endpoints are excluded from recording to
avoid feedback noise.
Browser vs desktop
Identical. The store also captures real client-side round-trip latency, which reflects wherever the backend lives (local or remote).
Agent context
Both the widget and the panel expose a getAgentContext() snapshot (total calls,
error count, and the most recent calls with source/method/target/status/duration),
so the agent can reason about live traffic — e.g. "did that request fail?". See
agent tools.
Not yet
Client-side streaming calls (the agent chat/pull streams bypass request<T>, so
they show as outbound on the backend but not as client events), inbound response
bodies (see above), and persistence across reloads (the buffer is in-memory). The
panel filter is panel-local state, so it resets when the pane is unmounted.