From bef9d18c693c4873752539d8ca88c820ed98060d Mon Sep 17 00:00:00 2001 From: evlawler <4238658+evlawler@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:51:07 +0000 Subject: [PATCH 01/54] Record Deno background work, add behavior-diff tracing agent Brings appmap-react up to date with the work done since the initial snapshot: - Deno: work handed to EdgeRuntime.waitUntil (runs after the response is sent) is now recorded, and a recording cut off mid-write is repaired instead of lost. New deno/appmap_test.ts. Doc 11. - Tracing agent (linker/bin/appmap-trace.mjs): ASCII call tree and mermaid sequence diagram per interaction, plus a behavior diff against a baseline set of recordings. Doc 10. - Tracing agent no longer drops one backend call when an interaction fires several fetches at once (recorder/test/concurrency.test.ts). - Recorder: value-size cap (APPMAP_EVENT_VALUESIZE), build config for publishing (tsconfig.build.json), recording tests. - CI workflow for the npm and deno test suites. - Recorder emits AppMap version 1.12; docs updated to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017qStU1BiJDPwFbmGPPPtLT --- .github/workflows/ci.yml | 37 + README.md | 26 +- deno/appmap.ts | 101 ++- deno/appmap_test.ts | 122 ++++ deno/preload.ts | 12 +- docs/HANDOFF.md | 6 +- ...ording-sessions-and-interaction-windows.md | 38 +- ...2-cross-map-correlation-via-traceparent.md | 2 +- docs/design/08-labels.md | 8 +- docs/design/10-behavior-diff-tracing-agent.md | 109 +++ docs/design/11-waituntil-background-work.md | 117 ++++ .../src/hooks/useOwnerSearch.ts | 38 +- examples/petclinic-react/src/main.tsx | 7 +- .../petclinic-react/src/pages/CreateOwner.tsx | 40 +- .../test/e2e/fullstack.test.tsx | 12 +- .../test/interactionRecorder.test.tsx | 2 +- examples/petclinic-react/test/labels.test.ts | 16 +- examples/petclinic-react/vite.config.ts | 3 +- linker/bin/appmap-trace.mjs | 131 ++++ linker/bin/simulate-backend.mjs | 2 +- linker/package.json | 6 +- linker/src/trace-agent.mjs | 658 ++++++++++++++++++ linker/test/trace-agent.test.mjs | 353 ++++++++++ package-lock.json | 95 +-- package.json | 2 +- recorder/package.json | 19 +- recorder/src/index.ts | 20 +- recorder/src/instrument.ts | 19 +- recorder/src/recording.ts | 218 +++++- recorder/src/testRecording.ts | 7 +- recorder/src/transform.ts | 151 ++-- recorder/src/types.ts | 16 +- recorder/src/vitePlugin.ts | 31 +- recorder/test/concurrency.test.ts | 95 +++ recorder/test/recording.test.ts | 143 ++++ recorder/tsconfig.build.json | 17 + 36 files changed, 2442 insertions(+), 237 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 deno/appmap_test.ts create mode 100644 docs/design/10-behavior-diff-tracing-agent.md create mode 100644 docs/design/11-waituntil-background-work.md create mode 100755 linker/bin/appmap-trace.mjs create mode 100644 linker/src/trace-agent.mjs create mode 100644 linker/test/trace-agent.test.mjs create mode 100644 recorder/test/concurrency.test.ts create mode 100644 recorder/test/recording.test.ts create mode 100644 recorder/tsconfig.build.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e20da3f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + pull_request: + +jobs: + node: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run typecheck --workspaces --if-present + - run: npm run build --workspaces --if-present + - run: npm test --workspaces --if-present + + deno: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + # Deno resolves the recorder's npm deps (e.g. npm:@types/node during + # type-checking) from node_modules, so install them first. + - run: npm ci + - uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + # deno/test contains Vitest-hosted unit tests; native Deno smoke tests + # live in deno/appmap_test.ts. + - run: deno test -A --unstable-sloppy-imports deno/appmap_test.ts diff --git a/README.md b/README.md index 3ff52cb..a6caa99 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ An [AppMap](https://appmap.io) agent for React (browser) apps and Deno edge functions, sharing one recorder core. Together they record -[AppMap v1.2](https://github.com/getappmap/appmap) JSON from component +[AppMap v1.12](https://github.com/getappmap/appmap) JSON from component renders, hooks, event handlers, `fetch` calls, and `Deno.serve` requests — and the headline goal is **full-stack linking**: correlating frontend AppMaps with backend AppMaps via W3C Trace @@ -28,7 +28,7 @@ specific to this repo is in `docs/design/`. ## Status -Nine design docs, listed below with what each one proved. Highlights: +Eleven design docs, listed below with what each one proved. Highlights: full-stack linking (doc 02) now has a real end-to-end test with no external dependency (doc 09), and both runtimes record with zero application-code changes (docs 06 and 07). Interaction-window capture @@ -37,7 +37,7 @@ is still pending a manual real-browser run (doc 04). - [`recorder/`](recorder) — the recorder core (Enter/Exit + CallToken contract, value capture with size caps, `fetch` → `http_client_request`/`response` events with `traceparent` stamping, - AppMap v1.2 serializer), Vitest per-test recording hooks + AppMap v1.12 serializer), Vitest per-test recording hooks (`./vitest`), and the Vite plugin (`./vite`) that auto-instruments top-level functions in configured paths — dev/test only, with components and hooks labeled by naming convention. @@ -72,6 +72,16 @@ is still pending a manual real-browser run (doc 04). **not** Supabase Edge Functions, which expose no equivalent hook — that gap is named, not silently missing. See [doc 06](docs/design/06-zero-touch-deno.md). + Work a handler hands to `EdgeRuntime.waitUntil` (runs after the + response is sent) is recorded too, and a recording cut off mid-write + is repaired rather than lost. See + [doc 11](docs/design/11-waituntil-background-work.md). +- [`linker/bin/appmap-trace.mjs`](linker/bin/appmap-trace.mjs) — the + tracing agent: shows each interaction as an ASCII call tree and a + mermaid sequence diagram, and with `--baseline ` shows a + behavior diff (added, removed and changed steps) against an earlier + set of recordings. Label-aware, no dependencies. See + [doc 10](docs/design/10-behavior-diff-tracing-agent.md). ## Quickstart @@ -86,6 +96,9 @@ ls examples/petclinic-react/tmp/appmap/links/ # appmap-links.json + .puml diag npm test --workspace examples/deno-edge # real deno run, if deno is on PATH # (this also runs the real React <-> Deno # e2e test in examples/petclinic-react, doc 09) + +# tracing agent: ASCII + mermaid per interaction (add --baseline to diff) +node linker/bin/appmap-trace.mjs examples/petclinic-react/tmp/appmap ``` To run the example app against a live PetClinicGo backend @@ -131,3 +144,10 @@ not history rewrites. - [09 — real end-to-end test: React ↔ real Deno backend](docs/design/09-real-e2e-deno.md) (no sibling repo needed, unlike the Go e2e test — real appmap-deno process, real fetch, real appmap-link, on every checkout with `deno`) +- [10 — behavior-diff tracing agent](docs/design/10-behavior-diff-tracing-agent.md) + (ASCII + mermaid views and a computed behavior diff; spiked by + `appmap-trace` over the example recordings, checked against the real + mermaid parser) +- [11 — recording background work (`EdgeRuntime.waitUntil`)](docs/design/11-waituntil-background-work.md) + (found by the first real edge function the Deno driver met; also + repairs recordings cut off mid-write) diff --git a/deno/appmap.ts b/deno/appmap.ts index 7f6704a..9046d26 100644 --- a/deno/appmap.ts +++ b/deno/appmap.ts @@ -6,6 +6,11 @@ // open, the function's own outbound fetches are stamped and recorded // too, so an edge function shows up as a middle tier in the stitch. // +// Background work handed to EdgeRuntime.waitUntil (a handler that returns +// 202 immediately and scrapes/synthesizes in the background) is captured +// too: the recording stays open until those tasks settle, without +// delaying the response. See docs/design/11. +// // Usage (after transforming the function source with // recorder/bin/transform-file.ts, runtime module pointed at this file): // @@ -17,16 +22,20 @@ // relative imports). Output: APPMAP_DIR (default tmp/appmap/requests), // or POSTed to APPMAP_COLLECTOR if set. // -// This file is not part of the Node/Vitest suite; it is exercised by -// the Deno validation procedure in docs (this repo has no Deno in CI yet). +// This file is not part of the Node/Vitest suite — it's exercised by +// deno/appmap_test.ts, run via `deno test` and wired into the `deno` +// job in .github/workflows/ci.yml. -import { - Recording, - startRecording, - stopRecording, - activeRecording, - autoInstrument, -} from '../recorder/src/index'; +// Import directly from the specific submodules the driver needs, not +// the './index' barrel — that barrel also re-exports +// interactionRecording.ts, which is typed against browser DOM globals +// (document, Element, HTMLInputElement) that don't exist under Deno's +// type checker. This was never actually exercised until +// deno/appmap_test.ts (added alongside CI automation) started running +// `deno check` for the first time. +import { Recording } from '../recorder/src/recording'; +import { startRecording, stopRecording, activeRecording } from '../recorder/src/session'; +import { autoInstrument } from '../recorder/src/instrument'; // Re-export so this file can serve as the transform's `runtimeModule`. export { autoInstrument }; @@ -37,6 +46,14 @@ declare const Deno: { writeTextFile(path: string, text: string): Promise; }; +// Supabase Edge Runtime (and Deno Deploy) expose a global EdgeRuntime +// with waitUntil(promise), used to keep the isolate alive for background +// work after the response is sent. See patchWaitUntil / withAppMap below +// and docs/design/11. +declare const EdgeRuntime: + | { waitUntil?: (promise: Promise) => void } + | undefined; + const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/; type Handler = (req: Request) => Response | Promise; @@ -47,6 +64,38 @@ export interface WithAppMapOptions { dir?: string; } +// Promises the current recording is waiting on before it closes — the +// background tasks the handler handed to EdgeRuntime.waitUntil (docs/design/11). +// Module-level because EdgeRuntime.waitUntil is a shared global and the +// one-at-a-time recording invariant (doc 01) guarantees only one +// recording is collecting at a time. `undefined` outside a recording, so +// the patch never touches waitUntil calls we aren't recording. +let pendingWaitUntil: Promise[] | undefined; +let waitUntilPatched = false; + +// Wrap EdgeRuntime.waitUntil once so that, while a recording is open, +// every background promise the handler registers is also awaited by the +// recorder before it closes and ships. Without this the recording closes +// the instant the handler returns its (often 202) response, and a +// scrape/synthesis pipeline running under waitUntil — which is the whole +// point of waitUntil — is never captured (finding E0a from a real-app +// pilot). No-ops cleanly where EdgeRuntime/waitUntil don't exist (plain +// `deno run`, the Node/Vitest tests), leaving today's handler-scoped +// behavior unchanged. +function patchWaitUntil(): void { + if (waitUntilPatched) return; + const er = typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : undefined; + if (!er || typeof er.waitUntil !== 'function') return; + const original = er.waitUntil.bind(er); + er.waitUntil = (promise: Promise): void => { + // Swallow rejections in our copy only (so allSettled can't be + // skewed); the original still sees the unaltered promise. + if (pendingWaitUntil) pendingWaitUntil.push(Promise.resolve(promise).catch(() => {})); + original(promise); + }; + waitUntilPatched = true; +} + export function withAppMap(handler: Handler, options: WithAppMapOptions = {}): Handler { const dir = options.dir ?? Deno.env.get('APPMAP_DIR') ?? 'tmp/appmap/requests'; const collector = Deno.env.get('APPMAP_COLLECTOR'); @@ -57,8 +106,11 @@ export function withAppMap(handler: Handler, options: WithAppMapOptions = {}): H // Record only stamped requests (recording-driven semantics), and only // one at a time — the ambient-session invariant from doc 01. A // request arriving while another is being recorded runs unrecorded. + // (With waitUntil capture, "being recorded" now extends through the + // background window; a request arriving during it is skipped, doc 11.) if (!match || activeRecording()) return handler(req); + patchWaitUntil(); const url = new URL(req.url); const recording = startRecording( new Recording({ @@ -79,15 +131,40 @@ export function withAppMap(handler: Handler, options: WithAppMapOptions = {}): H const token = recording.httpServerRequest(req.method, url.pathname, { traceparent: match[0], }); + // Collect waitUntil promises the handler registers during its (sync + // path to the) response. + const deferred: Promise[] = []; + pendingWaitUntil = deferred; + + const finalize = (): void => { + pendingWaitUntil = undefined; + stopRecording(); + void ship(recording, dir, collector, ++seq); + }; + let status = 500; try { const response = await handler(req); status = response.status; + // Record the real response now, at its true time — not after the + // background work. Background call/return events (fetches, SQL) land + // after this server-response event in the flat list; that ordering + // is unusual but valid (parent_id linkage, doc 01). + recording.httpServerResponse(token, status); + pendingWaitUntil = undefined; // handler done registering + if (deferred.length > 0) { + // Do NOT block the response on the background work — that is why + // the handler used waitUntil. Keep the recording open and finalize + // once the background settles. + void Promise.allSettled(deferred).then(finalize); + } else { + finalize(); + } return response; - } finally { + } catch (err) { recording.httpServerResponse(token, status); - stopRecording(); - void ship(recording, dir, collector, ++seq); + finalize(); + throw err; } }; } diff --git a/deno/appmap_test.ts b/deno/appmap_test.ts new file mode 100644 index 0000000..db48f2a --- /dev/null +++ b/deno/appmap_test.ts @@ -0,0 +1,122 @@ +// Smoke test for the Deno driver (docs/design/02's Deno twin of the +// PetClinicGo middleware). This is the Deno path's first automated +// test — previously it was validated by an undocumented manual +// procedure only (see the header comment in appmap.ts, now updated). +// +// Run: deno test -A --unstable-sloppy-imports deno/ +// (permissions and sloppy-imports match the usage note in appmap.ts; +// wired into CI via .github/workflows/ci.yml's `deno` job.) + +import assert from 'node:assert/strict'; +import { withAppMap } from './appmap.ts'; + +async function waitForFile(dir: string, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const entry of Deno.readDirSync(dir)) { + if (entry.isFile) return entry.name; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`no file appeared in ${dir} within ${timeoutMs}ms`); +} + +Deno.test('withAppMap records a traceparent-stamped request and writes an AppMap', async () => { + const dir = await Deno.makeTempDir({ prefix: 'appmap-deno-test-' }); + const handler = (_req: Request) => Response.json({ ok: true }); + const wrapped = withAppMap(handler, { app: 'test-app', dir }); + + const traceId = 'a'.repeat(32); + const spanId = 'b'.repeat(16); + const req = new Request('http://localhost/widgets', { + headers: { traceparent: `00-${traceId}-${spanId}-01` }, + }); + + const res = await wrapped(req); + assert.equal(res.status, 200); + + // ship() is fire-and-forget (not awaited by the wrapped handler), so + // the file may not exist the instant the response resolves. + const file = await waitForFile(dir); + const appmap = JSON.parse(await Deno.readTextFile(`${dir}/${file}`)); + + assert.equal(appmap.version, '1.12'); + assert.equal(appmap.metadata.trace_id, traceId); + assert.equal(appmap.metadata.parent_span_id, spanId); + assert.equal(appmap.metadata.recorder.name, 'funwithappmap-deno'); + + const call = appmap.events.find((e: { event: string }) => e.event === 'call'); + assert.ok(call.http_server_request); + assert.equal(call.http_server_request.path_info, '/widgets'); + assert.equal(call.http_server_request.headers.traceparent, req.headers.get('traceparent')); + + const ret = appmap.events.find((e: { event: string }) => e.event === 'return'); + assert.equal(ret.http_server_response.status_code, 200); +}); + +Deno.test('withAppMap runs the handler unrecorded when there is no traceparent header', async () => { + const dir = await Deno.makeTempDir({ prefix: 'appmap-deno-test-unstamped-' }); + const handler = (_req: Request) => Response.json({ ok: true }); + const wrapped = withAppMap(handler, { app: 'test-app', dir }); + + const res = await wrapped(new Request('http://localhost/widgets')); + assert.equal(res.status, 200); + + const files = [...Deno.readDirSync(dir)]; + assert.equal(files.length, 0); +}); + +Deno.test('withAppMap captures EdgeRuntime.waitUntil background work (docs/design/11, E0a)', async () => { + const dir = await Deno.makeTempDir({ prefix: 'appmap-deno-test-waituntil-' }); + + // Stand in for the Supabase Edge Runtime global: waitUntil keeps a + // handle so the test can await the same background work the driver does. + // The driver reads and patches the global `EdgeRuntime`, which is this + // same object, so the handler's edge.waitUntil(...) hits the patched fn. + const background: Promise[] = []; + const edge = { + waitUntil(p: Promise) { + background.push(p); + }, + }; + const globals = globalThis as Record; + globals.EdgeRuntime = edge; + + // A 202-then-background handler, like discovery-scan: it returns + // immediately and does the real work (here, one fetch) under waitUntil. + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(Response.json({ scraped: true })); + try { + const handler = (_req: Request): Response => { + const pipeline = (async () => { + await new Promise((r) => setTimeout(r, 10)); + await fetch('https://vendor.example/scrape'); + })(); + edge.waitUntil(pipeline); + return new Response(null, { status: 202 }); + }; + const wrapped = withAppMap(handler, { app: 'scan-app', dir }); + + const req = new Request('http://localhost/discovery-scan', { + headers: { traceparent: `00-${'a'.repeat(32)}-${'c'.repeat(16)}-01` }, + }); + const res = await wrapped(req); + assert.equal(res.status, 202); // response is NOT delayed by the background + + // Let the driver's deferred finalize run: await the same background, + // then a couple of macrotasks for ship() to write. + await Promise.allSettled(background); + const file = await waitForFile(dir); + const appmap = JSON.parse(await Deno.readTextFile(`${dir}/${file}`)); + + // The background fetch was captured — the whole point of E0a. + const clientReq = appmap.events.find( + (e: { http_client_request?: unknown }) => e.http_client_request, + ); + assert.ok(clientReq, 'background fetch under waitUntil should be recorded'); + assert.equal(appmap.metadata.truncated, undefined); // closed cleanly, balanced + } finally { + globalThis.fetch = originalFetch; + delete globals.EdgeRuntime; + } +}); diff --git a/deno/preload.ts b/deno/preload.ts index 3cb75c5..11936dc 100644 --- a/deno/preload.ts +++ b/deno/preload.ts @@ -21,7 +21,7 @@ import { withAppMap } from './appmap.ts'; type AnyHandler = (...args: unknown[]) => Response | Promise; const appName = Deno.env.get('APPMAP_APP'); -const original = Deno.serve; +const original = Deno.serve as unknown as (...args: unknown[]) => unknown; function wrap(handler: AnyHandler): AnyHandler { // withAppMap's Handler type is (req: Request) => Response|Promise — @@ -38,18 +38,18 @@ Deno.serve = ((...args: unknown[]) => { const [first, second] = args; if (typeof first === 'function') { - return (original as AnyHandler)(wrap(first as AnyHandler), ...args.slice(1)); + return original(wrap(first as AnyHandler), ...args.slice(1)); } if (first && typeof first === 'object') { const options = first as Record; if (typeof second === 'function') { - return (original as AnyHandler)(options, wrap(second as AnyHandler), ...args.slice(2)); + return original(options, wrap(second as AnyHandler), ...args.slice(2)); } if (typeof options.handler === 'function') { - return (original as AnyHandler)({ ...options, handler: wrap(options.handler as AnyHandler) }); + return original({ ...options, handler: wrap(options.handler as AnyHandler) }); } } - return (original as AnyHandler)(...args); -}) as typeof Deno.serve; + return original(...args); +}) as unknown as typeof Deno.serve; diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 7e5dcac..230edc8 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -27,14 +27,14 @@ amendments are dated sections, not history rewrites. ## What the React agent records -AppMap JSON v1.2, same as the siblings. Mapping: +AppMap JSON v1.12, same as the siblings. Mapping: - Components → classMap classes (module/file → package); renders and event handlers → `call`/`return` events with props/args captured (size-capped, like APPMAP_EVENT_VALUESIZE in the .NET agent). - Hooks (`useState`/`useEffect`/custom) → labeled function events. - `fetch`/XHR → `http_client_request` / `http_client_response` events - (already in the v1.2 spec — this is the linking hook, see below). + (already in the v1.12 spec — this is the linking hook, see below). - Recording unit: **one AppMap per user interaction** (the analogue of per-HTTP-request on servers): start at the triggering DOM event, stop when the microtask queue drains / idle timeout. Route @@ -92,7 +92,7 @@ backend map's incoming `traceparent` parent-span-id. One interaction fans out to N fetches → 1 frontend map linked to N backend request maps (1:N, by design). -**The linker:** AppMap v1.2 has no cross-map link concept, so don't +**The linker:** AppMap v1.12 has no cross-map link concept, so don't fight the format — build a small CLI (`appmap-link`) that scans a directory of frontend + backend maps and: diff --git a/docs/design/01-recording-sessions-and-interaction-windows.md b/docs/design/01-recording-sessions-and-interaction-windows.md index 18f1424..0a175f4 100644 --- a/docs/design/01-recording-sessions-and-interaction-windows.md +++ b/docs/design/01-recording-sessions-and-interaction-windows.md @@ -88,7 +88,7 @@ exactly the prologue/epilogue the doc 03 transform will inject, with `try/finally` (and promise `.then` chaining for async functions) playing the role of Go's `defer`. -**Why linearized events tolerate async overlap.** AppMap v1.2 events +**Why linearized events tolerate async overlap.** AppMap v1.12 events are a flat list where each `return` names its `call` via `parent_id`. Two in-flight fetches interleave in the stream but stay correctly paired — no tree structure has to be repaired when completions arrive @@ -144,3 +144,39 @@ windows. Those are doc 04's spike. If real-world interaction recording shows frequent overlap (slow fetches + fast clicking), that surfaces as thrown errors we can measure, and becomes the trigger to invest in mechanism 3. + +## Amendment (2026-09-01): thread assignment under concurrency + +The "why linearized events tolerate async overlap" claim above is true +for *pairing* (`return.parent_id` always identifies the right `call`, +regardless of settlement order) but was incomplete for *hierarchical +reconstruction*. `thread_id` is a required AppMap field precisely +because a single flat, positionally-nested event stream ("push on +call, pop on return, per thread") can only represent one call being +open at a time on a given thread — true concurrent siblings (e.g. both +legs of a `Promise.all`, both still open at once) violate that if they +share a `thread_id`. The doc 01 spike's own owner-detail example +(`getOwner` and `getVets` both fetching concurrently, sharing +`thread_id: 1`) is exactly this shape, and would reconstruct +incorrectly under the positional-stack model standard AppMap tooling +uses, even though `parent_id` pairing alone stayed correct. + +**Fix:** `Recording` (`recorder/src/recording.ts`) now assigns threads +based on real synchronous nesting rather than a single constant. It +tracks `syncStack` — call ids currently *synchronously* executing, +mirroring the real single-threaded JS call stack, popped the instant a +call yields control back to its caller (returns, or hands back a +pending `Promise`) — plus which threads currently have a call that has +left its sync frame but not yet settled ("dangling"). A new call +inherits its parent's thread when safe; when the candidate thread +already has a dangling, non-ancestor call open (a genuine concurrent +sibling), it gets a fresh thread instead. This requires no +`AsyncLocalStorage`, Zone.js, or continuation-passing (mechanisms 2/3 +above stay exactly as expensive/deferred as before) — it only needs to +know whether an invocation's result was a `Promise`, which the +Enter/Exit wrapper already had to know. + +Ordinary sequential (non-overlapping) calls are unaffected and stay on +one thread, as before. See `recorder/test/concurrency.test.ts` for the +Promise.all case this fixes, asserted against the actual pairing ++ positional-nesting invariant standard tooling relies on. diff --git a/docs/design/02-cross-map-correlation-via-traceparent.md b/docs/design/02-cross-map-correlation-via-traceparent.md index 6a1ac40..dededc1 100644 --- a/docs/design/02-cross-map-correlation-via-traceparent.md +++ b/docs/design/02-cross-map-correlation-via-traceparent.md @@ -55,7 +55,7 @@ makes timestamps unsafe for ordering. ## The linker: `appmap-link` -AppMap v1.2 has no cross-map link concept, so we don't fight the +AppMap v1.12 has no cross-map link concept, so we don't fight the format. [`linker/`](../../linker) is a small dependency-free Node CLI that scans directories of maps (frontend = has `http_client_request` events; backend = has `http_server_request` events) and: diff --git a/docs/design/08-labels.md b/docs/design/08-labels.md index fe9347d..50f806c 100644 --- a/docs/design/08-labels.md +++ b/docs/design/08-labels.md @@ -106,7 +106,7 @@ this cost one iteration on this doc's own test suite before landing on - Nothing here requires touching the doc 06/07 zero-touch story — a `@label` comment or a `supabase.from()` call is already in the developer's own code; the transform reads what's there. -- Built-in labels currently only run inside the same top-level - function the transform wraps — a call nested inside a closure the - transform doesn't reach (doc 03's "nested functions are not - instrumented" boundary) is invisible to this pass too. +- Built-in labels are attributed to the top-level function whose body + contains the recognized call. Nested handlers are also instrumented + by the current transform, but do not receive a separate built-in + label from that call. diff --git a/docs/design/10-behavior-diff-tracing-agent.md b/docs/design/10-behavior-diff-tracing-agent.md new file mode 100644 index 0000000..68d987a --- /dev/null +++ b/docs/design/10-behavior-diff-tracing-agent.md @@ -0,0 +1,109 @@ +# 5. Behavior-diff tracing agent + +Status: **implemented; validated against real recordings and a real +mermaid parser.** The agent +([`linker/src/trace-agent.mjs`](../../linker/src/trace-agent.mjs)) and its CLI +([`linker/bin/appmap-trace.mjs`](../../linker/bin/appmap-trace.mjs)) are +covered by 14 automated tests +([`linker/test/trace-agent.test.mjs`](../../linker/test/trace-agent.test.mjs)) +and were smoke-rendered on the example app's real recordings. Every +generated mermaid diagram was checked with the actual `mermaid.parse()`. + +## The decision + +Recordings and links are the *data*; a person needs a *view*. The .NET and +Go siblings render PlantUML; this repo already does too (doc 02, +[`diagram.mjs`](../../linker/src/diagram.mjs)). This doc adds the view that a +code reviewer actually wants: **what a change did to behavior**, rendered +where they read code — the terminal and a GitHub PR. + +So the tracing agent produces, per interaction: + +1. an **ASCII call-graph** — the full honest tree, for the terminal; +2. a **GitHub-native mermaid sequence diagram** — no PlantUML server needed; +3. an optional **behavior diff** against a baseline recording, with changed/ + added steps in an amber band and removed steps in red, plus a one-line + plain-English caption. + +## The contract it reads + +The agent reads exactly what this repo already produces — AppMap v1.2 maps +plus the linker's join — and nothing else. The contract itself is described in docs +[01](01-recording-sessions-and-interaction-windows.md), [02](02-cross-map-correlation-via-traceparent.md) and [05](05-deno-edge-functions.md). The +one thing worth repeating here: **AppMap v1.2 in this repo has no native diff +export** — no `diffMode` enum, no `subtreeDigest` field, no `formerName`. Those +belong to upstream `@appland/sequence-diagram`, which we do not use. The diff +is therefore *computed by the agent* from two recordings; its status words +(`unchanged | added | removed | changed`) are the agent's, not the data's. + +## How the diff works + +- **Build an honest model.** Reconstruct the call tree from the flat event + list (call/return + `parent_id` stack), then splice each linked fetch to its + backend handler + SQL. Labels come from the `classMap` + (`component`/`hook`/`event-handler`), joined by `defined_class`+`method_id`. +- **Digest for identity and for collapse.** `nodeDigest` = a step's identity + ignoring volatile detail; `subtreeDigest` (FNV-1a over the node + its + ordered children) = whether a whole subtree behaved identically. Equal + subtreeDigests is exactly when it is safe to collapse in a diff. +- **Match children order-preservingly** (LCS over node digests). Unmatched + current children are `added`; unmatched baseline children are `removed` and + **spliced back in at position** so nothing is hidden. A matched step is + `changed` if its own outcome differs (HTTP status, exception, SQL text) or + any descendant changed — change propagates up the ancestry, the same + intuition as a subtree digest mismatch. + +## Honesty rules (enforced by tests) + +- Never draw a call that is not in the recording; an unlinked fetch draws no + backend. +- Never drop a changed/added/removed step to fit a cap. +- Collapse **only** unchanged subtrees (proven equal by `subtreeDigest`). + +## Label-aware highlighting + +Any function label matching `--highlight` (default +`^(security|secret|auth|crypto)`) is banded amber (mermaid) / marked `⚠` +(ASCII) regardless of the diff, and named in the caption when it is new. This +is the highest-value hook: the day a `security.*` label appears in a +recording, a new sensitive call shows up highlighted with no further work. + +## Spike / how to run + +``` +npm run link:demo # produce real recordings + links under tmp/appmap +node linker/bin/appmap-trace.mjs examples/petclinic-react/tmp/appmap +# behavior diff against a baseline set of recordings: +node linker/bin/appmap-trace.mjs --baseline --out out +``` + +## Validation + +- 14 unit tests: call-tree reconstruction, label join, honest stitching (no + invented calls), digest equality, diff detection + change propagation, + security caption, ASCII collapse/marks, and a mermaid **balance guard** + (every `rect`/`activate` closes, none crosses). +- Smoke-rendered on all 10 example interactions (ASCII + mermaid, plain + + diff). +- All 20 generated mermaid diagrams pass the real `mermaid.parse()` + (mermaid v11 under jsdom). + +## The async gap (concurrent fetches) + +The recorder has no async context (doc 01), so an interaction that fires +several fetches at once (OwnerDetail's `Promise.all([getOwner, getVets])`) +produces events that interleave out of strict call/return nesting. The tree +reconstruction is written for this: a return closes **only** the call it +names (never the calls opened above it), and a fetch is treated as an async +leaf that does not adopt later concurrent calls. The guarantee is that every +call and every linked backend is preserved — a test on the two-fetch case +locks it. The residual limit is honest: the exact parent of a concurrent call +is ambiguous without async context, so it may attach to a sibling branch. An +earlier version of this agent got this wrong and silently dropped the second +fetch; that is fixed and regression-tested. + +## Not covered + +The full-stack e2e test still needs a live PetClinicGo backend and is skipped +without one (doc 02); the backend maps used here are the simulated ones, which +share the exact v1.2 shape the real Go middleware will emit. diff --git a/docs/design/11-waituntil-background-work.md b/docs/design/11-waituntil-background-work.md new file mode 100644 index 0000000..8905297 --- /dev/null +++ b/docs/design/11-waituntil-background-work.md @@ -0,0 +1,117 @@ +# 11. Recording background work (`EdgeRuntime.waitUntil`) + +Status: **accepted**, prompted by a real-app pilot (finding **E0a**) — +the first time the Deno driver met a real edge function instead of the +`deno-edge` toy. Validated by +[`deno/appmap_test.ts`](../../deno/appmap_test.ts) (waitUntil capture, +under real `deno test`) and +[`recorder/test/recording.test.ts`](../../recorder/test/recording.test.ts) +(the self-heal, under Vitest/Node). + +## The finding (E0a) + +The pilot app's `discovery-scan` edge function returns **202 immediately** and +does the entire scrape-and-synthesis pipeline — 2–4 minutes of vendor +calls — in a background task: + +```ts +// discovery-scan/index.ts (paraphrased) +EdgeRuntime.waitUntil(runPipeline()); // scrape + synthesis, 2-4 min +return new Response(null, { status: 202 }); +``` + +`EdgeRuntime.waitUntil(promise)` is how Supabase Edge Runtime / Deno +Deploy keep the isolate alive to finish work *after* the response is +sent. The doc 05/06 driver closed the recording the instant the +handler resolved: + +```ts +const response = await handler(req); // resolves at the 202 +... +} finally { + recording.httpServerResponse(token, status); + stopRecording(); // closed here — background hasn't run yet + void ship(...); +} +``` + +So the recorded map held the accept path and the first couple of +synchronous DB reads, and **none** of the vendor calls the pipeline +makes under `waitUntil`. The pilot saw a lone +`GET /rest/v1/user_source_materials` and no Firecrawl, no +ScrapeCreators, no transcript fetch — the "conservation ledger" (does +every source read get a write?) can't be built from a trace that stops +at the 202. A settle-delay doesn't help: the recording is already +closed and shipped. + +## Two problems, two fixes + +### 1. Capture the background work + +`EdgeRuntime.waitUntil` is a global, so the driver wraps it once +([`deno/appmap.ts`](../../deno/appmap.ts), `patchWaitUntil`) — the same +kind of global patch as doc 06's `Deno.serve` preload. While a +recording is open, every promise the handler hands to `waitUntil` is +also collected by the recorder. The wrapped handler then: + +1. records the real response at its true time (the 202 — **not** + delayed); +2. returns the response immediately, so the client is never blocked on + the background work (blocking it would defeat the reason the handler + used `waitUntil` at all); +3. keeps the recording **open**, and finalizes (`stopRecording` + + `ship`) only after `Promise.allSettled(background)` settles. + +Because the recording stays open through the background window, the +pipeline's own instrumented calls and its stamped outbound fetches land +in the same map — the edge function shows up complete, and as a middle +tier in the full-stack stitch. + +**Event ordering.** The `http_server_response` (202) event is emitted +before the background `call`/`return` events. That's unusual — a +request's "response" appears before some of its work — but valid: the +event list is flat and linked by `parent_id` (doc 01), not by position. +The server-request event is an async leaf (opened via `openDangling`), +so nothing is mis-nested under it. + +**Cost: the ambient session stays held.** One-at-a-time recording +(doc 01) now extends across the whole background window, so a second +stamped request arriving during those 2–4 minutes runs **unrecorded**. +Accepted: for capturing a specific journey this is fine, and the +alternative (concurrent recordings) is the doc 01 async-gap problem we +have deliberately deferred. If overlapping long-running recordings ever +matter, that's the trigger to revisit mechanisms 2/3 there. + +**Scope.** No-ops cleanly where `EdgeRuntime.waitUntil` doesn't exist — +plain `deno run`, the zero-touch runner, the Node/Vitest suite — so +existing behavior is unchanged. Nested `waitUntil` (background work that +itself calls `waitUntil`) is not chased; noted, not handled. + +### 2. Never ship an unbalanced map + +The pilot's truncated recording also **could not be sanitized**: +appmap-js's sanitizer threw "failed trying to compute event stack, +call.id: 47". That's the second failure and a more general one: a +`call` with no matching `return` — which any hard teardown produces, +`waitUntil` or not (the process is killed while calls are open) — makes +any tool that reconstructs the call stack throw. A map that can't be +sanitized can't be committed, so a truncated recording was worthless +even for the part it *did* capture. + +`Recording.toAppMap()` now **self-heals**: any call still open at +serialization gets a synthesized `return` appended, so the event list +is always balanced, and `metadata.truncated: true` flags that some +returns are synthetic. This is defense in depth — fix 1 makes clean +teardown the normal case; fix 2 guarantees that even a genuinely killed +isolate yields a balanced, sanitizable, committable (if incomplete) +map instead of an unusable one. The synthesis is a non-mutating +snapshot: the recording's own event list is untouched, so it composes +with everything else. + +## What the fixes do not solve + +- A hard `kill -9` mid-`waitUntil` still loses the un-run tail of the + pipeline (nothing client-side can capture that); fix 2 just makes the + captured prefix committable, flagged truncated. +- Attribution of concurrent background work to distinct recordings is + still the doc 01 async gap. diff --git a/examples/petclinic-react/src/hooks/useOwnerSearch.ts b/examples/petclinic-react/src/hooks/useOwnerSearch.ts index 7fbf1c3..9f88ca8 100644 --- a/examples/petclinic-react/src/hooks/useOwnerSearch.ts +++ b/examples/petclinic-react/src/hooks/useOwnerSearch.ts @@ -1,5 +1,4 @@ import { useCallback, useState } from 'react'; -import { instrumentHandler } from '@funwithappmap/react-recorder'; import { findOwners, ApiError } from '../api/client'; import { useClinic } from '../context/ClinicContext'; import type { Owner } from '../types'; @@ -12,8 +11,9 @@ export interface OwnerSearch { } // The hook itself is auto-instrumented (top-level, use* naming → hook -// label). The nested `search` callback is below the transform's -// granularity, so it keeps a hand-applied wrapper. +// label). The nested `search` callback — the first argument to +// useCallback — is instrumented by the transform too (docs/design/03): +// no manual instrumentHandler / hardcoded line numbers needed. export function useOwnerSearch(): OwnerSearch { const { apiBase } = useClinic(); const [owners, setOwners] = useState(); @@ -21,26 +21,18 @@ export function useOwnerSearch(): OwnerSearch { const [error, setError] = useState(); const search = useCallback( - instrumentHandler( - async (lastName: string) => { - setLoading(true); - setError(undefined); - try { - setOwners(await findOwners(apiBase, lastName)); - } catch (err) { - setOwners(undefined); - setError(err instanceof ApiError ? err.message : 'search failed'); - } finally { - setLoading(false); - } - }, - { - definedClass: 'useOwnerSearch', - methodId: 'search', - path: 'src/hooks/useOwnerSearch.ts', - lineno: 25, - }, - ), + async (lastName: string) => { + setLoading(true); + setError(undefined); + try { + setOwners(await findOwners(apiBase, lastName)); + } catch (err) { + setOwners(undefined); + setError(err instanceof ApiError ? err.message : 'search failed'); + } finally { + setLoading(false); + } + }, [apiBase], ); diff --git a/examples/petclinic-react/src/main.tsx b/examples/petclinic-react/src/main.tsx index 51a3ff2..87b2efb 100644 --- a/examples/petclinic-react/src/main.tsx +++ b/examples/petclinic-react/src/main.tsx @@ -4,11 +4,8 @@ import { BrowserRouter } from 'react-router-dom'; import { ClinicProvider } from './context/ClinicContext'; import { App } from './App'; -// Interaction recording (one AppMap per user interaction, shipped to -// the Vite dev server's collector → tmp/appmap/interactions/) is -// zero-touch: the `app` option on appmapVitePlugin in vite.config.ts -// injects installInteractionRecorder() into every page. Nothing here -// calls it. See docs/design/07. +// Interaction recording is zero-touch: appmapVitePlugin injects the +// recorder from vite.config.ts in development and test builds. createRoot(document.getElementById('root')!).render( diff --git a/examples/petclinic-react/src/pages/CreateOwner.tsx b/examples/petclinic-react/src/pages/CreateOwner.tsx index 9541e7f..3927f67 100644 --- a/examples/petclinic-react/src/pages/CreateOwner.tsx +++ b/examples/petclinic-react/src/pages/CreateOwner.tsx @@ -1,6 +1,5 @@ import { useState, type FormEvent } from 'react'; import { useNavigate } from 'react-router-dom'; -import { instrumentHandler } from '@funwithappmap/react-recorder'; import { createOwner, ApiError } from '../api/client'; import { useClinic } from '../context/ClinicContext'; @@ -14,28 +13,23 @@ export function CreateOwner() { const set = (field: keyof typeof form) => (e: { target: { value: string } }) => setForm((f) => ({ ...f, [field]: e.target.value })); - const submit = instrumentHandler( - async () => { - setSubmitting(true); - setError(undefined); - try { - const owner = await createOwner(apiBase, form); - navigate(`/owners/${owner.id}`); - } catch (err) { - // The backend's validation error path: POST /owners → 400 - // {"error": "..."} rendered in the UI. - setError(err instanceof ApiError ? err.message : 'could not create owner'); - } finally { - setSubmitting(false); - } - }, - { - definedClass: 'CreateOwner', - methodId: 'submit', - path: 'src/pages/CreateOwner.tsx', - lineno: 17, - }, - ); + // Instrumented by the transform (docs/design/03): nested named + // consts inside a component body are wrapped automatically, no + // manual instrumentHandler / hardcoded line numbers needed. + const submit = async () => { + setSubmitting(true); + setError(undefined); + try { + const owner = await createOwner(apiBase, form); + navigate(`/owners/${owner.id}`); + } catch (err) { + // The backend's validation error path: POST /owners → 400 + // {"error": "..."} rendered in the UI. + setError(err instanceof ApiError ? err.message : 'could not create owner'); + } finally { + setSubmitting(false); + } + }; const onSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/examples/petclinic-react/test/e2e/fullstack.test.tsx b/examples/petclinic-react/test/e2e/fullstack.test.tsx index 2642d7c..db09d90 100644 --- a/examples/petclinic-react/test/e2e/fullstack.test.tsx +++ b/examples/petclinic-react/test/e2e/fullstack.test.tsx @@ -48,11 +48,21 @@ const E2E_DIR = join(EXAMPLE_ROOT, 'tmp', 'appmap', 'e2e'); const PETCLINIC_GO_DIR = process.env.PETCLINIC_GO_DIR ?? - join(REPO_ROOT, '..', 'FunwithAppMapandClaudeGolang', 'FunwithAppMapandClaudeGolang', 'examples', 'PetClinicGo'); + join(REPO_ROOT, '..', 'FunwithAppMapandClaudeGolang', 'examples', 'PetClinicGo'); const goAvailable = spawnSync('go', ['version']).status === 0; const backendAvailable = goAvailable && existsSync(join(PETCLINIC_GO_DIR, 'main.go')); +if (!backendAvailable) { + console.warn( + `appmap: skipping full-stack e2e test — ${ + !goAvailable + ? 'no `go` binary on PATH' + : `no PetClinicGo checkout at ${PETCLINIC_GO_DIR} (set PETCLINIC_GO_DIR to override)` + }`, + ); +} + function freePort(): Promise { return new Promise((resolve, reject) => { const srv = createServer(); diff --git a/examples/petclinic-react/test/interactionRecorder.test.tsx b/examples/petclinic-react/test/interactionRecorder.test.tsx index 1272041..ff18d20 100644 --- a/examples/petclinic-react/test/interactionRecorder.test.tsx +++ b/examples/petclinic-react/test/interactionRecorder.test.tsx @@ -49,7 +49,7 @@ describe('interaction-window recording', () => { } const appmap = shipped[0] as any; - expect(appmap.version).toBe('1.2'); + expect(appmap.version).toBe('1.12'); expect(appmap.metadata.name).toMatch(/^click button/); expect(appmap.metadata.recorder.name).toBe('funwithappmap-react'); diff --git a/examples/petclinic-react/test/labels.test.ts b/examples/petclinic-react/test/labels.test.ts index 82311d0..e1d5494 100644 --- a/examples/petclinic-react/test/labels.test.ts +++ b/examples/petclinic-react/test/labels.test.ts @@ -100,7 +100,13 @@ describe('label merging at runtime (autoInstrument)', () => { it('combines a compile-time label with the naming-convention label for a component', () => { stopRecording(); - startRecording(new Recording({ name: 'test' })); + startRecording( + new Recording({ + name: 'test', + client: { name: 'labels.test', url: 'https://example.invalid' }, + recorder: { name: 'funwithappmap-react', type: 'tests' }, + }), + ); const Component = autoInstrument( () => 'ok', { definedClass: 'x', methodId: 'LoginForm', path: 'src/x.tsx', labels: ['security.authentication'] }, @@ -114,7 +120,13 @@ describe('label merging at runtime (autoInstrument)', () => { it('a plain function with no compile-time label still gets no labels at all', () => { stopRecording(); - startRecording(new Recording({ name: 'test' })); + startRecording( + new Recording({ + name: 'test', + client: { name: 'labels.test', url: 'https://example.invalid' }, + recorder: { name: 'funwithappmap-react', type: 'tests' }, + }), + ); const fn = autoInstrument(() => 'ok', { definedClass: 'x', methodId: 'helper', path: 'src/x.ts' }, []); fn(); const appmap = stopRecording().toAppMap(); diff --git a/examples/petclinic-react/vite.config.ts b/examples/petclinic-react/vite.config.ts index be7ac34..440a17a 100644 --- a/examples/petclinic-react/vite.config.ts +++ b/examples/petclinic-react/vite.config.ts @@ -12,8 +12,7 @@ const recorderSrc = (file: string) => export default defineConfig({ // appmap.yml equivalent: instrument everything under src/. The plugin // is dev/test-only; production builds get untouched code. `app` also - // zero-touch-injects installInteractionRecorder() (docs/design/07) — - // main.tsx does not call it. + // injects interaction recording without application code changes. plugins: [appmapVitePlugin({ include: ['src'], app: 'petclinic-react' }), react()], resolve: { alias: [ diff --git a/linker/bin/appmap-trace.mjs b/linker/bin/appmap-trace.mjs new file mode 100755 index 0000000..4e71f40 --- /dev/null +++ b/linker/bin/appmap-trace.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// appmap-trace: the tracing agent CLI. Scan a directory of AppMap +// recordings (frontend interaction/test maps + linked backend request +// maps) and render, per interaction: +// +// (a) an ASCII call-graph to the terminal, and +// (b) a GitHub-native mermaid sequence diagram. +// +// With --baseline , it diffs each interaction against the matching +// interaction (by metadata.name) in the baseline set and bands the +// changed/added steps amber, removed steps red, with a plain-English +// "what changed" caption. +// +// usage: +// appmap-trace ... [options] +// --baseline diff against this recording set +// --out write .md (mermaid) + .txt (ascii) per interaction +// --format ascii|mermaid|both default: both +// --interaction only interactions whose name contains +// --highlight label highlight (default: security|secret|auth|crypto) + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { scanAppMaps } from '../src/scan.mjs'; +import { isFrontendMap } from '../src/link.mjs'; +import { renderInteraction, backendIndex } from '../src/trace-agent.mjs'; + +const args = process.argv.slice(2); +const dirs = []; +let baseline; +let out; +let format = 'both'; +let filter; +let highlight; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--baseline') baseline = args[++i]; + else if (a === '--out') out = args[++i]; + else if (a === '--format') format = args[++i]; + else if (a === '--interaction') filter = args[++i]; + else if (a === '--highlight') highlight = new RegExp(args[++i], 'i'); + else if (a === '--help' || a === '-h') { + printUsage(); + process.exit(0); + } else dirs.push(a); +} +if (dirs.length === 0) { + printUsage(); + process.exit(2); +} + +function loadSet(scanDirs) { + const maps = scanAppMaps(scanDirs); + const frontends = maps.filter((m) => isFrontendMap(m.appmap)); + return { frontends, spanToBackend: backendIndex(maps) }; +} + +const current = loadSet(dirs); +const base = baseline ? loadSet([baseline]) : null; +const baseByName = new Map(); +if (base) for (const f of base.frontends) baseByName.set(f.appmap.metadata?.name, f.appmap); + +if (out) mkdirSync(out, { recursive: true }); + +let rendered = 0; +for (const f of current.frontends) { + const name = f.appmap.metadata?.name ?? f.path; + if (filter && !name.includes(filter)) continue; + + const baselineAppmap = base ? baseByName.get(name) : undefined; + const result = renderInteraction(f.appmap, { + spanToBackend: current.spanToBackend, + baselineAppmap, + baselineSpanToBackend: base?.spanToBackend, + highlight, + }); + + rendered++; + const slug = sanitize(name); + + if (out) { + if (format === 'ascii' || format === 'both') + writeFileSync(join(out, `${slug}.txt`), result.ascii); + if (format === 'mermaid' || format === 'both') + writeFileSync(join(out, `${slug}.md`), toMarkdown(name, result)); + } else { + if (format === 'ascii' || format === 'both') { + process.stdout.write(result.ascii); + process.stdout.write('\n'); + } + if (format === 'mermaid' || format === 'both') { + process.stdout.write('```mermaid\n'); + process.stdout.write(result.mermaid); + process.stdout.write('```\n\n'); + } + } +} + +if (baseline && rendered === 0) { + console.error(`no interactions matched between ${dirs.join(', ')} and baseline ${baseline}`); +} +console.log( + `traced ${rendered} interaction(s)${baseline ? ` (diffed against ${baseline})` : ''}` + + (out ? ` → ${out}` : ''), +); + +function toMarkdown(name, result) { + const parts = [`# ${name}`, '']; + if (result.caption) parts.push(`> ${result.caption}`, ''); + parts.push('```mermaid', result.mermaid.trimEnd(), '```', ''); + return parts.join('\n'); +} + +function sanitize(name) { + return String(name) + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .slice(0, 200); +} + +function printUsage() { + console.log( + [ + 'usage: appmap-trace ... [options]', + ' --baseline diff each interaction against this recording set', + ' --out write .md (mermaid) + .txt (ascii) per interaction', + ' --format ascii|mermaid|both (default: both)', + ' --interaction only interactions whose name contains ', + ' --highlight label highlight (default: security|secret|auth|crypto)', + ].join('\n'), + ); +} diff --git a/linker/bin/simulate-backend.mjs b/linker/bin/simulate-backend.mjs index bea0f57..0a2e60e 100755 --- a/linker/bin/simulate-backend.mjs +++ b/linker/bin/simulate-backend.mjs @@ -111,7 +111,7 @@ function buildBackendMap(req) { ret({ http_server_response: { status_code: req.status ?? 200 } }); return { - version: '1.2', + version: '1.12', metadata: { name: route, app: 'PetClinicGo', diff --git a/linker/package.json b/linker/package.json index d6fa4bf..56dc1a4 100644 --- a/linker/package.json +++ b/linker/package.json @@ -2,12 +2,14 @@ "name": "appmap-link", "private": true, "version": "0.0.1", - "description": "Joins frontend interaction AppMaps to backend request AppMaps via W3C Trace Context ids, and renders stitched PlantUML sequence diagrams.", + "description": "Joins frontend interaction AppMaps to backend request AppMaps via W3C Trace Context ids, renders stitched PlantUML sequence diagrams, and (appmap-trace) renders ASCII call-graphs + GitHub-native mermaid sequence diagrams with an optional behavior diff.", "type": "module", "bin": { "appmap-link": "bin/appmap-link.mjs", - "appmap-simulate-backend": "bin/simulate-backend.mjs" + "appmap-simulate-backend": "bin/simulate-backend.mjs", + "appmap-trace": "bin/appmap-trace.mjs" }, + "files": ["bin", "src"], "scripts": { "test": "vitest run" }, diff --git a/linker/src/trace-agent.mjs b/linker/src/trace-agent.mjs new file mode 100644 index 0000000..6a7b330 --- /dev/null +++ b/linker/src/trace-agent.mjs @@ -0,0 +1,658 @@ +// The tracing agent: turn AppMap recordings (this repo's sequence export) +// into two honest, dependency-free views — +// +// (a) an ASCII call-graph for the terminal, and +// (b) a GitHub-native mermaid sequence diagram, +// +// and, when given a baseline recording, a BEHAVIOR DIFF that bands +// changed/added steps in amber and removed steps in red, with a one-line +// plain-English "what changed" caption. +// +// Honesty rules this file keeps: +// - never draw a call that is not in the recording, +// - never drop a changed/added/removed call to fit a cap, +// - collapse only *unchanged* subtrees (proven equal by subtreeDigest). +// +// Note on the contract: AppMap v1.2 has no native diff export — no +// diffMode enum, no subtreeDigest field. This repo produces plain v1.2 +// maps plus the linker's appmap-links.json. So the agent COMPUTES the +// diff itself from two recordings; the status vocabulary it emits +// (unchanged / added / removed / changed) is defined here, not read from +// the data. See docs/design/05 and docs/design/10. + +import { parseTraceparent } from './link.mjs'; + +// --------------------------------------------------------------------------- +// Model building — the honest sequence tree +// --------------------------------------------------------------------------- + +/** Labels that light up the amber highlight band by default (case-insensitive + * prefix match). security.* is the headline case; extend via options.highlight. */ +const DEFAULT_HIGHLIGHT = /^(security|secret|auth|crypto)/i; + +const SQL_PREVIEW = 72; + +/** Reconstruct the call tree from a flat AppMap event list. + * + * Nesting is implied by call/return ordering, but this repo's recorder has no + * async context (the "async gap", docs/design/01): concurrent `await`s make + * returns arrive out of LIFO order and interleave calls from different + * branches. Two rules keep the reconstruction honest under that: + * + * - A return closes **only** the call it names (`parent_id`), wherever that + * call sits in the open set — never the calls opened above it. A naive + * "pop everything above" would silently drop a concurrent sibling. + * - When choosing a new call's parent, skip any open `http_client_request` + * frame. A fetch is an async leaf whose only completion is its response; + * a call that appears after it is concurrent work, not the fetch's child. + * Without this, a second concurrent fetch nests *inside* the first fetch's + * subtree and looks like the backend made it. + * + * The result never drops a call, but with no async context the parent of a + * concurrent call is genuinely ambiguous — it may attach to a sibling branch + * rather than its true caller. That residual imprecision is the async gap, + * not a bug this layer can close. Returns root nodes { event, return?, children[] }. */ +export function buildCallTree(events) { + const roots = []; + const byId = new Map(); + const open = []; // ids of calls seen but not yet returned, in call order + const isFetch = (node) => Boolean(node?.event.http_client_request); + for (const e of events ?? []) { + if (e.event === 'call') { + const node = { event: e, return: undefined, children: [] }; + byId.set(e.id, node); + let parentId; + for (let i = open.length - 1; i >= 0; i--) { + if (isFetch(byId.get(open[i]))) continue; // fetches don't adopt children + parentId = open[i]; + break; + } + if (parentId != null) byId.get(parentId).children.push(node); + else roots.push(node); + open.push(e.id); + } else if (e.event === 'return') { + const owner = byId.get(e.parent_id); + if (owner) owner.return = e; + const idx = open.lastIndexOf(e.parent_id); + if (idx !== -1) open.splice(idx, 1); // close just this call; tolerate interleaving + } + } + return roots; +} + +/** Build a lookup of AppMap labels by "Class.method", read from the + * classMap (labels live there, not on the call events). */ +export function labelIndex(classMap) { + const index = new Map(); + const walkClass = (cls, pkgLabelPath) => { + for (const child of cls.children ?? []) { + if (child.type === 'function') { + index.set(`${cls.name}.${child.name}`, child.labels ?? []); + } + } + }; + const walk = (node) => { + if (node.type === 'class') walkClass(node); + for (const child of node.children ?? []) walk(child); + }; + for (const root of classMap ?? []) walk(root); + return index; +} + +function pathOf(url) { + try { + const u = new URL(url); + return u.pathname + u.search; + } catch { + return url; + } +} + +function normalizeSql(sql) { + return String(sql).replace(/\s+/g, ' ').trim(); +} + +function previewSql(sql) { + const s = normalizeSql(sql); + return s.length > SQL_PREVIEW ? s.slice(0, SQL_PREVIEW) + '…' : s; +} + +/** + * Build the honest sequence model for one interaction: the frontend call + * tree, with each linked fetch stitched to its backend request map. + * + * @param {object} frontendAppmap the frontend interaction/test map + * @param {object} [opts] + * @param {Map} [opts.spanToBackend] span-id → backend appmap + * @returns {object} the root model node + */ +export function buildInteractionModel(frontendAppmap, opts = {}) { + const spanToBackend = opts.spanToBackend ?? new Map(); + const labels = labelIndex(frontendAppmap.classMap); + + const root = { + kind: 'interaction', + actor: 'User', + target: 'frontend', + label: frontendAppmap.metadata?.name ?? 'interaction', + labels: [], + detail: {}, + children: buildCallTree(frontendAppmap.events).map((n) => + frontendNodeToModel(n, labels, spanToBackend), + ), + }; + return root; +} + +function frontendNodeToModel(node, labels, spanToBackend) { + const e = node.event; + + if (e.http_client_request) { + const method = e.http_client_request.request_method; + const url = e.http_client_request.url; + const ctx = parseTraceparent(e.http_client_request.headers?.traceparent); + const status = node.return?.http_client_response?.status_code; + const backend = ctx?.spanId ? spanToBackend.get(ctx.spanId) : undefined; + const app = backend?.metadata?.app ?? 'network'; + // The backend subtree is the fetch's "children". buildCallTree treats a + // fetch as an async leaf, so node.children is normally empty — but keep any + // frontend calls it did attribute here rather than discarding them, so a + // call is never silently dropped no matter how the events interleaved. + const backendKids = backend ? backendChildren(backend, app) : []; + const strayKids = node.children.map((c) => frontendNodeToModel(c, labels, spanToBackend)); + return { + kind: 'fetch', + actor: 'frontend', + target: app, + label: `${method} ${pathOf(url)}`, + labels: ['http'], + detail: { status: status ?? null, linked: Boolean(backend) }, + children: [...backendKids, ...strayKids], + }; + } + + const cls = e.defined_class; + const method = e.method_id; + const fnLabels = labels.get(`${cls}.${method}`) ?? []; + return { + kind: 'call', + actor: 'frontend', + target: 'frontend', + label: `${cls}.${method}`, + labels: fnLabels, + detail: { + exception: node.return?.exceptions?.[0]?.class ?? null, + returnClass: node.return?.return_value?.class ?? null, + }, + children: node.children.map((c) => frontendNodeToModel(c, labels, spanToBackend)), + }; +} + +/** Model children for a backend request map: its handler calls become + * app self-messages, its SQL become app→DB messages. The http_server_request + * root is unwrapped (the fetch arrow already represents the request). */ +function backendChildren(backendAppmap, app) { + const labels = labelIndex(backendAppmap.classMap); + const roots = buildCallTree(backendAppmap.events); + const out = []; + for (const node of roots) { + if (node.event.http_server_request) { + for (const child of node.children) out.push(backendNodeToModel(child, app, labels)); + } else { + out.push(backendNodeToModel(node, app, labels)); + } + } + return out; +} + +function backendNodeToModel(node, app, labels) { + const e = node.event; + if (e.sql_query) { + return { + kind: 'sql', + actor: app, + target: 'DB', + label: previewSql(e.sql_query.sql), + labels: ['sql'], + detail: { sql: normalizeSql(e.sql_query.sql) }, + children: [], + }; + } + const cls = e.defined_class ?? '?'; + const method = e.method_id ?? '?'; + return { + kind: 'call', + actor: app, + target: app, + label: `${cls}.${method}`, + labels: labels.get(`${cls}.${method}`) ?? [], + detail: { exception: node.return?.exceptions?.[0]?.class ?? null }, + children: node.children.map((c) => backendNodeToModel(c, app, labels)), + }; +} + +// --------------------------------------------------------------------------- +// Digests — stable identity (for diffing) and subtree equality (for collapse) +// --------------------------------------------------------------------------- + +/** Identity of a step, ignoring volatile detail (elapsed, ids, statuses). + * Two steps with the same nodeDigest are "the same step". */ +export function nodeDigest(node) { + return `${node.kind}|${node.actor}>${node.target}|${node.label}`; +} + +/** A step is "materially different" when its own observable outcome differs, + * even though it is the same step (same nodeDigest). */ +function detailDigest(node) { + const d = node.detail ?? {}; + if (node.kind === 'fetch') return `status=${d.status}|linked=${d.linked}`; + if (node.kind === 'sql') return `sql=${d.sql}`; + return `exc=${d.exception ?? ''}|ret=${d.returnClass ?? ''}`; +} + +/** FNV-1a 32-bit — a tiny deterministic string hash, no dependencies. */ +function fnv1a(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(16).padStart(8, '0'); +} + +/** Digest of a whole subtree: own identity + own outcome + ordered child + * subtreeDigests. Equal subtreeDigests ⇒ the subtree behaved identically, + * which is exactly when it is safe to collapse in a diff. */ +export function subtreeDigest(node) { + const childPart = (node.children ?? []).map(subtreeDigest).join(','); + return fnv1a(`${nodeDigest(node)}#${detailDigest(node)}[${childPart}]`); +} + +// --------------------------------------------------------------------------- +// Behavior diff — annotate a current model against a baseline model +// --------------------------------------------------------------------------- + +/** + * Diff two interaction models. Returns a new tree whose nodes carry a + * `status` of 'unchanged' | 'added' | 'removed' | 'changed', plus a + * summary. Removed steps (present only in the baseline) are spliced back + * in at their position so nothing is hidden. + * + * @returns {{ tree: object, summary: {added:number, removed:number, changed:number, unchanged:number} }} + */ +export function diffModels(baseline, current) { + const summary = { added: 0, removed: 0, changed: 0, unchanged: 0 }; + const tree = diffNode(baseline, current, summary); + return { tree, summary }; +} + +function diffNode(base, cur, summary) { + // Fast path: identical subtree ⇒ everything below is unchanged. + if (base && cur && subtreeDigest(base) === subtreeDigest(cur)) { + return mark(cur, 'unchanged', summary, /*deep*/ true); + } + + const node = { ...cur, children: [] }; + node.children = diffChildren(base?.children ?? [], cur.children ?? [], summary); + + const childChanged = node.children.some((c) => c.status !== 'unchanged'); + const outcomeChanged = base ? detailDigest(base) !== detailDigest(cur) : false; + node.status = outcomeChanged || childChanged ? 'changed' : 'unchanged'; + if (node.status === 'changed') summary.changed++; + else summary.unchanged++; + node.outcomeChanged = outcomeChanged; + return node; +} + +/** Order-preserving match of two child lists by nodeDigest (greedy LCS). + * Unmatched current children are 'added'; unmatched baseline children are + * 'removed' and spliced in place. */ +function diffChildren(baseChildren, curChildren, summary) { + const result = []; + const baseDigests = baseChildren.map(nodeDigest); + const curDigests = curChildren.map(nodeDigest); + const lcs = lcsMatch(baseDigests, curDigests); + const matchedCur = new Map(lcs.map(([b, c]) => [c, b])); + + let bi = 0; + for (let ci = 0; ci < curChildren.length; ci++) { + if (matchedCur.has(ci)) { + const bIndex = matchedCur.get(ci); + // Emit any baseline children skipped before this match as 'removed'. + while (bi < bIndex) result.push(mark(baseChildren[bi++], 'removed', summary, true)); + bi = bIndex + 1; + result.push(diffNode(baseChildren[bIndex], curChildren[ci], summary)); + } else { + result.push(mark(curChildren[ci], 'added', summary, true)); + } + } + while (bi < baseChildren.length) result.push(mark(baseChildren[bi++], 'removed', summary, true)); + return result; +} + +/** Longest common subsequence of two digest arrays → list of [baseIndex,curIndex]. */ +function lcsMatch(a, b) { + const n = a.length; + const m = b.length; + const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) + for (let j = m - 1; j >= 0; j--) + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + const pairs = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + pairs.push([i, j]); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) i++; + else j++; + } + return pairs; +} + +function mark(node, status, summary, deep) { + summary[status] = (summary[status] ?? 0) + 1; + const out = { ...node, status }; + out.children = deep + ? (node.children ?? []).map((c) => markSilently(c, status)) + : node.children ?? []; + return out; +} + +// Descendants of an added/removed/unchanged subtree inherit its status +// without inflating the top-level counts (the subtree is counted once). +function markSilently(node, status) { + return { + ...node, + status, + children: (node.children ?? []).map((c) => markSilently(c, status)), + }; +} + +// --------------------------------------------------------------------------- +// Caption — one line of plain English +// --------------------------------------------------------------------------- + +export function captionFor(summary, tree, options = {}) { + const highlight = options.highlight ?? DEFAULT_HIGHLIGHT; + const parts = []; + if (summary.added) parts.push(`${summary.added} added`); + if (summary.removed) parts.push(`${summary.removed} removed`); + if (summary.changed) parts.push(`${summary.changed} changed`); + if (parts.length === 0) return 'No behavior change: every step matched the baseline.'; + + // Surface the single most notable step: a highlighted (e.g. security) add + // wins, then any added cross-lane call, then the first changed outcome. + const notable = firstNotable(tree, highlight); + const lead = `Behavior changed — ${parts.join(', ')}.`; + return notable ? `${lead} ${notable}` : lead; +} + +function firstNotable(tree, highlight) { + let securityAdd = null; + let addedCrossLane = null; + let changedOutcome = null; + const visit = (node) => { + const isHi = (node.labels ?? []).some((l) => highlight.test(l)); + if (node.status === 'added' && isHi && !securityAdd) + securityAdd = `New sensitive step: ${node.label} [${node.labels.join(', ')}].`; + if (node.status === 'added' && node.actor !== node.target && !addedCrossLane) + addedCrossLane = `New call ${node.actor}→${node.target}: ${node.label}.`; + if (node.status === 'changed' && node.outcomeChanged && !changedOutcome) + changedOutcome = `${node.label} now returns a different outcome.`; + for (const c of node.children ?? []) visit(c); + }; + visit(tree); + return securityAdd ?? addedCrossLane ?? changedOutcome; +} + +// --------------------------------------------------------------------------- +// ASCII call-graph renderer +// --------------------------------------------------------------------------- + +const STATUS_MARK = { added: '+', removed: '-', changed: '~', unchanged: ' ' }; + +/** + * Render an ASCII call-graph. In plain mode every step is shown. In diff + * mode, unchanged subtrees collapse to a "… (N unchanged)" line; changed, + * added and removed steps are always shown, marked +/-/~. + */ +export function renderAscii(tree, options = {}) { + const diff = options.diff ?? false; + const highlight = options.highlight ?? DEFAULT_HIGHLIGHT; + const lines = []; + const title = tree.label; + lines.push(title); + if (diff && options.caption) lines.push(` ${options.caption}`); + lines.push(''); + + const renderChildren = (children, prefix) => { + const visible = collapseUnchanged(children, diff); + visible.forEach((item, i) => { + const last = i === visible.length - 1; + const branch = last ? '└─ ' : '├─ '; + const childPrefix = prefix + (last ? ' ' : '│ '); + if (item.collapsed) { + lines.push(`${prefix}${branch}… (${item.count} unchanged)`); + return; + } + const node = item; + const mark = diff ? STATUS_MARK[node.status ?? 'unchanged'] : ' '; + const hi = (node.labels ?? []).some((l) => highlight.test(l)) ? ' ⚠' : ''; + const labelStr = node.labels?.length ? ` [${node.labels.join(', ')}]` : ''; + const outcome = outcomeSuffix(node); + lines.push(`${prefix}${branch}${mark} ${arrow(node)}${node.label}${labelStr}${outcome}${hi}`); + renderChildren(node.children ?? [], childPrefix); + }); + }; + + renderChildren(tree.children ?? [], ''); + return lines.join('\n') + '\n'; +} + +function arrow(node) { + if (node.kind === 'fetch') return `→ ${node.target}: `; + if (node.kind === 'sql') return `→ DB: `; + return ''; +} + +function outcomeSuffix(node) { + const d = node.detail ?? {}; + if (node.kind === 'fetch') { + const s = d.status == null ? '?' : d.status; + return d.linked ? ` (${s})` : ` (${s}, no backend map)`; + } + if (d.exception) return ` !${d.exception}`; + return ''; +} + +/** Replace maximal runs of unchanged siblings with a single collapse marker + * (diff mode only). Non-unchanged nodes are always kept. */ +function collapseUnchanged(children, diff) { + if (!diff) return children; + const out = []; + let run = 0; + for (const child of children) { + if (child.status === 'unchanged') { + run++; + } else { + if (run) { + out.push({ collapsed: true, count: run }); + run = 0; + } + out.push(child); + } + } + if (run) out.push({ collapsed: true, count: run }); + return out; +} + +// --------------------------------------------------------------------------- +// Mermaid sequence-diagram renderer (GitHub-native) +// --------------------------------------------------------------------------- + +const AMBER = 'rgb(255, 236, 179)'; // changed / added +const RED = 'rgb(255, 205, 210)'; // removed +const HI_AMBER = 'rgb(255, 224, 130)'; // highlighted label (e.g. security.*) + +/** + * Render a GitHub-native mermaid sequence diagram. Participants are User, + * frontend, each backend app (first-seen order) and DB. In diff mode, + * changed/added steps sit in an amber band and removed steps in a red band; + * a leading `%%` comment and a Note carry the plain-English caption. + */ +export function renderMermaid(tree, options = {}) { + const diff = options.diff ?? false; + const highlight = options.highlight ?? DEFAULT_HIGHLIGHT; + const participants = orderedParticipants(tree); + const alias = mkAlias(); // per-render, so BE aliases are stable within one diagram + const lines = ['sequenceDiagram', ' autonumber']; + for (const p of participants) lines.push(` participant ${alias(p)} as ${p}`); + + if (options.caption) { + lines.push(` %% ${sanitizeComment(options.caption)}`); + lines.push(` Note over ${alias(participants[0])},${alias(participants[participants.length - 1])}: ${escapeNote(options.caption)}`); + } + + // Opening interaction message. + lines.push(` ${alias('User')}->>${alias('frontend')}: ${escapeMsg(tree.label)}`); + + const emit = (node, depth) => { + const band = diff ? bandFor(node.status) : null; + const hi = !band && (node.labels ?? []).some((l) => highlight.test(l)); + if (band) lines.push(` rect ${band}`); + else if (hi) lines.push(` rect ${HI_AMBER}`); + + const from = alias(node.actor); + const to = alias(node.target); + const labelStr = node.labels?.length ? ` [${node.labels.join(', ')}]` : ''; + const msg = escapeMsg(node.label + labelStr + outcomeSuffix(node)); + if (node.kind === 'fetch' || node.kind === 'sql' || node.actor !== node.target) { + lines.push(` ${from}->>${to}: ${msg}`); + const kids = collapseUnchanged(node.children ?? [], diff); + for (const k of kids) emitItem(k, depth + 1); + // Return arrow for a real cross-lane round trip. + if (node.kind === 'fetch') lines.push(` ${to}-->>${from}: ${node.detail?.status ?? '?'}`); + else if (node.kind === 'sql') lines.push(` ${to}-->>${from}: rows`); + } else { + // Self-call: show as an activation bar so nesting stays visible. + lines.push(` ${from}->>${from}: ${msg}`); + lines.push(` activate ${from}`); + const kids = collapseUnchanged(node.children ?? [], diff); + for (const k of kids) emitItem(k, depth + 1); + lines.push(` deactivate ${from}`); + } + + if (band || hi) lines.push(' end'); + }; + + const emitItem = (item, depth) => { + if (item.collapsed) { + lines.push(` Note over ${alias('frontend')}: … ${item.count} unchanged step(s)`); + return; + } + emit(item, depth); + }; + + for (const item of collapseUnchanged(tree.children ?? [], diff)) emitItem(item, 0); + return lines.join('\n') + '\n'; +} + +function bandFor(status) { + if (status === 'added' || status === 'changed') return AMBER; + if (status === 'removed') return RED; + return null; +} + +function orderedParticipants(tree) { + const seen = []; + const add = (p) => { + if (!seen.includes(p)) seen.push(p); + }; + add('User'); + add('frontend'); + let hasDb = false; + const visit = (node) => { + if (node.kind === 'fetch') add(node.target); + if (node.kind === 'sql') hasDb = true; + for (const c of node.children ?? []) visit(c); + }; + visit(tree); + if (hasDb) add('DB'); + return seen; +} + +/** A fresh alias resolver per diagram: User/frontend/DB are fixed, each + * backend app gets a stable BE in first-seen order. */ +function mkAlias() { + const cache = new Map(); + return (name) => { + if (name === 'User') return 'User'; + if (name === 'frontend') return 'FE'; + if (name === 'DB') return 'DB'; + if (!cache.has(name)) cache.set(name, 'BE' + cache.size); + return cache.get(name); + }; +} + +function escapeMsg(text) { + return String(text).replace(/[\n\r]+/g, ' ').replace(/;/g, ',').replace(/[<>]/g, ''); +} +function escapeNote(text) { + return String(text).replace(/[\n\r]+/g, ' ').replace(/[:<>]/g, ''); +} +function sanitizeComment(text) { + return String(text).replace(/[\n\r]+/g, ' '); +} + +// --------------------------------------------------------------------------- +// One-call convenience: build + (optionally diff) + render both views +// --------------------------------------------------------------------------- + +/** + * @param {object} frontendAppmap current interaction map + * @param {object} [opts] + * @param {Map} [opts.spanToBackend] current span→backend index + * @param {object} [opts.baselineAppmap] baseline interaction map (enables diff) + * @param {Map} [opts.baselineSpanToBackend] baseline index + * @param {RegExp} [opts.highlight] label highlight matcher + * @returns {{ model, diff?, caption?, ascii, mermaid }} + */ +export function renderInteraction(frontendAppmap, opts = {}) { + const highlight = opts.highlight ?? DEFAULT_HIGHLIGHT; + const model = buildInteractionModel(frontendAppmap, { spanToBackend: opts.spanToBackend }); + + if (opts.baselineAppmap) { + const baseModel = buildInteractionModel(opts.baselineAppmap, { + spanToBackend: opts.baselineSpanToBackend ?? new Map(), + }); + const { tree, summary } = diffModels(baseModel, model); + const caption = captionFor(summary, tree, { highlight }); + return { + model, + diff: { tree, summary }, + caption, + ascii: renderAscii(tree, { diff: true, caption, highlight }), + mermaid: renderMermaid(tree, { diff: true, caption, highlight }), + }; + } + + return { + model, + ascii: renderAscii(model, { diff: false, highlight }), + mermaid: renderMermaid(model, { diff: false, highlight }), + }; +} + +/** Build the span-id → backend appmap index the model builder needs, from a + * set of scanned maps (the same {path, appmap} shape scan.mjs returns). */ +export function backendIndex(maps) { + const index = new Map(); + for (const m of maps) { + const span = m.appmap?.metadata?.parent_span_id; + if (span) index.set(span, m.appmap); + } + return index; +} diff --git a/linker/test/trace-agent.test.mjs b/linker/test/trace-agent.test.mjs new file mode 100644 index 0000000..deb6362 --- /dev/null +++ b/linker/test/trace-agent.test.mjs @@ -0,0 +1,353 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCallTree, + labelIndex, + buildInteractionModel, + subtreeDigest, + nodeDigest, + diffModels, + captionFor, + renderAscii, + renderMermaid, + renderInteraction, + backendIndex, +} from '../src/trace-agent.mjs'; + +const TRACE = 'a'.repeat(32); +const SPAN = '1'.repeat(16); + +// A frontend interaction map: event-handler → hook → fetch, with labels +// carried in the classMap (where AppMap actually puts them). +function frontendMap() { + return { + version: '1.2', + metadata: { name: 'save owner', app: 'petclinic-react', trace_id: TRACE }, + classMap: [ + { + type: 'class', + name: 'CreateOwner', + children: [ + { type: 'function', name: 'submit', location: 'x', static: true, labels: ['event-handler'] }, + { type: 'function', name: 'useClinic', location: 'x', static: true, labels: ['hook'] }, + ], + }, + ], + events: [ + { id: 1, event: 'call', thread_id: 1, defined_class: 'CreateOwner', method_id: 'submit', path: 'p', static: true }, + { id: 2, event: 'call', thread_id: 1, defined_class: 'CreateOwner', method_id: 'useClinic', path: 'p', static: true }, + { id: 3, event: 'return', thread_id: 1, parent_id: 2, return_value: { class: 'Object', value: '{}' } }, + { + id: 4, + event: 'call', + thread_id: 1, + http_client_request: { + request_method: 'POST', + url: 'http://localhost:8080/owners', + headers: { traceparent: `00-${TRACE}-${SPAN}-01` }, + }, + }, + { id: 5, event: 'return', thread_id: 1, parent_id: 4, http_client_response: { status_code: 201 } }, + { id: 6, event: 'return', thread_id: 1, parent_id: 1, return_value: { class: 'undefined', value: 'undefined' } }, + ], + }; +} + +function backendMap({ span = SPAN, status = 201, sql = ['INSERT INTO owners (...) VALUES (?)'] } = {}) { + let id = 0; + const events = []; + const stack = []; + const call = (b) => { const e = { id: ++id, event: 'call', thread_id: 1, ...b }; events.push(e); stack.push(e.id); return e.id; }; + const ret = (b = {}) => events.push({ id: ++id, event: 'return', thread_id: 1, parent_id: stack.pop(), ...b }); + call({ http_server_request: { request_method: 'POST', path_info: '/owners' } }); + call({ defined_class: 'handlers', method_id: 'createOwner', path: 'h.go', static: false }); + for (const s of sql) { call({ sql_query: { sql: s, database_type: 'sqlite' } }); ret(); } + ret(); + ret({ http_server_response: { status_code: status } }); + return { version: '1.2', metadata: { name: 'POST /owners', app: 'PetClinicGo', parent_span_id: span }, classMap: [], events }; +} + +describe('buildCallTree', () => { + it('reconstructs nesting from flat call/return via parent_id', () => { + const roots = buildCallTree(frontendMap().events); + expect(roots).toHaveLength(1); + expect(roots[0].event.method_id).toBe('submit'); + const kids = roots[0].children.map((c) => c.event.method_id ?? 'fetch'); + expect(kids).toEqual(['useClinic', 'fetch']); // useClinic call + fetch (no method_id) + expect(roots[0].children[1].event.http_client_request).toBeTruthy(); + expect(roots[0].children[1].return.http_client_response.status_code).toBe(201); + }); +}); + +describe('labelIndex', () => { + it('reads labels out of the classMap by Class.method', () => { + const idx = labelIndex(frontendMap().classMap); + expect(idx.get('CreateOwner.submit')).toEqual(['event-handler']); + expect(idx.get('CreateOwner.useClinic')).toEqual(['hook']); + }); +}); + +describe('buildInteractionModel', () => { + it('stitches fetch → backend handler → SQL and keeps labels', () => { + const spanToBackend = new Map([[SPAN, backendMap()]]); + const model = buildInteractionModel(frontendMap(), { spanToBackend }); + expect(model.kind).toBe('interaction'); + const submit = model.children[0]; + expect(submit.label).toBe('CreateOwner.submit'); + expect(submit.labels).toEqual(['event-handler']); + const fetch = submit.children.find((c) => c.kind === 'fetch'); + expect(fetch.target).toBe('PetClinicGo'); + expect(fetch.detail.status).toBe(201); + expect(fetch.detail.linked).toBe(true); + const sql = fetch.children[0].children.find((c) => c.kind === 'sql'); + expect(sql.label).toContain('INSERT INTO owners'); + }); + + it('is honest: an unlinked fetch draws no backend, and no invented calls appear', () => { + const model = buildInteractionModel(frontendMap(), { spanToBackend: new Map() }); + const fetch = model.children[0].children.find((c) => c.kind === 'fetch'); + expect(fetch.detail.linked).toBe(false); + expect(fetch.children).toEqual([]); + // No method that is not in the recording ever shows up. + const json = JSON.stringify(model); + expect(json).not.toContain('createOwner'); + }); +}); + +describe('digests', () => { + it('subtreeDigest is equal for identical trees and differs when SQL changes', () => { + const a = buildInteractionModel(frontendMap(), { spanToBackend: new Map([[SPAN, backendMap()]]) }); + const b = buildInteractionModel(frontendMap(), { spanToBackend: new Map([[SPAN, backendMap()]]) }); + expect(subtreeDigest(a)).toBe(subtreeDigest(b)); + const c = buildInteractionModel(frontendMap(), { + spanToBackend: new Map([[SPAN, backendMap({ sql: ['INSERT INTO owners (...) VALUES (?, ?)'] })]]), + }); + expect(subtreeDigest(c)).not.toBe(subtreeDigest(a)); + }); +}); + +describe('diffModels', () => { + const base = () => buildInteractionModel(frontendMap(), { spanToBackend: new Map([[SPAN, backendMap()]]) }); + + it('reports no change for identical recordings', () => { + const { summary } = diffModels(base(), base()); + expect(summary.added).toBe(0); + expect(summary.removed).toBe(0); + expect(summary.changed).toBe(0); + }); + + it('detects a removed SQL step and propagates change up the ancestry', () => { + const cur = buildInteractionModel(frontendMap(), { + spanToBackend: new Map([[SPAN, backendMap({ sql: [] })]]), + }); + const { tree, summary } = diffModels(base(), cur); + expect(summary.removed).toBe(1); + // The fetch and its handler are marked changed because a descendant changed. + const submit = tree.children.find((c) => c.label === 'CreateOwner.submit'); + const fetch = submit.children.find((c) => c.kind === 'fetch'); + expect(fetch.status).toBe('changed'); + }); + + it('detects an added step and a changed HTTP status', () => { + // baseline: 201; current: 500 + an extra SQL statement (added). + const cur = buildInteractionModel(frontendMap(), { + spanToBackend: new Map([[SPAN, backendMap({ status: 500, sql: ['INSERT INTO owners (...) VALUES (?)', 'INSERT INTO audit (...) VALUES (?)'] })]]), + }); + const { summary } = diffModels(base(), cur); + expect(summary.added).toBe(1); + expect(summary.changed).toBeGreaterThan(0); + }); +}); + +describe('captionFor', () => { + it('names a new security-labeled step in plain English', () => { + const baseMap = frontendMap(); + const curMap = frontendMap(); + // add a security.crypto call under submit in the current recording + curMap.classMap[0].children.push({ type: 'function', name: 'encrypt', location: 'x', static: true, labels: ['security.crypto'] }); + curMap.events.splice(1, 0, + { id: 90, event: 'call', thread_id: 1, defined_class: 'CreateOwner', method_id: 'encrypt', path: 'p', static: true }, + { id: 91, event: 'return', thread_id: 1, parent_id: 90 }); + const base = buildInteractionModel(baseMap, { spanToBackend: new Map() }); + const cur = buildInteractionModel(curMap, { spanToBackend: new Map() }); + const { tree, summary } = diffModels(base, cur); + const caption = captionFor(summary, tree); + expect(caption).toContain('security.crypto'); + expect(caption).toMatch(/added/); + }); + + it('says so when nothing changed', () => { + const m = buildInteractionModel(frontendMap(), { spanToBackend: new Map() }); + const { tree, summary } = diffModels(m, m); + expect(captionFor(summary, tree)).toMatch(/No behavior change/i); + }); +}); + +describe('renderAscii', () => { + it('shows every step in plain mode with labels', () => { + const model = buildInteractionModel(frontendMap(), { spanToBackend: new Map([[SPAN, backendMap()]]) }); + const ascii = renderAscii(model); + expect(ascii).toContain('CreateOwner.submit'); + expect(ascii).toContain('[event-handler]'); + expect(ascii).toContain('→ PetClinicGo: POST /owners'); + expect(ascii).toContain('→ DB: INSERT INTO owners'); + }); + + it('in diff mode: collapses unchanged, marks +/-/~, and flags highlighted labels', () => { + const baseMap = frontendMap(); + const curMap = frontendMap(); + curMap.classMap[0].children.push({ type: 'function', name: 'encrypt', location: 'x', static: true, labels: ['security.crypto'] }); + curMap.events.splice(1, 0, + { id: 90, event: 'call', thread_id: 1, defined_class: 'CreateOwner', method_id: 'encrypt', path: 'p', static: true }, + { id: 91, event: 'return', thread_id: 1, parent_id: 90 }); + const { ascii } = renderInteraction(curMap, { baselineAppmap: baseMap }); + expect(ascii).toMatch(/\+ .*encrypt/); + expect(ascii).toContain('⚠'); + expect(ascii).toContain('unchanged)'); + }); +}); + +// Mermaid grammar guardrails: rect/end and activate/deactivate must be +// balanced and correctly nested (a crossing would break GitHub's renderer). +function assertBalancedMermaid(src) { + const rect = []; + const activations = new Map(); // participant → depth + for (const raw of src.split('\n')) { + const line = raw.trim(); + if (line.startsWith('rect ')) rect.push(line); + else if (line === 'end') { + expect(rect.length, `unbalanced end: ${line}`).toBeGreaterThan(0); + rect.pop(); + } else if (line.startsWith('activate ')) { + const p = line.slice('activate '.length); + activations.set(p, (activations.get(p) ?? 0) + 1); + } else if (line.startsWith('deactivate ')) { + const p = line.slice('deactivate '.length); + expect(activations.get(p) ?? 0, `deactivate with no activation: ${p}`).toBeGreaterThan(0); + activations.set(p, activations.get(p) - 1); + } + } + expect(rect.length, 'unclosed rect block(s)').toBe(0); + for (const [p, depth] of activations) expect(depth, `unbalanced activation for ${p}`).toBe(0); +} + +describe('renderMermaid', () => { + it('emits a valid, balanced sequenceDiagram with participants and DB', () => { + const model = buildInteractionModel(frontendMap(), { spanToBackend: new Map([[SPAN, backendMap()]]) }); + const mmd = renderMermaid(model); + expect(mmd).toContain('sequenceDiagram'); + expect(mmd).toContain('participant BE0 as PetClinicGo'); + expect(mmd).toContain('participant DB as DB'); + expect(mmd).toContain('FE->>BE0: POST /owners'); + assertBalancedMermaid(mmd); + }); + + it('bands changed/added amber and removed red, stays balanced, and never drops a changed step', () => { + const baseMap = frontendMap(); + const cur = buildInteractionModel(frontendMap(), { + spanToBackend: new Map([[SPAN, backendMap({ sql: [] })]]), + }); + const baseModel = buildInteractionModel(baseMap, { spanToBackend: new Map([[SPAN, backendMap()]]) }); + const { tree } = diffModels(baseModel, cur); + const mmd = renderMermaid(tree, { diff: true, caption: 'x' }); + expect(mmd).toContain('rect rgb(255, 205, 210)'); // red for the removed SQL + expect(mmd).toContain('INSERT INTO owners'); // removed step still drawn, not dropped + assertBalancedMermaid(mmd); + }); +}); + +// The 1:N showcase case (docs/HANDOFF): one interaction fires two concurrent +// fetches (Promise.all). Concurrent awaits interleave the events out of LIFO +// order — the reconstruction must keep BOTH fetches and BOTH backends, and +// must not bury a concurrent call inside a fetch's backend subtree. +const SPAN_OWNER = '1'.repeat(16); +const SPAN_VETS = '2'.repeat(16); + +// Real interleave (from an actual OwnerDetail recording): getOwner and getVets +// both start before either awaits, so both are "open" when the second begins, +// and /vets responds before /owners. +function concurrentFrontendMap() { + const fetchReq = (id, url, span) => ({ + id, event: 'call', thread_id: 1, + http_client_request: { request_method: 'GET', url, headers: { traceparent: `00-${TRACE}-${span}-01` } }, + }); + return { + version: '1.2', + metadata: { name: 'owner detail', app: 'petclinic-react', trace_id: TRACE }, + classMap: [ + { type: 'class', name: 'client', children: [ + { type: 'function', name: 'getOwner', location: 'x', static: true }, + { type: 'function', name: 'getVets', location: 'x', static: true }, + { type: 'function', name: 'request', location: 'x', static: true }, + ] }, + ], + events: [ + { id: 9, event: 'call', thread_id: 1, defined_class: 'client', method_id: 'getOwner', path: 'p', static: true }, + { id: 10, event: 'call', thread_id: 1, defined_class: 'client', method_id: 'request', path: 'p', static: true }, + fetchReq(11, 'http://localhost:8080/owners/1', SPAN_OWNER), + { id: 12, event: 'call', thread_id: 1, defined_class: 'client', method_id: 'getVets', path: 'p', static: true }, + { id: 13, event: 'call', thread_id: 1, defined_class: 'client', method_id: 'request', path: 'p', static: true }, + fetchReq(14, 'http://localhost:8080/vets', SPAN_VETS), + { id: 15, event: 'return', thread_id: 1, parent_id: 14, http_client_response: { status_code: 200 } }, + { id: 16, event: 'return', thread_id: 1, parent_id: 11, http_client_response: { status_code: 200 } }, + { id: 17, event: 'return', thread_id: 1, parent_id: 13 }, + { id: 18, event: 'return', thread_id: 1, parent_id: 12 }, + { id: 19, event: 'return', thread_id: 1, parent_id: 10 }, + { id: 20, event: 'return', thread_id: 1, parent_id: 9 }, + ], + }; +} + +function ownersBackend() { + return { version: '1.2', metadata: { name: 'GET /owners/{id}', app: 'PetClinicGo', parent_span_id: SPAN_OWNER }, + classMap: [], events: [ + { id: 1, event: 'call', thread_id: 1, http_server_request: { request_method: 'GET', path_info: '/owners/1' } }, + { id: 2, event: 'call', thread_id: 1, sql_query: { sql: 'SELECT * FROM owners WHERE id = ?' } }, + { id: 3, event: 'return', thread_id: 1, parent_id: 2 }, + { id: 4, event: 'return', thread_id: 1, parent_id: 1, http_server_response: { status_code: 200 } }, + ] }; +} +function vetsBackend() { + return { version: '1.2', metadata: { name: 'GET /vets', app: 'PetClinicGo', parent_span_id: SPAN_VETS }, + classMap: [], events: [ + { id: 1, event: 'call', thread_id: 1, http_server_request: { request_method: 'GET', path_info: '/vets' } }, + { id: 2, event: 'call', thread_id: 1, sql_query: { sql: 'SELECT id, last_name FROM vets' } }, + { id: 3, event: 'return', thread_id: 1, parent_id: 2 }, + { id: 4, event: 'return', thread_id: 1, parent_id: 1, http_server_response: { status_code: 200 } }, + ] }; +} + +describe('concurrent fetches (1:N, the async-gap case)', () => { + const spanToBackend = () => new Map([[SPAN_OWNER, ownersBackend()], [SPAN_VETS, vetsBackend()]]); + + function fetchNodes(node, acc = []) { + if (node.kind === 'fetch') acc.push(node); + for (const c of node.children ?? []) fetchNodes(c, acc); + return acc; + } + + it('keeps BOTH fetches and BOTH backend subtrees — nothing dropped', () => { + const model = buildInteractionModel(concurrentFrontendMap(), { spanToBackend: spanToBackend() }); + const fetches = fetchNodes(model); + expect(fetches.map((f) => f.label).sort()).toEqual(['GET /owners/1', 'GET /vets']); + // each fetch reached its own backend (SQL present under each) + const sql = (f) => JSON.stringify(f).includes('sql'); + expect(fetches.every(sql)).toBe(true); + }); + + it('never nests an instrumented frontend call inside a fetch node', () => { + const model = buildInteractionModel(concurrentFrontendMap(), { spanToBackend: spanToBackend() }); + for (const f of fetchNodes(model)) { + const frontendKids = (f.children ?? []).filter((c) => c.actor === 'frontend' && c.kind === 'call'); + expect(frontendKids, `fetch ${f.label} adopted a frontend call`).toEqual([]); + } + }); + + it('renders both fetches in ASCII and mermaid, and the mermaid stays balanced', () => { + const { ascii, mermaid } = renderInteraction(concurrentFrontendMap(), { spanToBackend: spanToBackend() }); + for (const text of [ascii, mermaid]) { + expect(text).toContain('GET /owners/1'); + expect(text).toContain('GET /vets'); + } + assertBalancedMermaid(mermaid); + }); +}); diff --git a/package-lock.json b/package-lock.json index 3c763b1..8beacac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,8 @@ "version": "0.0.1", "bin": { "appmap-link": "bin/appmap-link.mjs", - "appmap-simulate-backend": "bin/simulate-backend.mjs" + "appmap-simulate-backend": "bin/simulate-backend.mjs", + "appmap-trace": "bin/appmap-trace.mjs" }, "devDependencies": { "vitest": "^3.1.3" @@ -1622,7 +1623,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -1633,14 +1634,14 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -1723,7 +1724,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -1740,7 +1741,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@vitest/spy": "3.2.6", @@ -1767,7 +1768,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" @@ -1780,7 +1781,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@vitest/utils": "3.2.6", @@ -1795,7 +1796,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.6", @@ -1810,7 +1811,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tinyspy": "^4.0.3" @@ -1823,7 +1824,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.6", @@ -1886,7 +1887,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1941,7 +1942,7 @@ "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1971,7 +1972,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", @@ -1988,7 +1989,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 16" @@ -2129,7 +2130,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2187,14 +2188,14 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2245,7 +2246,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -2255,7 +2256,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -2292,7 +2293,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2539,7 +2540,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/lru-cache": { @@ -2566,7 +2567,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2680,7 +2681,7 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -2742,14 +2743,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.16" @@ -2769,7 +2770,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2782,7 +2783,7 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "opencollective", @@ -2943,7 +2944,7 @@ "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -3040,7 +3041,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -3060,7 +3061,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3070,7 +3071,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/statuses": { @@ -3087,7 +3088,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/strict-event-emitter": { @@ -3142,7 +3143,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^9.0.1" @@ -3155,7 +3156,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/symbol-tree": { @@ -3182,21 +3183,21 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3213,7 +3214,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -3223,7 +3224,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -3233,7 +3234,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -3366,7 +3367,7 @@ "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -3441,7 +3442,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", @@ -3464,7 +3465,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -3598,7 +3599,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -3737,7 +3738,9 @@ }, "devDependencies": { "@types/babel__core": "^7.20.5", - "vite": "^6.3.5" + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vitest": "^3.1.3" }, "peerDependencies": { "vitest": ">=2" diff --git a/package.json b/package.json index 9581510..33d5baa 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "appmap-react", "private": true, "version": "0.0.1", - "description": "AppMap agent for React (browser) and Deno edge functions: records AppMap v1.2 from both and links frontend AppMaps to backend AppMaps via W3C Trace Context.", + "description": "AppMap agent for React (browser) and Deno edge functions: records AppMap v1.12 and links frontend AppMaps to backend AppMaps via W3C Trace Context.", "workspaces": [ "recorder", "linker", diff --git a/recorder/package.json b/recorder/package.json index c99f723..b1458a7 100644 --- a/recorder/package.json +++ b/recorder/package.json @@ -12,12 +12,29 @@ "./vite": "./src/vitePlugin.ts", "./transform": "./src/transform.ts" }, + "publishConfig": { + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./vitest": "./dist/vitest.js", + "./vite": "./dist/vitePlugin.js", + "./transform": "./dist/transform.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest run" + }, "dependencies": { "@babel/core": "^7.26.0" }, "devDependencies": { "@types/babel__core": "^7.20.5", - "vite": "^6.3.5" + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vitest": "^3.1.3" }, "peerDependencies": { "vitest": ">=2" diff --git a/recorder/src/index.ts b/recorder/src/index.ts index c3d2447..7ec8f98 100644 --- a/recorder/src/index.ts +++ b/recorder/src/index.ts @@ -1,5 +1,23 @@ export type * from './types'; -export { Recording, formatValue, VALUE_SIZE_CAP, type CallToken } from './recording'; +export { + Recording, + formatValue, + VALUE_SIZE_CAP, + setValueSizeCap, + type CallToken, + type ObjectIdTracker, +} from './recording'; +import { setValueSizeCap as __setValueSizeCap } from './recording'; + +// APPMAP_EVENT_VALUESIZE, like the .NET agent: the in-page recorder has +// no process.env of its own, so the Vite plugin injects this constant +// into the client bundle (define) when the env var is set at build/dev +// time; `typeof` is safe here even when the identifier is never defined. +declare const __APPMAP_EVENT_VALUESIZE__: number | undefined; +// eslint-disable-next-line no-undef +if (typeof __APPMAP_EVENT_VALUESIZE__ !== 'undefined') { + __setValueSizeCap(__APPMAP_EVENT_VALUESIZE__); +} export { startRecording, stopRecording, activeRecording } from './session'; export { instrument, diff --git a/recorder/src/instrument.ts b/recorder/src/instrument.ts index ebca96b..68c02d8 100644 --- a/recorder/src/instrument.ts +++ b/recorder/src/instrument.ts @@ -29,6 +29,10 @@ export function instrument(fn: F, info: FunctionInfo, argNames? try { const result = fn.apply(this, args as never[]); if (result instanceof Promise) { + // The call is yielding control back to its caller now, even + // though it's still logically open — see the thread-assignment + // design in recording.ts. + recording.leaveSyncFrame(token); return result.then( (value) => { recording.exit(token, { returnValue: value }); @@ -71,15 +75,20 @@ export function instrumentHandler(fn: F, info: Omit(fn: F, info: FunctionInfo, argNames?: string[]): F { + * Labels from the transform (comments/built-ins) are additive to + * naming-convention labels (PascalCase → component, use[A-Z]… → hook). */ +export function autoInstrument( + fn: F, + info: FunctionInfo, + argNames?: string[], +): F { const conventionLabel = /^use[A-Z]/.test(info.methodId) ? 'hook' : /^[A-Z]/.test(info.methodId) ? 'component' : undefined; - const labels = [...(info.labels ?? []), ...(conventionLabel ? [conventionLabel] : [])]; + const labels = [ + ...new Set([...(info.labels ?? []), ...(conventionLabel ? [conventionLabel] : [])]), + ]; return instrument(fn, { ...info, labels: labels.length ? labels : undefined }, argNames); } diff --git a/recorder/src/recording.ts b/recorder/src/recording.ts index 63e7632..6a9993f 100644 --- a/recorder/src/recording.ts +++ b/recorder/src/recording.ts @@ -9,11 +9,30 @@ import type { ParameterValue, } from './types'; -/** Maximum captured length of any single value string, like - * APPMAP_EVENT_VALUESIZE in the .NET agent. */ +/** Default maximum captured length of any single value string, like + * APPMAP_EVENT_VALUESIZE in the .NET agent. Overridable at runtime via + * setValueSizeCap (wired to the APPMAP_EVENT_VALUESIZE env var by + * testRecording.ts and vitePlugin.ts). */ export const VALUE_SIZE_CAP = 1024; -export function formatValue(v: unknown): { class: string; value: string } { +let currentValueSizeCap = VALUE_SIZE_CAP; + +/** Override the value-size cap at runtime (see VALUE_SIZE_CAP). */ +export function setValueSizeCap(n: number): void { + currentValueSizeCap = n; +} + +/** Tracks object identity within one recording so repeated references to + * the same object across events share an object_id, per the AppMap spec. */ +export interface ObjectIdTracker { + ids: WeakMap; + next: number; +} + +export function formatValue( + v: unknown, + tracker?: ObjectIdTracker, +): { class: string; value: string; size?: number; object_id?: number } { let cls: string; if (v === null) cls = 'null'; else if (v === undefined) cls = 'undefined'; @@ -31,24 +50,62 @@ export function formatValue(v: unknown): { class: string; value: string } { } catch { str = String(v); } - if (str.length > VALUE_SIZE_CAP) str = str.slice(0, VALUE_SIZE_CAP) + '…'; - return { class: cls, value: str }; + if (str.length > currentValueSizeCap) str = str.slice(0, currentValueSizeCap) + '…'; + + const result: { class: string; value: string; size?: number; object_id?: number } = { + class: cls, + value: str, + }; + + if (v !== null && typeof v === 'object') { + result.size = Array.isArray(v) ? v.length : Object.keys(v as object).length; + if (tracker) { + let id = tracker.ids.get(v as object); + if (id === undefined) { + id = tracker.next++; + tracker.ids.set(v as object, id); + } + result.object_id = id; + } + } + + return result; } /** Handle returned by Recording.enter; consumed exactly once by exit. * Same contract as the Go recorder spike's CallToken: the injected - * epilogue (here, a `finally` block) must call exit with it. */ + * epilogue (here, a `finally` block) must call exit with it. Carries its + * own thread_id (see the thread-assignment design in + * docs/design/01-recording-sessions-and-interaction-windows.md, 2026 + * amendment) so exit/response methods don't have to re-derive it. */ export interface CallToken { callId: number; startMs: number; + threadId: number; } export class Recording { readonly events: Event[] = []; readonly metadata: Metadata; - private nextId = 1; private functions = new Map(); + private objectIds: ObjectIdTracker = { ids: new WeakMap(), next: 1 }; + + // Thread assignment (docs/design/01, 2026 amendment): each thread_id's + // own event subsequence must independently nest like balanced + // parentheses. `syncStack` mirrors the real, single-threaded JS call + // stack — entries leave it the instant a call yields control back to + // its caller (returns synchronously, or hands back a pending Promise), + // even though the call may still be logically open. A call started + // while its would-be parent thread already has another call that has + // left its sync frame but not yet settled (a genuine concurrent + // sibling, e.g. one leg of a Promise.all) gets a fresh thread instead + // of corrupting the parent thread's nesting. + private syncStack: number[] = []; + private openCalls = new Map(); // callId -> threadId + private danglingByThread = new Map>(); // threadId -> callIds that left their sync frame, still open + private nextThreadId = 2; + private readonly primaryThreadId = 1; constructor(metadata: Metadata) { this.metadata = { ...metadata, trace_id: randomHex(16) }; @@ -56,47 +113,103 @@ export class Recording { /** One trace id per recording — the frontend half of the * traceparent join (docs/design/02). Reads live from - * `metadata.trace_id` rather than a value frozen at construction: - * the Deno driver overwrites `metadata.trace_id` after construction - * to copy in the caller's inbound trace id, and every outbound - * stamp fetchPatch.ts makes for the rest of this recording must - * carry that value too, or a downstream call can never join back to - * the original frontend interaction. */ + * `metadata.trace_id` so a backend driver can copy in an inbound + * trace id after construction and outbound fetches still join it. */ get traceId(): string { return this.metadata.trace_id!; } + private allocateThread(): number { + const parent = this.syncStack[this.syncStack.length - 1]; + if (parent !== undefined) { + const parentThread = this.openCalls.get(parent)!; + return this.hasDangling(parentThread) ? this.nextThreadId++ : parentThread; + } + if (this.openCalls.size === 0) return this.primaryThreadId; + return this.hasDangling(this.primaryThreadId) ? this.nextThreadId++ : this.primaryThreadId; + } + + private hasDangling(threadId: number): boolean { + const set = this.danglingByThread.get(threadId); + return !!set && set.size > 0; + } + + private markDangling(threadId: number, callId: number): void { + let set = this.danglingByThread.get(threadId); + if (!set) { + set = new Set(); + this.danglingByThread.set(threadId, set); + } + set.add(callId); + } + + /** Open a call that always leaves its sync frame immediately — used by + * the http_client_request/http_server_request events, which have no + * instrumented children of their own and settle asynchronously. */ + private openDangling(): { callId: number; threadId: number } { + const callId = this.nextId++; + const threadId = this.allocateThread(); + this.openCalls.set(callId, threadId); + this.markDangling(threadId, callId); + return { callId, threadId }; + } + + /** Move an open call from the sync stack to "dangling" — called by the + * instrument() wrapper the instant a wrapped call hands back a pending + * Promise, i.e. the moment it yields control back to its caller. */ + leaveSyncFrame(token: CallToken): void { + const idx = this.syncStack.lastIndexOf(token.callId); + if (idx !== -1) this.syncStack.splice(idx, 1); + this.markDangling(token.threadId, token.callId); + } + + private closeCall(token: CallToken): void { + this.openCalls.delete(token.callId); + this.danglingByThread.get(token.threadId)?.delete(token.callId); + const idx = this.syncStack.lastIndexOf(token.callId); + if (idx !== -1) this.syncStack.splice(idx, 1); + } + enter(fn: FunctionInfo, args?: { name?: string; value: unknown }[]): CallToken { const key = `${fn.path}:${fn.definedClass}.${fn.methodId}`; if (!this.functions.has(key)) this.functions.set(key, fn); const id = this.nextId++; + const threadId = this.allocateThread(); + this.syncStack.push(id); + this.openCalls.set(id, threadId); + const parameters: ParameterValue[] | undefined = args?.map((a) => ({ name: a.name, - ...formatValue(a.value), + ...formatValue(a.value, this.objectIds), })); this.events.push({ id, event: 'call', - thread_id: 1, + thread_id: threadId, defined_class: fn.definedClass, method_id: fn.methodId, path: fn.path, lineno: fn.lineno, + // Always correct today: only module-level functions (components, + // hooks, handlers) are instrumented — there is no class/instance + // distinction yet. Revisit if instance-method instrumentation is + // ever added. static: true, ...(parameters && parameters.length ? { parameters } : {}), }); - return { callId: id, startMs: performance.now() }; + return { callId: id, startMs: performance.now(), threadId }; } exit(token: CallToken, outcome: { returnValue?: unknown; exception?: unknown }): void { const elapsed = (performance.now() - token.startMs) / 1000; + this.closeCall(token); if (outcome.exception !== undefined) { const e = outcome.exception; this.events.push({ id: this.nextId++, event: 'return', - thread_id: 1, + thread_id: token.threadId, parent_id: token.callId, elapsed, exceptions: [ @@ -110,36 +223,37 @@ export class Recording { this.events.push({ id: this.nextId++, event: 'return', - thread_id: 1, + thread_id: token.threadId, parent_id: token.callId, elapsed, ...(outcome.returnValue !== undefined - ? { return_value: formatValue(outcome.returnValue) } + ? { return_value: formatValue(outcome.returnValue, this.objectIds) } : {}), }); } } httpClientRequest(method: string, url: string, headers?: Record): CallToken { - const id = this.nextId++; + const { callId: id, threadId } = this.openDangling(); this.events.push({ id, event: 'call', - thread_id: 1, + thread_id: threadId, http_client_request: { request_method: method, url, ...(headers && Object.keys(headers).length ? { headers } : {}), }, }); - return { callId: id, startMs: performance.now() }; + return { callId: id, startMs: performance.now(), threadId }; } httpClientResponse(token: CallToken, statusCode: number, headers?: Record): void { + this.closeCall(token); this.events.push({ id: this.nextId++, event: 'return', - thread_id: 1, + thread_id: token.threadId, parent_id: token.callId, elapsed: (performance.now() - token.startMs) / 1000, http_client_response: { @@ -157,11 +271,11 @@ export class Recording { headers?: Record, normalizedPathInfo?: string, ): CallToken { - const id = this.nextId++; + const { callId: id, threadId } = this.openDangling(); this.events.push({ id, event: 'call', - thread_id: 1, + thread_id: threadId, http_server_request: { request_method: method, path_info: pathInfo, @@ -169,28 +283,66 @@ export class Recording { ...(headers && Object.keys(headers).length ? { headers } : {}), }, }); - return { callId: id, startMs: performance.now() }; + return { callId: id, startMs: performance.now(), threadId }; } httpServerResponse(token: CallToken, statusCode: number): void { + this.closeCall(token); this.events.push({ id: this.nextId++, event: 'return', - thread_id: 1, + thread_id: token.threadId, parent_id: token.callId, elapsed: (performance.now() - token.startMs) / 1000, http_server_response: { status_code: statusCode }, }); } - /** Serialize to AppMap v1.2. The classMap contains exactly the functions - * that produced events, grouped package-per-directory like appmap-agent-js. */ + /** Number of calls opened but not yet returned. Non-zero at + * serialization time means the recording is being closed with work + * still in flight (a hard teardown). */ + openCallCount(): number { + return this.openCalls.size; + } + + /** Serialize to AppMap v1.12. The classMap contains exactly the + * functions that produced events, grouped package-per-directory like + * appmap-agent-js. + * + * Self-healing (docs/design/11): any call still open at this point + * gets a synthesized `return` appended, so the event list is always + * balanced. An unbalanced list — a `call` with no matching `return`, + * which happens when a process is torn down mid-flight (a Supabase + * edge function killed during EdgeRuntime.waitUntil background work) — + * makes downstream tools that reconstruct the call stack throw + * ("failed trying to compute event stack, call.id: N"). A balanced, + * if incomplete, map is sanitizable; an unbalanced one is not + * committable at all. Synthetic returns carry no elapsed/return_value + * and are flagged collectively by metadata.truncated. */ toAppMap(overrides?: Partial): AppMap { + const events: Event[] = [...this.events]; + const open = [...this.openCalls]; + if (open.length > 0) { + // Innermost-first so nested synthetic returns nest correctly. + let syntheticId = this.nextId; + for (const [callId, threadId] of open.reverse()) { + events.push({ + id: syntheticId++, + event: 'return', + thread_id: threadId, + parent_id: callId, + }); + } + } return { - version: '1.2', - metadata: { ...this.metadata, ...overrides }, + version: '1.12', + metadata: { + ...this.metadata, + ...(open.length > 0 ? { truncated: true } : {}), + ...overrides, + }, classMap: buildClassMap([...this.functions.values()]), - events: this.events, + events, }; } } @@ -230,6 +382,8 @@ function buildClassMap(functions: FunctionInfo[]): ClassMapEntry[] { type: 'function', name: fn.methodId, location: fn.lineno ? `${fn.path}:${fn.lineno}` : fn.path, + // See the matching comment in enter(): always correct today, + // no instance-method instrumentation exists yet. static: true, ...(fn.labels?.length ? { labels: fn.labels } : {}), }); diff --git a/recorder/src/testRecording.ts b/recorder/src/testRecording.ts index 9c4de56..f837e5e 100644 --- a/recorder/src/testRecording.ts +++ b/recorder/src/testRecording.ts @@ -1,7 +1,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Metadata } from './types'; -import { Recording } from './recording'; +import { Recording, setValueSizeCap } from './recording'; import { startRecording, stopRecording } from './session'; // Test recording: one AppMap per test, written to tmp/appmap/tests/. @@ -11,6 +11,11 @@ import { startRecording, stopRecording } from './session'; const OUTPUT_DIR = join('tmp', 'appmap', 'tests'); +// APPMAP_EVENT_VALUESIZE, like the .NET agent: override the default +// value-size cap for this process. +const envValueSize = Number(process.env.APPMAP_EVENT_VALUESIZE); +if (Number.isFinite(envValueSize) && envValueSize > 0) setValueSizeCap(envValueSize); + export interface TestRecordingOptions { app?: string; sourceLocation?: string; diff --git a/recorder/src/transform.ts b/recorder/src/transform.ts index 60c9753..9509db6 100644 --- a/recorder/src/transform.ts +++ b/recorder/src/transform.ts @@ -1,5 +1,11 @@ import { basename } from 'node:path'; -import { transformAsync, types as t, type BabelFileResult, type NodePath, type PluginObj } from '@babel/core'; +import { + transformAsync, + types as t, + type BabelFileResult, + type PluginObj, + type NodePath, +} from '@babel/core'; // The build-time instrumentation transform (docs/design/03), host- // agnostic: no Vite types here, so any driver can run it — the Vite @@ -19,17 +25,21 @@ import { transformAsync, types as t, type BabelFileResult, type NodePath, type P // one thing hosts genuinely disagree on: a bare npm specifier for // Vite/Node, a URL or import-map name for Deno. // -// Labels (docs/design/08): a `@label` line in the function's leading -// comment, no import required — the appmap-java / -// com.appland.appmap.annotation equivalent, but free: nothing to add -// to package.json. +// 2026 amendment (docs/design/03): the transform also reaches inside +// each top-level function/component/hook body — nested named +// functions, nested `const x = arrow/function`, arrows passed as the +// first argument to useCallback/useMemo, and arrow/function +// expressions used inline as JSX event-handler props (onClick, +// onSubmit, …) — using the same 2-arg call shape the hand-written +// `instrumentHandler` used, since Babel gives real `loc` info here +// with no hand-supplied line numbers needed. `instrumentHandler` +// itself stays exported as the advanced-scenarios escape hatch for +// shapes the transform still doesn't reach (e.g. functions built up +// dynamically at runtime). // -// /** @label security.authz */ -// export function checkAccess(user) {...} -// -// These are additive to autoInstrument's own naming-convention labels -// (PascalCase → component, use[A-Z]… → hook), not a replacement — -// instrument.ts merges the two. +// Labels (docs/design/08) can be supplied by `@label` comments or inferred +// from common security/data-access calls in a function body. They are passed +// to autoInstrument, which merges them with convention labels. function extractLabels(comments: readonly t.Comment[] | null | undefined): string[] { if (!comments) return []; @@ -44,33 +54,12 @@ function extractLabels(comments: readonly t.Comment[] | null | undefined): strin return labels; } -// Built-in labels (docs/design/08): recognizes calls to well-known -// security/data-access APIs *inside* a wrapped function's body and -// labels the function automatically — no comment, no naming -// convention needed. This is appmap-java/appmap-dotnet's "built-in -// hooks" idea (their SQL/crypto/auth labeling of known driver calls), -// scoped here to the JS/TS/Deno + Supabase stack this project actually -// targets. Matches on the callee's own source text (a raw slice of -// the original code, not a resolved import) — deliberately simple -// pattern matching, not import-graph analysis, so it stays -// dependency-free and fast; the tradeoff is it can't tell a real -// Supabase client from a differently-shaped object that happens to -// have a `.from()` method. Good enough as a first pass; a false -// positive is a label, not a wrong behavior. const BUILTIN_LABEL_PATTERNS: Array<{ label: string; test: RegExp }> = [ - // Supabase auth vs. data access share one client — split on the - // fluent-call path, not the import. - // Note: callee.start/.end bounds the callee expression only, never - // the call's own parentheses — `supabase.rpc(x)`'s callee text is - // "supabase.rpc", not "supabase.rpc(". Patterns below match against - // that, anchored with $ where the property name is the last segment. { label: 'security.authentication', test: /\.auth\.(signIn\w*|signUp|signOut|verifyOtp|admin\.\w+|getUser|getSession|refreshSession|resetPasswordForEmail)$/ }, { label: 'io.sql', test: /\.(from|rpc)$/ }, { label: 'io.sql', test: /\.(select|insert|update|upsert|delete)$/ }, - // Web Crypto API and the common password-hashing libraries. { label: 'security.crypto', test: /crypto\.subtle\./ }, { label: 'security.crypto', test: /\b(bcrypt|argon2|scrypt)\b/i }, - // JWTs. { label: 'security.authentication', test: /\bjwt\.(sign|verify|decode)$/ }, { label: 'security.authentication', test: /\bjose\./ }, ]; @@ -91,6 +80,7 @@ function detectBuiltinLabels(fnPath: NodePath, code: string): string[] { } const RUNTIME_NAME = '__appmap_instrument__'; +const HANDLER_RUNTIME_NAME = '__appmap_instrument_handler__'; export const DEFAULT_RUNTIME_MODULE = '@funwithappmap/react-recorder'; export interface TransformOptions { @@ -122,11 +112,12 @@ export async function transformSource( return { code: result.code, map: result.map }; } -export function instrumentBabelPlugin(relPath: string, runtimeModule: string, code: string): PluginObj { +export function instrumentBabelPlugin(relPath: string, runtimeModule: string, code = ''): PluginObj { const definedClass = basename(relPath).replace(/\.[jt]sx?$/, ''); let wrapped = 0; + let handlerWrapped = 0; - const infoObject = (name: string, lineno: number | undefined, labels: string[]) => + const infoObject = (name: string, lineno: number | undefined, labels: string[] = []) => t.objectExpression([ t.objectProperty(t.identifier('definedClass'), t.stringLiteral(definedClass)), t.objectProperty(t.identifier('methodId'), t.stringLiteral(name)), @@ -161,6 +152,76 @@ export function instrumentBabelPlugin(relPath: string, runtimeModule: string, co ]); }; + // The 2-arg shape the hand-written instrumentHandler used: no + // argNames, always labeled ['event-handler']. Used for everything + // this transform reaches below the top level — nested closures are + // overwhelmingly event handlers/callbacks in this domain, matching + // what the codebase already did by hand. + const wrapHandlerCall = (fn: t.Expression, name: string, lineno?: number) => { + handlerWrapped++; + return t.callExpression(t.identifier(HANDLER_RUNTIME_NAME), [fn, infoObject(name, lineno)]); + }; + + // Nested instrumentation (2026 amendment, docs/design/03): walks a + // top-level function/component/hook's body for closures below the + // top-level visitor's granularity. Run on each top-level function + // BEFORE that function itself is wrapped (see below), so this always + // sees the pristine, unwrapped tree. + const instrumentNested = (fnPath: NodePath) => { + fnPath.traverse({ + FunctionDeclaration(path) { + const { id, params, body, generator, async: isAsync, loc } = path.node; + if (!id || generator) return; + path.replaceWith( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(id.name), + wrapHandlerCall( + t.functionExpression(id, params, body, generator, isAsync), + id.name, + loc?.start.line, + ), + ), + ]), + ); + path.skip(); + }, + VariableDeclarator(path) { + const idPath = path.get('id'); + const init = path.get('init'); + if (!idPath.isIdentifier() || !init.node) return; + + // const name = useCallback(fn, deps) / useMemo(fn, deps) — wrap + // just the callback argument, leave the hook call itself alone. + if (init.isCallExpression()) { + const callee = init.node.callee; + const calleeName = t.isIdentifier(callee) ? callee.name : undefined; + if (calleeName !== 'useCallback' && calleeName !== 'useMemo') return; + const first = init.get('arguments')[0]; + if (!first || (!first.isArrowFunctionExpression() && !first.isFunctionExpression())) return; + first.replaceWith(wrapHandlerCall(first.node, idPath.node.name, first.node.loc?.start.line)); + path.skip(); + return; + } + + if (init.isArrowFunctionExpression() || init.isFunctionExpression()) { + init.replaceWith(wrapHandlerCall(init.node, idPath.node.name, init.node.loc?.start.line)); + path.skip(); + } + }, + JSXAttribute(path) { + const name = path.node.name; + if (!t.isJSXIdentifier(name) || !/^on[A-Z]/.test(name.name)) return; + const value = path.get('value'); + if (!value.isJSXExpressionContainer()) return; + const expr = value.get('expression'); + if (!expr.isArrowFunctionExpression() && !expr.isFunctionExpression()) return; + expr.replaceWith(wrapHandlerCall(expr.node, name.name, expr.node.loc?.start.line)); + path.skip(); + }, + }); + }; + return { visitor: { Program: { @@ -176,6 +237,7 @@ export function instrumentBabelPlugin(relPath: string, runtimeModule: string, co if (decl.isFunctionDeclaration() && decl.node.id && !decl.node.generator) { const { id, params, loc } = decl.node; const labels = [...new Set([...commentLabels, ...detectBuiltinLabels(decl, code)])]; + instrumentNested(decl); // Function declarations are mutable bindings, and ESM // exports are live: reassigning after the declaration // rebinds the export too. @@ -195,6 +257,7 @@ export function instrumentBabelPlugin(relPath: string, runtimeModule: string, co if (!idPath.isIdentifier()) continue; if (init.isArrowFunctionExpression() || init.isFunctionExpression()) { const labels = [...new Set([...commentLabels, ...detectBuiltinLabels(init, code)])]; + instrumentNested(init); init.replaceWith( wrapCall( init.node, @@ -210,13 +273,19 @@ export function instrumentBabelPlugin(relPath: string, runtimeModule: string, co } }, exit(program) { - if (wrapped === 0) return; - program.node.body.unshift( - t.importDeclaration( - [t.importSpecifier(t.identifier(RUNTIME_NAME), t.identifier('autoInstrument'))], - t.stringLiteral(runtimeModule), - ), - ); + if (wrapped === 0 && handlerWrapped === 0) return; + const specifiers: t.ImportSpecifier[] = []; + if (wrapped > 0) { + specifiers.push( + t.importSpecifier(t.identifier(RUNTIME_NAME), t.identifier('autoInstrument')), + ); + } + if (handlerWrapped > 0) { + specifiers.push( + t.importSpecifier(t.identifier(HANDLER_RUNTIME_NAME), t.identifier('instrumentHandler')), + ); + } + program.node.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(runtimeModule))); }, }, }, diff --git a/recorder/src/types.ts b/recorder/src/types.ts index 6ed284e..0a5890e 100644 --- a/recorder/src/types.ts +++ b/recorder/src/types.ts @@ -1,8 +1,8 @@ -// AppMap data format v1.2 — the subset this agent emits. +// AppMap data format v1.12 — the subset this agent emits. // https://github.com/getappmap/appmap (appmap.json spec) export interface AppMap { - version: '1.2'; + version: '1.12'; metadata: Metadata; classMap: ClassMapEntry[]; events: Event[]; @@ -23,6 +23,12 @@ export interface Metadata { // Backend request maps only: the span-id of the frontend fetch that // caused this request (copied from the incoming traceparent). parent_span_id?: string; + // Set by toAppMap() when it had to synthesize returns for calls still + // open at serialization time (a hard teardown mid-flight — e.g. a + // Supabase edge function killed during EdgeRuntime.waitUntil work, + // docs/design/11). The map is balanced and safe to sanitize, but + // incomplete: some returns are synthetic. + truncated?: boolean; } export type ClassMapEntry = PackageEntry | ClassEntry | FunctionEntry; @@ -60,6 +66,10 @@ export interface ParameterValue { class: string; value: string; kind?: 'req'; + /** Element/key count for array/object values. */ + size?: number; + /** Stable identity for object-valued params within one recording. */ + object_id?: number; } export interface CallEvent { @@ -80,7 +90,7 @@ export interface ReturnEvent { thread_id: number; parent_id: number; elapsed?: number; - return_value?: { class: string; value: string }; + return_value?: { class: string; value: string; size?: number; object_id?: number }; exceptions?: { class: string; message: string }[]; } diff --git a/recorder/src/vitePlugin.ts b/recorder/src/vitePlugin.ts index 34960ec..e83967b 100644 --- a/recorder/src/vitePlugin.ts +++ b/recorder/src/vitePlugin.ts @@ -6,7 +6,7 @@ import { transformSource } from './transform'; const COLLECTOR_PATH = '/__appmap/interactions'; const COLLECTOR_BODY_LIMIT = 50 * 1024 * 1024; const INTERACTION_RECORDER_VIRTUAL_ID = 'virtual:appmap-interaction-recorder'; -const RESOLVED_INTERACTION_RECORDER_VIRTUAL_ID = '\0' + INTERACTION_RECORDER_VIRTUAL_ID; +const RESOLVED_INTERACTION_RECORDER_VIRTUAL_ID = `\0${INTERACTION_RECORDER_VIRTUAL_ID}`; // Build-time instrumentation (docs/design/03). This plugin is the React // agent's analogue of the Go agent's toolexec wrapper — except Vite @@ -17,20 +17,10 @@ const RESOLVED_INTERACTION_RECORDER_VIRTUAL_ID = '\0' + INTERACTION_RECORDER_VIR // gates on mode, and hosts the interaction collector. // // Labels (component / hook) are derived at runtime from naming -// conventions. Nested functions are not instrumented — wrap those by -// hand (instrumentHandler) where wanted. +// conventions. Nested functions and handlers are handled by the transform. // // Gating: the transform applies in dev and test, never in production // builds, unless `force` overrides. -// -// Zero-touch interaction recording (docs/design/07): passing `app` -// auto-injects installInteractionRecorder() into every page via -// transformIndexHtml — the same trick @vitejs/plugin-react itself uses -// to inject its Fast Refresh preamble. Application code (main.tsx) -// needs no import, no call. Explicit installInteractionRecorder() is -// still there and still documented for callers who want non-default -// options (custom idleMs, a different collector, etc.) — de-emphasized, -// not removed. export interface AppMapPluginOptions { /** Project-root-relative directory prefixes to instrument (the @@ -40,10 +30,7 @@ export interface AppMapPluginOptions { exclude?: string[]; /** Instrument even in production builds. Default: never. */ force?: boolean; - /** App name for interaction AppMaps. Set to auto-inject - * installInteractionRecorder() into every page with no application - * code changes; omit to leave interaction recording opt-in and - * hand-wired (see installInteractionRecorder). */ + /** App name for zero-touch interaction recording injection. */ app?: string; } @@ -64,6 +51,18 @@ export function appmapVitePlugin(options: AppMapPluginOptions): Plugin { return { name: 'appmap-instrument', enforce: 'pre', + config() { + // APPMAP_EVENT_VALUESIZE, like the .NET agent: propagate the + // value-size cap into the client bundle, since the in-page + // recorder has no process.env of its own. Read by recorder/src/ + // index.ts at import time. + const raw = process.env.APPMAP_EVENT_VALUESIZE; + const n = raw ? Number(raw) : undefined; + if (n !== undefined && Number.isFinite(n) && n > 0) { + return { define: { __APPMAP_EVENT_VALUESIZE__: JSON.stringify(n) } }; + } + return undefined; + }, configResolved(config) { root = config.root; enabled = options.force || config.mode !== 'production'; diff --git a/recorder/test/concurrency.test.ts b/recorder/test/concurrency.test.ts new file mode 100644 index 0000000..c48aecc --- /dev/null +++ b/recorder/test/concurrency.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { Recording } from '../src/recording'; +import { instrument } from '../src/instrument'; +import { startRecording, stopRecording } from '../src/session'; +import type { Event, Metadata } from '../src/types'; + +// Thread assignment (docs/design/01, 2026 amendment). The AppMap format +// requires each thread_id's own event subsequence to independently nest +// like balanced parentheses. These tests pin down the fix for the bug +// found in review: concurrent siblings sharing one thread could produce +// out-of-order returns that don't nest correctly. + +function baseMetadata(): Metadata { + return { + name: 'concurrency test', + client: { name: '@funwithappmap/react-recorder', url: 'https://github.com/getappmap/appmap-react' }, + recorder: { name: 'funwithappmap-react', type: 'requests' }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** Assert every thread's own event subsequence independently nests like + * balanced parentheses (call pushes, return must close the most + * recently opened still-open call on that same thread). */ +function assertWellNestedPerThread(events: Event[]): void { + const byThread = new Map(); + for (const event of events) { + const list = byThread.get(event.thread_id) ?? []; + list.push(event); + byThread.set(event.thread_id, list); + } + for (const [threadId, threadEvents] of byThread) { + const stack: number[] = []; + for (const event of threadEvents) { + if (event.event === 'call') { + stack.push(event.id); + } else { + const top = stack.pop(); + expect(top, `thread ${threadId}: return for ${event.parent_id} must close the top of its own thread's stack`).toBe( + event.parent_id, + ); + } + } + expect(stack, `thread ${threadId}: every call must have closed`).toHaveLength(0); + } +} + +describe('thread assignment under concurrency', () => { + it('gives concurrent Promise.all siblings distinct threads that each nest correctly, regardless of settlement order', async () => { + const recording = startRecording(new Recording(baseMetadata())); + + const dA = deferred(); + const dB = deferred(); + const callA = instrument(() => dA.promise, { definedClass: 'X', methodId: 'a', path: 'x.ts' }); + const callB = instrument(() => dB.promise, { definedClass: 'X', methodId: 'b', path: 'x.ts' }); + const parent = instrument( + async () => { + const [a, b] = await Promise.all([callA(), callB()]); + return a + b; + }, + { definedClass: 'X', methodId: 'parent', path: 'x.ts' }, + ); + + const resultPromise = parent(); + // B settles before A, out of call order — the scenario the original + // shared-thread bug mishandled. + dB.resolve(2); + dA.resolve(1); + expect(await resultPromise).toBe(3); + stopRecording(); + + const threadIds = new Set(recording.events.map((e) => e.thread_id)); + expect(threadIds.size).toBeGreaterThanOrEqual(2); + assertWellNestedPerThread(recording.events); + }); + + it('keeps two fully sequential (non-overlapping) calls on the same thread', async () => { + const recording = startRecording(new Recording(baseMetadata())); + const callA = instrument(async () => 'a', { definedClass: 'X', methodId: 'a', path: 'x.ts' }); + const callB = instrument(async () => 'b', { definedClass: 'X', methodId: 'b', path: 'x.ts' }); + + await callA(); + await callB(); + stopRecording(); + + const threadIds = new Set(recording.events.map((e) => e.thread_id)); + expect(threadIds.size).toBe(1); + assertWellNestedPerThread(recording.events); + }); +}); diff --git a/recorder/test/recording.test.ts b/recorder/test/recording.test.ts new file mode 100644 index 0000000..c7b5088 --- /dev/null +++ b/recorder/test/recording.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Recording, formatValue, setValueSizeCap, VALUE_SIZE_CAP } from '../src/recording'; +import { startTestRecording } from '../src/testRecording'; +import { stopRecording } from '../src/session'; +import type { Metadata } from '../src/types'; + +function baseMetadata(): Metadata { + return { + name: 'unit test', + client: { name: '@funwithappmap/react-recorder', url: 'https://github.com/getappmap/appmap-react' }, + recorder: { name: 'funwithappmap-react', type: 'tests' }, + }; +} + +describe('Recording.toAppMap', () => { + it('serializes AppMap v1.12 (the real spec tag, not v1.2)', () => { + const recording = new Recording(baseMetadata()); + expect(recording.toAppMap().version).toBe('1.12'); + }); +}); + +describe('formatValue value-size cap', () => { + afterEach(() => setValueSizeCap(VALUE_SIZE_CAP)); + + it('defaults to VALUE_SIZE_CAP', () => { + const { value } = formatValue('a'.repeat(VALUE_SIZE_CAP + 50)); + expect(value.length).toBe(VALUE_SIZE_CAP + 1); // + the truncation ellipsis + }); + + it('honors setValueSizeCap (wired to APPMAP_EVENT_VALUESIZE)', () => { + setValueSizeCap(10); + const { value } = formatValue('a'.repeat(50)); + expect(value.length).toBe(11); + }); +}); + +describe('formatValue object metadata', () => { + it('adds size for array and object values', () => { + expect(formatValue([1, 2, 3]).size).toBe(3); + expect(formatValue({ a: 1, b: 2 }).size).toBe(2); + }); + + it('omits size for primitive values', () => { + expect(formatValue('hello').size).toBeUndefined(); + expect(formatValue(42).size).toBeUndefined(); + expect(formatValue(null).size).toBeUndefined(); + }); + + it('assigns a stable object_id for repeated references within one tracker', () => { + const tracker = { ids: new WeakMap(), next: 1 }; + const obj = { a: 1 }; + const first = formatValue(obj, tracker); + const second = formatValue(obj, tracker); + const other = formatValue({ a: 1 }, tracker); + expect(first.object_id).toBe(second.object_id); + expect(other.object_id).not.toBe(first.object_id); + }); + + it('omits object_id when no tracker is supplied', () => { + expect(formatValue({ a: 1 }).object_id).toBeUndefined(); + }); +}); + +describe('Recording.enter parameter capture', () => { + it('includes size and object_id for an object-valued parameter', () => { + const recording = new Recording(baseMetadata()); + const token = recording.enter({ definedClass: 'Foo', methodId: 'bar', path: 'src/Foo.ts' }, [ + { name: 'opts', value: { a: 1, b: 2 } }, + ]); + recording.exit(token, { returnValue: undefined }); + + const callEvent = recording.events[0] as { parameters?: { size?: number; object_id?: number }[] }; + expect(callEvent.parameters?.[0].size).toBe(2); + expect(callEvent.parameters?.[0].object_id).toBeTypeOf('number'); + }); +}); + +describe('Recording.toAppMap self-heals open calls (docs/design/11)', () => { + it('a fully balanced recording is not flagged truncated and gains no synthetic returns', () => { + const recording = new Recording(baseMetadata()); + const token = recording.enter({ definedClass: 'Foo', methodId: 'bar', path: 'src/Foo.ts' }); + recording.exit(token, { returnValue: 1 }); + const appmap = recording.toAppMap(); + expect(appmap.metadata.truncated).toBeUndefined(); + expect(appmap.events).toHaveLength(2); + expect(recording.openCallCount()).toBe(0); + }); + + it('synthesizes a return for a call left open at serialization and flags the map truncated', () => { + const recording = new Recording(baseMetadata()); + // Open a call and never exit it — a hard teardown mid-flight, e.g. a + // Supabase edge function killed during EdgeRuntime.waitUntil work. + const token = recording.enter({ definedClass: 'Scan', methodId: 'pipeline', path: 'src/scan.ts' }); + expect(recording.openCallCount()).toBe(1); + + const appmap = recording.toAppMap(); + expect(appmap.metadata.truncated).toBe(true); + + // The event list is balanced: every call has a matching return. + const calls = appmap.events.filter((e) => e.event === 'call'); + const returns = appmap.events.filter((e) => e.event === 'return'); + expect(returns).toHaveLength(calls.length); + const synthetic = returns.find((r) => (r as { parent_id: number }).parent_id === token.callId); + expect(synthetic).toBeDefined(); + + // Non-mutating snapshot: the recording's own event list is untouched, + // so a later real exit still lands correctly. + expect(recording.events).toHaveLength(1); + recording.exit(token, { returnValue: 'ok' }); + expect(recording.openCallCount()).toBe(0); + expect(recording.toAppMap().metadata.truncated).toBeUndefined(); + }); +}); + +describe('metadata shape per recording mode', () => { + afterEach(() => { + try { + stopRecording(); + } catch { + // no active recording — fine, test already cleaned up + } + }); + + it('test-mode metadata includes language, frameworks, and source_location', () => { + const recording = startTestRecording('example test', { sourceLocation: 'test/example.test.ts' }); + const { metadata } = recording.toAppMap(); + expect(metadata.language).toEqual({ name: 'javascript', engine: 'node', version: process.version }); + expect(metadata.frameworks).toEqual([{ name: 'vitest' }]); + expect(metadata.source_location).toBe('test/example.test.ts'); + }); + + it('interaction-mode metadata has no language/frameworks/source_location today (not a mode-specific bug — unimplemented for that mode entirely)', () => { + const recording = new Recording({ + name: 'click button', + client: { name: '@funwithappmap/react-recorder', url: 'https://github.com/getappmap/appmap-react' }, + recorder: { name: 'funwithappmap-react', type: 'requests' }, + }); + const { metadata } = recording.toAppMap(); + expect(metadata.language).toBeUndefined(); + expect(metadata.frameworks).toBeUndefined(); + expect(metadata.source_location).toBeUndefined(); + }); +}); diff --git a/recorder/tsconfig.build.json b/recorder/tsconfig.build.json new file mode 100644 index 0000000..dea92cb --- /dev/null +++ b/recorder/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} From 1eab09b8753be9a2a5c9ad3220ad13f552373d40 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:04:16 +0000 Subject: [PATCH 02/54] CI: install deno in node job so Deno e2e tests run on every PR Agent-Logs-Url: https://github.com/getappmap/appmap-react/sessions/7af20183-8716-46ec-bbda-a8f3f65c373d Co-authored-by: kgilpin <86395+kgilpin@users.noreply.github.com> --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e20da3f..915d14b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,12 @@ jobs: with: node-version: 20 cache: npm + # `deno` is needed by examples/petclinic-react/test/e2e/deno-fullstack.test.ts, + # which otherwise skips itself. Installing it here makes the Deno half of + # the full-stack e2e test run on every PR instead of being silently skipped. + - uses: denoland/setup-deno@v1 + with: + deno-version: v2.x - run: npm ci - run: npm run typecheck --workspaces --if-present - run: npm run build --workspaces --if-present From 3d00c68c30fa8937b034d191ee8b24e29f79bbd6 Mon Sep 17 00:00:00 2001 From: evlawler <4238658+evlawler@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:33:32 +0000 Subject: [PATCH 03/54] acceptance(fullstack): expectations for supabase edge-functions app + function, written before recording Target: supabase/supabase @ 74a3be9 examples/edge-functions (React test client + select-from-table-with-auth-rls Deno function). Written from reading the source and running the app with no recorder attached; no recording exists yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017qStU1BiJDPwFbmGPPPtLT --- .../EXPECTATIONS.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 acceptance/supabase-edge-functions-app/EXPECTATIONS.md diff --git a/acceptance/supabase-edge-functions-app/EXPECTATIONS.md b/acceptance/supabase-edge-functions-app/EXPECTATIONS.md new file mode 100644 index 0000000..88b10ec --- /dev/null +++ b/acceptance/supabase-edge-functions-app/EXPECTATIONS.md @@ -0,0 +1,215 @@ +# Expectations: full-stack e2e — Supabase's edge-functions example app (React) ↔ its `select-from-table-with-auth-rls` edge function (Deno) + +Written from reading the app's source, and from running the app with no +recorder attached, **before any recording was made** (ACCEPTANCE-SPEC rule 4). +Not edited after the first recording. If something here turns out to be +wrong, RESULTS.md says so. + +## Recorder under test + +- Repo: evlawler/funwithappmapreact, branch `ci/oss-e2e`, based on + `getappmap/appmap-react` branch `deno-waituntil-trace-agent` @ `1eab09b8753be9a2a5c9ad3220ad13f552373d40`. + Recorder code (`recorder/`, `deno/`, `linker/`) last changed in `bef9d18c693c4873752539d8ca88c820ed98060d`. + Tested as is: no recorder change is made by this harness. +- Frontend: `appmapVitePlugin` (`recorder/src/vitePlugin.ts`) with `app` set, which injects the + zero-touch interaction recorder and serves the collector at `/__appmap/interactions` (docs 04, 07). +- Backend: `deno/bin/appmap-deno.ts` (zero-touch runner, doc 06) around the unmodified function file. +- Join: `linker/bin/appmap-link.mjs` (doc 02). + +## The app, and why this one + +- **App:** the "Supabase Edge Functions Test Client" in [supabase/supabase](https://github.com/supabase/supabase), + `examples/edge-functions/app/` (React 18, Create React App), and the edge function it is written to + exercise, `examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts` + (Deno, `Deno.serve`), with the project's own migrations `examples/edge-functions/supabase/migrations/*.sql`. + License: Apache-2.0. Written and maintained by Supabase, not by us. +- **Pinned commit:** `74a3be9aa8706755e05f7326f3d25472729cd977` (2025-06-09). This is the same commit + `acceptance/supabase-restful-tasks` pins, for the same reason: it is the last commit at which the + example functions are plain `Deno.serve` **and** import the real client (`jsr:@supabase/supabase-js@2`). + From 3c390c7 (2025-06-10) the functions import `npm:supabase-js@2`, a security placeholder package + that cannot run; from d5fde192 (2026-06-26) they use `export default { fetch }` (the `deno serve` + convention). The pin was chosen for that reason only, before any recording, not for how the + recorder does on it. +- **Criteria (task 1) and how this app meets them:** + 1. Real, public, open-source, not built by us: yes (Supabase's official examples, Apache-2.0). + 2. Frontend is React: yes (`app/package.json`: react 18, react-scripts 5.0.0). + 3. Backend is Deno: yes, a Supabase Edge Function using `Deno.serve` (index.ts:10). + 4. Frontend actually calls the backend over HTTP: yes, `supabase.functions.invoke(supaFunction, …)` + (App.js:18), which is a `fetch` to `/functions/v1/`. The dropdown lists + `select-from-table-with-auth-rls` (functionsList.js:4) and the page heading is "Log in to see RLS + in action" (App.js:80): signing in and invoking this function is the app's designed flow. + 5. Backend does real work: yes. It calls GoTrue (`auth.getUser`, index.ts:36-38) and queries the + `users` table through PostgREST under the caller's JWT, so row-level security applies + (index.ts:41; policy `auth.uid() = id`, migrations/20220331105910_init.sql:12). +- **Other candidates considered and rejected (real search, 2026-09-24):** + - `denoland/react-vite-ts-template` (Deno's official React + Vite + Oak template): its API only + returns a bundled JSON file. No DB, KV or outbound calls, so it fails criterion 5. + - `runreal/deno-monorepo-template` (MIT; React/Vite + Hono/tRPC on Deno + Drizzle/Postgres): a + starter kit, not an app. Its only DB endpoint (`getUsers`) is behind BetterAuth magic-link + login, which needs an email provider (Resend). The only public endpoint does no DB work. + - `NeaByteLab/IDX-UI` (MIT; React/Vite + Deno + SQLite): its data comes from a hard-coded + `https://www.idx.co.id` origin (services/Client.ts:12) on startup and on a `Deno.cron`. Running + it locally without calling that deployed service would mean faking the exchange's API. + - `vincenzo-afk/ethos-wear` (React/Vite + Supabase edge function): proprietary license, and a + hard-coded `https://.supabase.co` URL. + - Many personal/generated apps using `supabase.functions.invoke`: no license, or no way to run + them locally without a deployed project. +- **Known weaknesses of this choice, stated in advance:** + - The frontend is **Create React App** (webpack). The recorder integrates only with Vite. To + record at all, the harness runs the app's unmodified `src/` under Vite with a harness-provided + config (`app-config/cra-compat.mjs`) that does what react-scripts does for this app: serve + `public/index.html` with the `src/index.js` entry, compile JSX inside `.js` files, and provide + `process.env` (`NODE_ENV`, `PUBLIC_URL`, `REACT_APP_*`). This is a toolchain substitution, not an + app edit, but it goes in RESULTS.md under "App changes needed" and counts against the recorder. + - The app has **no tests of its own**. The "tests" here are real browser interactions (S1–S6) + plus direct HTTP requests to the function (R1–R4). F ("a failing test still leaves a + recording, marked failed") therefore has no test status to check; see F below. + - The function's only code is one anonymous handler (index.ts:10). There are no named helper + functions to find. + - The function reaches the database through PostgREST over HTTP, so there is no SQL on the + Deno side. "SQL tables touched" can only show up as the PostgREST URL (`/rest/v1/users?select=*`); + the RLS filter (`auth.uid() = id`) runs inside Postgres, where no AppMap agent runs. + - The app's `package.json` has no lockfile at this commit, so npm resolves current versions + (supabase-js 2.117.1, @supabase/auth-ui-react 0.2.8, react 18.3.1 when this was written). Its + documented `npm install` also fails on npm ≥ 7 (`@testing-library/react@12` peer-depends on + react < 18); the harness installs with `--legacy-peer-deps` (an install flag, not an app change). + +## Local stack (all on 127.0.0.1, nothing deployed) + +What `supabase start` + `supabase functions serve` would give, built from the real parts: + +| Piece | What runs | Notes | +|---|---|---| +| Postgres 16 | real `initdb`/`pg_ctl` | platform roles (`anon`, `authenticated`, `service_role`, `authenticator`), `auth` schema and default grants the supabase/postgres image sets up; then the example's 3 migrations, unmodified | +| Auth | real GoTrue (`supabase/auth` v2.177.0 release binary) | runs its own migrations; email sign-up auto-confirmed (no mail is sent) | +| REST | real PostgREST v12.2.12 | | +| Gateway (Kong stand-in) | `infra/gateway.mjs` on :54321 | `/auth/v1` and `/rest/v1` with Kong's `cors` plugin behaviour (preflight answered, requested headers reflected); `/functions/v1/` passed through to the function, OPTIONS included, because edge functions handle their own CORS; unknown names → 404; JWT not verified (`--no-verify-jwt`, as the example README's local command says) | +| Function | `deno run` of the unmodified `index.ts` (plain), or `appmap-deno` around it (recorded) | env `SUPABASE_URL=http://127.0.0.1:54321`, `SUPABASE_ANON_KEY=` | +| Frontend | the app's unmodified `src/` under Vite on :3000 | no `.env`: `supabaseClient.js:4-6` falls back to `http://localhost:54321` and the CLI's well-known local anon key, which is why the stack uses the CLI's well-known JWT secret | +| Browser | real Chromium, driven by Playwright (`scripts/drive.mjs`) | external hosts unresolvable; no request interception (Playwright answers CORS preflights itself when routing is on, which would hide the behaviour under test) | + +Observed with **no recorder** (this is how the expectations below were checked against the real app, +not guessed): S1–S6 all work; S3 returns `{"user": null, "data": []}`; S5 returns the signed-in user +and exactly one `users` row, the caller's own. The function's preflight response allows exactly +`authorization, x-client-info, apikey, content-type` (`_shared/cors.ts:3`). + +## Browser interactions (S1–S6), one window each + +A correct **frontend** recording per interaction (one AppMap per interaction window, collected by the +Vite plugin's collector) must contain: + +- **S1. Load the app.** No click, so no interaction map is required. The page must render. +- **S2. Click "Invoke Function" with the default dropdown entry** (`functionsList[0]`, "local: Whatever + function is currently served by the CLI", functionsList.js:2; App.js:12). + - Call event: `invokeFunction` (App.js:16, arrow inside `App`), triggered from the button's + `onClick` (App.js:70). Its `setResponseJson` causes `App` (App.js:10) to re-render: `App` call event(s). + - `http_client_request` `POST http://localhost:54321/functions/v1/local:%20Whatever%20function%20is%20currently%20served%20by%20the%20CLI` + (supabase-js URL-encodes the name). Without a recorder the browser gets a 404 from the gateway + with no CORS headers and `fetch` rejects (`FunctionsFetchError`, shown with `alert`, App.js:21). + The recording must pair the request with a response; for a rejected `fetch` the recorder's own + contract is status `0` (recorder/src/fetchPatch.ts:48-52). + - No backend map (the function is not called). +- **S3. Select `select-from-table-with-auth-rls`, click "Invoke Function", signed out.** + - Call event: `invokeFunction` (App.js:16); `App` re-render(s). The `