Skip to content

feat(telemetry): add an execution viewer to the desktop, VS Code, and MCP hosts - #1117

Closed
ParidelPooya wants to merge 19 commits into
mainfrom
feat/telemetry-execution-viewer
Closed

ParidelPooya wants to merge 19 commits into
mainfrom
feat/telemetry-execution-viewer

Conversation

@ParidelPooya

@ParidelPooya ParidelPooya commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The telemetry tools only read what the exporter wrote to a destination. None of them show one execution's operations as they ran. To see a step's result, a retry, or which invocation failed, a user has to leave the tools and call the Lambda API by hand. In the MCP server, an agent can return execution ARNs but has nothing to link them to.

Change

  • core: new lambdaExecutions.ts reads durable functions and executions from the Lambda API (ListDurableExecutionsByFunction, GetDurableExecution, GetDurableExecutionHistory, plus Invoke and StopDurableExecution). It pages history up to 10,000 events and folds it into an operation tree, with retries, wait end times, callback IDs, and chained-invoke targets. Each call goes to the region in the execution ARN. ExplorerSession exposes these as messages, so the VS Code and desktop hosts share one code path.
  • VS Code and desktop: a new Executions area: durable functions, then a function's executions, then one execution's operation tree, input, output, invocations, and history (exportable). A running execution refreshes every 4 seconds and can be stopped after a confirmation. Query and SQS result rows that hold an execution ARN get an Open link.
  • MCP: rows from query and list_executions that hold an execution ARN gain a viewerLink column, and get_execution gains a viewerLink field. Each is a Markdown link titled with the execution name, or show execution when there is none. list_executions now returns the execution name on every destination. The link opens a read-only page served by the server, with a Refresh button to load the latest state. The server now sends instructions at connect time, so clients that defer tool schemas route a bare "list the last 10 executions" here and show the links without being asked.
  • MCP consent: the prompt from refactor(telemetry): replace the write-access check with a notice #1115 gains an optional "Don't ask again" checkbox. It counts only with the confirm box. It saves a SHA-256 of the destination settings, region, and profile to ~/.durable-telemetry/accepted-credentials.json (mode 0600). Changing any of them asks again, and an S3 + Athena configuration warning is always shown.
  • Renderer tests: refactor(telemetry): replace the write-access check with a notice #1115 removed the renderer test runner and its CI step with its only test. This PR adds renderer tests again, so it restores both.

The local viewer server (MCP)

The page shows execution payloads fetched with the user's credentials, so only the user's own browser should reach it.

  • It starts on the first result that needs a link, not at startup, and listens on 127.0.0.1 only. There is no setting to widen the bind.
  • Every execution page needs a session token: 32 random bytes made at startup, kept only in memory, and carried in the path of every page URL (/s/<token>/execution?arn=…). Child links and the Refresh button are relative, so both keep it. Anything without it gets 403 before any AWS call. There is no cookie: browsers send a cookie to every port on a host, so a cookie would hand the token to any other server on 127.0.0.1 the browser loads. This is the scheme Jupyter and the VS Code server use for loopback servers opened from a terminal link. The checks below stay as a second layer.
  • It serves only executions this process linked in a tool result, plus up to 1,000 child executions per page. Any other ARN gets 404 with no AWS call, so a local caller cannot make it fetch an execution of its choosing in another account or region. get_execution links only when the record is found. query links every ARN its rows hold, so an agent can still link an ARN by selecting it as a literal; the README names this.
  • It rejects any Host header other than its own loopback address (DNS rebinding), any request a browser marks as cross-site or same-site, a foreign Origin, any Sec-Fetch-Dest other than a page or its stylesheet, and absolute-form request lines.
  • It runs at most four fetches at once (503, with a Try again link), shares one fetch and one rendered page per execution, and reuses a finished page for 5 s before dropping it. Each Lambda call stops after 30 s without progress, and a whole page fetch is aborted after 60 s across calls and SDK retries, showing the 502 page. It holds at most 64 MB of pages browsers have not finished reading, and closes a page not delivered 60 s after it was written.
  • It stops history after about 8 MB (checked per history page), and caps payload text at 64 KB each and 2 MB per page, the operations and history tables at 2,000 rows, each rendered name at 256 characters, and each ARN, callback ID, or error message at 2,048.
  • It accepts only GET and HEAD (HEAD makes no AWS call), sends no CORS headers, and sends a CSP with no script, frames, or forms, plus COOP. The page has no JavaScript.
  • ARN tails are limited to letters, digits, -, _, ., and /, and viewer URLs encode ( ) ! * ' ~, so a link cannot end early in Markdown. Link text drops C0/C1 controls, bidi overrides, and zero-width characters, and escapes [ ] \ < > and backticks.
  • It is read-only. A test fails if the MCP package imports startExecution or stopExecution.
  • Every local user and process that reaches loopback can reach the port, but reads nothing without the token. The token is in the links the agent shows and in browser history, so anyone who can read the agent's chat, the terminal, or the browser history can read the linked executions while the server runs. DURABLE_TELEMETRY_VIEWER_DISABLED=true turns it off. DURABLE_TELEMETRY_VIEWER_PORT fixes the port.

Decisions

  • Viewer reads the Lambda API, not the destination. It works for any durable function, and for executions whose records were truncated or sampled out.
  • MCP page is server-rendered HTML, not the React bundle. Reusing the bundle would add about 3 MB to the published package and require building webview-ui before publishing.
  • Short links were not used. Some terminal clients print a Markdown link's URL in full. A server-side short-ID table would hide it, but adds state for a display limit of the client.
  • A per-process token rather than header checks alone. The header checks detect browser behavior; the token verifies a credential, and it stops a local process that knows a linked ARN. No managed authentication service fits a server on the developer's machine that is opened from a terminal link, so this uses the same per-process token scheme as Jupyter and the VS Code server.
  • Retry status (Execution viewer shows a step waiting to retry as FAILED #1118) is fixed here. A failure with a scheduled retry shows as PENDING with its next attempt time, a retry clears the earlier attempt's error, attempt counts Started events, and operations still open in an ended execution show as INCOMPLETE.

Fixes #1118.

Viewer correctness

  • Statuses: retries, attempts, and unfinished operations are shown as described above. Against all 146 recorded histories in the repo: 0 attempt mismatches (was 30), 0 succeeded operations carrying an error (was 3), and 0 operations left running in an ended execution (was 34).
  • History notices: when reading history fails part-way, both viewers say it is incomplete, rather than that operations are not shown. The truncation notice says shown operations may look unfinished.
  • Region and version: a function reached from an execution in another region is listed and started in that region. "Start new execution" keeps the version or alias the execution ran on, and the modal names it.
  • ARN rules: the renderer's ARN check and ARN-column detection now match core and the MCP server; a parity test checks the rules agree.
  • Consent file: concurrent "Don't ask again" saves take a lock file, so none is lost.
  • Tests: the renderer test runner now runs tests in subfolders and fails on a missing bundle. A minimal hook runtime lets it test the viewer's state hook without a DOM.

Testing

  • core 521, VS Code 65, desktop 48, MCP 584, and renderer 26 tests pass. typecheck:hosts, biome, and both host builds pass.
  • The three viewer pages were rendered in headless Chrome with stub data.
  • An independent security review's probes, re-run after the fixes with a fake Lambda client: other-account, other-region, aws-cn, and aws-us-gov ARNs get 404 with 0 Lambda calls (were 200). 200 parallel requests for 200 linked executions: 4 served, 196 got 503, peak 4 fetches in flight, 241 MB RSS (before: the process died of heap exhaustion at 100). 200 parallel requests for one execution: all 200 served from 1 fetch, 259 MB. One execution of 10,000 events x 256 KB: a 2.5 MB page at 160 MB RSS (before: a 525 MB page at 3.4 GB). A foreign Origin without fetch metadata gets 403 (was 200), HEAD makes no Lambda call, and absolute-form requests get 400.
  • A second round of both reviews found that a caller knowing one linked execution could hold unbounded memory with sockets that stop reading, and that a silent Lambda call held a fetch slot forever. After the fixes, the reviewer's probe with 200 stalled sockets peaks at 94 MB of buffers (was 6.3 GB and growing). get_execution for an ARN not in telemetry no longer makes it servable (404, 0 Lambda calls; was 200). A 64 KB Name in every history event stops paging after one page (was 10,000 events and a 502). A real Lambda client against a server that never answers fails within the timeout.
  • A third review round found that the first token design used a cookie, which browsers sent to every server on 127.0.0.1 (another local server received and replayed it), and that a shadowing cookie could make every link fail. With the token in the URL only, the same Chrome and Firefox probes show other local servers receive no token, the viewer flow (link, stylesheet, reloads, child links) works, and every same-site request from another local port gets 403. Without the token, a request with perfect headers for a linked ARN gets 403 and no Lambda call. A body that stalls after its headers is now ended (was pending indefinitely); a client waiting on a fetch that never finishes gets the 502 page at 60 s (was a connection reset); ten pages with 1,000 children each keep their parents (were all 404); expired cache entries are dropped (16 entries / 192 MiB held before).
  • The MCP viewer was run against a real execution: the page loaded, a cross-site request got 403, and a foreign Host got 421. lsof showed the listener on 127.0.0.1 only.
  • The built MCP server was driven through Claude Code: the prompt appeared with both checkboxes, and after confirming with "Don't ask again", a new session listed executions without a prompt.
  • Start and Stop are covered by unit tests with a fake Lambda client only.

Notes for reviewers

  • The MCP README's data-disclosure section gains one bullet (the viewer shows payloads in the browser), and its "stdio in, JSON out" sentence now mentions the loopback viewer.
  • The viewer needs lambda:GetDurableExecution and lambda:GetDurableExecutionHistory. Without them the page shows the AWS error and the tools still work.

Adds lambdaExecutions.ts: list durable functions and their executions,
fetch one execution with its paged history folded into an operation tree,
and start or stop an execution. ExplorerSession exposes these as messages
so every host gets the same execution viewer.
A new Executions area lists durable functions, a function's executions,
and one execution's operation tree, payloads, invocations, and history,
read from the Lambda API. A running execution refreshes every 4 seconds
and can be stopped after a confirmation. Query and SQS result rows that
carry an execution ARN get an Open link into the viewer.
Rows from query and list_executions that hold an execution ARN gain a
viewerUrl column, and get_execution gains a viewerUrl field. The link
opens a read-only page with the execution's operations, payloads,
invocations, and history from the Lambda API, reloading while it runs.

The page is served on first use from 127.0.0.1 only. It rejects foreign
Host headers and requests a browser marks cross-site, accepts only GET and
HEAD, and sends a no-script CSP. DURABLE_TELEMETRY_VIEWER_DISABLED turns it
off and DURABLE_TELEMETRY_VIEWER_PORT fixes its port.
Clients that defer tool schemas show the agent only tool names, so a bare
request such as "list the last 10 executions" had nothing pointing at this
server. The instructions say what the server is for, which tool to start
with, and to link executions to their viewerUrl.
Rows now carry viewerLink, a ready-made Markdown link titled with the
execution name, or "show execution" when there is none, in place of the
raw viewerUrl. list_executions returns the execution name on every
destination so the title is available. The server instructions, tool
descriptions, and skill tell the agent to show these links whenever it
lists executions, without being asked, and never to print the raw URL.
#1115 removed the renderer test runner and its CI step along with its only
test. The execution viewer adds renderer tests, so this restores both.
The prompt gains a "Don't ask again for these credentials" checkbox. It
counts only with the confirm box, and saves a hash of the destination
settings, region, and profile to ~/.durable-telemetry/accepted-credentials.json
(mode 0600). Later sessions with the same settings skip the prompt; changing
any of them asks again. An S3 + Athena configuration warning is always
shown, because a saved answer covers the fixed notice only.
The viewer served any execution ARN a caller named, in any account or
region the credentials reach, and had no bound on its work, so a few
requests for a large execution ran the process out of memory.

- Serve only executions this process linked, plus the child executions
  their pages link to; any other ARN gets 404 with no AWS call.
- Run at most four fetches at once (503 beyond that), share one fetch per
  execution, stop history at 8 MB, and cap payload text at 64 KB each and
  2 MB per page, with history rows capped at 2,000.
- Reject a foreign Origin, any Sec-Fetch-Dest other than a page or its
  stylesheet, and absolute-form request lines. Answer HEAD with no AWS
  call. Send Cross-Origin-Opener-Policy.
- Accept only letters, digits, '-', '_', '.' and '/' after the
  /durable-execution/ marker, encode ( ) ! * ' ~ in viewer URLs, and
  collapse control characters and escape < > and backticks in link text,
  so a link cannot end early.
- Write the consent file through a random temp name opened with wx,
  refuse a symlinked directory, and tighten an existing loose one to 0700.
- Rename the checkbox to "Don't ask again" and rewrite the README's
  exposure section to match.
Concurrent requests for one execution already shared the fetch, but each
rendered and encoded its own copy of the page, which can be megabytes.
They now share one encoded page.
Serializing each history page to measure it allocated a second copy of
every payload. Summing the payload lengths plus a fixed per-event
overhead measures the same thing without the copy.
A caller that knew one linked execution could hold unbounded memory: each
request after a fetch finished built a new page, a client that stopped
reading kept it alive with no timeout, and names and rows were rendered
without a cap. A Lambda call that went silent also held a fetch slot
forever.

- Reuse a finished page for 5 s (16 pages at most), which also stops a
  looping caller from calling Lambda on every request.
- Refuse with 503 when pages not yet read by browsers would pass 64 MB,
  counting a shared page once; close a response with no write progress
  for 60 s.
- Cap each rendered field at 256 characters (ARNs and errors at 2,048)
  and the operations table at 2,000 rows.
- Fail each Lambda request after 30 s (requestTimeout with
  throwOnRequestTimeout), and make the Busy page reload after 5 s.
- Link get_execution only when the record is found, using the record's
  ARN, and let at most 1,000 children per page into the linked set.
- Count Name, Id, SubType and ParentId in the history byte estimate.
- Strip C1, bidi, zero-width and BOM characters from link text.
- Log a failed "Don't ask again" save to stderr.
- Correct stale comments, a test name, and the README's limits, and name
  the query-literal case the allowlist cannot stop.
Opening an execution from a query row while another function's list was
loading left that loading state set, so the breadcrumb page never loaded.
A start reply that landed after the user closed the modal moved them to
the new execution.
The viewer's other checks detect how a browser behaves, and the allowlist
still let any local process read an execution whose linked ARN it knew.
Every execution page now needs this process's session token: 32 random
bytes made at startup, kept in memory, and carried in each link
(/s/<token>/execution?arn=...). The first request with it sets an
HttpOnly, SameSite=Strict cookie named for the port, and a page load is
redirected to the URL without the token, so it stays out of browser
history. A request with neither the token nor the cookie gets 403 before
any AWS call. Tokens are compared in constant time. The header checks,
allowlist, and limits stay as a second layer.

This is the scheme Jupyter and the VS Code server use for loopback
servers opened from a terminal link.
… gaps

Browsers send a cookie to every port on a host, so the session cookie
reached any other server on 127.0.0.1 the browser loaded, and another
server could shadow it to make every link fail. Chrome also kept the
token URL in history despite the redirect. The token now lives only in
the URL path: no cookie and no redirect. Child links are relative and the
meta refresh reloads the same URL, so both keep the token.

- Show callback IDs in full (up to 2,048 characters); they are about 364.
- End a Lambda response body that stalls after its headers (socketTimeout)
  and abort a whole page fetch after 60 s across calls and retries, which
  then shows the 502 page.
- Start the 60 s delivery deadline after the page is written, so a client
  waiting on a slow fetch is not cut off, and track pipelined responses
  through their connection.
- Re-link the parent after its children, so viewed pages are not evicted.
- Drop expired cache entries, and do not cache a page whose history failed.
- Correct the README to match.
A reply for one function's start could close a newer start modal for
another function and move the user. Replies are now dropped unless they
name the function of the open start, closing the modal clears its
in-progress state, and the ref is written in an effect.
…rectly

Fixes #1118.

- A failure with RetryDetails.NextAttemptDelaySeconds is a scheduled retry:
  the operation is PENDING, with the time of its next attempt and the error
  of the attempt that failed, not FAILED with an end time.
- A Started event clears the previous attempt's error, so a step that
  succeeded on retry no longer shows an error next to its result.
- attempt counts Started events; the service's CurrentAttempt runs one
  behind that count in every recorded history.
- An operation still open when the execution ended is INCOMPLETE rather
  than shown as running, and an operation with no Started event has no
  duration rather than 0 ms.
- When history reading fails part-way, both viewers say the history is
  incomplete instead of claiming operations are not shown, and the
  truncation notice says shown operations may look unfinished.
- An empty execution status no longer makes the MCP page reload forever
  or the extension show Live.

Against all 146 recorded histories: 0 attempt mismatches (was 30), 0
succeeded operations carrying an error (was 3), 0 operations left running
in an ended execution (was 34).
…t saves

- A function reached from an execution in another region is listed and
  started in that region, not the configured one.
- Start new execution keeps the version or alias the execution ran on,
  instead of switching to $LATEST, and the modal names it.
- Concurrent "Don't ask again" saves from several sessions take a lock
  file, so none is lost (was 11 of 30 keys lost across rounds).
- Add a minimal hook runtime (src/testing/fakeReact.ts) so the renderer's
  test runner can test useExecutionViewer without a DOM, and test its
  stale-reply, polling, region, version, and start-reply rules.
- Run renderer tests in subfolders: outputs keep their path under src,
  and a missing bundle now fails the run instead of being skipped.
- The renderer repeats core's execution-ARN rules, and detects the ARN
  column the same way the MCP server does; a parity test checks the ARN
  rules agree with core (0 disagreements, was 5).
… button

A running execution's page reloaded itself every 5 seconds. Each reload
read the execution and its whole history from Lambda again, and reset the
reader's scroll position and opened sections.

The page no longer reloads itself. A Refresh button, with an inline icon so
the page still loads nothing but its stylesheet, loads it again on demand
and skips the 5-second page cache, so a click always shows current data.
The Busy and load-error pages get a Try again link instead of reloading.
The VS Code and desktop views are unchanged: they update in place.
@ParidelPooya

Copy link
Copy Markdown
Contributor Author

Split into two smaller PRs: #1119 (core read and MCP viewer) and #1120 (VS Code and desktop viewer, based on #1119). The viewer no longer starts or stops executions; it only shows them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Execution viewer shows a step waiting to retry as FAILED

1 participant