Live debugging
just run_local and just stack up start the LGTM collector by default and
the agent browser with --with-chrome (both global: one per machine, shared
across instances, left running):
--traces off disables the collector; --traces jaeger|datadog swaps it.
All collectors bind 4317/4318 — exactly one runs at a time.
Every Rust service exports spans AND its tracing events (as correlated log
records) over OTLP. The frontend exports browser spans through the proxy and
propagates traceparent, so one trace covers browser → proxy → services.
Wiring happens at stack start: if you start a collector by hand, restart the
stack to pick it up.
Query telemetry (prefer APIs over the Grafana UI)
The grafana MCP server (.mcp.json / opencode.json / .cursor/mcp.json,
Docker mcp/grafana on the host network) is pointed at this Grafana. Prefer its tools:
- Logs:
query_loki_logs, list_loki_label_values, find_error_pattern_logs
- Traces:
tempo_traceql-search, tempo_get-trace,
tempo_get-attribute-values, tempo_docs-traceql (proxied from Tempo's
own MCP server; every tempo_* call needs datasourceUid: "tempo")
- Metrics:
query_prometheus; plus search_dashboards, generate_deeplink
Datasource UIDs are stable: loki, prometheus, tempo, pyroscope.
Typical calls — service names are the binary names (email_service,
document-storage-service); match on route, duration, or any span attribute:
tempo_traceql-search {datasourceUid: "tempo", query: '{resource.service.name="email_service" && status=error}'}
(also '{span.http.route="/documents" && duration>500ms}'), then
tempo_get-trace with the returned trace ID. Searches default to the past
hour; widen with RFC3339 start/end.
query_loki_logs {datasourceUid: "loki", logql: '{service_name="email_service"} |= "error"'}
— log lines carry trace_id/span_id for correlation. Discover services
with list_loki_label_values on service_name.
Timing quirks: Tempo's search index flushes every ~30s — a trace you just
produced is fetchable by ID immediately but may not show in search yet.
Everything above is also plain HTTP: TraceQL search at
http://localhost:3200/api/search?q=<traceql>, trace fetch at
/api/traces/<id>, LogQL at
http://localhost:3100/loki/api/v1/query_range?query=<logql> (start/end
are unix epoch nanoseconds). Use the HTTP form when there is no MCP (pi),
and for bulk retrieval you want to reduce before reading — a full trace can
be 50+ spans, so curl /api/traces/<id> | jq (filter to slow spans, compute
offsets) beats dumping tempo_get-trace output into context. docker compose -p macro logs -f <service> still works for raw stdout, but Loki is queryable
and survives restarts.
Verbosity knobs (set in the shell before just run_local, or per service in
Doppler): RUST_LOG filters console + Loki output; OTEL_TRACE_FILTER
independently filters exported spans (default info). Lowering RUST_LOG
never silences traces.
Drive the shared headless Chrome
--with-chrome runs a headless Chrome in Docker with CDP on 9222 (if 9222
doesn't answer, start it: the compose command is in the headless-chrome
comment in docker/docker-compose.yml). The chrome-devtools MCP server
(.mcp.json / opencode.json / .cursor/mcp.json) is already pointed at
it — prefer its tools (navigate, snapshot, click, evaluate, console, network) for browser
work. State (cookies, login) persists across agent sessions until the
container restarts.
- The container uses host networking, so plain localhost URLs work: the app
is http://localhost:3000/app, the proxy
http://localhost:8090 (named
instances remap these ports; read the stack summary).
- A human can watch the browser live at http://localhost:6080/vnc.html
(noVNC over the Xvfb display) — work in the visible window, not isolated
contexts, when someone may be watching.
- Login is passwordless: any email works, and the login API returns the code
in its response (also visible in Mailpit at http://localhost:8025).
- From Playwright instead:
chromium.connectOverCDP('http://localhost:9222').
For token-injection and route-interception recipes see
apps/web/docs/playwright-debugging.md.
Correlate a browser repro with backend traces: note the time, then search
Tempo for that window — the browser's traceparent means the frontend action
and the Rust handler share one trace ID.
chrome-devtools technique
- Snapshot-first:
take_snapshot after every navigation or pane change; act
only on uids from the latest snapshot (uid prefixes bump on re-render).
Macro snapshots are huge — split panes duplicate the doc text — so save
big ones to a file (filePath param) and grep them.
navigate_page can time out while the SPA actually loaded (cold Vite
compile); follow with wait_for on expected text instead of re-navigating.
wait_for matches any text presence, including placeholders. For "AI
finished"-style conditions, poll in evaluate_script for the Stop
button's absence — the only reliable completion signal.
fill works on plain inputs but NOT contenteditable: click to focus, then
type_text (Enter splits paragraphs/sends). Combobox token fields need
type → wait for the "N options available" live region → Enter to tokenize.
- If a radio/tab control won't click ("did not become interactive"), click
its adjacent label text node instead.
- On any error dialog or blank state:
list_console_messages +
list_network_requests (filter xhr/fetch), then get_network_request for
the failing request's body — pairing console error with failing request
localizes the fault in one step.
- Verify editor state with
evaluate_script (e.g. query
[contenteditable] strong to confirm an AI edit) — cheaper and more
precise than screenshots.
Driving the Macro app
The condensed version is below; the full field-tested guide (routes, every
surface, keyboard model, crash recovery, trace correlation from a network
request's traceparent) is docs/AGENT_GUIDE/.
- Unauthenticated users land on
/app/welcome: "Continue with email" → fill
the email input → "Continue". Locally this may log in with no code prompt;
otherwise the code is in Mailpit. First login auto-creates the user.
- Documents live at
/app/md/<uuid>; a doc-scoped AI chat at
/app/md/<uuid>/chat/<chatId>; split panes give the right pane its own URL
segment (/app/md/<uuid>/channel/<channelId>).
- Everything is created via the top-left "Create" button (Document D,
Channel G, Message M, Task T, …). Sidebar buttons are named "Go to X" in
the a11y tree. Search is the "Search" button — results appear as you type,
no Enter.
- Editor: title field is focused on creation; type the title, Enter moves
into the body. The contenteditable's a11y
value exposes the full body
text, so snapshots double as content verification.
- AI edit: "Edit with AI" button under the editor → type the instruction →
Enter. Edits apply in place; done when the "Stop" button disappears.
AI chat: "Ask Macro" in the doc's Actions panel (doc pre-attached), Enter
sends, "Stop generating" disappears when the response is complete.
- Channels: "Create" → Channel → name it, tokenize invitees in the "To:"
combobox, "Create Channel". Verify membership in the Participants tab.
Composer: Enter sends; the "Task" switch turns a message into a task.
Write tracing code that is debuggable
Follow CLAUDE.md's tracing rules (err on Result-returning #[instrument],
never level = "info", tracing::error!(error=?e, "msg"), prefer
.inspect_err). Beyond those:
- Instrument boundaries, not plumbing: HTTP handlers get spans from
macro_tower_layers; add #[tracing::instrument] to queue consumers,
cross-service client calls, and multi-step business operations — the places
a trace would otherwise go dark.
- Skip bulky args (
#[instrument(skip(payload), fields(document_id = %id))])
and record the IDs you will actually search by: entity IDs, user IDs,
counts. A span you can't find by ID is a span you can't use.
- Record late-known values with
tracing::Span::current().record(...) rather
than emitting a second event.
- Events inside a span inherit its trace: one
tracing::warn! with fields
beats three unstructured debug!s. Fields, not format strings —
warn!(attempts, "retrying"), not warn!("retrying attempt {attempts}").
- Verify your instrumentation live: run the code path, then confirm the span
shows up in Tempo with the fields you expect. Unverified instrumentation is
the usual reason "the trace was empty" during a real incident.
1---2name: live-debug3description: Debug the running local stack with traces, logs, and a shared headless browser. Use when investigating a bug in a running service, tracing a request across services, reading service logs, reproducing a frontend issue, or writing/reviewing tracing instrumentation.4---56# Live debugging78`just run_local` and `just stack up` start the LGTM collector by default and9the agent browser with `--with-chrome` (both global: one per machine, shared10across instances, left running):1112| Endpoint | What |13| --- | --- |14| http://localhost:3001 | Grafana (anonymous admin) — traces + logs UI |15| http://localhost:3200 | Tempo API — traces |16| http://localhost:3100 | Loki API — service logs |17| http://localhost:9090 | Prometheus API — metrics |18| localhost:4317 / 4318 | OTLP intake (gRPC / HTTP), alias `otel-collector` |19| http://localhost:9222 | Headless Chrome (CDP) |2021`--traces off` disables the collector; `--traces jaeger|datadog` swaps it.22All collectors bind 4317/4318 — exactly one runs at a time.2324Every Rust service exports spans AND its `tracing` events (as correlated log25records) over OTLP. The frontend exports browser spans through the proxy and26propagates `traceparent`, so one trace covers browser → proxy → services.27Wiring happens at stack start: if you start a collector by hand, restart the28stack to pick it up.2930## Query telemetry (prefer APIs over the Grafana UI)3132The `grafana` MCP server (`.mcp.json` / `opencode.json` / `.cursor/mcp.json`,33Docker `mcp/grafana` on the host network) is pointed at this Grafana. Prefer its tools:3435- Logs: `query_loki_logs`, `list_loki_label_values`, `find_error_pattern_logs`36- Traces: `tempo_traceql-search`, `tempo_get-trace`,37 `tempo_get-attribute-values`, `tempo_docs-traceql` (proxied from Tempo's38 own MCP server; every `tempo_*` call needs `datasourceUid: "tempo"`)39- Metrics: `query_prometheus`; plus `search_dashboards`, `generate_deeplink`4041Datasource UIDs are stable: `loki`, `prometheus`, `tempo`, `pyroscope`.4243Typical calls — service names are the binary names (`email_service`,44`document-storage-service`); match on route, duration, or any span attribute:4546- `tempo_traceql-search` `{datasourceUid: "tempo", query: '{resource.service.name="email_service" && status=error}'}`47 (also `'{span.http.route="/documents" && duration>500ms}'`), then48 `tempo_get-trace` with the returned trace ID. Searches default to the past49 hour; widen with RFC3339 `start`/`end`.50- `query_loki_logs` `{datasourceUid: "loki", logql: '{service_name="email_service"} |= "error"'}`51 — log lines carry `trace_id`/`span_id` for correlation. Discover services52 with `list_loki_label_values` on `service_name`.5354Timing quirks: Tempo's search index flushes every ~30s — a trace you just55produced is fetchable by ID immediately but may not show in search yet.5657Everything above is also plain HTTP: TraceQL search at58`http://localhost:3200/api/search?q=<traceql>`, trace fetch at59`/api/traces/<id>`, LogQL at60`http://localhost:3100/loki/api/v1/query_range?query=<logql>` (`start`/`end`61are unix epoch **nanoseconds**). Use the HTTP form when there is no MCP (pi),62and for bulk retrieval you want to reduce before reading — a full trace can63be 50+ spans, so `curl /api/traces/<id> | jq` (filter to slow spans, compute64offsets) beats dumping `tempo_get-trace` output into context. `docker compose65-p macro logs -f <service>` still works for raw stdout, but Loki is queryable66and survives restarts.6768Verbosity knobs (set in the shell before `just run_local`, or per service in69Doppler): `RUST_LOG` filters console + Loki output; `OTEL_TRACE_FILTER`70independently filters exported spans (default `info`). Lowering `RUST_LOG`71never silences traces.7273## Drive the shared headless Chrome7475`--with-chrome` runs a headless Chrome in Docker with CDP on 9222 (if 922276doesn't answer, start it: the compose command is in the `headless-chrome`77comment in `docker/docker-compose.yml`). The `chrome-devtools` MCP server78(`.mcp.json` / `opencode.json` / `.cursor/mcp.json`) is already pointed at79it — prefer its tools (navigate, snapshot, click, evaluate, console, network) for browser80work. State (cookies, login) persists across agent sessions until the81container restarts.8283- The container uses host networking, so plain localhost URLs work: the app84 is **http://localhost:3000/app**, the proxy `http://localhost:8090` (named85 instances remap these ports; read the stack summary).86- A human can watch the browser live at **http://localhost:6080/vnc.html**87 (noVNC over the Xvfb display) — work in the visible window, not isolated88 contexts, when someone may be watching.89- Login is passwordless: any email works, and the login API returns the code90 in its response (also visible in Mailpit at http://localhost:8025).91- From Playwright instead: `chromium.connectOverCDP('http://localhost:9222')`.92 For token-injection and route-interception recipes see93 `apps/web/docs/playwright-debugging.md`.9495Correlate a browser repro with backend traces: note the time, then search96Tempo for that window — the browser's `traceparent` means the frontend action97and the Rust handler share one trace ID.9899### chrome-devtools technique100101- Snapshot-first: `take_snapshot` after every navigation or pane change; act102 only on uids from the latest snapshot (uid prefixes bump on re-render).103 Macro snapshots are huge — split panes duplicate the doc text — so save104 big ones to a file (`filePath` param) and grep them.105- `navigate_page` can time out while the SPA actually loaded (cold Vite106 compile); follow with `wait_for` on expected text instead of re-navigating.107- `wait_for` matches any text presence, including placeholders. For "AI108 finished"-style conditions, poll in `evaluate_script` for the Stop109 button's absence — the only reliable completion signal.110- `fill` works on plain inputs but NOT contenteditable: click to focus, then111 `type_text` (Enter splits paragraphs/sends). Combobox token fields need112 type → wait for the "N options available" live region → Enter to tokenize.113- If a radio/tab control won't click ("did not become interactive"), click114 its adjacent label text node instead.115- On any error dialog or blank state: `list_console_messages` +116 `list_network_requests` (filter xhr/fetch), then `get_network_request` for117 the failing request's body — pairing console error with failing request118 localizes the fault in one step.119- Verify editor state with `evaluate_script` (e.g. query120 `[contenteditable] strong` to confirm an AI edit) — cheaper and more121 precise than screenshots.122123### Driving the Macro app124125The condensed version is below; the full field-tested guide (routes, every126surface, keyboard model, crash recovery, trace correlation from a network127request's `traceparent`) is `docs/AGENT_GUIDE/`.128129- Unauthenticated users land on `/app/welcome`: "Continue with email" → fill130 the email input → "Continue". Locally this may log in with no code prompt;131 otherwise the code is in Mailpit. First login auto-creates the user.132- Documents live at `/app/md/<uuid>`; a doc-scoped AI chat at133 `/app/md/<uuid>/chat/<chatId>`; split panes give the right pane its own URL134 segment (`/app/md/<uuid>/channel/<channelId>`).135- Everything is created via the top-left "Create" button (Document D,136 Channel G, Message M, Task T, …). Sidebar buttons are named "Go to X" in137 the a11y tree. Search is the "Search" button — results appear as you type,138 no Enter.139- Editor: title field is focused on creation; type the title, Enter moves140 into the body. The contenteditable's a11y `value` exposes the full body141 text, so snapshots double as content verification.142- AI edit: "Edit with AI" button under the editor → type the instruction →143 Enter. Edits apply in place; done when the "Stop" button disappears.144 AI chat: "Ask Macro" in the doc's Actions panel (doc pre-attached), Enter145 sends, "Stop generating" disappears when the response is complete.146- Channels: "Create" → Channel → name it, tokenize invitees in the "To:"147 combobox, "Create Channel". Verify membership in the Participants tab.148 Composer: Enter sends; the "Task" switch turns a message into a task.149150## Write tracing code that is debuggable151152Follow CLAUDE.md's tracing rules (`err` on `Result`-returning `#[instrument]`,153never `level = "info"`, `tracing::error!(error=?e, "msg")`, prefer154`.inspect_err`). Beyond those:155156- Instrument boundaries, not plumbing: HTTP handlers get spans from157 `macro_tower_layers`; add `#[tracing::instrument]` to queue consumers,158 cross-service client calls, and multi-step business operations — the places159 a trace would otherwise go dark.160- Skip bulky args (`#[instrument(skip(payload), fields(document_id = %id))]`)161 and record the IDs you will actually search by: entity IDs, user IDs,162 counts. A span you can't find by ID is a span you can't use.163- Record late-known values with `tracing::Span::current().record(...)` rather164 than emitting a second event.165- Events inside a span inherit its trace: one `tracing::warn!` with fields166 beats three unstructured `debug!`s. Fields, not format strings —167 `warn!(attempts, "retrying")`, not `warn!("retrying attempt {attempts}")`.168- Verify your instrumentation live: run the code path, then confirm the span169 shows up in Tempo with the fields you expect. Unverified instrumentation is170 the usual reason "the trace was empty" during a real incident.