diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..79dad1f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,187 @@ +name: CI + +# Every push and every PR runs every test. Nothing is allowed to skip +# silently: tests that skip themselves locally when a tool is missing +# (deno, ...) fail instead when CI=true, which GitHub Actions always sets. +# +# The three acceptance jobs run real open-source apps end to end +# (acceptance/*/run.sh), each through acceptance/known-failures.mjs: every +# verdict must match acceptance//KNOWN_FAILURES.json exactly. A new +# failure, a changed verdict, or a known failure that starts passing all +# fail the job. Known failures are documented limitations and are still +# reported as FAIL in the job log and RESULTS.md; nothing is +# continue-on-error. +on: + push: + pull_request: + +permissions: + contents: read + +env: + NODE_VERSION: '22' + DENO_VERSION: 'v2.x' + # Chromium r1194, the build the acceptance harnesses were written against. + PLAYWRIGHT_VERSION: '1.56.1' + APPMAP_TELEMETRY_DISABLED: 'true' + +jobs: + build: + name: typecheck + build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run typecheck --workspaces --if-present + - run: npm run build --workspaces --if-present + + unit: + name: unit tests (Vitest) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm test --workspace recorder --workspace linker --workspace deno + # The example app's unit tests; its e2e tests run in the e2e-deno job. + - run: npx vitest run --exclude 'test/e2e/**' + working-directory: examples/petclinic-react + + deno: + name: Deno tests (native) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + 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@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: ${{ env.DENO_VERSION }} + - run: deno test -A --unstable-sloppy-imports deno/appmap_test.ts + + e2e-deno: + name: Deno e2e (real deno run + React <-> Deno join) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: ${{ env.DENO_VERSION }} + - run: deno --version + # examples/deno-edge smoke test: the unmodified function through appmap-deno. + - run: npm test --workspace examples/deno-edge + # React <-> this repo's own deno-edge example, joined by appmap-link. + - run: npx vitest run test/e2e + working-directory: examples/petclinic-react + + acceptance-supabase-edge-functions-app: + name: acceptance / full-stack e2e (supabase edge-functions app, React + Deno) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: ${{ env.DENO_VERSION }} + - name: PostgreSQL server binaries (initdb/pg_ctl; the harness runs its own cluster) + run: | + ls /usr/lib/postgresql/*/bin/initdb 2>/dev/null || + { sudo apt-get update && sudo apt-get install -y postgresql; } + ls /usr/lib/postgresql/*/bin/initdb + - name: Chromium (Playwright's build, installed in Actions only) + run: | + npx -y "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium + echo "CHROME=$(find ~/.cache/ms-playwright -type f -path '*chromium-*/chrome-linux*/chrome' | head -1)" >> "$GITHUB_ENV" + - run: npm ci + # Downloads GoTrue + PostgREST release binaries, clones the app at its + # pinned SHA, runs everything on 127.0.0.1; verdicts held to KNOWN_FAILURES.json. + - run: node acceptance/known-failures.mjs acceptance/supabase-edge-functions-app -- acceptance/supabase-edge-functions-app/run.sh + env: + WORK: ${{ runner.temp }}/acc-fullstack + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-supabase-edge-functions-app + path: | + acceptance/supabase-edge-functions-app/evidence + ${{ runner.temp }}/acc-fullstack/logs + if-no-files-found: warn + + acceptance-bulletproof-react: + name: acceptance / React recorder (bulletproof-react) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - name: yarn, bc (used by run.sh) + run: | + command -v yarn || npm install -g yarn@1.22.22 + command -v bc || { sudo apt-get update && sudo apt-get install -y bc; } + - name: Chromium (Playwright's build, installed in Actions only) + run: | + npx -y "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium + echo "CHROME=$(find ~/.cache/ms-playwright -type f -path '*chromium-*/chrome-linux*/chrome' | head -1)" >> "$GITHUB_ENV" + - run: npm ci + - run: node acceptance/known-failures.mjs acceptance/bulletproof-react -- acceptance/bulletproof-react/run.sh "$RUNNER_TEMP/acc-bulletproof" + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-bulletproof-react + path: ${{ runner.temp }}/acc-bulletproof/out + if-no-files-found: warn + + acceptance-supabase-restful-tasks: + name: acceptance / Deno recorder (supabase restful-tasks) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: ${{ env.DENO_VERSION }} + - name: PostgreSQL server binaries (initdb/pg_ctl; the harness runs its own cluster) + run: | + ls /usr/lib/postgresql/*/bin/initdb 2>/dev/null || + { sudo apt-get update && sudo apt-get install -y postgresql; } + ls /usr/lib/postgresql/*/bin/initdb + - run: npm ci + # Downloads PostgREST, clones the app at its pinned SHA, runs everything + # on 127.0.0.1; verdicts held to KNOWN_FAILURES.json. + - run: node acceptance/known-failures.mjs acceptance/supabase-restful-tasks -- acceptance/supabase-restful-tasks/run.sh + env: + WORK: ${{ runner.temp }}/acc-deno + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-supabase-restful-tasks + path: acceptance/supabase-restful-tasks/evidence + if-no-files-found: warn diff --git a/README.md b/README.md index 3ff52cb..4d1ed5a 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,11 @@ 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 -renders, hooks, event handlers, `fetch` calls, and `Deno.serve` +[AppMap](https://github.com/getappmap/appmap) 1.12 JSON — checked with +the official validator, see [doc 12](docs/design/12-format-validity.md) — +from component +renders, hooks, event handlers, `fetch` and `XMLHttpRequest` calls, +and `Deno.serve` requests — and the headline goal is **full-stack linking**: correlating frontend AppMaps with backend AppMaps via W3C Trace Context, so a user interaction can be followed from click to SQL. This @@ -28,16 +31,41 @@ specific to this repo is in `docs/design/`. ## Status -Nine 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 -is still pending a manual real-browser run (doc 04). +Twelve design docs, listed below with what each one proved. Highlights: +full-stack linking (doc 02) is tested end to end against a **real +open-source app neither side was built around** — Supabase's +edge-functions example (a React app calling a Deno edge function, with +real Postgres, GoTrue and PostgREST on localhost) — in +[`acceptance/supabase-edge-functions-app`](acceptance/supabase-edge-functions-app), +which CI runs on every PR. Read its `RESULTS.md` for the current state: +the recorder bugs it found are fixed, and the join works end to end once +the function's CORS allows `traceparent`; the suite still fails on two +checks that are not recorder bugs (the app is Create React App, which the +recorder cannot hook without the app switching to Vite, and at the pinned +commit the function's CORS refuses the header). +Both runtimes record with zero application-code changes on this repo's +own examples (docs 06 and 07). Interaction-window capture in a real +browser is exercised by the acceptance runs. + +### Acceptance suites (real OSS apps, run by CI on every PR) + +| Suite | What it proves | App | +|---|---|---| +| [`acceptance/supabase-edge-functions-app`](acceptance/supabase-edge-functions-app) | the full-stack join: a real Chromium click in the React app → `fetch` with `traceparent` → Deno backend map with matching `parent_span_id` → `appmap-link` stitch | `supabase/supabase` examples/edge-functions (app + `select-from-table-with-auth-rls`) @ `74a3be9` | +| [`acceptance/bulletproof-react`](acceptance/bulletproof-react) | the React recorder, checks A–J, Vitest + real browser | `alan2207/bulletproof-react` @ `9506629` | +| [`acceptance/supabase-restful-tasks`](acceptance/supabase-restful-tasks) | the Deno recorder, checks A–J | `supabase/supabase` examples/edge-functions `restful-tasks` @ `74a3be9` | + +Each has `EXPECTATIONS.md` (written before any recording), `run.sh` +(clean clone at the pinned SHA → every check, non-zero exit on any +failure) and `RESULTS.md`. - [`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 + contract, side-effect-free value capture with size caps and credential + redaction, `fetch` and + `XMLHttpRequest` (axios) → `http_client_request`/`response` events + with `traceparent` stamping, + AppMap 1.12 serializer, validated by the official + `@appland/appmap-validate`), 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. @@ -51,12 +79,13 @@ is still pending a manual real-browser run (doc 04). to backend request maps via W3C Trace Context (`traceparent`) ids, emitting `appmap-links.json` and a stitched PlantUML sequence diagram per interaction (click → component → fetch → handler → - SQL). A clearly-marked simulator can synthesize backend maps, and a - **real end-to-end integration test** boots the actual PetClinicGo - server (with its new AppMap middleware) and asserts the stitch on - real maps — it runs automatically when the Go toolchain and the Go - sibling repo are present (`PETCLINIC_GO_DIR` to point elsewhere), - and skips itself otherwise. See the doc 02 amendment. + SQL). A clearly-marked simulator can synthesize backend maps for the + demo. The real end-to-end proof is + [`acceptance/supabase-edge-functions-app`](acceptance/supabase-edge-functions-app) + (a real OSS React + Deno app, real browser, real backend recordings); + see the doc 02 amendment of 2026-09-24. The earlier PetClinicGo-backed + e2e test was retired: it depended on this project's own experimental + Go tracer and a sibling repo, and skipped itself in CI. - [`deno/`](deno) — `withAppMap`, a per-request session driver for `Deno.serve` / Supabase edge functions: reuses the same recorder core and the same build-time transform (unchanged), adding only a @@ -72,6 +101,17 @@ 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 by a + signal, a crash or even `kill -9` is kept (truncated) 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 @@ -84,10 +124,71 @@ npm run link:demo # tests + simulate backend + lin ls examples/petclinic-react/tmp/appmap/links/ # appmap-links.json + .puml diagrams 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) + # (examples/petclinic-react also has a React <-> Deno + # e2e test against this repo's own deno-edge example, doc 09; + # with CI=true both fail instead of skipping without deno) + +# full-stack e2e on a real OSS app (needs deno, postgres binaries, chromium; see run.sh) +acceptance/supabase-edge-functions-app/run.sh + +# tracing agent: ASCII + mermaid per interaction (add --baseline to diff) +node linker/bin/appmap-trace.mjs examples/petclinic-react/tmp/appmap +``` + +### Using the recorder in another Vite/Vitest app + +The recorder package is built to `recorder/dist` (`npm run build`; +`npm pack` builds it for you) and imported by these specifiers: + +```bash +npm pack --workspace recorder # -> funwithappmap-react-recorder-.tgz +cd your-app && npm install -D /path/to/funwithappmap-react-recorder-.tgz +``` + +```ts +// vite.config.ts +import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite'; +export default defineConfig({ + plugins: [appmapVitePlugin({ include: ['src'], app: 'your-app' }), react()], + test: { setupFiles: ['./appmap.setup.ts'] }, +}); + +// appmap.setup.ts (Vitest: one AppMap per test in tmp/appmap/tests) +import { registerAppMapHooks } from '@funwithappmap/react-recorder/vitest'; +registerAppMapHooks({ app: 'your-app' }); ``` +`include`/`exclude` take directory prefixes or globs +(`'**/*.stories.tsx'`); test files (`__tests__/`, `__mocks__/`, +`*.test.*`, `*.spec.*`) are excluded by default (`defaultExclude`, doc 03). + +With `app` set, `vite` dev also records one AppMap per user interaction +into `tmp/appmap/interactions` (doc 07). Installing straight from a +checkout (`file:…/recorder`) works too, once `npm run build` has run +there. + +**Linking to a backend on another origin needs one setting.** Every +request made while recording is recorded, but only same-origin requests +carry the `traceparent` header that links a frontend map to its backend +map. A cross-origin request carries it only when its origin is listed, +because a header the backend's CORS policy does not allow makes the +browser block the request and would break your app (this is +OpenTelemetry's `propagateTraceHeaderCorsUrls` model): + +```ts +appmapVitePlugin({ + include: ['src'], + app: 'your-app', + propagateTraceHeaderOrigins: ['https://api.example.com'], // or APPMAP_PROPAGATE_TRACE_HEADER_ORIGINS=https://api.example.com +}); +``` + +That backend must also allow the header: `traceparent` in its +`Access-Control-Allow-Headers` (for a Supabase edge function, add it to +`corsHeaders` in `_shared/cors.ts`). Server-side code (Deno, Node) has no +CORS and stamps every outgoing request. See doc 02, "Cross-origin +requests". + To run the example app against a live PetClinicGo backend (`FunwithAppMapandClaudeGolang/examples/PetClinicGo` on :8080): @@ -122,8 +223,9 @@ not history rewrites. Edge Runtime gap explicitly rather than leaving it implicit) - [07 — zero-touch interaction recording for React](docs/design/07-zero-touch-react.md) (the same standard applied to the frontend: `main.tsx` no longer - calls `installInteractionRecorder` itself; verified against a real - dev server and a real production build, not just a unit test) + calls `installInteractionRecorder` itself; the 2026-09-24 amendment + fixes the injected import so a real browser actually loads it, proven + with a real dev server and headless Chromium) - [08 — labels: comment tags and built-in patterns](docs/design/08-labels.md) (`@label` comments, no import required; automatic `security.authentication` / `io.sql` / `security.crypto` labels for @@ -131,3 +233,13 @@ 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) +- [12 — format validity and the declared version](docs/design/12-format-validity.md) + (every recording mode checked with the official validator; calls + serialized as a tree; why the declared version is 1.12) diff --git a/acceptance/.gitignore b/acceptance/.gitignore new file mode 100644 index 0000000..0eab5b8 --- /dev/null +++ b/acceptance/.gitignore @@ -0,0 +1,2 @@ +# Regenerated by each run; CI uploads it as an artifact. +evidence/ diff --git a/acceptance/bulletproof-react/EXPECTATIONS.md b/acceptance/bulletproof-react/EXPECTATIONS.md new file mode 100644 index 0000000..aa75540 --- /dev/null +++ b/acceptance/bulletproof-react/EXPECTATIONS.md @@ -0,0 +1,159 @@ +# Expectations: bulletproof-react (apps/react-vite) under the React recorder + +Written from the app source **before any recording was made**. Not to be +edited after the first recording; if something here turns out wrong, that +goes in RESULTS.md. + +Target: `alan2207/bulletproof-react` @ `9506629ed003a561c6627735480cce4994244bb4`, +app `apps/react-vite`. All paths below are relative to `apps/react-vite/`. + +Recorder config used (plugin options, the documented appmap.yml equivalent): +`include: ['src'], exclude: ['src/testing']` (src/testing is the app's MSW +mock backend + test utils = test-support code). + +## Ground facts about the app that shape every expectation + +- **HTTP goes through axios, not `fetch`.** `src/lib/api-client.ts:17` + (`Axios.create({ baseURL: env.API_URL })`); axios is 1.6.8 in + `yarn.lock`, whose adapters are `xhr` then `http` (no fetch adapter in + 1.6.x). In jsdom and in Chromium it uses `XMLHttpRequest`. A correct + recording of this app must still contain the outbound HTTP calls + (`http_client_request` / `http_client_response`), because that is what + the app does; how the recorder hooks them is its problem. +- Under Vitest, `API_URL` = `https://api.bulletproofapp.com` (the value in + `.env.example`, passed as env var `VITE_APP_API_URL`); MSW + (`src/testing/mocks/server.ts`) answers in-process. No request leaves + the machine. +- In the browser runs, `API_URL` = `http://localhost:8080/api`, answered + by the app's own mock server (`mock-server.ts`, express + the same MSW + handlers). +- SQL: none. This is a browser app. The "db" is `@mswjs/data` inside the + mock backend (`src/testing/mocks/db.ts`), which is excluded test-support + code. Expected SQL events: **zero** in every recording. +- Every `http_client_request` must carry a `traceparent` header of the form + `00--<16 hex>-01` (recorder README / docs/design/02). +- Naming: the recorder names a function `defined_class` = module basename, + `method_id` = function name, `path` = file path. Components get label + `component`, `use*` get `hook`, inline JSX `on*` arrows and nested + closures get `event-handler`. + +Handler line numbers (MSW, `src/testing/mocks/handlers/`): +`auth.ts:30` POST /auth/register, `auth.ts:112` POST /auth/login, +`auth.ts:152` GET /auth/me, `comments.ts:14` GET /comments, +`comments.ts:75` POST /comments, `comments.ts:98` DELETE /comments/:id, +`discussions.ts:19` GET /discussions, `discussions.ts:81` GET +/discussions/:id, `discussions.ts:133` POST /discussions, +`discussions.ts:158` PATCH /discussions/:id, `discussions.ts:193` DELETE +/discussions/:id. + +## Vitest tests (the app's own suite) + +### T1. `src/features/auth/components/__tests__/login-form.test.tsx` — "should login new user and call onSuccess cb ..." +- Functions: + - `AppProvider` (`src/app/provider.tsx:17`) [component] — via `renderApp`. + - `getUser` (`src/lib/auth.tsx:13`) — AuthLoader's `userFn` (`auth.tsx:63`), called on mount. + - `LoginForm` (`src/features/auth/components/login-form.tsx:12`) [component], props contain `onSuccess`. + - `Form` (`src/components/ui/form/form.tsx:182`) [component]. + - inline `onSubmit` arrow (`login-form.tsx:22`) [event-handler], called once with `{email, password}` after the click. + - `loginWithEmailAndPassword` (`src/lib/auth.tsx:29`), called once, arg contains the user's email. +- HTTP (in this order): + 1. `GET https://api.bulletproofapp.com/auth/me` → 200 (`auth.ts:152`; no cookie, `requireAuth` returns `{user:null}` without throwing, handler returns `{data:null}` 200). + 2. `POST https://api.bulletproofapp.com/auth/login` → 200 (`auth.ts:112`), after the `onSubmit` call. +- Exceptions: none. Status: succeeded. + +### T2. `src/features/auth/components/__tests__/register-form.test.tsx` — "should register new user ..." +- Functions: `AppProvider`, `getUser` (`auth.tsx:13`), `RegisterForm` (`register-form.tsx:17`) [component], `Form` (`form.tsx:182`), inline `onSubmit` (`register-form.tsx:30`) [event-handler] called once, `registerWithEmailAndPassword` (`auth.tsx:56`) called once with firstName/lastName/email/password/teamName. +- HTTP: `GET .../auth/me` → 200, then `POST https://api.bulletproofapp.com/auth/register` → 200 (`auth.ts:30`). +- Exceptions: none. + +### T3. `src/app/routes/app/discussions/__tests__/discussion.test.tsx` — "should render discussion" +- Functions: `DiscussionRoute` (`src/app/routes/app/discussions/discussion.tsx:38`) [component]; + `useDiscussion` (`src/features/discussions/api/get-discussion.ts:27`) [hook] with `{discussionId}`; + `getDiscussionQueryOptions` (`get-discussion.ts:15`); `getDiscussion` (`get-discussion.ts:7`) with `{discussionId: }`; + `ContentLayout` (`src/components/layouts/content-layout.tsx:10`); `DiscussionView` (`src/features/discussions/components/discussion-view.tsx:8`); + `UpdateDiscussion` (`update-discussion.tsx:18`); `Authorization` (`src/lib/authorization.tsx:63`) + `useAuthorization` (`authorization.tsx:28`) [hook] + `checkAccess` (`authorization.tsx:35`, useCallback) returning true (user is ADMIN — `createUser()` defaults, team creator); + `Comments` (`src/features/comments/components/comments.tsx:8`); `CreateComment` (`create-comment.tsx:16`); `CommentsList` (`comments-list.tsx:19`); + `useInfiniteComments` (`src/features/comments/api/get-comments.ts:43`) [hook]; `getInfiniteCommentsQueryOptions` (`get-comments.ts:22`); `getComments` (`get-comments.ts:7`) with `{discussionId, page: 1}`; + `MDPreview` (`src/components/ui/md-preview/md-preview.tsx:10`); `formatDate` (`src/utils/format.ts:3`). +- HTTP: `GET .../auth/me` → 200; `GET https://api.bulletproofapp.com/discussions/` → 200 (`discussions.ts:81`); `GET https://api.bulletproofapp.com/comments?discussionId=&page=1` → 200 (`comments.ts:14`). +- Exceptions: none. + +### T4. same file — "should update discussion" +- Everything in T3, plus: `FormDrawer` (`src/components/ui/form/form-drawer.tsx:24`) and its inline `onOpenChange` (`form-drawer.tsx:42`) [event-handler] called with `true`; + inline `onSubmit` (`update-discussion.tsx:57`) [event-handler]; `updateDiscussion` (`src/features/discussions/api/update-discussion.ts:17`) with `{data:{title, body}, discussionId}`. +- HTTP additionally: `PATCH https://api.bulletproofapp.com/discussions/` → 200 (`discussions.ts:158`), then a second `GET .../discussions/` → 200 (the `refetchQueries` in `update-discussion.ts:40`). +- Exceptions: none. + +### T5. same file — "should create and delete a comment on the discussion" +- Everything in T3, plus: inline `onSubmit` (`create-comment.tsx:53`); `createComment` (`src/features/comments/api/create-comment.ts:17`) with `{data:{body:'Hello World', discussionId}}`; + `DeleteComment` (`delete-comment.tsx:14`); `ConfirmationDialog` (`src/components/ui/dialog/confirmation-dialog/confirmation-dialog.tsx:27`); + inline `onClick` (`delete-comment.tsx:48`) [event-handler]; `deleteComment` (`src/features/comments/api/delete-comment.ts:8`) with `{commentId}`. +- HTTP additionally, in order: `POST https://api.bulletproofapp.com/comments` → 200 (`comments.ts:75`); `GET .../comments?discussionId=&page=1` → 200 (invalidate, `create-comment.ts:40`); + `DELETE https://api.bulletproofapp.com/comments/` → 200 (`comments.ts:98`); `GET .../comments?...` → 200 (invalidate, `delete-comment.ts:28`). +- Exceptions: none. + +### T6. `src/app/routes/app/discussions/__tests__/discussions.test.tsx` — "should create, render and delete discussions" +- Functions: `DiscussionsRoute` (`discussions.tsx:25`); `DiscussionsList` (`src/features/discussions/components/discussions-list.tsx:19`); + `useDiscussions` (`get-discussions.ts:34`) [hook]; `getDiscussionsQueryOptions` (`get-discussions.ts:20`); `getDiscussions` (`get-discussions.ts:7`) with page 1; + `CreateDiscussion` (`create-discussion.tsx:13`); inline `onSubmit` (`create-discussion.tsx:49`); `createDiscussion` (`src/features/discussions/api/create-discussion.ts:17`) with `{data:{title, body}}`; + `Table` (`src/components/ui/table/table.tsx:138`); `DeleteDiscussion` (`delete-discussion.tsx:14`); inline `onClick` (`delete-discussion.tsx:43`); `deleteDiscussion` (`src/features/discussions/api/delete-discussion.ts:8`) with `{discussionId}`. +- HTTP in order: `GET .../auth/me` → 200; `GET https://api.bulletproofapp.com/discussions?page=1` → 200 (`discussions.ts:19`); + `POST https://api.bulletproofapp.com/discussions` → 200 (`discussions.ts:133`); `GET .../discussions?page=1` → 200 (invalidate, `create-discussion.ts:38`); + `DELETE https://api.bulletproofapp.com/discussions/` → 200 (`discussions.ts:193`); `GET .../discussions?page=1` → 200 (invalidate, `delete-discussion.ts:29`). +- Exceptions: none (the test silences `console.error`, `discussions.test.tsx:15-17`, but no app function is expected to throw). + +### T7. `src/lib/__tests__/authorization.test.tsx` — "should view protected resource if user role is matching" / "... does not match ..." +- Functions: `Authorization` (`authorization.tsx:63`); `useAuthorization` (`authorization.tsx:28`) [hook]; `checkAccess` (`authorization.tsx:35`) called with `{allowedRoles:['ADMIN']}` returning `true` (ADMIN user) / `false` (USER user). +- HTTP: `GET .../auth/me` → 200. +- The two policyCheck tests: `Authorization` + `useAuthorization`, **no** `checkAccess` call (`authorization.tsx:73` only calls it when `allowedRoles` is set). + +### T8. `src/hooks/__tests__/use-disclosure.test.ts` — "should toggle the state" (and siblings) +- Functions: `useDisclosure` (`src/hooks/use-disclosure.ts:3`) [hook]; `toggle` (`use-disclosure.ts:8`, useCallback) called exactly 2× in "should toggle the state"; `open` (`use-disclosure.ts:6`) 1× in "should open the state"; `close` (`use-disclosure.ts:7`) 1× in "should close the state". +- HTTP: none. Exceptions: none. + +### T9. `src/components/seo/__tests__/head.test.tsx` +- Functions: `Head` (`src/components/seo/head.tsx:10`) [component], props `{title:'Hello World', description:'This is a description'}`. HTTP: none. + +## Exception check (E) — extra test file kept outside the app tree +Renders `` inside `AppProvider` with +no logged-in user. `useAuthorization` must throw `Error('User does not exist!')` +(`src/lib/authorization.tsx:31-33`). The recording must contain the +`return` event of `useAuthorization` with +`exceptions: [{class: 'Error', message: 'User does not exist!'}]`, preceded +by `GET .../auth/me` → 200. + +## Failing test (F) — extra test file kept outside the app tree +A copy of T9 whose assertion expects the wrong title. The recording must +still be written, with `metadata.test_status: "failed"`, and still contain +the `Head` call. + +## Noise (D) +- Nothing from `node_modules` (react, react-query, axios, radix, msw). +- Nothing from `src/testing/**` (excluded): no `authenticate`, `requireAuth`, `hash`, MSW handler closures, `initializeDb`. +- Nothing defined in test files: e.g. `TestDialog` (`src/components/ui/dialog/__tests__/dialog.test.tsx:20`) and `TestDrawer` (`drawer.test.tsx:19`) are test code and should not be recorded. +- Storybook files (`*.stories.tsx`) are not loaded by tests; they must not appear. + +## Browser interaction recording (real Chromium, dev server + plugin `app` option) +Backend: `mock-server.ts` on `localhost:8080`, `VITE_APP_API_URL=http://localhost:8080/api`. +One AppMap per interaction must land in `tmp/appmap/interactions/`. + +- **B1 register**: on `/auth/register`, fill the form and click "Register". + Map named after the click on the Register button. Contains inline `onSubmit` + (`register-form.tsx:30`), `registerWithEmailAndPassword` (`auth.tsx:56`), + `POST http://localhost:8080/api/auth/register` → 200 with a `traceparent` + header that was also actually sent on the wire, and the `onSuccess` + arrow (`src/app/routes/auth/register.tsx:24`) that navigates to `/app`. +- **B2 list page**: click the "Discussions" nav link. Contains + `DiscussionsRoute` (`discussions.tsx:25`), `DiscussionsList`, + `getDiscussions` (`get-discussions.ts:7`) and + `GET http://localhost:8080/api/discussions?page=1` → 200 (+ traceparent). +- **B3 open drawer**: click "Create Discussion". Contains `onOpenChange` + (`form-drawer.tsx:42`) with `true`. No HTTP. +- **B4 create item**: fill title/body, click "Submit". Contains `onSubmit` + (`create-discussion.tsx:49`), `createDiscussion` (`create-discussion.ts:17`), + `POST http://localhost:8080/api/discussions` → 200 then + `GET http://localhost:8080/api/discussions?page=1` → 200. +- **B5 concurrency**: two clicks fired ~20 ms apart. Per doc 04 the second + is absorbed into the first window; each resulting map must contain only + the work of the clicks it names, and no map may contain work from an + interaction before or after it. diff --git a/acceptance/bulletproof-react/KNOWN_FAILURES.json b/acceptance/bulletproof-react/KNOWN_FAILURES.json new file mode 100644 index 0000000..c8a4490 --- /dev/null +++ b/acceptance/bulletproof-react/KNOWN_FAILURES.json @@ -0,0 +1,48 @@ +{ + "note": "Every verdict this suite must produce. A FAIL here is a known, documented limitation (see RESULTS.md); it is still reported as FAIL. CI fails if any verdict differs from this file, if a known failure starts passing, or if a failing check's evidence line changes. Update this file only together with RESULTS.md.", + "checks": { + "A": { + "verdict": "PASS" + }, + "B": { + "verdict": "PASS" + }, + "C": { + "verdict": "FAIL", + "reason": "T6: the test ends before its final refetch gets a response; MSW only makes an XHR visible once the mocked response starts, so that GET /discussions?page=1 is never seen by the recorder. Wrapping the app's XHR in a proxy to catch it risks breaking apps; not done.", + "evidence": [ + "totals {'found': 127, 'wrong': 1}", + "T6 should create, render and delete discussions 15/16 found | wrong: GET https://api\\.bulletproofapp\\.com/discussions\\?page=1$" + ] + }, + "D": { + "verdict": "PASS" + }, + "E": { + "verdict": "PASS" + }, + "F": { + "verdict": "PASS" + }, + "G": { + "verdict": "PASS" + }, + "H": { + "verdict": "PASS" + }, + "I": { + "verdict": "FAIL (vitest isolation: PASS, browser two clicks: FAIL)", + "reason": "Browser has no async context: two clicks 20 ms apart share one interaction window. The map is marked metadata.ambiguous and names both clicks, but it is one map. Test isolation (Vitest) passes." + }, + "J": { + "verdict": "MEASURED" + }, + "BROWSER": { + "verdict": "FAIL (zero-touch: FAIL, with workaround: FAIL)", + "reason": "Only step B5 (two clicks 20 ms apart) fails, for the same reason as I; B1-B4 each produce exactly one map.", + "evidence": [ + "zerotouch maps per step: {'B1 register': 1, 'B2 list page': 1, 'B3 open create drawer': 1, 'B4 create item': 1, 'B5 two clicks 20ms apart': 1} verdict FAIL" + ] + } + } +} diff --git a/acceptance/bulletproof-react/RESULTS.md b/acceptance/bulletproof-react/RESULTS.md new file mode 100644 index 0000000..7a91185 --- /dev/null +++ b/acceptance/bulletproof-react/RESULTS.md @@ -0,0 +1,283 @@ +# Results: React recorder on bulletproof-react (apps/react-vite) + +- **Recorder under test:** `upstream-pr/deno-waituntil-trace-agent` @ `bef9d18c693c4873752539d8ca88c820ed98060d` (unchanged). +- **Target:** `alan2207/bulletproof-react` @ `9506629ed003a561c6627735480cce4994244bb4`, app `apps/react-vite`. It is a Vite 5.2.11 + React 18.3.1 app, tested with Vitest 2.1.4, React Testing Library and MSW 2.2.14, and its HTTP client is axios 1.6.8. The suite has 21 tests in 12 files. +- **Tools:** + - Node 22.22.2 and yarn 1.22.22. + - Official AppMap CLI `@appland/appmap` 3.204.0 (the latest at run time). + - Official validator `@appland/appmap-validate` 2.5.1 (getappmap/appmap-js `packages/validate`). + - Spec: `getappmap/appmap` @ `fa68b13`. + - Playwright 1.59.1, driving Chromium r1194 from `/opt/pw-browsers` via `executablePath`. Playwright's own r1217 is not installed, and `playwright install` was not run. +- **One command:** `acceptance/bulletproof-react/run.sh [WORK_DIR]`. It clones the app at the pinned SHA, installs, runs every check and exits 1 if any check fails. Evidence from the final clean-clone run is in `evidence/`: the reports plus every recording (tests run 1, the extra tests, the browser interactions, and the run 1 sequence diagrams). + +EXPECTATIONS.md was committed on its own, before any recording (commit `2b897ae`). + +## Update: `integration/pr1` — the fixes merged (read this first) + +Branch `integration/pr1` merges `fix/recorder-worst-bugs` into `ci/oss-e2e` and adds further fixes. This +section is the current result; the rest of this file is the original run against the unfixed recorder +(`bef9d18`), kept as the record of what it found. `evidence/` holds that original run; a current run writes +its evidence to `WORK_DIR/out` (CI uploads it as an artifact). + +Run: fresh clone of `integration/pr1`, `CI=true`, `npm ci`, then `acceptance/bulletproof-react/run.sh`, on +localhost in a private network namespace (the harness needs ports 3000 and 8080). Same app SHA and tools as +below. "Merged, old checks" is an earlier run of the merged code (with the harness config change, check +change 2) before the other check changes. + +| Check | Before (unfixed) | Merged, old checks | After | One line (after) | +|---|---|---|---|---| +| A. Setup | FAIL | PASS | **PASS** | the documented `@funwithappmap/react-recorder/vite` import loads; no app changes. | +| B. Validity | FAIL (0/29) | PASS | **PASS** | 30/30 valid at 1.12 (and 1.6.0–1.13.1). | +| C. Ground truth | FAIL (94 found / 1 wrong / 33 missing) | FAIL (117 / 6 / 5) | **FAIL** (127 found / 1 wrong) | Every HTTP call, `checkAccess`, `toggle`/`open`/`close` and T2's `teamName` are found. The one miss: T6's last `GET /discussions?page=1`, see below. | +| D. Noise | FAIL | PASS | **PASS** | 1311 call events; none from test files, `src/testing` or `node_modules`. | +| E. Exception | PASS | PASS | **PASS** | `useAuthorization` return: `Error: User does not exist!` (with `object_id`). | +| F. Failing test | PASS | PASS | **PASS** | `test_status: "failed"`, `Head` call present. | +| G. Stability | PASS | FAIL | **PASS** | 21/21 identical (random mock-backend ids normalized, rule (d)). | +| H. Change detection | FAIL | FAIL | **PASS** | appmap-trace shows the change (`page` dropped from `GET /comments`) in exactly the three comment-loading tests and nothing else, once the mock backend's random ids are normalized (check change 7). | +| I. Concurrency | FAIL | FAIL | **FAIL** | Vitest isolation: 21/21 identical (PASS). Browser, two clicks 20 ms apart: one window, now marked `ambiguous` and naming both clicks, but still one map (FAIL, known limitation). | +| J. Overhead | MEASURED (+7%) | MEASURED | MEASURED | median 10.9 s without, 11.8 s with (+9%, local CI run sharing the machine); an earlier run: 11.3 → 11.8 s (+5%). | +| Browser | FAIL (0 maps zero-touch) | FAIL | **FAIL** | B1–B4: every expected function and request found, one map each; 6/6 requests on the wire carry `traceparent` (the page load included). B5: see I. | + +The recorder no longer changes the app's behaviour: all 21 tests pass with it (before: `discussions.test.tsx` +failed 3/3 under the recorder). + +**Remaining failures, and why:** + +- **C, T6** — the test's last step deletes a discussion and ends when "Discussion Deleted" appears. The + delete's `onSuccess` starts a refetch (`getDiscussions`, recorded, its return still pending at the end: + `truncated: true`) whose `GET /discussions?page=1` answer arrives after the test has finished, so no + per-test recording can hold that `→ 200`. The request itself is missing too, which is a recorder + limitation: under MSW's XHR interceptor the request is only observable when the mocked response starts + (MSW fires `loadstart` then, and intercepts `send()` itself), so a request still waiting when the + recording closes leaves nothing. Not fixed: seeing it would mean wrapping the app's XHR object in a + proxy, which risks breaking apps. Left failing. +- **I (browser) and Browser B5** — the browser has no async context, so two clicks 20 ms apart share one + interaction window. The map is now marked `ambiguous: true` with both clicks in + `metadata.interactions` instead of being silently named after the first, but the check requires two + separate maps. Known limitation. +- **Page-load requests**: now recorded, in a `load ` window opened by the zero-touch injection + (0fdc935). + +**Bugs listed below, now:** 1 (package entry points) fixed (49fee5c); 2 (zero-touch injection) fixed +(32157ad); 3 (observer effect) fixed (6c8680c); 4 (XHR invisible) fixed (753a208); 5 (`React.useCallback`) +fixed (cfcffce); 6 (not valid 1.12) fixed (4893004); 7 (thread nesting) fixed (4893004); 8 (test files +recorded, `exclude` prefix-only) fixed (aca9d44: globs, test files excluded by default); 9 (function props +vanish) fixed (6c8680c: `[function name]`); 9b (React dev-mode probe calls recorded as calls throwing +TypeError): still there, in the E recording only (`Authorization` and `AppProvider` called with no props by +React's dev-mode component-stack probing, which catches the error). Not changed: those calls really run; the +recorder records what runs, and telling React's probe apart from app calls would mean guessing. + +### Check changes + +Each is its own commit; the message quotes the old and new rule. EXPECTATIONS.md is unchanged. + +1. **URLs are `url` + `message`** (c658fa2, rule (a), AppMap spec): C and the browser check match URL + patterns against `url` plus the event's `message` parameters. +2. **Config: the documented plugin import, and the API origin listed** (b9ec2bd, the user's requested + behaviour): `vite.config.appmap.ts` imports `@funwithappmap/react-recorder/vite` and sets + `propagateTraceHeaderOrigins` to the origin of `VITE_APP_API_URL`. The recorder stamps cross-origin + requests only for listed origins, and EXPECTATIONS.md requires `traceparent` on every request; this app's + API is cross-origin in both modes. No check logic changed. +3. **A field name may be found in parameter `properties`** (0369b02, AppMap spec): values are capped at 100 + characters (schema 1.6+), so T2's `teamName` cannot be in the value; the recorder now writes the spec's + parameter `properties` for plain objects (f652594), and the check accepts an exact field name there. +4. **checkAccess's line: expectation wrong** (4e509ac): EXPECTATIONS.md says line 35 + (`const checkAccess = React.useCallback(`); the function, the arrow, starts on line 36. The check now + requires 36 and says "expectation wrong" in its evidence. +5. **Random mock-backend ids normalized in G and I only** (1c623ba, rule (d)): regex + `(https://api\.bulletproofapp\.com/(?:discussions|comments)/)(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Za-z0-9_-]{21})(?=$|[/?\s])` + → `\1`, and the official tool's `digest`/`subtreeDigest` (hashes over those URLs) left out, in those + two comparisons only. +6. **H requires the query change in the trace diff** (f7c7411, rule (f)): the official diagrams cannot + show a query-only change (the query is in `message`); they are reported. H requires appmap-trace to show + each `…&page=1` → without `page` in the three comment-loading tests, and nothing else changed anywhere. + +7. **H ignores the mock backend's random ids** (4965306, rule (d) extended to H): the before and after + runs name the same created discussion/comment by different random ids, which is run-to-run noise like a + timestamp. With the same regex as G and I, a `- X` / `+ X` pair that is identical once the ids are + normalized is the same step. Anything else marked still fails H (checked: turning one GET into a POST in + the saved trace output makes H fail). + +In CI this suite runs through `acceptance/known-failures.mjs`: every verdict must match +`KNOWN_FAILURES.json` exactly (C, I and Browser are listed as known failures, with C's evidence line), and +a known failure that starts passing also fails the job. Local CI run of 7877d44 on Node 22: every verdict +matches. + +Reporting only: B5's evidence shows the `ambiguous` flag (a619051). Setup only: `run.sh` builds the +recorder before installing it (2cc967c); shellcheck cleanups (0ddada4). + +## Summary table + +| Check | Result | Evidence (one line) | +|---|---|---| +| A. Setup | **FAIL** | The documented plugin import `@funwithappmap/react-recorder/vite` cannot be loaded by the app's Vite config (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, `evidence/a-pkgimport.log`). Recordings needed a relative import into `node_modules`. No app source changes were needed. | +| B. Validity | **FAIL** | Official validator: **0 of 29 recordings valid** at the declared 1.12, and 0 at every version from 1.2 to 1.13.1. 24 fail on `metadata.frameworks[0]` missing required `version`; the 5 browser maps fail on values over 100 chars (`evidence/b-validate.json`). | +| C. Ground truth | **FAIL** | 94 found, 1 wrong, 33 missing (`evidence/c-ground-truth.json`). All 25 expected HTTP calls are missing (axios uses XHR; the recorder only patches `fetch`). All 8 expected `React.useCallback` callbacks are missing. | +| D. Noise | **FAIL** | No `node_modules` or `src/testing` code is recorded. But functions defined in the app's own test files are: `renderDiscussion` (discussion.test.tsx:13), `TestDialog` (dialog.test.tsx:20) and `TestDrawer` (drawer.test.tsx:19). `exclude` only takes directory prefixes, so it can't express `**/__tests__/**` (`evidence/d-noise.json`). | +| E. Exception | **PASS** | `useAuthorization` return: `exceptions:[{"class":"Error","message":"User does not exist!"}]`, recorded 4×. The exception object lacks the `object_id` the spec requires (see B). | +| F. Failing test | **PASS** | `F_deliberately_failing_copy...appmap.json`: `test_status:"failed"`, `Head` call present. No `test_failure` (optional in 1.12). | +| G. Stability | **PASS** | 21 of 21 normalized sequence diagrams identical between run 1 and run 2 (`evidence/g-stability.json`). | +| H. Change detection | **FAIL** | `getComments` stopped sending `page`. `appmap-trace --baseline` printed "traced 0 interaction(s)" and exited 0. The official `sequence-diagram-diff` said "identical" for all 21 tests. Neither tool showed the change. | +| I. Concurrency | **FAIL** | Vitest: each of the 21 tests run alone produced the same diagram as inside the parallel suite, so nothing leaks. Browser: two clicks 20 ms apart produced **one** map named `click a "Users"` containing both the Users loader and the Dashboard render. | +| J. Overhead | MEASURED | Suite wall time: 12.0 s / 11.2 s without the agent, 12.4 s / 12.2 s with it (median +7%; the earlier run-a measured +11%). | +| Browser (interaction recording) | **FAIL** | As documented (zero-touch): **0 maps** for 5 interactions, because the injected script never loads. With a config-only workaround: 1 map per interaction with the right handlers, but no HTTP events, no `traceparent` on the wire, and 2 of 4 maps cut off before the response. | + +The recorder also **changes the app's behavior**: `discussions.test.tsx › should create, render and delete discussions` passes 3/3 without the recorder and fails 3/3 with it. See bug 3. + +## Details per check + +### A. Setup +Commands (run.sh does all of these): +``` +git clone https://github.com/alan2207/bulletproof-react.git && git checkout 9506629ed003a561c6627735480cce4994244bb4 +cd apps/react-vite +sed -i 's#https://registry.npmmirror.com/#https://registry.yarnpkg.com/#' yarn.lock # sandbox egress only, see below +yarn install --frozen-lockfile --ignore-scripts +yarn add -D file:/recorder --ignore-scripts +cp /app-config/*.ts . # vite.config.appmap.ts + appmap.setup.ts (+ diagnostics) +VITE_APP_API_URL=https://api.bulletproofapp.com npx vitest run --config vite.config.appmap.ts +``` +Config used, following the docs: `appmapVitePlugin({ include: ['src'], exclude: ['src/testing'], app: 'bulletproof-react' })`, plus a Vitest setup file calling `registerAppMapHooks({ app })`. Both live in new files, `vite.config.appmap.ts` (which merges into the app's own `vite.config.ts`) and `appmap.setup.ts`. The app's own files are untouched (`evidence/app-tree-changes.txt`: only `package.json`/`yarn.lock` from the install, plus the new config files). + +Why A fails: the plugin had to be imported as `./node_modules/@funwithappmap/react-recorder/src/vitePlugin` (bug 1). The documented package import fails for any consuming app. + +### B. Validity and declared-version honesty +- Official validator, every recording (21 tests, 3 extra, 5 browser maps): **0 valid** at the declared `1.12`, and 0 valid at every version from 1.2.0 to 1.13.1. The validator has no 1.4.1 schema. +- Reasons, in the order they block: + 1. `metadata.frameworks[0].version` is missing, and it is *Required* in the spec and in every schema. The default is `[{ name: 'vitest' }]` (recorder/src/testRecording.ts:35). + 2. Parameter and return values run to 1024 chars, but schemas 1.6 and later cap `value` at 100 (spec: "should be trimmed … to 100 characters"). + 3. `exceptions[]` lacks `object_id`, which every schema 1.2–1.13.1 requires. + 4. Versions 1.2–1.5 also require parameter `object_id` and `receiver`, which the recorder doesn't always write. + 5. From recorder code, confirmed only on the recorder's own example (supplementary): `http_client_request` events have no `message` array, which schemas 1.5 and later require. +- The spec also says `http_client_request.url` should exclude the query string, with params in `message`. The recorder puts the full URL, query included, in `url`. +- Diagnostic only, not a pass (`evidence/b-validate-bestconfig.json`): with every option the recorder exposes (frameworks passed with versions through `registerAppMapHooks`, and `APPMAP_EVENT_VALUESIZE=99`), **all 21 test recordings pass 1.6.0–1.13.1**, semantic checks included (per-thread call/return nesting, classMap consistency). The E recording still fails (no exception `object_id`). `APPMAP_EVENT_VALUESIZE=100` would not be enough, because the recorder appends "…" after cutting. +- **Highest version it actually satisfies: none, as shipped.** With non-default options it satisfies 1.6–1.13.1, but only for recordings with no exceptions and no HTTP events. So the "1.12" label is not honest for default output. + +### C. Ground truth (EXPECTATIONS.md T1–T9) +Found in the recordings, for example: +- `{"method_id":"loginWithEmailAndPassword","path":"src/lib/auth.tsx","lineno":29,"parameters":[{"name":"data","value":"{\"email\":…,\"password\":…}"}]}` +- `getDiscussion` called with `{"discussionId":…}`. +- `onOpenChange` (form-drawer.tsx:42) called with `true`. +- `updateDiscussion` with `{data:{title,body},discussionId}`. +- Labels `component`, `hook` and `event-handler` present as expected. + +Missing: +- **Every HTTP call** (25 items): `GET /auth/me`, `POST /auth/login`, `POST /auth/register`, `GET/PATCH/POST/DELETE /discussions…` and `GET/POST/DELETE /comments…`. So there is no `traceparent` to check either. Cause: the recorder only wraps `globalThis.fetch` (recorder/src/fetchPatch.ts:26), and axios 1.6.8 uses `XMLHttpRequest`. The only trace of HTTP in the maps is the app's axios interceptor `authRequestInterceptor` (src/lib/api-client.ts:7). +- `checkAccess` (authorization.tsx:35) in T3, T4, T5, T7a and T7b, and `toggle`/`open`/`close` (use-disclosure.ts:6-8). The transform only wraps callbacks of a bare `useCallback(...)`. This app writes `React.useCallback(...)` (recorder/src/transform.ts:198-199). + +Wrong: +- T1 `LoginForm` props are recorded as `"value":"{}","size":1`. The one prop (`onSuccess`, a function) is silently dropped from the value by `JSON.stringify`. + +Not predicted in EXPECTATIONS.md but observed: +- Parameter values carry plaintext passwords, e.g. `"password":"secret-pw-1"` in the browser map. +- In the E recording, React's dev-mode component-stack probing calls `Authorization()` and `AppProvider()` with no props. These are recorded as real calls throwing `TypeError: Cannot destructure property … of 'undefined'`. + +Where EXPECTATIONS.md itself was imprecise: +- B1 said the map would be "named after the click on the Register button". It is named `click span "Register"`, after the inner span. The substance holds. +- T3 predicted `checkAccess` returns true. That couldn't be checked, because `checkAccess` is never recorded. + +### D. Noise +1342 call events in run 1. The biggest share is `src/utils` (565, almost all the `cn` class-name helper). No calls come from `node_modules` or the excluded `src/testing`. But test-file code is recorded: `renderDiscussion` 3×, `TestDialog` 3×, `TestDialog`'s inline `onOpenChange` 1× and `TestDrawer` 1×. The plugin's `exclude` is a prefix match (recorder/src/vitePlugin.ts:44-50). With co-located `__tests__/` directories, the only way to keep test code out is to list every test directory by hand. + +### E. Exception (extra test outside the app's source, `app-config/appmap-extra/exception.test.tsx`) +`` with no user: +``` +call 14 useAuthorization → return 15 exceptions:[{"class":"Error","message":"User does not exist!"}] +return 16 Authorization exceptions:[{"class":"Error","message":"User does not exist!"}] +``` +PASS on content. Two caveats: the exception objects are schema-invalid (no `object_id`), and React's fake probe calls show up as extra TypeError exceptions. + +### F. Failing test (copy of head.test.tsx with a wrong assertion, outside `src/`) +The recording is written with `test_status: "failed"` and contains the `Head` call. PASS. `metadata.test_failure` (added in 1.12, optional) is not written. The app's own `discussions.test.tsx` failure, caused by bug 3, is also recorded as `failed`. + +### G. Stability +Two recorded runs of the whole suite, then `appmap sequence-diagram --format json` per test. Compared with `elapsed` and `eventIds` removed: **21 of 21 identical**. The one test that fails under the recorder fails the same way in both runs. + +### H. Change detection +The change, on a scratch branch: `src/features/comments/api/get-comments.ts` stops sending `page` (`app-config/h-change.patch`). The request becomes `GET /comments?discussionId=…` instead of `…&page=1`, and the tests still pass. +- `linker/bin/appmap-trace.mjs --baseline `: **"traced 0 interaction(s)"**, exit 0. It only looks at maps that contain an `http_client_request` event (linker/src/link.mjs:20-22), and no map from this app has one. It gives no warning. +- Official `appmap sequence-diagram` on both sets, then `appmap sequence-diagram-diff`: **"… are identical"** for all 21 tests, including the three that load comments. +- The diff showed nothing, so the change was not detected. The cause is C's missing HTTP events: the only thing that changed is a request URL. + +### I. Concurrency +- **Vitest:** the files run in parallel workers. Each of the 21 tests was also re-run alone (`vitest run -t '^name$'`), and every isolated diagram is identical to the one from the parallel suite. No test-file function shows up in another file's map. No leakage found. +- **Browser** (with the workaround): a click on "Users", then a click on "Dashboard" 20 ms later, produced one map, `click a "Users"`. It contains `getUsers` (Users loader) **and** `DashboardRoute` (the second click's work). Doc 04 says this merging is by design, but it means one map holds two interactions under the first one's name. + +### J. Overhead +| run | wall s | +|---|---| +| plain-1 / plain-2 | 12.05 / 11.19 | +| recorded-1 / recorded-2 | 12.42 / 12.24 | + +That is +7% median. The earlier full run (run-a) measured 12.8 s vs 14.1 s, +11%. + +### Browser interaction recording (real Chromium, all on localhost) +Setup: the app's own mock server (`mock-server.ts`, express + the app's MSW handlers) on :8080; `vite --config vite.config.appmap.ts` on :3000 with the plugin's `app` option; Playwright headless Chromium. Non-localhost requests were aborted (only the app's `rsms.me` web font). + +1. **As documented (zero-touch): FAIL, 0 maps.** The served HTML contains ``, and Chromium refuses it: *"Access to script at 'virtual:appmap-interaction-recorder' … blocked by CORS policy: Cross origin requests are only supported for protocol schemes …"*. The recorder's own example app (Vite 6.4.3) fails the same way in Chromium, so this is not specific to Vite 5 (bug 2). +2. **With a config-only workaround** (`app-config/vite.config.appmap-browserfix.ts` rewrites the specifier to `/@id/virtual:…`): 5 interactions produced 5 maps. + +| Step | Map | Recorded | Missing | +|---|---|---|---| +| B1 register | `click span "Register"` | `onSubmit` (register-form.tsx:30), `registerWithEmailAndPassword` | `POST /api/auth/register`; the `onSuccess` navigation (register.tsx:24). `truncated: true`: the window closed before the response. | +| B2 list page | `click a "Discussions"` | `DiscussionsRoute`, `DiscussionsList`, `getDiscussions(1)` | `GET /api/discussions?page=1` | +| B3 open drawer | `click span "Create Discussion"` | `onOpenChange(true)`, i.e. everything expected | — | +| B4 create item | `click span "Submit"` | `onSubmit`, `createDiscussion({data:{title,body}})` | `POST` and the refetch `GET`. `truncated: true`. | +| B5 two clicks | one merged map | see I | — | + +Wire capture: 6 requests to :8080, and **0 carry `traceparent`**. So the full-stack linking the recorder is built for cannot work on an axios app. The truncation follows from the missing HTTP events: the idle check only waits for *recorded* pending requests (recorder/src/interactionRecording.ts:67). With XHR invisible, the window closes 250–500 ms after the click, before the mock server replies (300–1000 ms), and the response handling is recorded nowhere. + +## Recorder bugs found + +Each has a symptom, a location and a repro. + +1. **The package entry points can't be used by a consuming app.** + - Where: recorder/package.json:9-13 and :25. + - Symptom: `exports` point at `.ts` source. Node refuses to type-strip under `node_modules`, so `import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite'` in an app's `vite.config.ts` throws `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. + - Packing makes it worse: `npm pack` ships only `package.json` + `src/index.ts`, because `files: ["dist"]` and no dist exists. After `npm run build`, dist uses extensionless relative imports (`from './recording'`) that Node ESM can't resolve, and `publishConfig.exports` isn't applied by npm. + - Repro: `run.sh` step A, or `vitest run --config vite.config.appmap-pkgimport.ts` in the app. +2. **Zero-touch interaction recording never starts in a browser.** + - Where: recorder/src/vitePlugin.ts:129-139. + - Symptom: `transformIndexHtml` is a plain function, so Vite runs it *after* its dev-HTML import rewriting (Vite `resolveHtmlTransforms` puts function hooks in `normalHooks`). The injected `import "virtual:appmap-interaction-recorder"` reaches the browser as a bare `virtual:` URL and is blocked. It affects Vite 5.2.11 (this app) and Vite 6.4.3 (the recorder's own example). Doc 07's "verified against a real dev server" checked the HTML text and the `/@id/` URL separately, never in a browser. + - Repro: browser pass 1 in run.sh (`evidence/browser-zerotouch.json`, console error). +3. **Recording changes app behavior (observer effect).** + - Where: recorder/src/recording.ts:49 (`JSON.stringify(v)`), called on every return value from recorder/src/instrument.ts:47 via recording.ts:230. + - Symptom: serializing a hook's return value reads every getter on React Query's *tracked* result object, which subscribes the component to every field. Components then re-render on `isFetching` changes they would otherwise ignore. In this app, `discussions.test.tsx` goes from 3/3 pass to 3/3 fail (deterministic). + - Not verified step by step: the most likely chain is that the extra `DiscussionsList` re-render remounts its inline `Cell` components (new component types each render). That closes the delete-confirmation dialog, and with it the aria-hidden that made the test's final assertion pass. + - Minimal repro: `app-config/appmap-extra/observer-effect.test.tsx`. Under an active recording the wrapped hook re-renders once after `invalidateQueries`; the unwrapped one re-renders 0 times (`expected { recorded: 1 } to deeply equal { recorded: +0 }`). The same getter-reading applies to react-hook-form's `formState` proxy and anything else with side-effecting getters. +4. **HTTP made through XMLHttpRequest (axios, and any XHR-based client) is invisible.** + - Where: recorder/src/fetchPatch.ts:26 patches only `fetch`. + - Effects: no `http_client_request` events and no `traceparent` stamping, in tests or in the browser. Interaction windows close before responses (interactionRecording.ts:67). `appmap-trace` silently traces 0 interactions (linker/src/link.mjs:20-22). + - Repro: run.sh check C, H and the browser pass. +5. **`React.useCallback` / `React.useMemo` callbacks are not instrumented.** + - Where: recorder/src/transform.ts:198-199 accepts only a bare `useCallback`/`useMemo` identifier. + - Repro: C items T7a/T8a (`toggle` and `checkAccess` never appear). +6. **Output is not valid 1.12 (or any version).** + - `frameworks` entries lack `version` (recorder/src/testRecording.ts:35). + - The value cap is 1024, and the spec/validator cap is 100 (recorder/src/recording.ts:16). The cap is also exceeded by one character, because "…" is appended after cutting (recording.ts:53). + - Exceptions lack `object_id` (recording.ts:215). + - `http_client_request` has no `message` (recording.ts:242). + - Repro: `node scripts/validate-all.cjs `, `evidence/b-validate*.json`. +7. **Thread nesting breaks around async children.** + - Supplementary, seen only on the recorder's own example app (examples/petclinic-react), not on bulletproof-react. + - Symptom: `onSubmit` (id 51) returns synchronously while the async `search → findOwners → request → fetch` it started (52-55) are still open on the same `thread_id` 1. After adding the missing `message`, the official validator reports `expected parent id of return event #56 to be 55 but got 51`. + - Where: thread allocation in recorder/src/recording.ts:122-166 (allocateThread / leaveSyncFrame). + - Repro: `npx vitest run test/owners.test.tsx` in examples/petclinic-react, then validate `OwnersSearch_finds_owners_by_last_name.appmap.json` with frameworks versions and values patched. +8. **Test-file code is recorded, and `exclude` can't prevent it** (recorder/src/vitePlugin.ts:44-50, prefix match only). See D. +9. **Function-valued props vanish from values:** `{onSuccess: fn}` is recorded as `"{}"` (recording.ts:49). Also, React dev-mode stack probes are recorded as real calls throwing TypeErrors (E recording, events 21-24). + +## App changes needed +- **App source / test changes: none.** +- Config (new files, the documented kind): `vite.config.appmap.ts` (plugin entry, merged into the app's own config) and `appmap.setup.ts` (the `registerAppMapHooks` setup file). +- Config workarounds forced by recorder bugs. These count against the recorder: + 1. Importing the plugin by relative path into `node_modules` (bug 1). + 2. `vite.config.appmap-browserfix.ts`, needed for any browser recording at all (bug 2). +- Environment only, not an app or recorder issue: the app's `yarn.lock` resolves one lint plugin (`eslint-plugin-check-file`) from `registry.npmmirror.com`, which this sandbox's egress policy blocks. run.sh rewrites that one URL to the default registry; the integrity hash is unchanged. `VITE_APP_*` env vars are passed on the command line instead of creating `.env`. + +## What could not be run, or was run differently +- The Playwright version installed by the app (1.59.1) expects Chromium r1217, which is not installed. Following instructions, `playwright install` was not run, and Chromium r1194 from `/opt/pw-browsers` was used via `executablePath`. It worked for everything above. +- No official "validate" command exists in `@appland/appmap` 3.204.0. The official validator package from getappmap/appmap-js (`@appland/appmap-validate`, which the spec README links) was used instead, along with the CLI's `sequence-diagram` and `sequence-diagram-diff`. +- Whether HTTP events carry `traceparent` and are schema-valid could not be tested on this app, because it produces none (bug 4). The HTTP event shape was checked only on the recorder's own example, and is reported as supplementary. +- `run.sh` pins the CLI to 3.204.0 for reproducibility. `APPMAP_CLI_VERSION=latest` overrides it. + +> Evidence (recordings, logs, reports) is regenerated by every run of `run.sh` and uploaded by CI as the job artifact; it is no longer committed. diff --git a/acceptance/bulletproof-react/app-config/appmap-extra/exception.test.tsx b/acceptance/bulletproof-react/app-config/appmap-extra/exception.test.tsx new file mode 100644 index 0000000..43d1ef5 --- /dev/null +++ b/acceptance/bulletproof-react/app-config/appmap-extra/exception.test.tsx @@ -0,0 +1,21 @@ +// Check E: a real app code path that throws. +// with no logged-in user makes useAuthorization() throw +// Error('User does not exist!') (src/lib/authorization.tsx:31-33); the app's +// ErrorBoundary (AppProvider -> MainErrorFallback) catches it. +import { AppProvider } from '../src/app/provider'; +import { Authorization, ROLES } from '../src/lib/authorization'; +import { rtlRender, screen } from '../src/testing/test-utils'; + +test('E: Authorization without a user throws in useAuthorization', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + rtlRender( + + secret + , + ); + expect( + await screen.findByText(/something went wrong/i, {}, { timeout: 4000 }), + ).toBeInTheDocument(); + expect(screen.queryByText('secret')).not.toBeInTheDocument(); + spy.mockRestore(); +}); diff --git a/acceptance/bulletproof-react/app-config/appmap-extra/failing.test.tsx b/acceptance/bulletproof-react/app-config/appmap-extra/failing.test.tsx new file mode 100644 index 0000000..a0e85ae --- /dev/null +++ b/acceptance/bulletproof-react/app-config/appmap-extra/failing.test.tsx @@ -0,0 +1,11 @@ +// Check F: a copy of src/components/seo/__tests__/head.test.tsx whose +// assertion is deliberately wrong, so the test fails. The recording must +// still be written, marked failed. +import { Head } from '../src/components/seo/head'; +import { render, waitFor } from '../src/testing/test-utils'; + +test('F: deliberately failing copy of the Head title test', async () => { + render(); + await waitFor(() => expect(document.title).toEqual('Hello World | Bulletproof React')); + expect(document.title).toEqual('this is not the title'); +}); diff --git a/acceptance/bulletproof-react/app-config/appmap-extra/observer-effect.test.tsx b/acceptance/bulletproof-react/app-config/appmap-extra/observer-effect.test.tsx new file mode 100644 index 0000000..a4b52ec --- /dev/null +++ b/acceptance/bulletproof-react/app-config/appmap-extra/observer-effect.test.tsx @@ -0,0 +1,52 @@ +// Repro for the recorder side effect found on discussions.test.tsx: the +// recorder captures a hook's return value with JSON.stringify +// (recorder/src/recording.ts formatValue), which reads every getter on +// React Query's *tracked* result object and so subscribes the component to +// every field (isFetching, dataUpdatedAt, ...). A component that only reads +// `data` then re-renders on refetch start, which it does not do unrecorded. +// +// Same hook, wrapped exactly the way the build-time transform wraps a +// top-level `use*` function (autoInstrument), vs. unwrapped. While a +// recording is active (registerAppMapHooks), render counts must be equal. +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; +import { act, render, waitFor } from '@testing-library/react'; +import * as React from 'react'; + +import { autoInstrument } from '@funwithappmap/react-recorder'; + +const useThingPlain = () => + useQuery({ queryKey: ['thing'], queryFn: () => new Promise((r) => setTimeout(() => r(1), 30)) }); +const useThingRecorded = autoInstrument(useThingPlain, { + definedClass: 'observer-effect', + methodId: 'useThingRecorded', + path: 'appmap-extra/observer-effect.test.tsx', + lineno: 1, +}, []); + +async function countRenders(useThing: () => { data?: number }) { + const client = new QueryClient(); + let renders = 0; + const Probe = () => { + renders++; + const q = useThing(); + return {q.data ?? 'none'}; + }; + const view = render( + + + , + ); + await waitFor(() => expect(view.container.textContent).toBe('1')); + const before = renders; + await act(async () => { + await client.invalidateQueries({ queryKey: ['thing'] }); + }); + view.unmount(); + return renders - before; +} + +test('recorder must not change how often a component re-renders', async () => { + const plain = await countRenders(useThingPlain); + const recorded = await countRenders(useThingRecorded); + expect({ recorded }).toEqual({ recorded: plain }); +}); diff --git a/acceptance/bulletproof-react/app-config/appmap.setup.bestconfig.ts b/acceptance/bulletproof-react/app-config/appmap.setup.bestconfig.ts new file mode 100644 index 0000000..d41120a --- /dev/null +++ b/acceptance/bulletproof-react/app-config/appmap.setup.bestconfig.ts @@ -0,0 +1,10 @@ +// DIAGNOSTIC ONLY (check B): the recorder's own options set as well as they +// can be — frameworks with a version (TestRecordingOptions.frameworks), used +// with APPMAP_EVENT_VALUESIZE=99 — to see which spec version the output can +// reach through configuration alone. Not used for any other check. +import { registerAppMapHooks } from '@funwithappmap/react-recorder/vitest'; + +registerAppMapHooks({ + app: 'bulletproof-react', + frameworks: [{ name: 'vitest', version: '2.1.4' }, { name: 'react', version: '18.3.1' }], +}); diff --git a/acceptance/bulletproof-react/app-config/appmap.setup.ts b/acceptance/bulletproof-react/app-config/appmap.setup.ts new file mode 100644 index 0000000..a2b58db --- /dev/null +++ b/acceptance/bulletproof-react/app-config/appmap.setup.ts @@ -0,0 +1,6 @@ +// AppMap acceptance: per-test recording hooks (the recorder's documented +// Vitest entry point). Added as an extra setupFiles entry by +// vite.config.appmap.ts; the app's own setup file is untouched. +import { registerAppMapHooks } from '@funwithappmap/react-recorder/vitest'; + +registerAppMapHooks({ app: 'bulletproof-react' }); diff --git a/acceptance/bulletproof-react/app-config/h-change.patch b/acceptance/bulletproof-react/app-config/h-change.patch new file mode 100644 index 0000000..7446f9c --- /dev/null +++ b/acceptance/bulletproof-react/app-config/h-change.patch @@ -0,0 +1,12 @@ +diff --git a/apps/react-vite/src/features/comments/api/get-comments.ts b/apps/react-vite/src/features/comments/api/get-comments.ts +index 84c07ee..4dfbff3 100644 +--- a/apps/react-vite/src/features/comments/api/get-comments.ts ++++ b/apps/react-vite/src/features/comments/api/get-comments.ts +@@ -14,7 +14,6 @@ export const getComments = ({ + return api.get(`/comments`, { + params: { + discussionId, +- page, + }, + }); + }; diff --git a/acceptance/bulletproof-react/app-config/vite.config.appmap-bestconfig.ts b/acceptance/bulletproof-react/app-config/vite.config.appmap-bestconfig.ts new file mode 100644 index 0000000..c67df25 --- /dev/null +++ b/acceptance/bulletproof-react/app-config/vite.config.appmap-bestconfig.ts @@ -0,0 +1,20 @@ +// DIAGNOSTIC ONLY (check B): as vite.config.appmap.ts, with the setup file +// that passes frameworks versions. Run with APPMAP_EVENT_VALUESIZE=99. +import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite'; +import { mergeConfig } from 'vite'; + +import base from './vite.config'; + +const apiOrigin = process.env.VITE_APP_API_URL ? new URL(process.env.VITE_APP_API_URL).origin : undefined; + +export default mergeConfig(base, { + plugins: [ + appmapVitePlugin({ + include: ['src'], + exclude: ['src/testing'], + app: 'bulletproof-react', + propagateTraceHeaderOrigins: apiOrigin ? [apiOrigin] : [], + }), + ], + test: { setupFiles: ['./appmap.setup.bestconfig.ts'] }, +}); diff --git a/acceptance/bulletproof-react/app-config/vite.config.appmap-browserfix.ts b/acceptance/bulletproof-react/app-config/vite.config.appmap-browserfix.ts new file mode 100644 index 0000000..2a60ecf --- /dev/null +++ b/acceptance/bulletproof-react/app-config/vite.config.appmap-browserfix.ts @@ -0,0 +1,25 @@ +// WORKAROUND for recorder bug 2 (see RESULTS.md), used only for the second +// browser pass so the interaction recorder itself can be evaluated. +// The plugin's transformIndexHtml injects +// +// as a plain-function hook, which Vite runs AFTER its own dev-HTML import +// rewriting, so the browser receives the bare `virtual:` specifier and +// refuses it (CORS: unsupported scheme). This post-order hook rewrites the +// specifier to Vite's /@id/ URL. Config only; no app or recorder change. +import { mergeConfig, type Plugin } from 'vite'; + +import appmapConfig from './vite.config.appmap'; + +const virtualIdFix: Plugin = { + name: 'acceptance-appmap-virtual-id-fix', + transformIndexHtml: { + order: 'post', + handler: (html) => + html.replace( + 'import "virtual:appmap-interaction-recorder"', + 'import "/@id/virtual:appmap-interaction-recorder"', + ), + }, +}; + +export default mergeConfig(appmapConfig, { plugins: [virtualIdFix] }); diff --git a/acceptance/bulletproof-react/app-config/vite.config.appmap-extra.ts b/acceptance/bulletproof-react/app-config/vite.config.appmap-extra.ts new file mode 100644 index 0000000..6e707cf --- /dev/null +++ b/acceptance/bulletproof-react/app-config/vite.config.appmap-extra.ts @@ -0,0 +1,13 @@ +// Runs only the acceptance's own extra test files (appmap-extra/), with the +// same recorder config and the app's own setup file (MSW server etc.). +// These files are copies/new files outside the app's src tree; no app test +// is edited. +import { mergeConfig } from 'vite'; + +import appmapConfig from './vite.config.appmap'; + +export default mergeConfig(appmapConfig, { + test: { + include: ['appmap-extra/**/*.test.{ts,tsx}'], + }, +}); diff --git a/acceptance/bulletproof-react/app-config/vite.config.appmap-pkgimport.ts b/acceptance/bulletproof-react/app-config/vite.config.appmap-pkgimport.ts new file mode 100644 index 0000000..ae9b730 --- /dev/null +++ b/acceptance/bulletproof-react/app-config/vite.config.appmap-pkgimport.ts @@ -0,0 +1,10 @@ +// Check A probe: the recorder's documented way to add the plugin, importing +// it by its package entry point. Expected to load; see RESULTS.md bug 1. +import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite'; +import { mergeConfig } from 'vite'; + +import base from './vite.config'; + +export default mergeConfig(base, { + plugins: [appmapVitePlugin({ include: ['src'], exclude: ['src/testing'], app: 'bulletproof-react' })], +}); diff --git a/acceptance/bulletproof-react/app-config/vite.config.appmap.ts b/acceptance/bulletproof-react/app-config/vite.config.appmap.ts new file mode 100644 index 0000000..4c56a6f --- /dev/null +++ b/acceptance/bulletproof-react/app-config/vite.config.appmap.ts @@ -0,0 +1,35 @@ +// AppMap acceptance config: the app's own vite.config.ts plus the recorder. +// Config only; no app source is touched. +// +// The plugin is imported by its documented package entry point. (Until the +// recorder was built to dist/, that import could not load and this file +// imported './node_modules/@funwithappmap/react-recorder/src/vitePlugin' +// instead; RESULTS.md, bug 1.) +// +// propagateTraceHeaderOrigins: the app calls its API on another origin +// (VITE_APP_API_URL: https://api.bulletproofapp.com under Vitest/MSW, +// http://localhost:8080 in the browser runs). The recorder stamps +// traceparent on cross-origin requests only for listed origins, so linking +// to this backend takes this one setting (and a backend that allows the +// header: the app's mock server reflects any requested header). Without +// it, every request is still recorded, just not stamped. +import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite'; +import { mergeConfig } from 'vite'; + +import base from './vite.config'; + +const apiOrigin = process.env.VITE_APP_API_URL ? new URL(process.env.VITE_APP_API_URL).origin : undefined; + +export default mergeConfig(base, { + plugins: [ + appmapVitePlugin({ + include: ['src'], + exclude: ['src/testing'], + app: 'bulletproof-react', + propagateTraceHeaderOrigins: apiOrigin ? [apiOrigin] : [], + }), + ], + test: { + setupFiles: ['./appmap.setup.ts'], + }, +}); diff --git a/acceptance/bulletproof-react/run.sh b/acceptance/bulletproof-react/run.sh new file mode 100755 index 0000000..ce139fd --- /dev/null +++ b/acceptance/bulletproof-react/run.sh @@ -0,0 +1,372 @@ +#!/usr/bin/env bash +# Acceptance run: AppMap React recorder on alan2207/bulletproof-react +# (apps/react-vite), from a clean clone at a pinned SHA to every check A-J +# plus the real-browser interaction checks. +# +# usage: acceptance/bulletproof-react/run.sh [WORK_DIR] +# env: CHROME= (default /opt/pw-browsers/chromium-1194/...) +# APPMAP_CLI_VERSION= (default 3.204.0, latest at time of writing) +# VALIDATE_VERSION= (default 2.5.1) +# Exits non-zero if any check FAILs. Evidence lands in WORK_DIR/out. +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +RECORDER_REPO=$(cd "$HERE/../.." && pwd) +WORK=${1:-${WORK:-$(mktemp -d)}} +mkdir -p "$WORK" +WORK=$(cd "$WORK" && pwd) +OUT=$WORK/out +APP_REPO=https://github.com/alan2207/bulletproof-react.git +APP_SHA=9506629ed003a561c6627735480cce4994244bb4 +CHROME=${CHROME:-/opt/pw-browsers/chromium-1194/chrome-linux/chrome} +APPMAP_CLI_VERSION=${APPMAP_CLI_VERSION:-3.204.0} +VALIDATE_VERSION=${VALIDATE_VERSION:-2.5.1} +PY="python3 $HERE/scripts/analyze.py" +export APPMAP_TELEMETRY_DISABLED=1 +# Vitest runs: the API base URL from the app's own .env.example; MSW answers +# in-process, nothing leaves the machine. +TEST_ENV=(VITE_APP_API_URL=https://api.bulletproofapp.com VITE_APP_ENABLE_API_MOCKING=true) +# Browser runs: the app's own mock server on localhost:8080. +BROWSER_ENV=(VITE_APP_API_URL=http://localhost:8080/api VITE_APP_ENABLE_API_MOCKING=false + VITE_APP_MOCK_API_PORT=8080 VITE_APP_APP_URL=http://localhost:3000) + +rm -rf "$OUT" && mkdir -p "$OUT" +declare -A VERDICT +log() { printf '\n=== %s\n' "$*" | tee -a "$OUT/run.log"; } +verdict() { VERDICT[$1]=$2; echo "$1: $2" | tee -a "$OUT/run.log"; } +now() { date +%s.%N; } +PIDS=() +# shellcheck disable=SC2329 # invoked by the EXIT trap below +cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill -- -"$p" 2>/dev/null; done; } +trap cleanup EXIT + +# ------------------------------------------------------------------ A -- +log "A. setup: clone app at $APP_SHA, install, add recorder" +setup_ok=1 +if [ ! -d "$WORK/bulletproof-react/.git" ]; then + git clone -q "$APP_REPO" "$WORK/bulletproof-react" || setup_ok=0 +fi +git -C "$WORK/bulletproof-react" checkout -q --force "$APP_SHA" || setup_ok=0 +git -C "$WORK/bulletproof-react" clean -qfdx apps/react-vite -e node_modules +APP=$WORK/bulletproof-react/apps/react-vite +echo "app HEAD: $(git -C "$WORK/bulletproof-react" rev-parse HEAD)" | tee -a "$OUT/run.log" +# Environment workaround (not an app change): yarn.lock pins one tarball on +# registry.npmmirror.com, which this sandbox's egress policy blocks. Same +# tarball, same integrity hash, from the default registry. +sed -i 's#https://registry.npmmirror.com/#https://registry.yarnpkg.com/#' "$APP/yarn.lock" +( cd "$APP" && yarn install --frozen-lockfile --ignore-scripts --network-concurrency 8 >"$OUT/yarn-install.log" 2>&1 ) || setup_ok=0 +# Install the recorder as a dev dependency straight from this checkout. Its +# entry points are the built dist/ (README: build first when installing from +# a checkout). +( cd "$RECORDER_REPO" && npm run build --workspace recorder >"$OUT/recorder-build.log" 2>&1 ) || setup_ok=0 +( cd "$APP" && yarn add -D "file:$RECORDER_REPO/recorder" --ignore-scripts >"$OUT/yarn-add-recorder.log" 2>&1 ) || setup_ok=0 +# Config files only; the extra test dir is copied in just for its own run so +# the app's default test glob never picks it up. +cp "$HERE"/app-config/*.ts "$APP/" +mkdir -p "$WORK/tools" +( cd "$WORK/tools" && [ -f package.json ] || npm init -y >/dev/null ) +( cd "$WORK/tools" && npm install --no-audit --no-fund "@appland/appmap@$APPMAP_CLI_VERSION" "@appland/appmap-validate@$VALIDATE_VERSION" >"$OUT/tools-install.log" 2>&1 ) || setup_ok=0 +APPMAP="$WORK/tools/node_modules/.bin/appmap" +VALIDATOR_DIR="$WORK/tools/node_modules/@appland/appmap-validate" +echo "appmap CLI $($APPMAP --version 2>/dev/null), validator $VALIDATE_VERSION" | tee -a "$OUT/run.log" +git -C "$WORK/bulletproof-react" status --short -- apps/react-vite > "$OUT/app-tree-changes.txt" +# Probe: the documented way to load the plugin (package entry point). +( cd "$APP" && env "${TEST_ENV[@]}" npx vitest run --config vite.config.appmap-pkgimport.ts src/hooks >"$OUT/a-pkgimport.log" 2>&1 ) +pkgimport_rc=$? +grep -o 'ERR_[A-Z_]*' "$OUT/a-pkgimport.log" | head -1 | sed 's/^/documented package import: /' | tee -a "$OUT/run.log" +if [ $setup_ok = 1 ] && [ $pkgimport_rc = 0 ]; then verdict A PASS; else verdict A FAIL; fi + +run_suite() { # $1 = config ("" for the app's own), $2 = dest dir, $3 = timing label + rm -rf "$APP/tmp/appmap/tests" + local cfg=() t0 t1 + [ -n "$1" ] && cfg=(--config "$1") + t0=$(now) + ( cd "$APP" && env "${TEST_ENV[@]}" npx vitest run "${cfg[@]}" >"$OUT/$3.log" 2>&1 ) + local rc=$? + t1=$(now) + echo "$3 $(echo "$t1 - $t0" | bc) rc=$rc" >> "$OUT/timings.txt" + if [ -n "$2" ]; then rm -rf "$2"; mkdir -p "$2"; cp -r "$APP/tmp/appmap/tests/." "$2/" 2>/dev/null; fi + grep -E '^ +(Tests|Test Files) ' "$OUT/$3.log" | sed "s/^/$3: /" | tee -a "$OUT/run.log" +} +seq_json() { # $1 = appmap dir, $2 = output dir + rm -rf "$2"; mkdir -p "$2" + ( cd "$1" && "$APPMAP" sequence-diagram --format json --output-dir "$2" ./*.appmap.json >"$2.log" 2>&1 ) +} + +# ------------------------------------------------------------ J (base) - +log "J. suite without the recorder (x2)" +run_suite "" "" plain-1 +run_suite "" "" plain-2 + +# ------------------------------------------------------- record run 1/2 - +log "Recorded suite, run 1 and run 2" +run_suite vite.config.appmap.ts "$OUT/run1" recorded-1 +run_suite vite.config.appmap.ts "$OUT/run2" recorded-2 +log "Extra tests (E exception, F failing, recorder side-effect repro)" +rm -rf "$APP/tmp/appmap/tests" +cp -r "$HERE/app-config/appmap-extra" "$APP/" +( cd "$APP" && env "${TEST_ENV[@]}" npx vitest run --config vite.config.appmap-extra.ts >"$OUT/extra.log" 2>&1 ) +rm -rf "$APP/appmap-extra" +mkdir -p "$OUT/extra" && cp -r "$APP/tmp/appmap/tests/." "$OUT/extra/" +grep -E '✓|×' "$OUT/extra.log" | tee -a "$OUT/run.log" + +# ------------------------------------------------------------ browser -- +start_bg() { # $1 = name, rest = command (run in app dir, own process group) + local name=$1; shift + # setsid may fork, so let the new session leader write its own pid. + # shellcheck disable=SC2016 # $$/$0/$@ expand in the inner shell + ( cd "$APP" && setsid bash -c 'echo $$ > "$0"; exec env "$@"' "$OUT/$name.pid" "$@" >"$OUT/$name.log" 2>&1 & ) + for _ in $(seq 1 50); do [ -s "$OUT/$name.pid" ] && break; sleep 0.1; done + PIDS+=("$(cat "$OUT/$name.pid")") +} +wait_url() { for _ in $(seq 1 60); do curl -sf -o /dev/null "$1" && return 0; sleep 1; done; return 1; } +stop_bg() { local p; p=$(cat "$OUT/$1.pid"); kill -- -"$p" 2>/dev/null; sleep 1; kill -9 -- -"$p" 2>/dev/null; } +wait_port_free() { for _ in $(seq 1 30); do curl -s -o /dev/null "$1" || return 0; sleep 1; done; echo "port still busy: $1" | tee -a "$OUT/run.log"; } +browser_pass() { # $1 = label, $2 = vite config + rm -f "$APP/mocked-db.json"; rm -rf "$APP/tmp/appmap/interactions" + wait_port_free http://localhost:3000/; wait_port_free http://localhost:8080/api/healthcheck + start_bg "mock-$1" "${BROWSER_ENV[@]}" npx vite-node mock-server.ts + start_bg "vite-$1" "${BROWSER_ENV[@]}" npx vite --config "$2" --port 3000 --strictPort + # shellcheck disable=SC2015 # report if either server did not start + wait_url http://localhost:8080/api/healthcheck && wait_url http://localhost:3000/ || echo "servers did not start" | tee -a "$OUT/run.log" + curl -s http://localhost:3000/ | grep -o ''); + res.setHeader('content-type', 'text/html'); + res.end(await server.transformIndexHtml(url, html)); + }); + }, + }; +} + +export const craBase = { + define: craEnv(), + server: { port: Number(process.env.APP_PORT ?? 3300), strictPort: true, host: '127.0.0.1' }, + optimizeDeps: { entries: ['src/index.js'], esbuildOptions: { loader: { '.js': 'jsx' } } }, +}; diff --git a/acceptance/supabase-edge-functions-app/app-config/h-change.patch b/acceptance/supabase-edge-functions-app/app-config/h-change.patch new file mode 100644 index 0000000..a57cf39 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/app-config/h-change.patch @@ -0,0 +1,13 @@ +diff --git a/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts b/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts +index 04fa80d..08ebd2b 100644 +--- a/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts ++++ b/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts +@@ -38,7 +38,7 @@ Deno.serve(async (req: Request) => { + } = await supabaseClient.auth.getUser(token) + + // And we can run queries in the context of our authenticated user +- const { data, error } = await supabaseClient.from('users').select('*') ++ const { data, error } = await supabaseClient.from('users').select('id') + if (error) throw error + + return new Response(JSON.stringify({ user, data }), { diff --git a/acceptance/supabase-edge-functions-app/app-config/p-cors-traceparent.patch b/acceptance/supabase-edge-functions-app/app-config/p-cors-traceparent.patch new file mode 100644 index 0000000..07fa3b4 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/app-config/p-cors-traceparent.patch @@ -0,0 +1,10 @@ +diff --git a/examples/edge-functions/supabase/functions/_shared/cors.ts b/examples/edge-functions/supabase/functions/_shared/cors.ts +index 2ac4d89..8dcb63c 100644 +--- a/examples/edge-functions/supabase/functions/_shared/cors.ts ++++ b/examples/edge-functions/supabase/functions/_shared/cors.ts +@@ -1,4 +1,4 @@ + export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', +- 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', ++ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, traceparent', + } diff --git a/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap-link.mjs b/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap-link.mjs new file mode 100644 index 0000000..9b13f47 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap-link.mjs @@ -0,0 +1,25 @@ +// Linking pass: the zero-touch config plus the one setting the recorder +// needs to link a cross-origin backend. The app (127.0.0.1:3300) calls its +// edge function on another origin (localhost:54321); the recorder stamps +// traceparent on cross-origin requests only for origins listed in +// propagateTraceHeaderOrigins (OpenTelemetry's propagateTraceHeaderCorsUrls +// model), because a header the backend's CORS does not allow makes the +// browser block the request. Linking also needs the backend to allow the +// header; at the pinned SHA this app's function does not (_shared/cors.ts), +// which is what pass P patches (an app change, diagnostic only). +import { defineConfig } from 'vite'; +import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite'; +import { craBase, craIndexHtml, craJsx } from './cra-compat.mjs'; + +export default defineConfig({ + ...craBase, + plugins: [ + craIndexHtml(), + appmapVitePlugin({ + include: ['src'], + app: 'supabase-edge-functions-app', + propagateTraceHeaderOrigins: ['http://localhost:54321'], + }), + craJsx(), + ], +}); diff --git a/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap-workaround.mjs b/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap-workaround.mjs new file mode 100644 index 0000000..1f1fb6c --- /dev/null +++ b/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap-workaround.mjs @@ -0,0 +1,28 @@ +// Workaround pass (config only), used only so the rest of the pipeline can +// be evaluated if the zero-touch pass fails: +// 1. compile JSX-in-.js BEFORE the recorder's 'pre' transform, whose Babel +// parser only enables JSX for .jsx/.tsx files; +// 2. rewrite the injected bare `virtual:` import to Vite's /@id/ URL +// (acceptance/bulletproof-react RESULTS.md, bug 2). +import { defineConfig } from 'vite'; +import { appmapVitePlugin } from './node_modules/@funwithappmap/react-recorder/src/vitePlugin.ts'; +import { craBase, craIndexHtml, craJsx } from './cra-compat.mjs'; + +const virtualIdFix = { + name: 'acceptance-appmap-virtual-id-fix', + transformIndexHtml: { + order: 'post', + handler: (html) => + html.replace('import "virtual:appmap-interaction-recorder"', 'import "/@id/virtual:appmap-interaction-recorder"'), + }, +}; + +export default defineConfig({ + ...craBase, + plugins: [ + craIndexHtml(), + craJsx({ pre: true }), + appmapVitePlugin({ include: ['src'], app: 'supabase-edge-functions-app' }), + virtualIdFix, + ], +}); diff --git a/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap.mjs b/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap.mjs new file mode 100644 index 0000000..8563695 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/app-config/vite.config.appmap.mjs @@ -0,0 +1,14 @@ +// Zero-touch pass: the recorder's Vite plugin exactly as documented +// (include src, app name for interaction recording), added to the baseline. +// Imported by its documented package entry point. (Until the recorder was +// built to dist/, that import could not load and this file imported +// './node_modules/@funwithappmap/react-recorder/src/vitePlugin.ts' instead; +// acceptance/bulletproof-react RESULTS.md, bug 1.) +import { defineConfig } from 'vite'; +import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite'; +import { craBase, craIndexHtml, craJsx } from './cra-compat.mjs'; + +export default defineConfig({ + ...craBase, + plugins: [craIndexHtml(), appmapVitePlugin({ include: ['src'], app: 'supabase-edge-functions-app' }), craJsx()], +}); diff --git a/acceptance/supabase-edge-functions-app/app-config/vite.config.cra.mjs b/acceptance/supabase-edge-functions-app/app-config/vite.config.cra.mjs new file mode 100644 index 0000000..8615f50 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/app-config/vite.config.cra.mjs @@ -0,0 +1,6 @@ +// Baseline: the app under Vite with NO recorder (for J, overhead, and to +// show the app itself works in this harness). +import { defineConfig } from 'vite'; +import { craBase, craIndexHtml, craJsx } from './cra-compat.mjs'; + +export default defineConfig({ ...craBase, plugins: [craIndexHtml(), craJsx()] }); diff --git a/acceptance/supabase-edge-functions-app/deno.lock b/acceptance/supabase-edge-functions-app/deno.lock new file mode 100644 index 0000000..661d5b2 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/deno.lock @@ -0,0 +1,66 @@ +{ + "version": "5", + "specifiers": { + "jsr:@supabase/supabase-js@2": "2.117.1", + "npm:@supabase/auth-js@2.117.1": "2.117.1", + "npm:@supabase/functions-js@2.117.1": "2.117.1", + "npm:@supabase/postgrest-js@2.117.1": "2.117.1", + "npm:@supabase/realtime-js@2.117.1": "2.117.1", + "npm:@supabase/storage-js@2.117.1": "2.117.1" + }, + "jsr": { + "@supabase/supabase-js@2.117.1": { + "integrity": "79c8b2cffe9805b11e0b89d7b0754df23b606b69707cb67f8422548eaed3017a", + "dependencies": [ + "npm:@supabase/auth-js", + "npm:@supabase/functions-js", + "npm:@supabase/postgrest-js", + "npm:@supabase/realtime-js", + "npm:@supabase/storage-js" + ] + } + }, + "npm": { + "@supabase/auth-js@2.117.1": { + "integrity": "sha512-3vJpc5thxaovbZ2zrWagzp/ZtCcXpQk1xftinzneNB1XLdhAmjqChOnj2Y8wpriYEFVvlqC+sFbR0JBUi83hDA==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/functions-js@2.117.1": { + "integrity": "sha512-yyMIbEPMDXmjpjWe7WKd7en3mn6q4eDGLHEQVPcqWQIyC1hC7xa3po+o4cWSyItI6a6GYY/voDUpwhZmkxjcPw==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/phoenix@0.4.5": { + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==" + }, + "@supabase/postgrest-js@2.117.1": { + "integrity": "sha512-2a7we4uKXA1d94CUc02MpUj+LzZWO3qm8fH2PDq60tVt0T6KQTIiMmEsRD53lrnwZA9C3SsaKL3mk4DiDH6GvQ==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/realtime-js@2.117.1": { + "integrity": "sha512-GkbZfeh5F5aqnUhG1chrdaVQmnRK8ZUL8H16Km/eVVsX99x0w2AijfxjONCB9YKgrXArcnMsKSJzOIQGCv+qvQ==", + "dependencies": [ + "@supabase/phoenix", + "tslib" + ] + }, + "@supabase/storage-js@2.117.1": { + "integrity": "sha512-ytDQbELKKzIVStLkShxTTK5FT4vds12eXF5QfW+gTHVvO7cB3VKZ7Hu2wkUaJlDZCaUfFdih3vznF6P8kNkJQg==", + "dependencies": [ + "iceberg-js", + "tslib" + ] + }, + "iceberg-js@0.8.1": { + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } +} diff --git a/acceptance/supabase-edge-functions-app/harness.mjs b/acceptance/supabase-edge-functions-app/harness.mjs new file mode 100644 index 0000000..355c408 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/harness.mjs @@ -0,0 +1,700 @@ +// Full-stack acceptance harness: React (supabase edge-functions test client) +// -> Deno (select-from-table-with-auth-rls) -> GoTrue/PostgREST/Postgres, with +// the AppMap React recorder, the AppMap Deno recorder and appmap-link. +// Run through run.sh, which clones the app at the pinned SHA, installs it and +// starts the local stack. Writes evidence/ and evidence/results.json; exits 1 +// if any check is not PASS (J is MEASURED). +import fs from 'node:fs'; +import path from 'node:path'; +import { + ACC, ROOT, WORK, SUPA, APP, FN_NAME, FN_REL, CORS_REL, APPMAP_CLI, LINK_CLI, TRACE_CLI, GATEWAY_LOG, + COLLECTOR_DIR, sleep, sh, bg, startFunction, startVite, drive, listMaps, readMap, waitForMap, traceparent, + invokeDirect, signUp, gatewayLines, summarize, validateFile, sequenceDiagrams, normalizeDiagram, ANON, +} from './lib/util.mjs'; + +const EVID = path.join(ACC, 'evidence'); +const REC = path.join(WORK, 'rec'); +fs.rmSync(EVID, { recursive: true, force: true }); +fs.rmSync(REC, { recursive: true, force: true }); +fs.mkdirSync(EVID, { recursive: true }); +fs.rmSync(path.join(WORK, 'logs'), { recursive: true, force: true }); + +const results = { verdicts: {}, diagnostics: {} }; +const log = (...a) => console.log('[acc]', ...a); +const j = (o) => JSON.stringify(o); +const save = (name, data) => { + const p = path.join(EVID, name); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, typeof data === 'string' ? data : JSON.stringify(data, null, 2)); +}; +function verdict(key, status, evidence) { + results.verdicts[key] = { status, evidence: [].concat(evidence) }; + log(`${key}: ${status}`); + for (const e of [].concat(evidence)) log(` ${e}`); +} +const resetDb = () => { + const r = sh(path.join(ACC, 'infra', 'stack.sh'), ['reset']); + if (r.code !== 0) throw new Error(`db reset failed: ${r.out}`); +}; +const copyDir = (from, to) => { + fs.mkdirSync(to, { recursive: true }); + for (const f of listMaps(from)) fs.copyFileSync(f, path.join(to, path.basename(f))); +}; +const mapByName = (dir) => Object.fromEntries(listMaps(dir).map((f) => [path.basename(f), f])); + +// ------------------------------------------------------------------ gateway +fs.rmSync(GATEWAY_LOG, { force: true }); +const gateway = bg('gateway', process.execPath, [path.join(ACC, 'infra', 'gateway.mjs')], { + env: { ...process.env, GATEWAY_LOG }, +}); +await sleep(800); + +const cfg = (name) => path.join(APP, name); +const FE_CONFIGS = { + baseline: cfg('vite.config.cra.mjs'), + Z: cfg('vite.config.appmap.mjs'), + ZL: cfg('vite.config.appmap-link.mjs'), + W: cfg('vite.config.appmap-workaround.mjs'), +}; + +/** One browser pass: fresh DB, function (plain or recorded), Vite, drive. */ +async function browserPass(tag, { fe, recorded, mode = 'sequence', corsPatch = false }) { + resetDb(); + const backendDir = path.join(REC, tag, 'backend'); + const frontendDir = path.join(REC, tag, 'frontend'); + fs.mkdirSync(backendDir, { recursive: true }); + if (corsPatch) sh('git', ['-C', SUPA, 'apply', path.join(ACC, 'app-config', 'p-cors-traceparent.patch')]); + const fn = await startFunction({ recorded, appmapDir: recorded ? backendDir : undefined, tag }); + const vite = await startVite(FE_CONFIGS[fe], tag); + const gwBefore = gatewayLines().length; + const t0 = Date.now(); + let report; + try { + report = drive(mode, path.join(WORK, `drive-${tag}.json`), `${tag}-${Date.now()}`); + } finally { + report && (report.wallMs = Date.now() - t0); + await vite.stop(); + await fn.stop(); + if (corsPatch) sh('git', ['-C', SUPA, 'checkout', '--', CORS_REL]); + } + copyDir(COLLECTOR_DIR, frontendDir); + report.gateway = gatewayLines().slice(gwBefore); + report.viteLog = vite.log().slice(-4000); + report.fnLog = fn.log().slice(-4000); + save(`browser-${tag}.json`, report); + // map each step to its frontend map / backend maps + for (const s of report.steps) { + s.frontend = (s.newMaps ?? []).map((f) => path.join(frontendDir, f)); + s.backend = []; + for (const w of s.wire ?? []) { + const span = w.traceparent?.split('-')[2]; + if (!span) continue; + const hit = listMaps(backendDir).find((f) => readMap(f).metadata?.parent_span_id === span); + if (hit) s.backend.push(hit); + } + } + log(`${tag}: ${report.steps.map((s) => `${s.id}${s.error ? '(err)' : ''} fe=${s.frontend.length} be=${s.backend.length}`).join(', ')}; ${listMaps(frontendDir).length} frontend, ${listMaps(backendDir).length} backend maps`); + // Enough detail in the log to diagnose a failing step without the artifact. + for (const s of report.steps.filter((x) => x.error && !x.error.startsWith('skipped'))) { + log(` ${tag} ${s.id} error: ${s.error.split('\n').slice(0, 3).join(' | ').slice(0, 300)}`); + } + if (report.steps.some((x) => x.error)) { + for (const g of report.gateway.filter((l) => l.route !== 'functions' || l.status >= 400).slice(-8)) { + log(` ${tag} gateway: ${g.method} ${g.url.slice(0, 80)} -> ${g.status}`); + } + for (const c of report.console.filter((c) => c.type === 'error').slice(-4)) log(` ${tag} console: ${c.text.slice(0, 200)}`); + for (const f of report.failed.slice(-4)) log(` ${tag} failed request: ${f.method} ${f.url.slice(0, 80)} ${f.error}`); + for (const d of report.dialogs.slice(-3)) log(` ${tag} dialog: ${d.message.slice(0, 160)}`); + for (const e of (report.errorResponses ?? []).slice(-4)) log(` ${tag} error response: ${e.status} ${e.method} ${e.url.slice(0, 80)} ${e.body.slice(0, 200)}`); + } + return { tag, report, frontendDir, backendDir }; +} + +// ------------------------------------------------------------ browser passes +log('baseline (no recorder), twice'); +const base1 = await browserPass('baseline-1', { fe: 'baseline', recorded: false }); +const base2 = await browserPass('baseline-2', { fe: 'baseline', recorded: false }); +log('Z: zero-touch (recorder plugin exactly as documented) + recorded function'); +const Z = await browserPass('Z', { fe: 'Z', recorded: true }); +const Zpar = await browserPass('Z-parallel', { fe: 'Z', recorded: true, mode: 'parallel' }); +log('W: config-only workaround (diagnostic), twice'); +const W1 = await browserPass('W1', { fe: 'W', recorded: true }); +const W2 = await browserPass('W2', { fe: 'W', recorded: true }); +log('ZL: zero-touch + the one linking setting (propagateTraceHeaderOrigins: the function\'s origin), app unpatched'); +const ZL = await browserPass('ZL', { fe: 'ZL', recorded: true }); +log('P: ZL + the app patched to allow traceparent in CORS (diagnostic only; an app change)'); +const P = await browserPass('P', { fe: 'ZL', recorded: true, corsPatch: true }); +const Ppar = await browserPass('P-parallel', { fe: 'ZL', recorded: true, mode: 'parallel', corsPatch: true }); + +// ------------------------------------------------------- direct requests R1-R4 +async function directSeries(tag, { recorded = true } = {}) { + resetDb(); + const dir = path.join(REC, tag, 'backend'); + fs.mkdirSync(dir, { recursive: true }); + const fn = await startFunction({ recorded, appmapDir: recorded ? dir : undefined, tag }); + const user = await signUp(`${tag}-${Date.now()}@example.com`); + const obs = {}; + try { + const reqs = [ + ['R1', { token: undefined, anonBearer: true }], + ['R2', { token: user.token }], + ['R3', { noAuth: true }], + ]; + for (const [i, [id, o]] of reqs.entries()) { + const tp = traceparent('direct', i + 1); + const before = gatewayLines().length; + const res = await invokeDirect({ token: o.anonBearer ? ANON : o.token, tp }); + const file = recorded ? await waitForMap(dir, tp.span) : undefined; + await sleep(100); + obs[id] = { + tp: tp.header, + status: res.status, + body: res.text, + file, + wire: gatewayLines().slice(before).filter((l) => l.traceparent?.includes(tp.trace)).map((l) => `${l.method} ${l.url} ${l.status}`), + }; + } + const pre = await invokeDirect({ + method: 'OPTIONS', + headers: { + Origin: 'http://127.0.0.1:3300', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'apikey,authorization,traceparent,x-client-info', + }, + }); + obs.R4 = { status: pre.status, body: pre.text, allowHeaders: pre.headers['access-control-allow-headers'] ?? null, backendMaps: listMaps(dir).length }; + } finally { + await fn.stop(); + } + obs.userId = user.id; + return { tag, dir, obs }; +} +log('R-series (direct stamped requests), twice, for C/E/G'); +const R1run = await directSeries('R-run1'); +const R2run = await directSeries('R-run2'); +save('direct-R-run1.json', R1run.obs); +save('direct-R-run2.json', R2run.obs); + +// ---------------------------------------------------------------- H change +log('H: one behaviour change in the function (select * -> select id), re-record R1-R3'); +const hPatch = path.join(ACC, 'app-config', 'h-change.patch'); +const applied = sh('git', ['-C', SUPA, 'apply', hPatch]); +let Hrun; +try { + Hrun = await directSeries('R-h'); +} finally { + sh('git', ['-C', SUPA, 'checkout', '--', FN_REL]); +} +save('direct-R-h.json', { applied: applied.code === 0, applyOut: applied.out, ...Hrun.obs }); + +// --------------------------------------------------------- I backend concurrency +log('I(a): 6 concurrent stamped requests to the recorded function'); +async function concurrentBackend() { + resetDb(); + const dir = path.join(REC, 'I-backend', 'backend'); + fs.mkdirSync(dir, { recursive: true }); + const fn = await startFunction({ recorded: true, appmapDir: dir, tag: 'I-backend' }); + const users = []; + for (let i = 0; i < 3; i++) users.push(await signUp(`conc${i}-${Date.now()}@example.com`)); + const plan = [0, 1, 2, 3, 4, 5].map((i) => ({ i, tp: traceparent(`conc${i}`, 100 + i), token: i < 3 ? ANON : users[i - 3].token })); + const before = gatewayLines().length; + const responses = await Promise.all(plan.map((p) => invokeDirect({ token: p.token, tp: p.tp }))); + await sleep(1500); + await fn.stop(); + const gw = gatewayLines().slice(before); + const out = plan.map((p, k) => { + const file = listMaps(dir).find((f) => readMap(f).metadata?.parent_span_id === p.tp.span); + const s = file ? summarize(readMap(file)) : undefined; + const ownWire = gw.filter((l) => l.traceparent?.includes(p.tp.trace) && l.route !== 'functions').map((l) => `${l.method} ${l.url}`); + return { + request: p.i, + status: responses[k].status, + traceparent: p.tp.header, + map: file ? path.basename(file) : null, + clients: s?.clients.map((c) => `${c.method} ${c.url.replace('http://127.0.0.1:54321', '')} ${c.status}`), + // each request makes exactly 2 outbound calls; more in one map = other requests' calls leaked in + leakedClients: s ? Math.max(0, s.clients.length - 2) : null, + // outbound calls that reached the gateway carrying THIS request's trace id (expected 2) + wireOutbound: ownWire, + }; + }); + return { dir, out, totalMaps: listMaps(dir).length }; +} +const Ib = await concurrentBackend(); +save('i-backend.json', Ib.out); + +// ------------------------------------------------------------------ J timing +log('J: 20 direct R2 requests, plain vs recorded'); +async function timeDirect(recorded) { + resetDb(); + const dir = path.join(REC, `J-${recorded ? 'rec' : 'plain'}`, 'backend'); + fs.mkdirSync(dir, { recursive: true }); + const fn = await startFunction({ recorded, appmapDir: recorded ? dir : undefined, tag: `J-${recorded}` }); + const user = await signUp(`timing-${recorded}-${Date.now()}@example.com`); + const t0 = performance.now(); + for (let i = 0; i < 20; i++) await invokeDirect({ token: user.token, tp: traceparent('timing', 1000 + i) }); + const ms = performance.now() - t0; + await fn.stop(); + return ms; +} +const jDirect = { plain: await timeDirect(false), recorded: await timeDirect(true) }; + +await gateway.stop(); + +// =============================================================== the checks +const stepOf = (pass, id) => pass.report.steps.find((s) => s.id === id) ?? { frontend: [], backend: [], wire: [] }; +const feSummary = (pass, id) => stepOf(pass, id).frontend.map((f) => summarize(readMap(f))); +const FN_URL = `http://localhost:54321/functions/v1/${FN_NAME}`; +const S2_URL = 'http://localhost:54321/functions/v1/local:%20Whatever%20function%20is%20currently%20served%20by%20the%20CLI'; + +// --- A --------------------------------------------------------------------- +const setup = JSON.parse(fs.readFileSync(path.join(WORK, 'setup.json'), 'utf8')); +const zRendered = !Z.report.appDidNotRender; +const zViteLog = fs.readFileSync(path.join(WORK, 'logs', 'vite-Z.log'), 'utf8'); +const zViteErr = [...new Set((zViteLog.match(/Internal server error: [^\n]*/gi) ?? []).map((l) => l.replace(/\/\S*\/(src\/)/, '$1')))].slice(0, 3); +const zConsole = Z.report.console.filter((c) => c.type === 'error').map((c) => c.text.slice(0, 160)).slice(0, 3); +const appTree = sh('git', ['-C', SUPA, 'status', '--short']).stdout.trim(); +save('a-app-tree-changes.txt', appTree + '\n'); +// A cannot pass while the recorder needs the app's build tool swapped out +// (pre-registered in EXPECTATIONS.md); the other conditions are reported too. +verdict('A', 'FAIL', [ + `setup ${setup.ok ? 'completed' : 'FAILED'}: ${setup.note}`, + `app source edits: none (git status of the app tree: ${j(appTree.split('\n'))} -- package.json/lockfile from the dev-dependency install and the harness's Vite config files only)`, + 'toolchain substitution needed: the recorder has no Create React App / webpack integration, so the app is run under Vite (app-config/cra-compat.mjs). Counts against the recorder (EXPECTATIONS.md).', + `zero-touch config (recorder plugin exactly as documented) ${zRendered ? 'serves a working app' : 'BREAKS the app: it does not render'}`, + ...zViteErr.map((e) => ` vite: ${e.slice(0, 300)}`), + ...zConsole.map((e) => ` browser: ${e}`), +]); + +// --- B --------------------------------------------------------------------- +const allMaps = [Z, Zpar, W1, ZL, P, Ppar].flatMap((p) => [...listMaps(p.frontendDir), ...listMaps(p.backendDir)]) + .concat(listMaps(R1run.dir), listMaps(Ib.dir)); +const bReport = allMaps.map(validateFile); +save('b-validate.json', bReport); +const bValid = bReport.filter((r) => r.declaredResult === 'valid').length; +const bReasons = {}; +for (const r of bReport) if (r.declaredResult !== 'valid') bReasons[r.declaredResult.slice(0, 200)] = (bReasons[r.declaredResult.slice(0, 200)] ?? 0) + 1; +const zMaps = listMaps(Z.frontendDir).length + listMaps(Z.backendDir).length; +verdict('B', bReport.length && bValid === bReport.length && zMaps > 0 ? 'PASS' : 'FAIL', [ + `official validator (@appland/appmap-validate): ${bValid}/${bReport.length} maps valid at their declared version (${[...new Set(bReport.map((r) => r.declared))].join(', ')})`, + ...Object.entries(bReasons).map(([k, v]) => `${v}x ${k}`), + `zero-touch pass produced ${zMaps} map(s)`, +]); + +// --- C --------------------------------------------------------------------- +// traceparent on the cross-origin function call: 'absent' in a pass without +// propagateTraceHeaderOrigins (the recorder must not stamp an unlisted +// cross-origin request), 'present' in the linking config. +function frontendItems(pass, { traceparent = 'absent' } = {}) { + const items = []; + const add = (id, expect, status, quote) => items.push({ id, expect, status, quote }); + // A step can open more than one interaction window (S4: the view-switch + // click, then the submit); judge the map that carries the step's request. + const one = (id, urlPart) => { + const maps = feSummary(pass, id); + if (!maps.length) add(id, 'one frontend interaction map', 'missing', stepOf(pass, id).error ?? 'no map collected'); + return (urlPart && maps.find((m) => m.clients.some((c) => c.url.includes(urlPart)))) || maps[0]; + }; + const fnItem = (id, m, method, lineno) => { + const f = m?.functions.find((x) => x.method_id === method || (method === '*' && x.lineno === lineno)); + if (!m) return; + if (!f) add(id, `call ${method} (src/App.js:${lineno})`, 'missing', m.functions.map((x) => `${x.fn}@${x.lineno}`).join(', ')); + else add(id, `call ${method} (src/App.js:${lineno})`, f.path === 'src/App.js' && f.lineno === lineno ? 'found' : 'wrong', `${f.fn} ${f.path}:${f.lineno}`); + }; + const clientItem = (id, m, method, url, status, needTp) => { + if (!m) return; + if (status === 0) { + // A rejected fetch (no HTTP response). EXPECTATIONS.md asked for the + // recorder's old contract, a request/response pair with status 0; that + // is not valid AppMap (http_client_response.status_code must be + // 100-599), so the expectation was wrong per the spec. The spec-valid + // recording lists the request in metadata.unanswered_http_requests + // with reason "network error", and has no event pair for it. + const exp = `${method} ${url.replace('http://localhost:54321', '')} -> no response: listed in metadata.unanswered_http_requests (network error), no event pair`; + const u = m.unanswered.find((x) => x.method === method && x.url === url); + const paired = m.clients.some((x) => x.method === method && x.url === url); + if (!u) add(id, exp, 'missing', `unanswered ${j(m.unanswered)}; clients ${m.clients.map((x) => `${x.method} ${x.url} ${x.status}`).join(', ') || 'none'}`); + else add(id, exp, u.reason === 'network error' && !paired ? 'found' : 'wrong', `${j(u)}${paired ? ' (also recorded as an event pair)' : ''}`); + return; + } + const c = m.clients.find((x) => x.method === method && x.url === url); + const tpWanted = needTp ? traceparent : 'any'; + const exp = `${method} ${url.replace('http://localhost:54321', '')} -> ${status}${tpWanted === 'present' ? ' with traceparent' : tpWanted === 'absent' ? ' without traceparent (cross-origin, not listed)' : ''}`; + if (!c) add(id, exp, 'missing', m.clients.map((x) => `${x.method} ${x.url} ${x.status}`).join(', ') || 'no http_client_request'); + else { + const tpOk = + tpWanted === 'any' || + (tpWanted === 'absent' ? !c.traceparent : !!c.traceparent && c.traceparent.split('-')[1] === m.trace_id); + add(id, exp, c.status === status && tpOk ? 'found' : 'wrong', `${c.method} ${c.url} status=${c.status} traceparent=${c.traceparent ?? 'none'}`); + } + }; + let m = one('S2'); + fnItem('S2', m, 'invokeFunction', 16); + fnItem('S2', m, 'App', 10); + clientItem('S2', m, 'POST', S2_URL, 0, false); + m = one('S3'); + fnItem('S3', m, 'invokeFunction', 16); + clientItem('S3', m, 'POST', FN_URL, 200, true); + m = one('S4', '/auth/v1/signup'); + clientItem('S4', m, 'POST', 'http://localhost:54321/auth/v1/signup', 200, false); + fnItem('S4', m, 'App', 10); + m = one('S5'); + fnItem('S5', m, 'invokeFunction', 16); + clientItem('S5', m, 'POST', FN_URL, 200, true); + m = one('S6'); + fnItem('S6', m, '*', 86); + clientItem('S6', m, 'POST', 'http://localhost:54321/auth/v1/logout?scope=global', 204, false); + return items; +} +function backendItems(run) { + const items = []; + const add = (id, expect, status, quote) => items.push({ id, expect, status, quote }); + const exp = { + R1: { status: 200, clients: [['GET', '/auth/v1/user', 403], ['GET', '/rest/v1/users?select=*', 200]] }, + R2: { status: 200, clients: [['GET', '/auth/v1/user', 200], ['GET', '/rest/v1/users?select=*', 200]] }, + R3: { status: 400, clients: [] }, + }; + for (const [id, e] of Object.entries(exp)) { + const o = run.obs[id]; + if (!o.file) { + add(id, 'backend map', 'missing', `response ${o.status}`); + continue; + } + const m = readMap(o.file); + const s = summarize(m); + const span = o.tp.split('-')[2]; + const trace = o.tp.split('-')[1]; + add(id, 'metadata trace_id/parent_span_id from traceparent', m.metadata.trace_id === trace && m.metadata.parent_span_id === span ? 'found' : 'wrong', `trace_id=${m.metadata.trace_id} parent_span_id=${m.metadata.parent_span_id}`); + const srv = s.servers[0]; + add(id, `http_server_request POST /${FN_NAME} -> ${e.status}`, srv && srv.method === 'POST' && srv.path === `/${FN_NAME}` && srv.status === e.status ? 'found' : srv ? 'wrong' : 'missing', j(srv ?? null)); + const h = s.functions.find((f) => f.path?.endsWith(`${FN_NAME}/index.ts`) && f.lineno === 10); + add(id, 'call event for the request handler (index.ts:10)', h ? 'found' : 'missing', h ? `${h.fn}@${h.lineno}` : `call events: ${j(s.functions.map((f) => `${f.fn}@${f.path}:${f.lineno}`))}`); + const got = s.clients.map((c) => [c.method, c.url.replace('http://127.0.0.1:54321', ''), c.status]); + e.clients.forEach((c, k) => { + const g = got[k]; + add(id, `outbound #${k + 1}: ${c.join(' ')}`, g && j(g) === j(c) ? (s.clients[k].traceparent?.split('-')[1] === trace ? 'found' : 'wrong') : g ? 'wrong' : 'missing', g ? `${g.join(' ')} traceparent=${s.clients[k].traceparent}` : 'none'); + }); + if (got.length > e.clients.length) add(id, `exactly ${e.clients.length} outbound call(s)`, 'wrong', j(got)); + if (id === 'R2') { + const body = JSON.parse(o.body.startsWith('{') ? o.body : '{}'); + add(id, 'response: user + exactly 1 row, their own (RLS)', body?.data?.length === 1 && body.data[0].id === run.obs.userId ? 'found' : 'wrong', o.body.slice(0, 120)); + } + } + const r4 = run.obs.R4; + add('R4', 'preflight 200, allow-headers exactly "authorization, x-client-info, apikey, content-type"', r4.status === 200 && r4.allowHeaders === 'authorization, x-client-info, apikey, content-type' ? 'found' : 'wrong', `${r4.status} ${r4.allowHeaders}`); + return items; +} +const cZ = frontendItems(Z); +const cW = frontendItems(W1); +const cZL = frontendItems(ZL, { traceparent: 'present' }); +const cR = backendItems(R1run); +save('c-ground-truth.json', { zeroTouchFrontend: cZ, workaroundFrontendDiagnostic: cW, linkingConfigFrontendDiagnostic: cZL, backend: cR }); +const tally = (items) => items.reduce((t, i) => ((t[i.status] = (t[i.status] ?? 0) + 1), t), {}); +const bad = (items) => items.filter((i) => i.status !== 'found').map((i) => `${i.id} ${i.status}: ${i.expect} [${String(i.quote).slice(0, 140)}]`); +verdict('C', [...cZ, ...cR].every((i) => i.status === 'found') ? 'PASS' : 'FAIL', [ + `zero-touch frontend: ${j(tally(cZ))}; backend: ${j(tally(cR))}; (diagnostic) workaround frontend: ${j(tally(cW))}; (diagnostic) linking config frontend: ${j(tally(cZL))}`, + ...bad(cZ).slice(0, 8), + ...bad(cR), + ...bad(cW).map((s) => `(W) ${s}`), + ...bad(cZL).map((s) => `(ZL) ${s}`), +]); + +// --- D --------------------------------------------------------------------- +function noise(dirs) { + const byPath = {}; + let total = 0; + for (const f of dirs.flatMap(listMaps)) { + for (const e of readMap(f).events) { + if (e.event !== 'call' || !e.method_id) continue; + total++; + const p = e.path ?? '(no path)'; + byPath[p] = (byPath[p] ?? 0) + 1; + } + } + const foreign = Object.entries(byPath).filter(([p]) => !(p.startsWith('src/') || p.endsWith(`${FN_NAME}/index.ts`))); + return { total, byPath, foreign }; +} +const dZ = noise([Z.frontendDir, Z.backendDir, R1run.dir]); +const dW = noise([W1.frontendDir, W1.backendDir]); +save('d-noise.json', { zeroTouch: dZ, workaroundDiagnostic: dW }); +verdict('D', dZ.total > 0 && dZ.foreign.length === 0 ? 'PASS' : 'FAIL', [ + `zero-touch + backend: ${dZ.total} call events by path ${j(dZ.byPath)}; outside app code: ${j(dZ.foreign)}`, + `(W) ${dW.total} call events by path ${j(dW.byPath)}; outside app code: ${j(dW.foreign)}`, + ...(dZ.total === 0 ? ['no call events at all in the zero-touch recordings: nothing to judge'] : []), +]); + +// --- E --------------------------------------------------------------------- +const r3 = R1run.obs.R3.file ? summarize(readMap(R1run.obs.R3.file)) : undefined; +const eBackend = !!r3 && r3.servers[0]?.status === 400 && r3.exceptions.length === 0 && r3.unbalanced.length === 0; +// S2's rejected fetch, in its spec-valid form (see clientItem): listed as +// unanswered with reason "network error", and the map's events balanced. +const zS2 = feSummary(Z, 'S2')[0]; +const zS2c = zS2?.unanswered.find((c) => c.method === 'POST' && c.url === S2_URL); +const eFrontend = !!zS2c && zS2c.reason === 'network error' && zS2.unbalanced.length === 0 && !zS2.clients.some((c) => c.url === S2_URL); +const wS2 = feSummary(W1, 'S2')[0]?.unanswered.find((c) => c.url === S2_URL); +verdict('E', eBackend && eFrontend ? 'PASS' : 'FAIL', [ + `backend R3 (TypeError thrown at index.ts:33, caught at :48): ${r3 ? `status ${r3.servers[0]?.status}, exceptions ${j(r3.exceptions)}, unbalanced ${r3.unbalanced.length}` : 'no map'}; response ${R1run.obs.R3.body}`, + `frontend S2 (fetch rejected): zero-touch ${zS2c ? `request recorded as unanswered (${zS2c.reason}), events balanced: ${zS2.unbalanced.length === 0}` : zS2 ? `map present, request not listed as unanswered: ${j(zS2.unanswered)}` : 'no map'}; (W) ${wS2 ? `unanswered (${wS2.reason})` : 'not listed'}`, + 'note: the app has no path where an exception escapes an app function (EXPECTATIONS.md, E).', +]); + +// --- F (analog) ------------------------------------------------------------ +verdict('F', feSummary(Z, 'S2').length ? 'PASS (analog)' : 'FAIL (analog)', [ + 'no test runner is involved, so there is no test status to mark (not applicable as specified)', + `analog: the failing interaction S2 (error alert) left a zero-touch frontend map: ${feSummary(Z, 'S2').length ? 'yes' : 'no'}; (W): ${feSummary(W1, 'S2').length ? 'yes' : 'no'}`, +]); + +// --- G --------------------------------------------------------------------- +function compareSeq(filesA, filesB, keyOf, label) { + const dA = path.join(WORK, 'seq', `${label}-a`); + const dB = path.join(WORK, 'seq', `${label}-b`); + sequenceDiagrams(filesA, dA); + sequenceDiagrams(filesB, dB); + const load = (dir, files) => { + const m = {}; + for (const f of files) { + const seq = path.join(dir, path.basename(f).replace(/\.appmap\.json$/, '.sequence.json')); + if (fs.existsSync(seq)) m[keyOf(f)] = normalizeDiagram(JSON.parse(fs.readFileSync(seq, 'utf8'))); + } + return m; + }; + const a = load(dA, filesA); + const b = load(dB, filesB); + const same = []; + const different = {}; + for (const k of Object.keys(a)) { + if (!(k in b)) continue; + if (j(a[k]) === j(b[k])) same.push(k); + else different[k] = { a: j(a[k]).slice(0, 1500), b: j(b[k]).slice(0, 1500) }; + } + return { same, different, onlyA: Object.keys(a).filter((k) => !(k in b)), onlyB: Object.keys(b).filter((k) => !(k in a)) }; +} +const stepKey = (pass) => (f) => { + const s = pass.report.steps.find((x) => x.frontend.includes(f)); + if (!s) return path.basename(f); + const i = s.frontend.indexOf(f); + return i ? `${s.id}#${i + 1}` : s.id; +}; +const reqKey = (run) => (f) => Object.entries(run.obs).find(([, o]) => o?.file === f)?.[0] ?? path.basename(f); +const gBackend = compareSeq(['R1', 'R2', 'R3'].map((k) => R1run.obs[k].file).filter(Boolean), ['R1', 'R2', 'R3'].map((k) => R2run.obs[k].file).filter(Boolean), (f) => reqKey(R1run)(f) !== path.basename(f) ? reqKey(R1run)(f) : reqKey(R2run)(f), 'g-backend'); +const gW = compareSeq(listMaps(W1.frontendDir), listMaps(W2.frontendDir), (f) => stepKey(W1)(f) !== path.basename(f) ? stepKey(W1)(f) : stepKey(W2)(f), 'g-w'); +const zCount = listMaps(Z.frontendDir).length; +save('g-stability.json', { backend: gBackend, workaroundFrontendDiagnostic: gW, zeroTouchFrontendMaps: zCount }); +const gBackOk = gBackend.same.length === 3 && !Object.keys(gBackend.different).length; +verdict('G', zCount === 0 ? 'NOT RUN' : gBackOk ? 'PASS' : 'FAIL', [ + `frontend (zero-touch): ${zCount === 0 ? 'NOT RUN, the zero-touch pass produced no recordings to compare' : 'see evidence'}`, + `backend R1-R3, run 1 vs run 2: same ${j(gBackend.same)}, different ${j(Object.keys(gBackend.different))}, only in one run ${j([...gBackend.onlyA, ...gBackend.onlyB])}`, + `(W) frontend S2-S6, run 1 vs run 2: same ${j(gW.same)}, different ${j(Object.keys(gW.different))}, only in one run ${j([...gW.onlyA, ...gW.onlyB])}`, +]); + +// --- H --------------------------------------------------------------------- +const hFiles = (run) => ['R1', 'R2', 'R3'].map((k) => run.obs[k]?.file).filter(Boolean); +const hCmp = compareSeq(hFiles(R1run), hFiles(Hrun), (f) => reqKey(R1run)(f) !== path.basename(f) ? reqKey(R1run)(f) : reqKey(Hrun)(f), 'h'); +const hDiffs = {}; +for (const k of ['R1', 'R2', 'R3']) { + const a = path.join(WORK, 'seq', 'h-a', path.basename(R1run.obs[k].file ?? 'x').replace(/\.appmap\.json$/, '.sequence.json')); + const b = path.join(WORK, 'seq', 'h-b', path.basename(Hrun.obs[k].file ?? 'x').replace(/\.appmap\.json$/, '.sequence.json')); + if (fs.existsSync(a) && fs.existsSync(b)) { + const outDir = path.join(WORK, 'seq', `h-diff-${k}`); + fs.rmSync(outDir, { recursive: true, force: true }); + const r = sh(APPMAP_CLI, ['sequence-diagram-diff', a, b, '--format', 'text', '--output-dir', outDir], { env: { ...process.env, APPMAP_TELEMETRY_DISABLED: 'true' }, cwd: WORK }); + const diffFile = path.join(outDir, 'diff.txt'); + hDiffs[k] = fs.existsSync(diffFile) ? fs.readFileSync(diffFile, 'utf8').trim() || '(empty diff: identical)' : `no diff file: ${r.out.trim().slice(0, 300)}`; + } +} +const hTrace = sh(process.execPath, [TRACE_CLI, Hrun.dir, '--baseline', R1run.dir]); +// The same diff per request, each before/after pair on its own, so the +// verdict does not depend on how the tool pairs same-named maps. +const hTraceOf = {}; +for (const k of ['R1', 'R2', 'R3']) { + const a = R1run.obs[k]?.file; + const b = Hrun.obs[k]?.file; + if (!a || !b) continue; + const d = path.join(WORK, 'seq', `h-trace-${k}`); + fs.rmSync(d, { recursive: true, force: true }); + fs.mkdirSync(path.join(d, 'before'), { recursive: true }); + fs.mkdirSync(path.join(d, 'after'), { recursive: true }); + fs.copyFileSync(a, path.join(d, 'before', path.basename(a))); + fs.copyFileSync(b, path.join(d, 'after', path.basename(b))); + hTraceOf[k] = sh(process.execPath, [TRACE_CLI, path.join(d, 'after'), '--baseline', path.join(d, 'before'), '--format', 'ascii']).out; +} +save('h-change.json', { patchApplied: applied.code === 0, compare: hCmp, sequenceDiagramDiff: hDiffs, appmapTracePerRequest: hTraceOf }); +save('h-appmap-trace.txt', hTrace.out); +const hClients = (run, k) => (run.obs[k]?.file ? summarize(readMap(run.obs[k].file)).clients.map((c) => c.url.replace('http://127.0.0.1:54321', '')) : null); +// Lines of an appmap-trace ASCII diff that mark a step added (+) or removed (-). +const hMarks = (text) => (text ?? '').split('\n').filter((l) => /^[\s│├└─]*[+-] /.test(l)).map((l) => l.replace(/^[\s│├└─]*/, '').replace(/\s+\[.*$/, '').trim()); +// Official tools: the change is a query-only change (select=* -> select=id). +// By the AppMap spec the query is in the event's `message`, which the +// official sequence diagram does not render, so it cannot see this change; +// its result is reported, and the change must show in appmap-trace, which +// reads `message`: exactly one call removed and one added in R1 and R2, +// nothing in R3. +const hTraceOk = (k, want) => { + const t = hTraceOf[k]; + if (!t) return false; + if (!want) return /No behavior change/.test(t) && hMarks(t).length === 0; + return ( + /Behavior changed — 1 added, 1 removed\./.test(t) && + j(hMarks(t).sort()) === j(['+ → network: GET /rest/v1/users?select=id', '- → network: GET /rest/v1/users?select=*']) + ); +}; +const hOk = + applied.code === 0 && + hTraceOk('R1', true) && + hTraceOk('R2', true) && + hTraceOk('R3', false) && + ['R1', 'R2'].every((k) => j(hClients(Hrun, k)) === j(['/auth/v1/user', '/rest/v1/users?select=id'])) && + j(hClients(Hrun, 'R3')) === j([]); +verdict('H', hOk ? 'PASS' : 'FAIL', [ + `patch applied: ${applied.code === 0}; R1 outbound after: ${j(hClients(Hrun, 'R1'))}; R2 after: ${j(hClients(Hrun, 'R2'))}; R3 after: ${j(hClients(Hrun, 'R3'))}`, + ...['R1', 'R2', 'R3'].map((k) => `appmap-trace --baseline ${k}: ${hTraceOk(k, k !== 'R3') ? 'ok' : 'NOT AS REQUIRED'}: ${(hTraceOf[k] ?? 'no output').split('\n').slice(1, 2).join('').trim()} marks ${j(hMarks(hTraceOf[k]))}`), + `(reported, not required: the query is in \`message\`, which the official sequence diagram does not render) official sequence diagrams before vs after: same ${j(hCmp.same)}, different ${j(Object.keys(hCmp.different))}; sequence-diagram-diff R1: ${(hDiffs.R1 ?? 'n/a').replace(/\s+/g, ' ').slice(0, 200)}`, + `appmap-trace --baseline over all three: ${hTrace.out.trim().split('\n').slice(-1)[0]}`, +]); + +// --- I --------------------------------------------------------------------- +const iBackOk = Ib.out.every((o) => o.map && o.clients?.length === 2 && o.wireOutbound.length === 2) && Ib.totalMaps === 6; +function browserIsolation(pass) { + const s = pass.report.steps.find((x) => x.id === 'I'); + if (!s || pass.report.appDidNotRender) return { ok: false, note: 'app did not render' }; + const maps = s.frontend.map((f) => summarize(readMap(f))); + const fnReqs = maps.map((m) => m.clients.filter((c) => c.url === FN_URL).length); + const traces = new Set(maps.map((m) => m.trace_id)); + return { ok: maps.length === 3 && fnReqs.every((n) => n === 1) && traces.size === 3, maps: maps.length, fnReqsPerMap: fnReqs, backendMaps: listMaps(pass.backendDir).length }; +} +const iZ = browserIsolation(Zpar); +const iP = browserIsolation(Ppar); +save('i-concurrency.json', { backend: Ib.out, zeroTouchBrowser: iZ, patchedBrowserDiagnostic: iP }); +verdict('I', iBackOk && iZ.ok ? 'PASS' : 'FAIL', [ + `backend: ${Ib.totalMaps}/6 maps for 6 concurrent stamped requests; per request: ${j(Ib.out.map((o) => ({ req: o.request, status: o.status, map: !!o.map, clientsInMap: o.clients?.length ?? null, leaked: o.leakedClients, ownTraceOnWire: o.wireOutbound.length })))}`, + ...Ib.out.filter((o) => o.leakedClients).map((o) => ` request ${o.request}'s map holds ${o.clients.length} outbound calls (expected 2): ${o.leakedClients} belong to the other concurrent requests, and were stamped with request ${o.request}'s trace id on the wire (${o.wireOutbound.length} gateway hits carry it)`), + ...Ib.out.filter((o) => !o.map).map((o) => ` request ${o.request} (${o.traceparent}) got HTTP ${o.status}, NO backend map, and ${o.wireOutbound.length} of its outbound calls carried its own trace id`), + `browser (zero-touch), 3 users clicking at once: ${j(iZ)}`, + `(P, diagnostic) browser with CORS patched: ${j(iP)}`, +]); + +// --- J --------------------------------------------------------------------- +const wall = (p) => p.report.wallMs; +results.timings = { + browserSequence: { baseline: [wall(base1), wall(base2)], zeroTouch: [wall(Z)], workaround: [wall(W1), wall(W2)] }, + direct20xR2: jDirect, +}; +save('j-overhead.json', results.timings); +verdict('J', 'MEASURED', [ + `browser S1-S6 wall time: no recorder ${wall(base1)} / ${wall(base2)} ms; recorded zero-touch ${wall(Z)} ms${Z.report.appDidNotRender ? ' (app did not render, not comparable)' : ''}; recorded (W config) ${wall(W1)} / ${wall(W2)} ms`, + `20 direct R2 requests: plain ${jDirect.plain.toFixed(0)} ms, recorded ${jDirect.recorded.toFixed(0)} ms (${((100 * (jDirect.recorded - jDirect.plain)) / jDirect.plain).toFixed(0)}%)`, +]); + +// --- L: the cross-map link ------------------------------------------------- +function linkCheck(pass, label) { + const out = {}; + const linksDir = path.join(WORK, 'links', label); + fs.rmSync(linksDir, { recursive: true, force: true }); + const link = sh(process.execPath, [LINK_CLI, pass.frontendDir, pass.backendDir, '--out', linksDir]); + out.linkStdout = link.out.trim(); + const baseS = Object.fromEntries(base1.report.steps.map((s) => [s.id, s])); + for (const id of ['S3', 'S5']) { + const s = stepOf(pass, id); + const r = { step: id }; + const wire = (s.wire ?? []).find((w) => w.method === 'POST' && w.url === FN_URL); + const fe = s.frontend[0] ? readMap(s.frontend[0]) : undefined; + const feReq = fe ? summarize(fe).clients.find((c) => c.url === FN_URL) : undefined; + const span = wire?.traceparent?.split('-')[2]; + r.L1 = !!(wire?.traceparent && fe && wire.traceparent.split('-')[1] === fe.metadata.trace_id && feReq?.traceparent === wire.traceparent); + r.L1quote = `wire traceparent=${wire?.traceparent ?? 'none'}; map trace_id=${fe?.metadata.trace_id ?? 'no map'}; map request traceparent=${feReq?.traceparent ?? 'none'}`; + const be = span ? listMaps(pass.backendDir).find((f) => readMap(f).metadata?.parent_span_id === span) : undefined; + r.L2 = !!(be && readMap(be).metadata.trace_id === fe?.metadata.trace_id); + r.L2quote = be ? `${path.basename(be)} trace_id=${readMap(be).metadata.trace_id} parent_span_id=${readMap(be).metadata.parent_span_id}` : 'no backend map with that parent_span_id'; + let links = { links: [], orphan_backends: [] }; + try { + links = JSON.parse(fs.readFileSync(path.join(linksDir, 'appmap-links.json'), 'utf8')); + } catch {} + const l = fe ? links.links.find((x) => x.interaction.path === s.frontend[0]) : undefined; + const lr = l?.requests.find((x) => x.request.url === FN_URL); + r.L3 = !!(lr?.backend && be && lr.backend.path === be) && links.orphan_backends.length === 0; + r.L3quote = `link: ${j(lr ?? null)}; orphans ${links.orphan_backends.length}`; + const puml = fe ? path.join(linksDir, path.basename(s.frontend[0]).replace(/\.appmap\.json$/, '.puml')) : undefined; + const diagram = puml && fs.existsSync(puml) ? fs.readFileSync(puml, 'utf8') : ''; + const has = { + click: /User -> FE : click button "Invoke Function"/.test(diagram), + handler: /invokeFunction/.test(diagram), + backend: new RegExp(`FE -> BE\\d : POST /functions/v1/${FN_NAME}`).test(diagram), + db: /rest\/v1\/users/.test(diagram), + }; + r.L4 = Object.values(has).every(Boolean); + r.L4quote = `diagram shows ${j(has)}${diagram ? '' : ' (no diagram written)'}`; + if (diagram) save(`links-${label}-${id}.puml`, diagram); + const same = (s.response ?? '') === (baseS[id]?.response ?? '') || (id === 'S5' && sameS5(s.response, baseS[id]?.response)); + r.L5 = same; + r.L5quote = `response ${JSON.stringify((s.response ?? '').replace(/\s+/g, ' ').slice(0, 100))} vs no recorder ${JSON.stringify((baseS[id]?.response ?? '').replace(/\s+/g, ' ').slice(0, 100))}`; + out[id] = r; + } + out.dialogs = pass.report.dialogs; + out.corsErrors = pass.report.console.filter((c) => /CORS/.test(c.text)).map((c) => c.text.slice(0, 220)); + return out; +} +function sameS5(a, b) { + try { + const x = JSON.parse(a); + const y = JSON.parse(b); + return x.data?.length === 1 && y.data?.length === 1 && x.data[0].id === x.user.id && y.data[0].id === y.user.id; + } catch { + return false; + } +} +const LZ = linkCheck(Z, 'Z'); +const LZL = linkCheck(ZL, 'ZL'); +const LW = linkCheck(W1, 'W'); +const LP = linkCheck(P, 'P'); +save('l-link.json', { zeroTouch: LZ, linkingConfig: LZL, workaroundDiagnostic: LW, corsPatchedDiagnostic: LP }); +const lStr = (L) => ['S3', 'S5'].map((id) => `${id}: ${['L1', 'L2', 'L3', 'L4', 'L5'].map((k) => `${k} ${L[id][k] ? 'ok' : 'FAIL'}`).join(', ')}`).join('; '); +// L is judged in the linking configuration: zero-touch plus +// propagateTraceHeaderOrigins for the function's origin, the one setting +// the recorder needs to stamp a cross-origin request (without it the +// recorder, by design, sends no traceparent there). +const lOk = ['S3', 'S5'].every((id) => ['L1', 'L2', 'L3', 'L4', 'L5'].every((k) => LZL[id][k])); +verdict('L', lOk ? 'PASS' : 'FAIL', [ + `linking config (zero-touch + propagateTraceHeaderOrigins, app unpatched): ${lStr(LZL)}`, + ` ZL S5: ${LZL.S5.L1quote}; ${LZL.S5.L2quote}; ${LZL.S5.L5quote}`, + ...LZL.corsErrors.slice(0, 1).map((c) => ` ZL browser console: ${c}`), + `zero-touch (no linking setting: cross-origin requests not stamped, by design): ${lStr(LZ)}`, + ` S5 ${LZ.S5.L1quote}; ${LZ.S5.L5quote}`, + `(W, diagnostic) ${lStr(LW)}`, + ` W S5: ${LW.S5.L1quote}; ${LW.S5.L2quote}; ${LW.S5.L5quote}`, + ...LW.corsErrors.slice(0, 1).map((c) => ` W browser console: ${c}`), + `(P, diagnostic, app CORS patched) ${lStr(LP)}`, + ` P S5: ${LP.S5.L2quote}; ${LP.S5.L3quote.slice(0, 200)}; ${LP.S5.L4quote}`, + ` appmap-link on P: ${LP.linkStdout.split('\n')[0]}`, +]); + +// ---------------------------------------------------------------- evidence +for (const [name, dir] of [ + ['Z/frontend', Z.frontendDir], ['Z/backend', Z.backendDir], ['W1/frontend', W1.frontendDir], ['W1/backend', W1.backendDir], + ['ZL/frontend', ZL.frontendDir], ['ZL/backend', ZL.backendDir], + ['P/frontend', P.frontendDir], ['P/backend', P.backendDir], ['R-run1/backend', R1run.dir], ['R-h/backend', Hrun.dir], + ['I-backend/backend', Ib.dir], ['P-parallel/frontend', Ppar.frontendDir], ['P-parallel/backend', Ppar.backendDir], +]) copyDir(dir, path.join(EVID, 'recordings', name)); +results.tools = { + node: process.version, + deno: sh('deno', ['--version']).stdout.split('\n')[0], + appmapCli: sh(APPMAP_CLI, ['--version']).out.trim(), + recorderRepoHead: sh('git', ['-C', ROOT, 'rev-parse', 'HEAD']).stdout.trim(), + appVersions: Object.fromEntries( + ['@supabase/supabase-js', '@supabase/auth-ui-react', 'react', 'react-dom', 'vite'].map((p) => { + try { + return [p, JSON.parse(fs.readFileSync(path.join(APP, 'node_modules', p, 'package.json'), 'utf8')).version]; + } catch { + return [p, null]; + } + }), + ), +}; +save('results.json', results); +let failed = false; +console.log('\n== summary'); +for (const k of ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'L']) { + const s = results.verdicts[k]?.status ?? 'NOT RUN'; + console.log(`${k} ${s}`); + if (!(s.startsWith('PASS') || s === 'MEASURED')) failed = true; +} +process.exit(failed ? 1 : 0); diff --git a/acceptance/supabase-edge-functions-app/infra/gateway.mjs b/acceptance/supabase-edge-functions-app/infra/gateway.mjs new file mode 100644 index 0000000..b460330 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/infra/gateway.mjs @@ -0,0 +1,99 @@ +// Local stand-in for the Supabase API gateway (Kong) on 127.0.0.1:54321, +// the URL the app's src/utils/supabaseClient.js falls back to. +// +// /auth/v1/* -> GoTrue (Kong `cors` plugin emulated: preflight +// /rest/v1/* -> PostgREST answered here, headers reflected) +// /functions/v1//* -> the one function being served, path rewritten +// to //*; OPTIONS passed through untouched, because +// edge functions handle their own CORS (that is why the +// example ships _shared/cors.ts). Unknown names -> 404. +// +// JWT verification for functions is off, as in the example README's +// documented local command (`supabase functions serve ... --no-verify-jwt`). +// Every request is appended to GATEWAY_LOG as one JSON line; that log is the +// wire-level ground truth the checks compare recordings against. +import http from 'node:http'; +import fs from 'node:fs'; + +const PORT = Number(process.env.GW_PORT ?? 54321); +const GOTRUE = `http://127.0.0.1:${process.env.GOTRUE_PORT ?? 54341}`; +const PGRST = `http://127.0.0.1:${process.env.PGRST_PORT ?? 54340}`; +const FUNCTION_NAME = process.env.FUNCTION_NAME ?? 'select-from-table-with-auth-rls'; +const FUNCTION_URL = `http://127.0.0.1:${process.env.FUNCTION_PORT ?? 18100}`; +const LOG = process.env.GATEWAY_LOG; + +const log = (rec) => LOG && fs.appendFileSync(LOG, JSON.stringify({ t: Date.now(), ...rec }) + '\n'); + +function kongCors(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) { + res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS,TRACE,CONNECT'); + if (req.headers['access-control-request-headers']) { + res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']); + } + res.writeHead(200).end(); + return true; + } + return false; +} + +async function proxy(req, res, target, body) { + const headers = { ...req.headers }; + for (const h of ['host', 'content-length', 'connection']) delete headers[h]; + let upstream; + try { + upstream = await fetch(target, { + method: req.method, + headers, + body: ['GET', 'HEAD'].includes(req.method) ? undefined : body, + redirect: 'manual', + }); + } catch (err) { + res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: String(err) })); + return 502; + } + const out = Buffer.from(await upstream.arrayBuffer()); + upstream.headers.forEach((v, k) => { + if (!['content-encoding', 'transfer-encoding', 'content-length', 'connection'].includes(k)) res.setHeader(k, v); + }); + res.writeHead(upstream.status).end(out); + return upstream.status; +} + +http + .createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const body = Buffer.concat(chunks); + const rec = { + method: req.method, + url: req.url, + traceparent: req.headers.traceparent ?? null, + acrh: req.headers['access-control-request-headers'] ?? null, + origin: req.headers.origin ?? null, + }; + let status; + let route; + const url = new URL(req.url, 'http://gateway'); + if (url.pathname.startsWith('/auth/v1/') || url.pathname.startsWith('/rest/v1/')) { + route = url.pathname.startsWith('/auth/') ? 'auth' : 'rest'; + if (kongCors(req, res)) status = 200; + else status = await proxy(req, res, (route === 'auth' ? GOTRUE : PGRST) + req.url.slice(8), body); + } else if (url.pathname.startsWith('/functions/v1/')) { + route = 'functions'; + const rest = req.url.slice('/functions/v1'.length); // "/..." + const name = decodeURIComponent(url.pathname.slice('/functions/v1/'.length).split('/')[0]); + if (name !== FUNCTION_NAME) { + res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `function ${name} not served` })); + status = 404; + } else { + status = await proxy(req, res, FUNCTION_URL + rest, body); + } + } else { + route = 'none'; + res.writeHead(404).end(); + status = 404; + } + log({ ...rec, route, status, allowHeaders: res.getHeader('access-control-allow-headers') ?? null }); + }) + .listen(PORT, '127.0.0.1', () => console.log(`gateway :${PORT} auth->${GOTRUE} rest->${PGRST} functions/${FUNCTION_NAME}->${FUNCTION_URL}`)); diff --git a/acceptance/supabase-edge-functions-app/infra/stack.sh b/acceptance/supabase-edge-functions-app/infra/stack.sh new file mode 100755 index 0000000..9207e1a --- /dev/null +++ b/acceptance/supabase-edge-functions-app/infra/stack.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Local stand-in for `supabase start` for the edge-functions example app: +# real PostgreSQL + real GoTrue (supabase/auth) + real PostgREST, all on +# 127.0.0.1. Nothing here talks to a deployed service. +# +# usage: stack.sh start|stop|reset +# env: WORK (scratch dir), APPDIR (supabase checkout), PGBIN, POSTGREST, +# GOTRUE (supabase/auth binary; its migrations/ dir must sit next to it) +# +# The JWT secret is the well-known local-dev secret the Supabase CLI uses, +# because the app's own src/utils/supabaseClient.js falls back to the CLI's +# well-known local anon key (signed with it) when no env is set. Using it +# means the app runs with no .env file at all. +set -euo pipefail +WORK=${WORK:?WORK must be set} +APPDIR=${APPDIR:-$WORK/supabase} +PGBIN=${PGBIN:-$(printf '%s\n' /usr/lib/postgresql/*/bin | sort -V | tail -1)} +PGPORT=${PGPORT:-54339} +PGRST_PORT=${PGRST_PORT:-54340} +GOTRUE_PORT=${GOTRUE_PORT:-54341} +POSTGREST=${POSTGREST:?POSTGREST must point at a postgrest binary} +GOTRUE=${GOTRUE:?GOTRUE must point at a supabase/auth binary} +JWT_SECRET=${JWT_SECRET:-super-secret-jwt-token-with-at-least-32-characters-long} +MIGRATIONS=$APPDIR/examples/edge-functions/supabase/migrations +PGDATA=$WORK/pgdata +RUNAS=() +if [ "$(id -u)" = 0 ]; then RUNAS=(runuser -u postgres --); fi +PSQL=(psql -q -h 127.0.0.1 -p "$PGPORT" -U postgres -d postgres -v ON_ERROR_STOP=1) +export PGOPTIONS="-c client_min_messages=warning" + +# What the supabase/postgres image sets up before any project migration runs: +# the API roles, the auth schema, and default grants on public so tables a +# migration creates are reachable through PostgREST (RLS still applies). +platform_sql() { +cat <<'SQL' +do $$ begin + if not exists (select 1 from pg_roles where rolname='anon') then create role anon nologin noinherit; end if; + if not exists (select 1 from pg_roles where rolname='authenticated') then create role authenticated nologin noinherit; end if; + if not exists (select 1 from pg_roles where rolname='service_role') then create role service_role nologin noinherit bypassrls; end if; + if not exists (select 1 from pg_roles where rolname='authenticator') then create role authenticator login noinherit password 'authpass'; end if; +end $$; +grant anon, authenticated, service_role to authenticator; +create schema if not exists auth; +create schema if not exists extensions; +grant usage on schema public to anon, authenticated, service_role; +grant usage on schema auth to anon, authenticated, service_role; +alter default privileges in schema public grant all on tables to anon, authenticated, service_role; +alter default privileges in schema public grant all on sequences to anon, authenticated, service_role; +alter default privileges in schema public grant all on functions to anon, authenticated, service_role; +SQL +} + +wait_http() { for _ in $(seq 1 100); do curl -s -o /dev/null "$1" && return 0; sleep 0.2; done; return 1; } + +gotrue_env() { + GOTRUE_ENV=(GOTRUE_DB_DRIVER=postgres + DATABASE_URL="postgres://postgres@127.0.0.1:$PGPORT/postgres?sslmode=disable&search_path=auth" + GOTRUE_DB_MIGRATIONS_PATH="$(dirname "$GOTRUE")/migrations" + GOTRUE_API_HOST=127.0.0.1 PORT="$GOTRUE_PORT" + API_EXTERNAL_URL=http://localhost:54321/auth/v1 GOTRUE_SITE_URL=http://127.0.0.1:3300 + GOTRUE_JWT_SECRET="$JWT_SECRET" GOTRUE_JWT_EXP=3600 GOTRUE_JWT_AUD=authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated GOTRUE_JWT_ADMIN_ROLES=service_role + GOTRUE_EXTERNAL_EMAIL_ENABLED=true GOTRUE_MAILER_AUTOCONFIRM=true GOTRUE_DISABLE_SIGNUP=false + GOTRUE_RATE_LIMIT_EMAIL_SENT=1000 GOTRUE_LOG_LEVEL=warn) +} + +start_services() { + # GoTrue: its own migrations into the auth schema, then serve. + gotrue_env + env "${GOTRUE_ENV[@]}" "$GOTRUE" migrate > "$WORK/gotrue-migrate.log" 2>&1 || + { echo "gotrue migrate failed"; cat "$WORK/gotrue-migrate.log"; exit 1; } + nohup env "${GOTRUE_ENV[@]}" "$GOTRUE" serve > "$WORK/gotrue.log" 2>&1 & + echo $! > "$WORK/gotrue.pid" + wait_http "http://127.0.0.1:$GOTRUE_PORT/health" || { echo "gotrue did not start"; cat "$WORK/gotrue.log"; exit 1; } +} + +start_postgrest() { + cat > "$WORK/postgrest.conf" < "$WORK/postgrest.log" 2>&1 & + echo $! > "$WORK/postgrest.pid" + wait_http "http://127.0.0.1:$PGRST_PORT/" || { echo "postgrest did not start"; cat "$WORK/postgrest.log"; exit 1; } +} + +apply_migrations() { + # Every migration the example project ships, in order, unmodified -- + # what `supabase db reset` would apply. + for f in "$MIGRATIONS"/*.sql; do "${PSQL[@]}" -f "$f" >/dev/null; done +} + +case "${1:-}" in + start) + mkdir -p "$WORK" + rm -rf "$PGDATA"; mkdir -p "$PGDATA" + [ "$(id -u)" = 0 ] && chown postgres "$PGDATA" + "${RUNAS[@]}" "$PGBIN/initdb" -D "$PGDATA" -A trust -U postgres >/dev/null + "${RUNAS[@]}" "$PGBIN/pg_ctl" -D "$PGDATA" -o "-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1" -l "$PGDATA/log" -w start >/dev/null + platform_sql | "${PSQL[@]}" >/dev/null + start_services + apply_migrations + start_postgrest + ;; + reset) + # Fresh users between runs: truncate auth + app tables, keep schema. + "${PSQL[@]}" -c "truncate auth.users cascade; truncate public.users;" >/dev/null + ;; + stop) + for p in gotrue postgrest; do + [ -f "$WORK/$p.pid" ] && kill "$(cat "$WORK/$p.pid")" 2>/dev/null || true + rm -f "$WORK/$p.pid" + done + [ -d "$PGDATA" ] && "${RUNAS[@]}" "$PGBIN/pg_ctl" -D "$PGDATA" -m fast stop >/dev/null 2>&1 || true + ;; + *) echo "usage: $0 start|stop|reset"; exit 2;; +esac diff --git a/acceptance/supabase-edge-functions-app/lib/util.mjs b/acceptance/supabase-edge-functions-app/lib/util.mjs new file mode 100644 index 0000000..de4f2fe --- /dev/null +++ b/acceptance/supabase-edge-functions-app/lib/util.mjs @@ -0,0 +1,328 @@ +// Shared helpers for the full-stack acceptance harness. +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +export const ACC = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +export const ROOT = path.resolve(ACC, '..', '..'); +export const WORK = process.env.WORK ?? '/tmp/acc-fullstack-work'; +export const SUPA = path.join(WORK, 'supabase'); +export const APP = path.join(SUPA, 'examples', 'edge-functions', 'app'); +export const FN_NAME = 'select-from-table-with-auth-rls'; +export const FN_REL = `examples/edge-functions/supabase/functions/${FN_NAME}/index.ts`; +export const CORS_REL = 'examples/edge-functions/supabase/functions/_shared/cors.ts'; +export const TOOLS = process.env.APPMAP_TOOLS ?? path.join(WORK, 'tools'); +export const APPMAP_CLI = path.join(TOOLS, 'node_modules', '.bin', 'appmap'); +export const LINK_CLI = path.join(ROOT, 'linker', 'bin', 'appmap-link.mjs'); +export const TRACE_CLI = path.join(ROOT, 'linker', 'bin', 'appmap-trace.mjs'); +export const APPMAP_DENO = path.join(ROOT, 'deno', 'bin', 'appmap-deno.ts'); +export const LOCK = path.join(ACC, 'deno.lock'); +export const GW = 'http://127.0.0.1:54321'; +export const FN_PORT = 18100; +export const GATEWAY_LOG = path.join(WORK, 'gateway.log'); +export const COLLECTOR_DIR = path.join(APP, 'tmp', 'appmap', 'interactions'); +// The Supabase CLI's well-known local anon key (also the app's own fallback, +// src/utils/supabaseClient.js:6), signed with the CLI's well-known local secret. +export const ANON = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs'; + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export function sh(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, ...opts }); + return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? ''), stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +/** Background process in its own process group; stop() kills the group. */ +export function bg(name, cmd, args, { cwd, env } = {}) { + const logFile = path.join(WORK, 'logs', `${name}.log`); + fs.mkdirSync(path.dirname(logFile), { recursive: true }); + const fd = fs.openSync(logFile, 'a'); + const proc = spawn(cmd, args, { cwd, env: env ?? process.env, detached: true, stdio: ['ignore', fd, fd] }); + fs.closeSync(fd); + return { + proc, + log: () => fs.readFileSync(logFile, 'utf8'), + async stop() { + if (proc.exitCode !== null || proc.signalCode) return; + try { + process.kill(-proc.pid, 'SIGTERM'); + } catch {} + for (let i = 0; i < 60 && proc.exitCode === null && !proc.signalCode; i++) await sleep(50); + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch {} + }, + }; +} + +export async function waitHttp(url, ms = 60000) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + try { + await fetch(url); + return true; + } catch {} + await sleep(200); + } + return false; +} + +export async function waitPortFree(url, ms = 20000) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + try { + await fetch(url); + } catch { + return true; + } + await sleep(200); + } + return false; +} + +/** The function, plain (`deno run`) or under the zero-touch runner. */ +export async function startFunction({ recorded, appmapDir, tag }) { + const env = { + ...process.env, + SUPABASE_URL: GW, + SUPABASE_ANON_KEY: ANON, + DENO_SERVE_ADDRESS: `tcp:127.0.0.1:${FN_PORT}`, + DENO_NO_UPDATE_CHECK: '1', + }; + if (appmapDir) env.APPMAP_DIR = appmapDir; + const args = recorded + ? ['--no-warnings', '--experimental-strip-types', APPMAP_DENO, '--app', FN_NAME, FN_REL, '--', `--lock=${LOCK}`] + : ['run', '-A', `--lock=${LOCK}`, FN_REL]; + await waitPortFree(`http://127.0.0.1:${FN_PORT}/`); + const p = bg(`fn-${tag}`, recorded ? process.execPath : 'deno', args, { cwd: SUPA, env }); + const t0 = Date.now(); + while (!/Listening on/.test(p.log())) { + if (p.proc.exitCode !== null) throw new Error(`function exited early:\n${p.log()}`); + if (Date.now() - t0 > 120000) throw new Error(`function did not start:\n${p.log()}`); + await sleep(100); + } + return p; +} + +export async function startVite(config, tag) { + fs.rmSync(path.join(APP, 'node_modules', '.vite'), { recursive: true, force: true }); + fs.rmSync(path.join(APP, 'tmp'), { recursive: true, force: true }); + await waitPortFree('http://127.0.0.1:3300/'); + const p = bg(`vite-${tag}`, path.join(APP, 'node_modules', '.bin', 'vite'), ['--config', config], { cwd: APP }); + if (!(await waitHttp('http://127.0.0.1:3300/'))) throw new Error(`vite did not start:\n${p.log()}`); + return p; +} + +export function drive(mode, out, runId, extraEnv = {}) { + const r = sh(process.execPath, [path.join(ACC, 'scripts', 'drive.mjs'), mode], { + env: { + ...process.env, + TOOLS, + OUT: out, + RUN_ID: runId, + COLLECTOR_DIR, + APP_URL: 'http://127.0.0.1:3300', + ...extraEnv, + }, + timeout: 600000, + }); + if (!fs.existsSync(out)) throw new Error(`drive ${mode} produced no report:\n${r.out}`); + return JSON.parse(fs.readFileSync(out, 'utf8')); +} + +export function listMaps(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith('.appmap.json')) + .sort() + .map((f) => path.join(dir, f)); +} + +export const readMap = (f) => JSON.parse(fs.readFileSync(f, 'utf8')); + +export async function waitForMap(dir, span, timeoutMs = 8000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + const hit = listMaps(dir).find((f) => { + try { + return readMap(f).metadata?.parent_span_id === span; + } catch { + return false; + } + }); + if (hit) return hit; + await sleep(50); + } + return undefined; +} + +let spanSeq = 0; +/** Deterministic traceparent for direct requests: fixed trace per tag. */ +export function traceparent(tag, n) { + const trace = Buffer.from(tag).toString('hex').padEnd(24, '0').slice(0, 24) + String(n).padStart(8, '0'); + const span = (String(n).padStart(8, '0') + String(++spanSeq).padStart(8, '0')).slice(0, 16); + return { header: `00-${trace}-${span}-01`, trace, span }; +} + +/** A direct request through the gateway, shaped like supabase-js's invoke. */ +export async function invokeDirect({ token, tp, method = 'POST', headers = {} }) { + const h = { apikey: ANON, 'x-client-info': 'acceptance-harness', 'content-type': 'text/plain;charset=UTF-8', ...headers }; + if (token) h.Authorization = `Bearer ${token}`; + if (tp) h.traceparent = tp.header; + const t0 = performance.now(); + const res = await fetch(`${GW}/functions/v1/${FN_NAME}`, { + method, + headers: h, + body: method === 'POST' ? JSON.stringify({ name: 'world' }) : undefined, + }); + const text = await res.text(); + return { status: res.status, text, headers: Object.fromEntries(res.headers), ms: performance.now() - t0 }; +} + +export async function signUp(email, password = 'acceptance-pass-123') { + const res = await fetch(`${GW}/auth/v1/signup`, { + method: 'POST', + headers: { apikey: ANON, 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const body = await res.json(); + if (!body.access_token) throw new Error(`sign-up failed: ${JSON.stringify(body)}`); + return { token: body.access_token, id: body.user.id }; +} + +export function gatewayLines() { + if (!fs.existsSync(GATEWAY_LOG)) return []; + return fs + .readFileSync(GATEWAY_LOG, 'utf8') + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)); +} + +/** Flatten an AppMap into what the checks compare. */ +/** url + '?' + the event's message parameters (name=value, in order). */ +export function withQuery(url, message) { + const q = (message ?? []).map((m) => `${m.name}=${m.value}`).join('&'); + return q ? `${url}?${q}` : url; +} + +export function summarize(appmap) { + const returns = new Map(appmap.events.filter((e) => e.event === 'return').map((e) => [e.parent_id, e])); + const out = { + name: appmap.metadata?.name, + trace_id: appmap.metadata?.trace_id, + parent_span_id: appmap.metadata?.parent_span_id, + servers: [], + functions: [], + clients: [], + sql: [], + unbalanced: [], + exceptions: [], + // Requests that got no HTTP response (network error, or none before the + // recording closed): the AppMap schema only lets an http_client_request + // be closed by a response with a 100-599 status, so the recorder lists + // them here instead of closing them with status 0 (docs/design/12). + unanswered: (appmap.metadata?.unanswered_http_requests ?? []).map((u) => ({ + method: u.request_method, + url: u.url, + reason: u.reason, + })), + }; + for (const e of appmap.events) { + if (e.event === 'return' && e.exceptions?.length) out.exceptions.push(...e.exceptions); + if (e.event !== 'call') continue; + const r = returns.get(e.id); + if (!r) out.unbalanced.push(e.id); + if (e.http_server_request) { + out.servers.push({ + method: e.http_server_request.request_method, + path: e.http_server_request.path_info, + traceparent: e.http_server_request.headers?.traceparent, + status: r?.http_server_response?.status_code, + }); + } else if (e.http_client_request) { + out.clients.push({ + method: e.http_client_request.request_method, + // AppMap spec: http_client_request.url is the URL "excluding the query + // string"; the query parameters are the event's `message`. Rebuild + // path+query from both so URL comparisons see what went on the wire. + url: withQuery(e.http_client_request.url, e.message), + traceparent: e.http_client_request.headers?.traceparent, + status: r?.http_client_response?.status_code, + }); + } else if (e.sql_query) { + out.sql.push(e.sql_query.sql); + } else if (e.method_id) { + out.functions.push({ + fn: `${e.defined_class}.${e.method_id}`, + method_id: e.method_id, + path: e.path, + lineno: e.lineno, + exceptions: r?.exceptions, + }); + } + } + return out; +} + +// --- official tools ------------------------------------------------------- +const toolsRequire = createRequire(path.join(TOOLS, 'package.json')); +const VERSIONS = ['1.2.0', '1.3.0', '1.4.0', '1.5.0', '1.5.1', '1.6.0', '1.7.0', '1.8.0', '1.9.0', '1.10.0', '1.11.0', '1.12.0', '1.13.0', '1.13.1']; +const firstLine = (err) => String(err && err.message).split('\n').slice(0, 6).join(' | '); + +/** Official validator (@appland/appmap-validate): the declared version, and + * the same content relabeled as every spec version. */ +export function validateFile(file) { + const { validate } = toolsRequire('@appland/appmap-validate'); + const data = readMap(file); + const entry = { file: path.relative(WORK, file), declared: data.version, declaredResult: null, perVersion: {} }; + try { + validate(data, {}); + entry.declaredResult = 'valid'; + } catch (err) { + entry.declaredResult = `INVALID: ${firstLine(err)}`; + } + for (const version of VERSIONS) { + try { + validate({ ...data, version }, { version }); + entry.perVersion[version] = 'valid'; + } catch (err) { + entry.perVersion[version] = `INVALID: ${firstLine(err)}`; + } + } + return entry; +} + +export function sequenceDiagrams(files, outDir) { + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + if (!files.length) return { code: 0, out: 'no files' }; + return sh(APPMAP_CLI, ['sequence-diagram', '--format', 'json', '--output-dir', outDir, ...files], { + env: { ...process.env, APPMAP_TELEMETRY_DISABLED: 'true' }, + }); +} + +/** Drop run-varying fields (timings, ids, uuids, trace ids) from a sequence-diagram JSON. */ +export function normalizeDiagram(d) { + const scrub = (s) => + s + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '') + .replace(/\b[0-9a-f]{32}\b/g, '') + .replace(/\b[0-9a-f]{16}\b/g, ''); + const strip = (n) => { + if (Array.isArray(n)) return n.map(strip); + if (n && typeof n === 'object') { + const o = {}; + for (const [k, v] of Object.entries(n)) { + if (['elapsed', 'eventIds', 'digest', 'subtreeDigest'].includes(k)) continue; + o[k] = strip(v); + } + return o; + } + return typeof n === 'string' ? scrub(n) : n; + }; + return strip(d); +} diff --git a/acceptance/supabase-edge-functions-app/run.sh b/acceptance/supabase-edge-functions-app/run.sh new file mode 100755 index 0000000..ee88cfa --- /dev/null +++ b/acceptance/supabase-edge-functions-app/run.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# One command: clean clone of supabase/supabase at the pinned SHA, then the +# full-stack acceptance run -- the edge-functions example React app in real +# Chromium, calling its select-from-table-with-auth-rls Deno function, both +# recorded, joined by appmap-link -- and checks A-J plus L (the cross-map +# link). Exits non-zero if any check is not PASS (J is MEASURED). +# +# Needs: node >= 22, git, curl, python3, PostgreSQL server binaries +# (initdb/pg_ctl/psql), network access to github.com, jsr.io and +# registry.npmjs.org for the one-time downloads. Everything the app talks to +# runs on 127.0.0.1; no deployed service is called. +# +# Env knobs: WORK (scratch dir, default /tmp/acc-fullstack-work; must be +# readable by the postgres user when run as root), DENO (deno binary), +# POSTGREST, GOTRUE (supabase/auth binary with migrations/ beside it), +# CHROME (Chromium executable; default: /opt/pw-browsers if present, else +# Playwright's own download), PGBIN (postgres bin dir). +set -uo pipefail + +ACC=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$ACC/../.." && pwd) +export WORK=${WORK:-/tmp/acc-fullstack-work} +APP_REPO=https://github.com/supabase/supabase +APP_SHA=74a3be9aa8706755e05f7326f3d25472729cd977 +GOTRUE_VERSION=v2.177.0 +POSTGREST_VERSION=v12.2.12 +APPMAP_CLI_VERSION=3.204.0 +VALIDATE_VERSION=2.5.1 +PLAYWRIGHT_VERSION=1.56.1 +mkdir -p "$WORK/bin" +export PATH="$WORK/bin:$PATH" +export APPMAP_TELEMETRY_DISABLED=true DENO_NO_UPDATE_CHECK=1 +SETUP_NOTES=() +setup_ok=1 +fail_setup() { setup_ok=0; SETUP_NOTES+=("$1"); echo "== SETUP FAILED: $1"; } + +# --- ports: refuse to run next to something else on them ---------------------- +for port in 54321 54339 54340 54341 18100 3300; do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + echo "port $port is already in use (another acceptance run?); stop it first" >&2 + exit 2 + fi +done + +# --- tools ------------------------------------------------------------------- +if [ -n "${DENO:-}" ]; then ln -sf "$DENO" "$WORK/bin/deno"; fi +# shellcheck disable=SC2015 # "download && unpack || fail_setup" is meant +if ! command -v deno >/dev/null; then + echo "== installing deno (latest 2.x release binary)" + curl -fsSL -o "$WORK/deno.zip" https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip && + python3 -c "import zipfile;zipfile.ZipFile('$WORK/deno.zip').extractall('$WORK/bin')" && chmod +x "$WORK/bin/deno" || + fail_setup "deno download" +fi + +if [ -z "${POSTGREST:-}" ]; then + if [ ! -x "$WORK/bin/postgrest" ]; then + echo "== downloading PostgREST $POSTGREST_VERSION" + curl -fsSL "https://github.com/PostgREST/postgrest/releases/download/$POSTGREST_VERSION/postgrest-$POSTGREST_VERSION-linux-static-x86-64.tar.xz" | + tar xJ -C "$WORK/bin" || fail_setup "postgrest download" + fi + export POSTGREST="$WORK/bin/postgrest" +fi + +if [ -z "${GOTRUE:-}" ]; then + if [ ! -x "$WORK/gotrue/auth" ]; then + echo "== downloading GoTrue (supabase/auth) $GOTRUE_VERSION" + mkdir -p "$WORK/gotrue" + curl -fsSL "https://github.com/supabase/auth/releases/download/$GOTRUE_VERSION/auth-$GOTRUE_VERSION-x86.tar.gz" | + tar xz -C "$WORK/gotrue" || fail_setup "gotrue download" + fi + export GOTRUE="$WORK/gotrue/auth" +fi + +export APPMAP_TOOLS="$WORK/tools" +if [ ! -x "$APPMAP_TOOLS/node_modules/.bin/appmap" ] || [ ! -d "$APPMAP_TOOLS/node_modules/playwright-core" ]; then + echo "== installing official AppMap CLI + validator, playwright-core" + mkdir -p "$APPMAP_TOOLS" + (cd "$APPMAP_TOOLS" && { [ -f package.json ] || npm init -y >/dev/null; } && + npm install --no-audit --no-fund --silent "@appland/appmap@$APPMAP_CLI_VERSION" \ + "@appland/appmap-validate@$VALIDATE_VERSION" "playwright-core@$PLAYWRIGHT_VERSION") || fail_setup "tools install" +fi +if [ -z "${CHROME:-}" ] && [ -x /opt/pw-browsers/chromium-1194/chrome-linux/chrome ]; then + export CHROME=/opt/pw-browsers/chromium-1194/chrome-linux/chrome +fi + +# recorder deps (babel for the build-time transform) at the recorder repo root +[ -d "$ROOT/node_modules/@babel/core" ] || (cd "$ROOT" && npm ci --no-audit --no-fund --silent) || fail_setup "recorder npm ci" +# the recorder package's entry points are its built dist/ (README: build +# first when installing from a checkout) +(cd "$ROOT" && npm run build --workspace recorder > "$WORK/recorder-build.log" 2>&1) || fail_setup "recorder build (see $WORK/recorder-build.log)" +VITE_VERSION=$(node -p "require('$ROOT/node_modules/vite/package.json').version") + +# --- app at the pinned SHA (clean clone every run) --------------------------- +echo "== cloning $APP_REPO @ $APP_SHA (sparse: examples/edge-functions app + function)" +rm -rf "$WORK/supabase" +git init -q "$WORK/supabase" +git -C "$WORK/supabase" remote add origin "$APP_REPO" +git -C "$WORK/supabase" config core.sparseCheckout true +cat > "$WORK/supabase/.git/info/sparse-checkout" </dev/null)" = "$APP_SHA" ] || fail_setup "app HEAD is not $APP_SHA" +APP="$WORK/supabase/examples/edge-functions/app" + +echo "== installing the app (npm install --legacy-peer-deps: its documented npm install fails on npm >= 7)" +(cd "$APP" && npm install --no-audit --no-fund --ignore-scripts --legacy-peer-deps > "$WORK/app-npm-install.log" 2>&1) || + fail_setup "app npm install (see $WORK/app-npm-install.log)" +echo "== adding vite@$VITE_VERSION and the recorder as dev dependencies" +(cd "$APP" && npm install -D --no-audit --no-fund --ignore-scripts --legacy-peer-deps "vite@$VITE_VERSION" "file:$ROOT/recorder" \ + > "$WORK/app-npm-add.log" 2>&1) || fail_setup "adding vite + recorder" +cp "$ACC"/app-config/*.mjs "$APP/" + +# --- local stack: Postgres + GoTrue + PostgREST -------------------------------- +export APPDIR="$WORK/supabase" +"$ACC/infra/stack.sh" stop >/dev/null 2>&1 || true +"$ACC/infra/stack.sh" start || fail_setup "local stack (postgres/gotrue/postgrest) did not start" +trap '"$ACC/infra/stack.sh" stop' EXIT + +echo "== versions" +deno --version | head -1 +node --version +"$POSTGREST" --version 2>/dev/null | head -1 +"$GOTRUE" version 2>/dev/null | head -1 +"$APPMAP_TOOLS/node_modules/.bin/appmap" --version +echo "repo HEAD $(git -C "$ROOT" rev-parse HEAD); recorder code last changed in $(git -C "$ROOT" log -1 --format=%H -- recorder deno linker); app $APP_SHA" + +note="clean sparse clone at $APP_SHA; npm install --legacy-peer-deps; vite@$VITE_VERSION + recorder added as dev deps; GoTrue $GOTRUE_VERSION, PostgREST $POSTGREST_VERSION" +[ ${#SETUP_NOTES[@]} -gt 0 ] && note="$note; FAILED: ${SETUP_NOTES[*]}" +python3 -c "import json,sys; json.dump({'ok': sys.argv[1] == '1', 'note': sys.argv[2]}, open(sys.argv[3], 'w'))" "$setup_ok" "$note" "$WORK/setup.json" +if [ "$setup_ok" != 1 ]; then + echo "A FAIL (setup): $note" + exit 1 +fi + +node "$ACC/harness.mjs" 2>&1 | tee "$WORK/harness.log" +rc=${PIPESTATUS[0]} +cp "$WORK/harness.log" "$ACC/evidence/run.log" 2>/dev/null +exit "$rc" diff --git a/acceptance/supabase-edge-functions-app/scripts/drive.mjs b/acceptance/supabase-edge-functions-app/scripts/drive.mjs new file mode 100644 index 0000000..1660324 --- /dev/null +++ b/acceptance/supabase-edge-functions-app/scripts/drive.mjs @@ -0,0 +1,240 @@ +// Drive the real, unmodified app in real Chromium through real user +// interactions (EXPECTATIONS.md S1-S6), and record for every step which +// collector files (frontend interaction AppMaps) appeared and which HTTP +// requests actually went over the wire, with their headers. +// +// usage: node drive.mjs sequence|parallel +// env: APP_URL, CHROME (optional executable), OUT (json report path), +// COLLECTOR_DIR (/tmp/appmap/interactions), RUN_ID (email suffix), +// TOOLS (dir whose node_modules has playwright-core), PARALLEL (N users) +import { createRequire } from 'node:module'; +import { readdirSync, existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const require = createRequire(join(process.env.TOOLS, 'package.json')); +const { chromium } = require('playwright-core'); + +const MODE = process.argv[2] ?? 'sequence'; +const APP_URL = process.env.APP_URL ?? 'http://127.0.0.1:3300'; +const COLLECTOR_DIR = process.env.COLLECTOR_DIR; +const RUN_ID = process.env.RUN_ID ?? String(Date.now()); +const FN = 'select-from-table-with-auth-rls'; +const PASSWORD = 'acceptance-pass-123'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const listMaps = () => + COLLECTOR_DIR && existsSync(COLLECTOR_DIR) + ? readdirSync(COLLECTOR_DIR).filter((f) => f.endsWith('.appmap.json')).sort() + : []; + +// Wait until no new collector file for `quietMs` (window idles out after +// >= 250 ms, then POSTs), capped at `maxMs`. +async function settle(quietMs = 1500, maxMs = 15000) { + const start = Date.now(); + let last = listMaps().length; + let lastChange = Date.now(); + while (Date.now() - start < maxMs) { + await sleep(100); + const n = listMaps().length; + if (n !== last) { + last = n; + lastChange = Date.now(); + } + if (Date.now() - lastChange >= quietMs) break; + } +} + +const browser = await chromium.launch({ + executablePath: process.env.CHROME || undefined, + headless: true, + args: [ + '--disable-background-networking', + '--disable-component-update', + '--no-first-run', + '--disable-sync', + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1', + ], +}); + +async function newPage(report, tag) { + const context = await browser.newContext(); + const page = await context.newPage(); + // CPU_THROTTLE=N slows the page's CPU N-fold (Chrome DevTools), to + // reproduce slow CI runners locally. + if (Number(process.env.CPU_THROTTLE ?? 1) > 1) { + const cdp = await context.newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: Number(process.env.CPU_THROTTLE) }); + } + // Local only. Not via page.route(): with request interception on, + // Playwright answers CORS preflights itself, which would hide exactly the + // preflight behaviour this harness has to observe. External hosts are made + // unresolvable at the browser's DNS layer instead (launch args below). + page.on('request', (r) => { + const u = new URL(r.url()); + if (u.port !== '54321') return; // only the Supabase gateway (app traffic) + report.wire.push({ tag, t: Date.now(), method: r.method(), url: r.url(), headers: r.headers() }); + }); + // Error responses from the Supabase gateway, with their bodies, so a + // failing step can be diagnosed from the report alone. + page.on('response', async (res) => { + const u = new URL(res.url()); + if (u.port !== '54321' || res.status() < 400) return; + let body = ''; + try { + body = (await res.text()).slice(0, 400); + } catch {} + report.errorResponses = report.errorResponses ?? []; + report.errorResponses.push({ tag, status: res.status(), method: res.request().method(), url: res.url(), body }); + }); + page.on('requestfailed', (r) => { + const u = new URL(r.url()); + if (!['localhost', '127.0.0.1'].includes(u.hostname)) report.blockedExternal.push(r.url()); + if (u.port !== '54321') return; + report.failed.push({ tag, method: r.method(), url: r.url(), error: r.failure()?.errorText }); + }); + page.on('console', (m) => report.console.push({ tag, type: m.type(), text: m.text().slice(0, 500) })); + page.on('pageerror', (e) => report.pageErrors.push({ tag, error: String(e).slice(0, 500) })); + page.on('dialog', async (d) => { + report.dialogs.push({ tag, message: d.message().slice(0, 300) }); + await d.dismiss(); + }); + return { context, page }; +} + +async function responseText(page) { + return (await page.locator('pre').first().textContent())?.trim() ?? ''; +} + +async function step(report, id, desc, fn) { + if (report.appDidNotRender) { + report.steps.push({ id, desc, error: 'skipped: the app did not render (S1 failed)', newMaps: [], wire: [], response: null }); + return; + } + const before = new Set(listMaps()); + const wireBefore = report.wire.length; + const t0 = Date.now(); + let error; + try { + await fn(); + } catch (e) { + error = String(e).slice(0, 500); + } + await settle(); + report.steps.push({ + id, + desc, + ms: Date.now() - t0, + error, + newMaps: listMaps().filter((f) => !before.has(f)), + wire: report.wire.slice(wireBefore).map((w) => ({ method: w.method, url: w.url, traceparent: w.headers.traceparent ?? null })), + response: step.page ? await responseText(step.page).catch(() => null) : null, + }); +} + +async function signUp(page, email) { + // @supabase/auth-ui-react 0.2.x shows the sign-in view first. Its inputs are + // uncontrolled, and switching views runs a useEffect that resets the form's + // React state to the values carried over from the previous view + // (EmailAuth.js: handleViewChange + useEffect(..., [authView])). Typing + // into the sign-up view before that effect has run leaves the DOM filled + // but the state empty, and the app POSTs an empty email (GoTrue: 422 + // "Anonymous sign-ins are disabled") -- seen on slower Actions runners and + // reproduced locally by delaying React's scheduler. So type the email and + // password in the sign-in view, then switch: the app itself carries both + // over to the sign-up view, whatever the timing. + await page.locator('input[name="email"]').fill(email); + await page.locator('input[name="password"]').fill(PASSWORD); + await page.getByText("Don't have an account? Sign up").click(); + await page.getByRole('button', { name: 'Sign up' }).click(); + await page.getByText(`Logged in as ${email}`).waitFor({ timeout: 15000 }); +} + +async function invoke(page) { + await page.getByRole('button', { name: 'Invoke Function' }).click(); + await page.waitForFunction(() => !document.querySelector('pre')?.textContent?.includes('"loading"'), null, { + timeout: 15000, + }); +} + +async function sequence() { + const report = { mode: 'sequence', steps: [], wire: [], failed: [], console: [], pageErrors: [], dialogs: [], blockedExternal: [] }; + const { page } = await newPage(report, 'seq'); + step.page = page; + const email = `seq-${RUN_ID}@example.com`; + await step(report, 'S1', 'load the app', async () => { + await page.goto(APP_URL); + await page.getByRole('button', { name: 'Invoke Function' }).waitFor({ timeout: 30000 }); + }); + if (report.steps[0].error) report.appDidNotRender = true; + await step(report, 'S2', 'invoke with the default dropdown entry (not served) -> 404', () => invoke(page)); + await step(report, 'S3', `select ${FN}, invoke signed out -> 200, no rows`, async () => { + await page.locator('select').selectOption(FN); + await invoke(page); + }); + await step(report, 'S4', 'sign up (auto-confirmed) -> signed in', () => signUp(page, email)); + await step(report, 'S5', 'invoke signed in -> 200, own row only (RLS)', () => invoke(page)); + await step(report, 'S6', 'sign out', async () => { + await page.getByRole('button', { name: 'Sign out' }).click(); + await page.getByText("Don't have an account? Sign up").waitFor({ timeout: 15000 }); + }); + report.email = email; + return report; +} + +async function parallel() { + const n = Number(process.env.PARALLEL ?? 3); + const report = { mode: 'parallel', users: [], steps: [], wire: [], failed: [], console: [], pageErrors: [], dialogs: [], blockedExternal: [] }; + const pages = []; + for (let i = 0; i < n; i++) { + const tag = `par${i}`; + const { page } = await newPage(report, tag); + const email = `par${i}-${RUN_ID}@example.com`; + await page.goto(APP_URL); + try { + await page.getByRole('button', { name: 'Invoke Function' }).waitFor({ timeout: 30000 }); + } catch (e) { + report.appDidNotRender = true; + report.steps.push({ id: 'I', desc: 'app did not render', error: String(e).slice(0, 300), newMaps: [], wire: [] }); + return report; + } + await page.locator('select').selectOption(FN); + // Sign-up is the first thing typed on this page: let the form's mount + // effects run first (they reset its state; see signUp). + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + try { + await signUp(page, email); + } catch (e) { + report.steps.push({ id: `I-signup-${tag}`, desc: `sign up ${tag}`, error: String(e).slice(0, 300), newMaps: [], wire: [] }); + continue; + } + pages.push({ tag, page, email }); + } + await settle(); + const before = new Set(listMaps()); + const wireBefore = report.wire.length; + // All N users click "Invoke Function" at the same moment. + await Promise.all(pages.map(({ page }) => page.getByRole('button', { name: 'Invoke Function' }).click())); + await Promise.all( + pages.map(({ page }) => + page.waitForFunction(() => !document.querySelector('pre')?.textContent?.includes('"loading"'), null, { timeout: 20000 }), + ), + ); + await settle(); + for (const p of pages) report.users.push({ tag: p.tag, email: p.email, response: await responseText(p.page) }); + report.steps.push({ + id: 'I', + desc: `${n} users invoke concurrently`, + newMaps: listMaps().filter((f) => !before.has(f)), + wire: report.wire.slice(wireBefore).map((w) => ({ tag: w.tag, method: w.method, url: w.url, traceparent: w.headers.traceparent ?? null })), + }); + return report; +} + +let report; +try { + report = MODE === 'parallel' ? await parallel() : await sequence(); +} finally { + await browser.close(); +} +writeFileSync(process.env.OUT, JSON.stringify(report, null, 2)); +console.log(JSON.stringify(report.steps.map((s) => ({ id: s.id, error: s.error, maps: s.newMaps.length, wire: s.wire.length })))); diff --git a/acceptance/supabase-restful-tasks/EXPECTATIONS.md b/acceptance/supabase-restful-tasks/EXPECTATIONS.md new file mode 100644 index 0000000..888bc85 --- /dev/null +++ b/acceptance/supabase-restful-tasks/EXPECTATIONS.md @@ -0,0 +1,169 @@ +# Expectations: AppMap Deno recorder on supabase `restful-tasks` + +Written from reading the app's source **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 `upstream-pr/deno-waituntil-trace-agent` + (same content as draft PR getappmap/appmap-react#1) +- Commit: `bef9d18c693c4873752539d8ca88c820ed98060d` +- Entry points used: `deno/bin/appmap-deno.ts` (zero-touch runner, `deno run --preload deno/preload.ts`), + which wraps `Deno.serve` with `withAppMap` from `deno/appmap.ts`. + +## Target app and why + +- **App:** Supabase's official example edge function `restful-tasks`, + `examples/edge-functions/supabase/functions/restful-tasks/index.ts` in + [supabase/supabase](https://github.com/supabase/supabase). +- **Pinned commit:** `74a3be9aa8706755e05f7326f3d25472729cd977` (2025-06-09). +- **Why this app:** + - It is the kind of code this recorder is meant for: a Supabase edge function (see doc 05, which + names a Supabase project as the downstream user). It is a real, published example + that the recorder's authors did not write. + - Plain `Deno.serve(async (req) => ...)` (line 68). No framework. + - It has 6 routes behind one handler: `OPTIONS *`, `GET /restful-tasks`, `GET /restful-tasks/:id`, + `POST /restful-tasks`, `PUT /restful-tasks/:id`, `DELETE /restful-tasks/:id` (lines 72-117). + - It uses a database through `@supabase/supabase-js` (`jsr:@supabase/supabase-js@2`, line 5). + The client talks to PostgREST over `fetch`, so all database work shows up as outbound HTTP. +- **Why this commit and not HEAD:** at supabase HEAD (`8ab2197e`, 2026-09), every example + function was migrated to `export default { fetch: withSupabase(...) }` (commit d5fde192, + 2026-06-26). That is the `deno serve` convention, which never calls `Deno.serve()`. The task + asks for plain `Deno.serve`, so HEAD does not qualify. From 3c390c7 (2025-06-10) until that + migration, the file imports `npm:supabase-js@2`. That npm package is a security placeholder + (`0.0.1-security`), so those versions cannot run at all. `74a3be9` is the last commit + where `restful-tasks` is plain `Deno.serve` and imports the real client + (`jsr:@supabase/supabase-js@2`). +- **Known weaknesses of this choice, stated in advance:** + - It is a single file, and all of its named functions are top-level. That suits a recorder + that only instruments the entry file (doc 06 non-goal). + - It has **no `EdgeRuntime.waitUntil`**, so the waitUntil checks use a separate synthetic probe + (below). + - It has **no test suite**, so check F and the "record `deno test`" item have nothing to run against. +- **Local services (all on 127.0.0.1, nothing deployed):** + - Real PostgreSQL 16 (system install) with a `public.tasks(id bigserial, name text, status int)` + table. There is no migration for it in the repo, so the harness creates it. + - Real PostgREST 12.2.12 (release binary) in front of that database. + - A small Node reverse proxy on `:54321` that maps `/rest/v1/*` to PostgREST, as Supabase's Kong + gateway does. It logs each request's method, URL and `traceparent` header. That log is the + ground truth for outbound calls. + - Env for the function: `SUPABASE_URL=http://127.0.0.1:54321`, `SUPABASE_ANON_KEY=`. + Callers send `Authorization: Bearer `, signed with the local + PostgREST secret. +- **No app source edits.** The recorder gets only a run command, `--app restful-tasks` and env vars. + +## Source facts the expectations rest on (index.ts @ 74a3be9) + +| Line | Fact | +|---|---| +| 5 | `import { createClient, SupabaseClient } from 'jsr:@supabase/supabase-js@2'` | +| 18-26 | `async function getTask(supabaseClient, id)`: `.from('tasks').select('*').eq('id', id)`; `if (error) throw error` (20); returns 200 JSON `{task}` | +| 28-36 | `async function getAllTasks(supabaseClient)`: `.from('tasks').select('*')`; returns 200 `{tasks}` | +| 38-46 | `async function deleteTask(supabaseClient, id)`: `.from('tasks').delete().eq('id', id)`; returns 200 `{}` | +| 48-56 | `async function updateTask(supabaseClient, id, task)`: `.from('tasks').update(task).eq('id', id)`; returns 200 `{task}` | +| 58-66 | `async function createTask(supabaseClient, task)`: `.from('tasks').insert(task)`; returns 200 `{task}` | +| 68 | `Deno.serve(async (req) => {`: the handler is an **anonymous arrow** | +| 72-74 | `OPTIONS` returns `new Response('ok', {headers: corsHeaders})` (200) before any client is made | +| 78-90 | `createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {global:{headers:{Authorization: req.headers.get('Authorization')!}}})` | +| 93-95 | `URLPattern({pathname: '/restful-tasks/:id'})` gives the id | +| 98-101 | `POST`/`PUT` read `await req.json()` and use `body.task` | +| 104-117 | dispatch, `return getTask(...)` etc. **without `await`** | +| 118-125 | `catch (error)` returns 400 `{error: error.message}`. Because 106-116 return the promise without awaiting it, a rejection from `getTask` & co. is **not** caught here. It escapes the `Deno.serve` handler and Deno answers 500. | + +## Ground-truth requests (check C) + +All requests go to `http://127.0.0.1:8000`. "Stamped" means the request carries a valid +`traceparent: 00-<32 hex>-<16 hex>-01`. Each stamped request uses a distinct trace id. +Task ids refer to rows the harness seeds before the run: id 1 "seed-1", id 2 "seed-2", id 3 "seed-3". + +A correct recording of each stamped request must contain: + +| # | Request | Server event | App functions (call + return) | Outbound HTTP (http_client_request, then response) | Exceptions | +|---|---|---|---|---|---| +| R1 | `OPTIONS /restful-tasks` | `http_server_request` OPTIONS `/restful-tasks`, then `http_server_response` 200 | anonymous handler (68) only. No named function. | none | none | +| R2 | `GET /restful-tasks` | GET `/restful-tasks` 200 | `index.getAllTasks` (index.ts:28), 1 call, returns a `Response` | GET `http://127.0.0.1:54321/rest/v1/tasks?select=*` 200. Table `tasks`, no filter. | none | +| R3 | `GET /restful-tasks/1` | GET `/restful-tasks/1` 200 | `index.getTask` (index.ts:18), params (supabaseClient, `"1"`) | GET `.../rest/v1/tasks?select=*&id=eq.1` 200. Table `tasks`, `WHERE id = 1`. | none | +| R4 | `POST /restful-tasks` body `{"task":{"name":"acc-new","status":0}}` | POST `/restful-tasks` 200 | `index.createTask` (index.ts:58), params (supabaseClient, `{name:"acc-new",status:0}`) | POST `.../rest/v1/tasks` 201 (insert into `tasks`) | none | +| R5 | `PUT /restful-tasks/1` body `{"task":{"name":"renamed","status":1}}` | PUT `/restful-tasks/1` 200 | `index.updateTask` (index.ts:48), params (supabaseClient, `"1"`, task) | PATCH `.../rest/v1/tasks?id=eq.1` 204. `UPDATE tasks ... WHERE id = 1`. | none | +| R6 | `DELETE /restful-tasks/2` | DELETE `/restful-tasks/2` 200 | `index.deleteTask` (index.ts:38), params (supabaseClient, `"2"`) | DELETE `.../rest/v1/tasks?id=eq.2` 204. `DELETE FROM tasks WHERE id = 2`. | none | +| R7 | `GET /restful-tasks/not-a-number` | GET 500 (the rejection escapes; see line 118 note) | `index.getTask` (18), whose return event carries `exceptions: [{class: "PostgrestError", message contains 'invalid input syntax for type bigint: "not-a-number"'}]` | GET `.../rest/v1/tasks?select=*&id=eq.not-a-number` 400 | the PostgrestError above (check E) | +| R8 | `POST /restful-tasks` with body `not json` | POST 400 (caught at 118, body `{error: ...}`) | anonymous handler only. The SyntaxError is caught inside it, so no exception event is expected on a named function. | none | none on named functions | + +Across all of the above: +- `sql_query` events: **none expected.** The app has no SQL driver. Every DB touch is an HTTP + call to PostgREST, so the table and filter evidence is the request URL (method + path + filter). +- The anonymous `Deno.serve` arrow (line 68) is the real entry function. A complete recording + would show it, but it has no name, and the recorder only wraps named top-level declarations + (transform.ts). **Decided now:** its absence is reported as a gap in C and does not by + itself fail C. C passes if every row's server event, status, named functions (with the + right params) and outbound calls are present and correct, and there is nothing extra. +- supabase-js internals (`jsr:`/`npm:` code) must not appear as function events (check D). Only + `index.ts` functions should appear. + +## traceparent gate (from doc 05, deno/appmap.ts:105-111) + +- T1. `GET /restful-tasks` with **no** `traceparent`: request served normally (200), **no** AppMap file written. +- T2. `GET /restful-tasks` with a **malformed** `traceparent` (e.g. version `ff`/uppercase hex/short id): no file. +- T3. A stamped request: exactly one file. `metadata.trace_id` equals the incoming trace id and + `metadata.parent_span_id` equals the incoming span id. `http_server_request.headers.traceparent` + equals the incoming header. +- T4. A stamped request's outbound PostgREST calls carry `traceparent: 00---01`. + This shows in the proxy log and in `http_client_request.headers.traceparent`. +- T5. An unstamped request's outbound PostgREST calls carry **no** recorder-added `traceparent` + (proxy log). Doc 02/05 say production traffic is left untouched. +- T6. doc 05: a stamped request that arrives while another recording is open runs unrecorded (no file). + +## waitUntil (synthetic probe, not the app) + +The app has no `EdgeRuntime.waitUntil`. A separate, clearly labelled **synthetic probe** +`acceptance/supabase-restful-tasks/probe/probe.ts` (Supabase edge-function style, plain +`Deno.serve`) is used instead. It defines a minimal `EdgeRuntime.waitUntil` shim only if the +host has none: plain `deno run` has no `EdgeRuntime`, and Supabase Edge Runtime cannot be +reached by the zero-touch runner (doc 06). Routes: + +- W1. `POST /probe/ingest?n=`: calls `EdgeRuntime.waitUntil(ingest(k))` and returns **202** at once. + `ingest` does: POST `/rest/v1/tasks` (insert `probe-`), sleeps 1.5 s, GET the local + "enrich" stub `http://127.0.0.1:54399/enrich?n=`, sleeps 1.5 s, PATCH + `/rest/v1/tasks?name=eq.probe-`. Expected: + - the client gets 202 in well under 1 s; + - the recording contains `http_server_response` 202, one `ingest` call/return pair, and all 3 + `http_client_request`s with their responses (201, 200, 204); + - the file appears only after the background work finishes (at least 3 s after the 202), and + has no `truncated` flag; + - the events are balanced. +- W2. `POST /probe/fire-and-forget?n=`: starts `ingest(k)` **without** waitUntil and returns 202. + Expected per doc 11: the recording closes at the response, and the still-open `ingest` call + gets a synthesized return with `metadata.truncated: true`. This is the self-heal path. +- W3. Crash mid-recording: start W1, then `kill -9` the deno process about 1.5 s into the + background window. Doc 11 claims "even a genuinely killed isolate yields a balanced, + sanitizable, committable (if incomplete) map". Expected by that claim: a file with + `truncated: true` and balanced events. (Predicted from reading `deno/appmap.ts`: `ship()` runs + only in `finalize()`, so a killed process writes **nothing**. The run decides which is true.) + The same is repeated with SIGTERM and SIGINT through the `appmap-deno` wrapper. + +## Other checks + +- B. `appmap-validate` (official, appmap-js `packages/validate`) and `appmap index` / + `appmap sequence-diagram` must accept every recording. Declared `version: "1.12"` must be + honest. Required fields (spec README) that I expect to be **missing** from reading + recording.ts/appmap.ts: `metadata.language.version` (appmap.ts:119 sets name+engine only) and + exception `object_id` (recording.ts:216-219). Also expected: `http_client_request.url` includes + the query string, though the spec says "excluding the query string". +- D. Events by package: only the app package (path of `index.ts`). Zero events from supabase-js, + jsr/npm deps, or the recorder itself. +- G. The same request sequence recorded twice gives identical normalized sequence diagrams per request. +- H. One change on a scratch copy: `deleteTask` first reads the row + (`.from('tasks').select('*').eq('id', id)`) before deleting. Expected diff: only the DELETE + request changes, gaining exactly one GET `.../rest/v1/tasks?select=*&id=eq.`. Every other + request is unchanged. +- I. 20 stamped requests fired at once across the routes, each with a unique trace id and + (where it has one) a unique task id, mixed with unstamped ones. Expected by doc 05/11: some + requests are recorded and the rest run unrecorded. **Every file that exists must contain only + its own request**: one `http_server_request` whose path matches, named functions for that route + only, and outbound URLs with its own id only. In the proxy log, every outbound call stamped + with that file's trace id must belong to that request. +- J. Overhead: wall time for 200 sequential mixed requests, plain `deno run` vs `appmap-deno` + with every request stamped. +- F. The app has no tests, and the recorder has no Deno test-recording mode. Expected: NOT + APPLICABLE / NOT RUN, with the reason. diff --git a/acceptance/supabase-restful-tasks/KNOWN_FAILURES.json b/acceptance/supabase-restful-tasks/KNOWN_FAILURES.json new file mode 100644 index 0000000..9ac2490 --- /dev/null +++ b/acceptance/supabase-restful-tasks/KNOWN_FAILURES.json @@ -0,0 +1,54 @@ +{ + "note": "Every verdict this suite must produce. A FAIL here is a known, documented limitation (see RESULTS.md); it is still reported as FAIL. CI fails if any verdict differs from this file, if a known failure starts passing, or if a failing check's evidence line changes. Update this file only together with RESULTS.md.", + "checks": { + "C": { + "verdict": "PASS" + }, + "C-traceparent-gate": { + "verdict": "PASS" + }, + "C-structure": { + "verdict": "PASS" + }, + "D": { + "verdict": "PASS" + }, + "E": { + "verdict": "PASS" + }, + "G": { + "verdict": "PASS" + }, + "H": { + "verdict": "PASS" + }, + "I": { + "verdict": "PASS" + }, + "W1-waitUntil-capture": { + "verdict": "PASS" + }, + "W4-waitUntil-overlap": { + "verdict": "PASS" + }, + "W2-self-heal-fire-and-forget": { + "verdict": "PASS" + }, + "W3-crash-self-heal": { + "verdict": "PASS" + }, + "J": { + "verdict": "PASS" + }, + "B": { + "verdict": "PASS" + }, + "A": { + "verdict": "PASS" + }, + "F": { + "verdict": "NOT RUN", + "reason": "The app has no tests, and the Deno recorder has no test-recording mode." + } + } +} diff --git a/acceptance/supabase-restful-tasks/RESULTS.md b/acceptance/supabase-restful-tasks/RESULTS.md new file mode 100644 index 0000000..f8f73d4 --- /dev/null +++ b/acceptance/supabase-restful-tasks/RESULTS.md @@ -0,0 +1,408 @@ +# Results: AppMap Deno recorder on supabase `restful-tasks` + +- **Recorder under test:** evlawler/funwithappmapreact, branch `upstream-pr/deno-waituntil-trace-agent`, + recorder code at `bef9d18c693c4873752539d8ca88c820ed98060d` (not modified). +- **App:** supabase/supabase `examples/edge-functions/supabase/functions/restful-tasks/index.ts` at + `74a3be9aa8706755e05f7326f3d25472729cd977`. EXPECTATIONS.md explains the choice and was committed + before any recording. +- **One command:** `acceptance/supabase-restful-tasks/run.sh`. It makes a clean sparse clone of the + app at the pinned SHA, starts a real Postgres and a real PostgREST, runs every check, and exits 1 + if any check fails. It exits 1 today. +- **Raw evidence:** `evidence/`. This includes every recording, the official sequence diagrams, + diffs, `results.json` and `run.log`. The same verdicts came out on four consecutive full runs. + +## Update: `integration/pr1` — the fixes merged (read this first) + +Branch `integration/pr1` merges `fix/recorder-worst-bugs` into `ci/oss-e2e` and adds further fixes. This +section is the current result; the rest of this file is the original run against the unfixed recorder +(`bef9d18`), kept as the record of what it found. The files in `evidence/` are that original run's; a current +run of `run.sh` rewrites them (CI uploads them as an artifact). + +Run: fresh clone of `integration/pr1`, `CI=true`, `npm ci`, then `acceptance/supabase-restful-tasks/run.sh`, +on 127.0.0.1 in a private network namespace. Same app SHA, tools and versions as below. "Merged, old checks" +is the same code run with the checks as they were before the check changes listed below. + +| Check | Before (unfixed) | Merged, old checks | After | One line (after) | +|---|---|---|---|---| +| A Setup / zero-touch | PASS | PASS | **PASS** | env only, app tree clean. | +| B Validity + honest version | FAIL (0/49) | PASS | **PASS** | 91/91 valid at 1.12 (and 1.6.0–1.13.1). | +| C Ground truth | FAIL (7/8) | FAIL | **PASS** | R1–R8 found; each has the `Deno.serve` handler (index.ts:68) as entry call with the app function nested in it; R7's exception now carries the thrown object's message. | +| C traceparent gate | PASS | PASS | **PASS** | | +| C call-tree structure | FAIL | PASS | **PASS** | every request is one tree rooted at its HTTP server request. | +| D Noise | PASS | PASS | **PASS** | only `restful-tasks/index.ts`. | +| E Exception | FAIL | FAIL | **PASS** | R7: `{class: "Object", message: "invalid input syntax for type bigint: \"not-a-number\""}` on `getTask`'s return. | +| F Failing test | NOT RUN | NOT RUN | **NOT RUN** | the app has no tests; no Deno test-recording mode. | +| G Stability | PASS | PASS | **PASS** | 8/8 identical. | +| H Change detection | PASS | FAIL | **PASS** | only R6 changed; official diff: "added HTTP client request `GET …/rest/v1/tasks`"; appmap-trace: "1 added. New call restful-tasks→network: GET /rest/v1/tasks?select=*&id=eq.2". | +| I Concurrency | FAIL | FAIL | **PASS** | 3 rounds × (20 stamped + 10 unstamped): 20 maps each, 0 leaks, 0 foreign trace ids on the wire. | +| W1 waitUntil capture | PASS | FAIL | **PASS** | | +| W4 waitUntil overlap | FAIL | FAIL | **PASS** | A and B recorded separately, each with only its own calls. | +| W2 self-heal | PASS | PASS | **PASS** | | +| W3 crash self-heal | FAIL | PASS | **PASS** | SIGKILL / SIGTERM / SIGINT each leave one truncated map. | +| J Overhead | PASS (measured) | PASS | **PASS** (measured) | 200 requests: plain 1136 ms, unstamped 1111 ms (0.98x), stamped 1060 ms (0.93x); an earlier run: 970 / 962 / 1040 ms. Within noise. | + +`run.sh` exits 0: nothing fails (F is NOT RUN, which the harness does not count as a failure). + +**Bugs listed below, now:** 1 and 2 (leaks, wrong trace ids) fixed by per-request async context (6402f0d); +3 (flat call tree) and 6 (declared version) fixed (4893004); 4 (crash loses the recording) fixed (971f16b); +5 (plain-object throws) fixed (0ad3121); 7 (anonymous `Deno.serve` handler) fixed (d4be568); 8 (tracer +mislabels Deno maps) fixed (84bae82), and its "1 added, 3 changed" count fixed (8227fac); 9 (credentials) +fixed (1315646). + +### Check changes + +Each is its own commit; the message quotes the old and new rule. EXPECTATIONS.md is unchanged. + +1. **URLs are `url` + `message`** (c02e032, rule (a), AppMap spec): `summarize()` rebuilds an outbound + call's URL from `url` and the event's `message`, for C, H, I and W1. +2. **The recorded `Deno.serve` handler is the entry call** (ad26266, rule (b)): C accepts + `index.handler` (index.ts:68) as the first function call, not nested in another function, and then + requires every expected function nested inside it and, compared exactly as before, equal to the + expected list. No other extra call. +3. **The supabase client parameter is identified by its class** (5b5d581; follows the requested + observer-effect fix, not one of rules (a)–(f): flagged for review): the old rule dropped the client by + its old rendering `[object Object]`; value capture now renders the client's data, so C drops the + parameter recorded with class `SupabaseClient` (the first parameter of every app function) and compares + the rest as before. +4. **I counts only other requests' events as leaks** (21900af, rule (e)): the request's own entry handler is + not foreign; every other call, a second handler included, is judged as before, and app functions must + be nested in the request's handler. +5. **W4 requires two separate recordings** (09244e1, rule (c)): A and B must each have their own map with + their own trace id, exactly their own `probe.ingest(n)` and outbound calls, and none of the other's. + Stricter than before (B used to be required to be *un*recorded). +6. **H reads the query from appmap-trace; the lane is the app's** (081f893, rules (a)/(f) and the requested + tracer fix): the official diff must name the added `GET …/rest/v1/tasks` (it cannot show the query, which + is in `message`); appmap-trace must say "New call restful-tasks→network: GET + /rest/v1/tasks?select=*&id=eq.2" (was "frontend→network", the mislabel of bug 8). + +Setup only: shellcheck cleanups (0ddada4). + +## Tool versions + +| Tool | Version | +|---|---| +| Deno | 2.9.7 (release binary from github.com/denoland/deno) | +| Node | 22.22.2 (runs `appmap-deno.ts` via `--experimental-strip-types`) | +| @supabase/supabase-js (jsr) | 2.117.1, pinned by `deno.lock` | +| PostgreSQL | 16.13 (system package) | +| PostgREST | 12.2.12 (release binary) | +| Official AppMap CLI `@appland/appmap` | 3.204.0 | +| Official validator `@appland/appmap-validate` | 2.5.1 (ships schemas 1.2.0 through 1.13.1) | +| AppMap spec | github.com/getappmap/appmap @ fa68b13 | + +## Summary + +| Check | Result | One line of evidence | +|---|---|---| +| A Setup / zero-touch | **PASS** | `node deno/bin/appmap-deno.ts --app restful-tasks -- --lock=…` with only env vars. `git status` in the app clone is clean after every run, and no `.appmap.*` copy is left behind. | +| B Validity + honest version | **FAIL** | Official `appmap-validate`: **0/49** recordings valid (`/metadata/language must have required property 'version'`). No recording satisfies **any** schema version as written. | +| C Ground truth (content) | **FAIL** (7/8) | R1-R6 and R8 match exactly: route, status, function+line+params, PostgREST method+URL+status, and they agree with the gateway log. R7's exception is recorded as `{"class":"object","message":"[object Object]"}`, so the error's message is lost. | +| C traceparent gate | **PASS** | Unstamped: HTTP 200, 0 files, outbound `traceparent` null. Four malformed headers: 0 files each. 8/8 stamped maps carry the caller's `trace_id`/`parent_span_id`, and outbound calls go out as `00---01`. | +| C call-tree structure | **FAIL** | For every request with a handler, the official `appmap sequence-diagram` shows 3 disconnected roots, e.g. `["GET /restful-tasks/1" (0 children), "GET …/rest/v1/tasks?select=*&id=eq.1" (0), "getTask" (0)]`. | +| D Noise | **PASS** | run1 call events: `http_server_request` 8, `function …/restful-tasks` 6, `http_client_request 127.0.0.1:54321` 6. No supabase-js, jsr/npm or recorder frames. | +| E Exception | **FAIL** | The throw on the R7 path is recorded on `getTask`'s return, but as `class:"object", message:"[object Object]"` with no `object_id`. The app threw `{code:"22P02", message:'invalid input syntax for type bigint: "not-a-number"'}`. | +| F Failing test | **NOT RUN** | The app has no tests, and the recorder has no Deno test-recording mode. | +| G Stability | **PASS** | 8/8 requests: normalized official sequence-diagram JSON (elapsed/eventIds removed) and the event/thread shape are identical across two fresh runs. | +| H Change detection | **PASS** (with tool caveats) | Only R6 (DELETE) changed. The official diff says "changed … `DELETE …?id=eq.2` to … `GET …?select=*&id=eq.2` … added … `DELETE …?id=eq.2`". `appmap-trace --baseline` says "New call frontend→network: GET /rest/v1/tasks?select=*&id=eq.2" for DELETE and "No behavior change" for the other 5. | +| I Concurrency | **FAIL** | In a burst of 20 stamped + 10 unstamped, **1** map is written (`GET /restful-tasks/4`). It holds **all 29 other requests'** function calls and PostgREST calls, is `truncated`, and has 58 synthetic returns. The gateway saw those 29 outbound calls stamped with **its** trace id. | +| waitUntil W1 capture | **PASS** | 202 in 12 ms. The map is written 3053 ms after the response. It contains `POST /rest/v1/tasks 201`, `GET /enrich?n=1 200`, `PATCH …name=eq.probe-1 204` and a completed `probe.ingest`, and is not truncated. | +| waitUntil W4 overlap | **FAIL** | A second stamped request (B) arriving during A's background window is correctly unrecorded, but **its** `ingest(3)`, insert and `GET /enrich?n=3` land in A's map. | +| waitUntil W2 self-heal (no waitUntil) | **PASS** | Map closed at the 202 with `truncated:true`. The open `probe.ingest` and fetch get synthetic returns, and the events are balanced. | +| waitUntil W3 crash mid-recording | **FAIL** | `kill -9` of deno, SIGTERM to the runner and SIGINT to the runner, each 1.5 s into the background: **0 files** written each time. There is nothing to self-heal. | +| J Overhead | **PASS** (measured) | 200 sequential requests: plain 999 ms; appmap-deno unstamped 1030 ms (1.03x); all stamped 1058 ms (1.06x, 200 maps). The spec sets no threshold. | + +Extra items the task asked for: + +| Item | Result | +|---|---| +| Record the app's own `deno test` suite | **NOT RUN.** The app has none. | +| Supabase Edge Runtime (doc 06's named gap) | **NOT RUN.** As doc 06 says, `appmap-deno` cannot reach it because it has no `--preload`. I tried the real runtime to at least test manual wiring. Docker Hub answered 429, and blob downloads from public.ecr.aws and ghcr.io were refused by this session's egress proxy (403). I did not work around it. All results here are plain `deno run`. | +| Declared format 1.12 honest? | **No.** See B. | + +## Details + +### A. Setup (exact commands) + +``` +acceptance/supabase-restful-tasks/run.sh + # clones supabase/supabase sparse at 74a3be9 into $WORK/supabase + # infra/db.sh start: initdb + pg_ctl (Postgres 16), table public.tasks (no migration in the app repo), PostgREST 12.2.12 + # infra/gateway.mjs: :54321 /rest/v1/* -> PostgREST (Kong's role), logs method/url/traceparent + # the recorded app, cwd $WORK/supabase: + SUPABASE_URL=http://127.0.0.1:54321 SUPABASE_ANON_KEY= APPMAP_DIR= \ + DENO_SERVE_ADDRESS=tcp:127.0.0.1:18000 \ + node --experimental-strip-types deno/bin/appmap-deno.ts --app restful-tasks \ + examples/edge-functions/supabase/functions/restful-tasks/index.ts -- --lock=acceptance/supabase-restful-tasks/deno.lock +``` + +**App changes needed: none.** `DENO_SERVE_ADDRESS` is a Deno env var used only to move the port off 8000. It is not a source edit. + +### B. Validity and the declared version + +The official validator (`appmap-validate `) rejects all 49 recordings: run1, run2, run3, +the concurrency rounds and the probe. `lib/schema-diagnose.mjs` finds the smallest set of named +additions that makes each map valid. It checks each schema version the official validator ships, +using that validator's own schema files: + +| Missing item | In 1.12 spec / validator | Where the recorder omits it | Maps affected | +|---|---|---|---| +| `metadata.language.version` | Spec README: *Required* whenever `language` is present | `deno/appmap.ts:119` sets `{name, engine}` only | 49/49 | +| `message` array on `http_server_request` / `http_client_request` call events | Required by the official validator schema (README describes `message` as where query params go) | `recorder/src/recording.ts:236-249`, `268-287` | 49/49 | +| exception `object_id` | Spec README: *Required* | `recorder/src/recording.ts:215-220` | 3 (R7 maps) | +| parameter `value` ≤ 100 chars | Validator schema (README: "should be trimmed … to 100 characters") | cap is 1024, `recorder/src/recording.ts:16,53` | 3 (probe maps holding a 191-char bearer token) | + +- **Highest version satisfied as recorded: none.** 1.2.0 through 1.13.1 all fail. +- With the two or three additions above, every map would satisfy 1.6.0 through 1.13.1. +- 6 maps without HTTP client events would also satisfy 1.2.0. +- Also against the spec text, though the validator does not enforce it: `http_client_request.url` includes the query string (`recorder/src/fetchPatch.ts:37`). The spec says "Request URL, excluding the query string". +- `appmap sequence-diagram` does not reject any of them (exit 0 over all 49). + +### C. Ground truth + +Evidence: `evidence/C-ground-truth.json`, `evidence/recordings/run1/`. A quote from R6 +(`DELETE_restful-tasks_2_…_006.appmap.json`): + +``` +{"id":1,"event":"call","thread_id":1,"http_server_request":{"request_method":"DELETE","path_info":"/restful-tasks/2","headers":{"traceparent":"00-73657100000000000000000000000006-0000000600000006-01"}}} +{"id":2,"event":"call","thread_id":2,"defined_class":"index","method_id":"deleteTask","path":"examples/edge-functions/supabase/functions/restful-tasks/index.ts","lineno":38,"static":true} +{"id":3,"event":"call","thread_id":3,"http_client_request":{"request_method":"DELETE","url":"http://127.0.0.1:54321/rest/v1/tasks?id=eq.2","headers":{"content-type":"application/json","traceparent":"00-73657100000000000000000000000006-bfad9f90e0f9fb7f-01"}}} +{"id":4,"event":"return","thread_id":3,"parent_id":3,"http_client_response":{"status_code":204}} +{"id":5,"event":"return","thread_id":2,"parent_id":2,"return_value":{"class":"Response","value":"{}",...}} +{"id":6,"event":"return","thread_id":1,"parent_id":1,"http_server_response":{"status_code":200}} +``` + +| # | Verdict | Note | +|---|---|---| +| R1 OPTIONS | found | 200, no functions, no outbound calls | +| R2 GET list | found | `index.getAllTasks@28`, `GET …/tasks?select=* 200` | +| R3 GET 1 | found | `index.getTask@18("1")`, `GET …/tasks?select=*&id=eq.1 200` | +| R4 POST | found | `index.createTask@58({"name":"acc-new","status":0})`, `POST …/tasks 201` | +| R5 PUT 1 | found | `index.updateTask@48("1",{…"renamed"…})`, `PATCH …/tasks?id=eq.1 204` | +| R6 DELETE 2 | found | `index.deleteTask@38("2")`, `DELETE …/tasks?id=eq.2 204` | +| R7 GET not-a-number | **wrong** | 500, `getTask`, `GET …?id=eq.not-a-number 400` all present. The exception is `{"class":"object","message":"[object Object]"}` (see E). | +| R8 POST bad JSON | found | 400, no functions, no outbound calls | + +- **The real handler is invisible.** The anonymous `Deno.serve(async (req) => …)` arrow at + index.ts:68 (the dispatcher, where OPTIONS, JSON parsing and the 400 path happen) never appears. + The transform only wraps named top-level declarations. As decided in EXPECTATIONS, this is a + gap and does not by itself fail C. +- **No `sql_query` events**, as expected. The app reaches the DB only through PostgREST over HTTP. +- **Structure (separate FAIL).** Each recording is a flat set of threads: + - the server request is on thread 1, `getTask` on thread 2, and the fetch on thread 3; + - the official `appmap sequence-diagram` therefore renders three unrelated roots with no + children (`evidence/sequence/run1/*.sequence.json`, `evidence/C-structure.json`); + - a reader of the official diagram cannot see that `getTask` ran inside `GET /restful-tasks/1` + or that the PostgREST call came from `getTask`. + +### D. Noise + +- Only `examples/edge-functions/supabase/functions/restful-tasks/index.ts` functions are recorded. +- supabase-js, auth-js, postgrest-js and the recorder itself produce zero events. +- The upside comes with a limit. Only the entry file is instrumented (doc 06 non-goal), so a + function split across files would record nothing beyond its entry. That did not matter for + this single-file app. + +### E. Exception + +`getTask` (index.ts:20) runs `if (error) throw error`, where `error` is the plain parsed JSON +object that supabase-js 2.117.1 returns (postgrest-js `PostgrestBuilder.ts:450`, +`error = JSON.parse(body)`). The recorder writes: + +``` +{"id":5,"event":"return","thread_id":2,"parent_id":2,"exceptions":[{"class":"object","message":"[object Object]"}]} +``` + +`recorder/src/recording.ts:217-218` uses `typeof e` and `String(e)` for anything that is not an +`Error`. So the class is `object` (the parameter formatter would have said `Object`), the message +is useless, and `object_id` (required) is missing. + +**My expectation was wrong about the class.** EXPECTATIONS.md said `PostgrestError`. It is a +plain object; supabase-js only builds a `PostgrestError` with `throwOnError()`. The message +expectation stands: the thrown object's `message` is `'invalid input syntax for type bigint: "not-a-number"'`. + +### G. Stability + +Two fresh processes, DB reset in between, same sequence and headers: no differences in +normalized sequence-diagram JSON or in the event/thread shape for any of the 8 requests +(`evidence/G-stability.json` is empty). + +### H. Change detection + +The change (`h-change.patch`, applied on scratch branch `acceptance-h-change` of the app clone, +then removed) makes `deleteTask` first run `supabaseClient.from('tasks').select('*').eq('id', id)`. +Line numbers of other functions are preserved. + +- **Official** `appmap sequence-diagram-diff run1 run3 --format text`: + - R6: `changed HTTP client request DELETE …?id=eq.2 to HTTP client request GET …?select=*&id=eq.2 and changed to return 200 instead of 204` / `added HTTP client request DELETE …?id=eq.2`. + - R1-R5, R7 and R8: identical. + - The change and only the change is detected. The wording reads as "DELETE became GET, plus a new DELETE" rather than "GET inserted before DELETE", because all calls are sibling roots (see C-structure). +- **Repo tool** `linker/bin/appmap-trace.mjs run3 --baseline run1`: + - DELETE: `Behavior changed — 1 added, 3 changed. New call frontend→network: GET /rest/v1/tasks?select=*&id=eq.2.` + - Other 5: `No behavior change`. + - It nests correctly, but labels the Deno request map as a "frontend", renders the server + request as `undefined.undefined`, and counts "3 changed" for one inserted call. That is a + tracer bug, below. + - OPTIONS and bad-JSON maps are skipped because they have no outbound call. + +### I. Concurrency + +`evidence/I-concurrency.json`, `evidence/recordings/concurrency-*`. Each round seeds 30 rows so +every request touches its own id. Every one of the 30 requests got HTTP 200. + +**Rounds 1-2 (one burst):** +- Only `GET /restful-tasks/4` (req#0) is recorded. That is the documented one-at-a-time rule + (doc 05/11). +- Its map has 61 call events. They are the other 19 stamped requests' and the 10 unstamped + requests' `getTask/updateTask/deleteTask/createTask` calls and their PostgREST calls, e.g. + ``` + {"id":4,"thread_id":4,"method_id":"updateTask"} params ["[object Object]","5","{\"name\":\"c-upd-1\",\"status\":1}"] + {"id":5,"event":"call","thread_id":5,"http_client_request":{"request_method":"PATCH","url":"http://127.0.0.1:54321/rest/v1/tasks?id=eq.5", + "headers":{"traceparent":"00-63310000000000000000000000000001-470676f35ffbc15f-01"}}} + ``` +- `00-6331…0001` is req#0's trace id. The PATCH belongs to req#1, which carried its own + `00-6331…0002-…` header. +- The gateway confirms the same thing on the wire: 29 foreign outbound calls went out stamped + with req#0's trace id. `appmap-link` would join those backend calls to the wrong map. +- The map closed while 29 of those calls were still open: `truncated:true`, 58 synthetic returns. + +**Round 3 (staggered 25 ms):** +- 20 maps are written. +- `GET /restful-tasks/4` still contains an unstamped request's `getTask(24)` and + `GET …?id=eq.24`, which went out stamped with req#0's trace id. + +### waitUntil (synthetic probe `probe/probe.ts`, not the app) + +`evidence/W-waituntil.json`, `evidence/recordings/probe-*`. + +- **W1: pass.** + - Capture works as doc 11 says. The response is not delayed (12 ms), and the map is written + only after the 3 s of background work. + - Under plain `deno run` this needs an `EdgeRuntime` global. The probe defines a minimal shim; + without one, `patchWaitUntil` (`deno/appmap.ts:85-97`) is a no-op. +- **W4: fail.** + - While A's background work runs, a stamped request B is correctly not recorded. + - But B's background `ingest(3)`, its insert and `GET /enrich?n=3` are all in A's map: + `['/probe/ingest', ingest(2), POST /rest/v1/tasks, sleep, ingest(3), POST /rest/v1/tasks, sleep, GET /enrich?n=2, sleep, GET /enrich?n=3, sleep, PATCH …probe-2]`. + - Doc 11 extends the recording window to the whole background run. That makes this leak much + wider than the request itself: seconds to minutes instead of milliseconds. +- **W2: pass.** Background work not registered with waitUntil is cut at the 202: + `truncated:true`, synthetic returns `{"id":5,"event":"return","thread_id":2,"parent_id":3}`, + balanced. +- **W3: fail.** + - Doc 11 (line 105) says "even a genuinely killed isolate yields a balanced, sanitizable, + committable (if incomplete) map". + - Measured: SIGKILL of deno, SIGTERM to the runner and SIGINT to the runner, each 1.5 s into + the background, write **no file**. + - `ship()` is only called from `finalize()` (`deno/appmap.ts:139-143`). There is no + signal/`unload`/`beforeunload` flush, so the self-heal in `toAppMap()` never runs on a + crash. It only runs on paths like W2 and I. + +### J. Overhead + +Best of 2 runs of 200 sequential GETs: + +| Mode | Time | vs plain | +|---|---|---| +| Plain `deno run` | 999 ms | 1.00x | +| `appmap-deno`, unstamped | 1030 ms | 1.03x | +| `appmap-deno`, every request stamped | 1058 ms | 1.06x (200 maps written) | + +That is within run-to-run noise at this size (`evidence/J-overhead.json`). + +## Recorder bugs found + +All repros assume `run.sh` has set up the DB, gateway and app clone (`WORK=/tmp/acc-deno-work`), +or run `run.sh` itself. + +1. **Events from concurrent requests leak into whichever recording is open.** + - Found by I and W4. + - Cause: the session is one module-global (`recorder/src/session.ts:11`, read by + `activeRecording()` at `:32`). Instrumented functions (`recorder/src/instrument.ts:19`) and + the fetch patch (`recorder/src/fetchPatch.ts:30`) attribute every call in the process to + it. `deno/appmap.ts:111` only stops a second recording from *starting*; it does not stop + other requests' events from *entering* the open one. + - Repro: + ``` + cd $WORK/supabase && APPMAP_DIR=/tmp/i SUPABASE_URL=http://127.0.0.1:54321 SUPABASE_ANON_KEY=… \ + node --experimental-strip-types /deno/bin/appmap-deno.ts examples/edge-functions/supabase/functions/restful-tasks/index.ts + ``` + Fire 20 stamped requests with `Promise.all`. You get one file with about 30 handler calls. +2. **Other requests' outbound calls are stamped with the open recording's trace id.** + - Found by I. + - `recorder/src/fetchPatch.ts:34` sets `traceparent: 00--…` on + *every* fetch in the process while a recording is open. That includes unstamped + production requests and stamped-but-skipped ones, whose own incoming trace context is + overwritten. + - This breaks doc 02/05's "production traffic is untouched" and gives `appmap-link` wrong + joins. + - Repro: same as #1, then look at the gateway log. 29/29 foreign PostgREST calls carry + req#0's trace id. +3. **The call tree is flattened, so official diagrams show disconnected roots.** + - Found by C-structure. + - `http_server_request` and `http_client_request` are opened with `openDangling()` + (`recorder/src/recording.ts:149-155`, used at `:237` and `:274`). + - `allocateThread()` (`:122-130`) then gives every handler call and every fetch a new + `thread_id`, so no call nests under the request. + - Repro: any single stamped request, then `appmap sequence-diagram --format json `. + `rootActions` has 3 entries with 0 children. +4. **Crash and teardown lose the whole recording; the self-heal is unreachable there.** + - Found by W3. + - `deno/appmap.ts:139-143` (`finalize`) is the only caller of `ship()`, and there is no + signal/unload hook. This contradicts doc 11:105. + - Repro: `POST /probe/ingest` stamped against `probe/probe.ts` under `appmap-deno`, then + `kill -9 ` (or SIGTERM/SIGINT to the runner) 1.5 s later. `APPMAP_DIR` stays + empty. +5. **Non-`Error` throws are recorded as `class:"object", message:"[object Object]"`.** + - Found by E. + - `recorder/src/recording.ts:217-218`. supabase-js errors are plain objects with a + `message`, so every PostgREST failure in a Supabase function records this way. + - Repro: `GET /restful-tasks/not-a-number` stamped. +6. **Declared `version: "1.12"` is not honest.** + - Found by B. + - Missing `metadata.language.version` (`deno/appmap.ts:119`), missing exception `object_id` + (`recording.ts:215-220`), no `message` on HTTP events (`recording.ts:236-249, 268-287`), + and values capped at 1024 rather than 100 (`recording.ts:16`). + - Official `appmap-validate`: 0/49 valid. + - `http_client_request.url` also keeps the query string (`fetchPatch.ts:37`). +7. **The anonymous `Deno.serve` handler is never recorded.** + - The transform (`recorder/src/transform.ts:237-272`) wraps only named top-level functions. + - For the common `Deno.serve(async (req) => …)` shape, the request's actual entry function, + including routing and the 400 path, is missing from every map. +8. **The tracer mislabels Deno request maps.** + - `linker/src/link.mjs:20-22` classifies any map with an `http_client_request` as a + frontend map. + - `linker/src/trace-agent.mjs:174-179` then renders the `http_server_request` event as + `undefined.undefined`, and the caption says "1 added, 3 changed" for one inserted call. + - Repro: `node linker/bin/appmap-trace.mjs evidence/recordings/run3-h-change --baseline evidence/recordings/run1`. +9. **Credentials are captured verbatim (observation).** + - Parameter values are recorded unredacted, up to 1024 chars, e.g. the probe's + `Authorization: Bearer eyJ…` passed to `ingest` (`evidence/recordings/probe-w1-w4`). + - That is a local test token here. On a real edge function the same thing would capture + service-role keys. + +## Could not run, and why + +- **F (failing test) and recording `deno test`:** the app has no tests. The Deno side has no + test-recording mode; `appmap-deno` only wraps `deno run`. +- **Supabase Edge Runtime:** + - Doc 06 already names it as unreachable for `appmap-deno` (it has no `--preload`). Reaching + it would also require editing the app (manual `withAppMap` wiring). + - I tried the real runtime image to test at least the probe there. Docker Hub returned 429, + and blob downloads from public.ecr.aws and ghcr.io returned 403 from this session's egress + proxy (policy). I did not work around it. + - Because of that, the waitUntil checks ran under plain `deno run` with an `EdgeRuntime` shim + in the synthetic probe. +- **Supabase CLI local stack (Kong/GoTrue):** not used. Real Postgres and real PostgREST were + used directly, behind a 60-line gateway that does Kong's path mapping. + +## Expectations that were wrong + +- **R7 exception class:** I expected `PostgrestError`. It is a plain object (see E). +- Everything else in EXPECTATIONS.md held: + - routes, statuses, functions, lines, params, PostgREST URLs and status codes (201/204/400); + - the 500 for R7 caused by the un-awaited `return getTask(...)` at index.ts:106; + - no `sql_query` events; + - the gate behaviour; + - the W3 prediction from code (no file) against the doc's claim (truncated file). + +> Evidence (recordings, logs, reports) is regenerated by every run of `run.sh` and uploaded by CI as the job artifact; it is no longer committed. diff --git a/acceptance/supabase-restful-tasks/deno.lock b/acceptance/supabase-restful-tasks/deno.lock new file mode 100644 index 0000000..661d5b2 --- /dev/null +++ b/acceptance/supabase-restful-tasks/deno.lock @@ -0,0 +1,66 @@ +{ + "version": "5", + "specifiers": { + "jsr:@supabase/supabase-js@2": "2.117.1", + "npm:@supabase/auth-js@2.117.1": "2.117.1", + "npm:@supabase/functions-js@2.117.1": "2.117.1", + "npm:@supabase/postgrest-js@2.117.1": "2.117.1", + "npm:@supabase/realtime-js@2.117.1": "2.117.1", + "npm:@supabase/storage-js@2.117.1": "2.117.1" + }, + "jsr": { + "@supabase/supabase-js@2.117.1": { + "integrity": "79c8b2cffe9805b11e0b89d7b0754df23b606b69707cb67f8422548eaed3017a", + "dependencies": [ + "npm:@supabase/auth-js", + "npm:@supabase/functions-js", + "npm:@supabase/postgrest-js", + "npm:@supabase/realtime-js", + "npm:@supabase/storage-js" + ] + } + }, + "npm": { + "@supabase/auth-js@2.117.1": { + "integrity": "sha512-3vJpc5thxaovbZ2zrWagzp/ZtCcXpQk1xftinzneNB1XLdhAmjqChOnj2Y8wpriYEFVvlqC+sFbR0JBUi83hDA==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/functions-js@2.117.1": { + "integrity": "sha512-yyMIbEPMDXmjpjWe7WKd7en3mn6q4eDGLHEQVPcqWQIyC1hC7xa3po+o4cWSyItI6a6GYY/voDUpwhZmkxjcPw==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/phoenix@0.4.5": { + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==" + }, + "@supabase/postgrest-js@2.117.1": { + "integrity": "sha512-2a7we4uKXA1d94CUc02MpUj+LzZWO3qm8fH2PDq60tVt0T6KQTIiMmEsRD53lrnwZA9C3SsaKL3mk4DiDH6GvQ==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/realtime-js@2.117.1": { + "integrity": "sha512-GkbZfeh5F5aqnUhG1chrdaVQmnRK8ZUL8H16Km/eVVsX99x0w2AijfxjONCB9YKgrXArcnMsKSJzOIQGCv+qvQ==", + "dependencies": [ + "@supabase/phoenix", + "tslib" + ] + }, + "@supabase/storage-js@2.117.1": { + "integrity": "sha512-ytDQbELKKzIVStLkShxTTK5FT4vds12eXF5QfW+gTHVvO7cB3VKZ7Hu2wkUaJlDZCaUfFdih3vznF6P8kNkJQg==", + "dependencies": [ + "iceberg-js", + "tslib" + ] + }, + "iceberg-js@0.8.1": { + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } +} diff --git a/acceptance/supabase-restful-tasks/h-change.patch b/acceptance/supabase-restful-tasks/h-change.patch new file mode 100644 index 0000000..785148f --- /dev/null +++ b/acceptance/supabase-restful-tasks/h-change.patch @@ -0,0 +1,20 @@ +diff --git a/examples/edge-functions/supabase/functions/restful-tasks/index.ts b/examples/edge-functions/supabase/functions/restful-tasks/index.ts +index 4b40c11..7de3adb 100644 +--- a/examples/edge-functions/supabase/functions/restful-tasks/index.ts ++++ b/examples/edge-functions/supabase/functions/restful-tasks/index.ts +@@ -36,6 +36,7 @@ async function getAllTasks(supabaseClient: SupabaseClient) { + } + + async function deleteTask(supabaseClient: SupabaseClient, id: string) { ++ await supabaseClient.from('tasks').select('*').eq('id', id) + const { error } = await supabaseClient.from('tasks').delete().eq('id', id) + if (error) throw error + +@@ -44,7 +45,6 @@ async function deleteTask(supabaseClient: SupabaseClient, id: string) { + status: 200, + }) + } +- + async function updateTask(supabaseClient: SupabaseClient, id: string, task: Task) { + const { error } = await supabaseClient.from('tasks').update(task).eq('id', id) + if (error) throw error diff --git a/acceptance/supabase-restful-tasks/harness.mjs b/acceptance/supabase-restful-tasks/harness.mjs new file mode 100644 index 0000000..44584a2 --- /dev/null +++ b/acceptance/supabase-restful-tasks/harness.mjs @@ -0,0 +1,653 @@ +// Acceptance harness: AppMap Deno recorder vs supabase restful-tasks @ 74a3be9. +// Run through run.sh (which starts Postgres + PostgREST and clones the app). +// Writes evidence/ and evidence/results.json; exits 1 if any check FAILs. +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { diagnose } from './lib/schema-diagnose.mjs'; +import { + ACC, ROOT, WORK, APPDIR, ENTRY_REL, PROBE, APP_PORT, PROBE_PORT, GW, GATEWAY_LOG, AUTH, ANON, + sleep, startServer, traceparent, request, listMaps, waitForMap, gatewayLines, summarize, sh, + validateMap, sequenceDiagrams, normalizeDiagram, APPMAP_CLI, +} from './lib/util.mjs'; + +const EVID = path.join(ACC, 'evidence'); +const REC = path.join(WORK, 'rec'); +fs.rmSync(EVID, { recursive: true, force: true }); +fs.rmSync(REC, { recursive: true, force: true }); +fs.mkdirSync(EVID, { recursive: true }); + +const results = {}; +const log = (...a) => console.log('[acc]', ...a); +function verdict(key, status, evidence) { + results[key] = { status, evidence }; + log(`${key}: ${status}`); + for (const e of [].concat(evidence)) log(` ${e}`); +} +const j = (o) => JSON.stringify(o); +const save = (name, data) => + fs.writeFileSync(path.join(EVID, name), typeof data === 'string' ? data : JSON.stringify(data, null, 2)); + +function resetDb() { + const r = sh(path.join(ACC, 'infra', 'db.sh'), ['reset'], { env: { ...process.env, WORK } }); + if (r.code !== 0) throw new Error(`db reset failed: ${r.out}`); +} + +// ---------------------------------------------------------------- gateway +fs.rmSync(GATEWAY_LOG, { force: true }); +const gateway = spawn(process.execPath, [path.join(ACC, 'infra', 'gateway.mjs')], { + env: { ...process.env, GATEWAY_LOG }, + stdio: 'ignore', +}); +await sleep(600); + +// ---------------------------------------------------------------- the ground-truth sequence +// (EXPECTATIONS.md R1-R8, T1-T2). Fixed traceparents so runs are comparable. +const SEQ = [ + { id: 'R1', method: 'OPTIONS', path: '/restful-tasks', status: 200, fns: [], clients: [] }, + { id: 'R2', method: 'GET', path: '/restful-tasks', status: 200, fns: [['index.getAllTasks', 28, []]], + clients: [['GET', `${GW}/rest/v1/tasks?select=*`, 200]] }, + { id: 'R3', method: 'GET', path: '/restful-tasks/1', status: 200, fns: [['index.getTask', 18, ['1']]], + clients: [['GET', `${GW}/rest/v1/tasks?select=*&id=eq.1`, 200]] }, + { id: 'R4', method: 'POST', path: '/restful-tasks', body: { task: { name: 'acc-new', status: 0 } }, status: 200, + fns: [['index.createTask', 58, ['{"name":"acc-new","status":0}']]], + clients: [['POST', `${GW}/rest/v1/tasks`, 201]] }, + { id: 'R5', method: 'PUT', path: '/restful-tasks/1', body: { task: { name: 'renamed', status: 1 } }, status: 200, + fns: [['index.updateTask', 48, ['1', '{"name":"renamed","status":1}']]], + clients: [['PATCH', `${GW}/rest/v1/tasks?id=eq.1`, 204]] }, + { id: 'R6', method: 'DELETE', path: '/restful-tasks/2', status: 200, fns: [['index.deleteTask', 38, ['2']]], + clients: [['DELETE', `${GW}/rest/v1/tasks?id=eq.2`, 204]] }, + { id: 'R7', method: 'GET', path: '/restful-tasks/not-a-number', status: 500, + fns: [['index.getTask', 18, ['not-a-number']]], + // EXPECTATIONS.md said class "PostgrestError"; that was wrong: supabase-js returns + // (and the app throws) the plain parsed JSON error object. See RESULTS.md. + exception: { message: 'invalid input syntax for type bigint' }, + clients: [['GET', `${GW}/rest/v1/tasks?select=*&id=eq.not-a-number`, 400]] }, + { id: 'R8', method: 'POST', path: '/restful-tasks', body: 'not json', status: 400, fns: [], clients: [] }, +]; + +/** Run SEQ against a fresh recorded app; returns per-request observations. */ +async function runSequence(label, { withGate = false } = {}) { + resetDb(); + const dir = path.join(REC, label); + const server = await startServer({ recorded: true, entry: ENTRY_REL, cwd: APPDIR, port: APP_PORT, appmapDir: dir, app: 'restful-tasks' }); + const obs = []; + try { + for (const [i, r] of SEQ.entries()) { + const tp = traceparent(`seq`, i + 1); + const before = gatewayLines().length; + const res = await request(APP_PORT, r.method, r.path, { body: r.body, tp }); + const map = await waitForMap(dir, tp.span); + await sleep(100); + const gw = gatewayLines().slice(before); + obs.push({ req: r, tp, res: { status: res.status, body: res.text.slice(0, 300) }, map, gw }); + } + if (withGate) { + // T1: no traceparent + const n0 = listMaps(dir).length; + const b1 = gatewayLines().length; + const t1 = await request(APP_PORT, 'GET', '/restful-tasks'); + await sleep(800); + const t1gw = gatewayLines().slice(b1); + const t1files = listMaps(dir).length - n0; + // T2: malformed traceparents + const bad = [ + '00-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-1111111111111111-01', // uppercase hex + 'ff-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111111111111111-01', // version ff + '00-aaaa-1111111111111111-01', // short trace id + '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111111111111111', // no flags + ]; + const t2 = []; + for (const h of bad) { + const n = listMaps(dir).length; + const res = await request(APP_PORT, 'GET', '/restful-tasks', { tp: h }); + await sleep(600); + t2.push({ header: h, status: res.status, newFiles: listMaps(dir).length - n }); + } + obs.gate = { t1: { status: t1.status, newFiles: t1files, outboundTraceparents: t1gw.map((g) => g.traceparent) }, t2 }; + } + } finally { + await server.stop(); + } + obs.command = server.command; + obs.dir = dir; + obs.appOutput = server.output(); + return obs; +} + +// ---------------------------------------------------------------- A + C (run 1) +log('run1: ground-truth sequence (recorded, zero-touch)'); +const srcBefore = fs.readFileSync(path.join(APPDIR, ENTRY_REL), 'utf8'); +const run1 = await runSequence('run1', { withGate: true }); +save('run1-app-output.txt', run1.appOutput); +fs.cpSync(run1.dir, path.join(EVID, 'recordings', 'run1'), { recursive: true }); + +const cRows = []; +let cFail = 0; +for (const o of run1) { + const r = o.req; + const problems = []; + const found = []; + if (o.res.status !== r.status) problems.push(`client got HTTP ${o.res.status}, expected ${r.status}`); + if (!o.map) { + problems.push('no recording file'); + cRows.push({ id: r.id, verdict: 'MISSING', problems }); + cFail++; + continue; + } + const s = summarize(o.map.appmap); + // server event + const srv = s.servers[0]; + if (s.servers.length !== 1) problems.push(`${s.servers.length} http_server_request events`); + if (!srv || srv.method !== r.method || srv.path !== r.path || srv.status !== r.status) + problems.push(`server event ${j(srv)} != ${r.method} ${r.path} ${r.status}`); + else found.push(`http_server_request ${srv.method} ${srv.path} -> http_server_response ${srv.status}`); + // functions + // The anonymous Deno.serve handler (index.ts:68) is now recorded (the + // requested fix; EXPECTATIONS.md lists it as a gap, not an expected + // call). It may appear once, as the entry call, and every expected + // function must then be nested inside it; the expected functions are + // compared exactly as before. + const isEntry = (f) => f.fn === 'index.handler' && f.lineno === 68 && f.path === ENTRY_REL && f.ancestors.every((a) => !s.functions.some((g) => g.id === a)); + const entry = s.functions[0] && isEntry(s.functions[0]) ? s.functions[0] : undefined; + const appFns = entry ? s.functions.slice(1) : s.functions; + if (entry) { + const outside = appFns.filter((f) => !f.ancestors.includes(entry.id)); + if (outside.length) problems.push(`functions not nested in the Deno.serve handler: ${j(outside.map((f) => f.fn))}`); + else found.push(`entry index.handler@68 (anonymous Deno.serve handler)`); + } + // Every app function takes the supabase client first (index.ts:18, 28, + // 38, 48, 58); compare the non-client params. The client is the + // parameter recorded with class SupabaseClient. + const gotFns = appFns.map((f) => `${f.fn}@${f.lineno}(${f.params.filter((_, i) => f.paramClasses[i] !== 'SupabaseClient').join(',')})`); + const expFns = r.fns.map(([fn, line, params]) => `${fn}@${line}(${params.join(',')})`); + const norm = (arr) => [...arr].sort(); + if (j(norm(gotFns)) !== j(norm(expFns))) problems.push(`functions ${j(gotFns)} != expected ${j(expFns)}`); + else if (expFns.length) found.push(`functions ${gotFns.join(' ')}`); + // outbound http + const gotCl = s.clients.map((c) => `${c.method} ${c.url} ${c.status}`); + const expCl = r.clients.map(([m, u, st]) => `${m} ${u} ${st}`); + if (j(gotCl) !== j(expCl)) problems.push(`clients ${j(gotCl)} != expected ${j(expCl)}`); + else if (expCl.length) found.push(`http_client ${gotCl.join('; ')}`); + // gateway agrees with recording + const gwCl = o.gw.map((g) => `${g.method} ${GW}${g.url} ${g.status}`); + if (j(gwCl) !== j(gotCl)) problems.push(`gateway saw ${j(gwCl)} but recording has ${j(gotCl)}`); + // exceptions + const exc = s.functions.flatMap((f) => f.exceptions ?? []); + if (r.exception) { + const hit = exc.find((e) => String(e.message).includes(r.exception.message)); + if (!hit) problems.push(`thrown value's message ${j(r.exception.message)} not in recorded exception ${j(exc)} (the app threw {code:'22P02', message:'invalid input syntax for type bigint: "not-a-number"', ...})`); + else found.push(`exception ${hit.class}: ${hit.message}`); + } else if (exc.length) problems.push(`unexpected exceptions ${j(exc)}`); + if (s.sql.length) problems.push(`unexpected sql_query events ${j(s.sql)}`); + if (s.unbalanced.length) problems.push(`unbalanced calls ${j(s.unbalanced)}`); + if (s.truncated) problems.push('metadata.truncated set'); + const v = problems.length ? 'WRONG' : 'FOUND'; + if (problems.length) cFail++; + cRows.push({ id: r.id, file: path.basename(o.map.file), verdict: v, found, problems }); +} +save('C-ground-truth.json', cRows); + +// traceparent gate (T1-T5) +const gate = run1.gate; +const t3 = run1 + .filter((o) => o.map) + .map((o) => ({ + id: o.req.id, + trace_ok: o.map.appmap.metadata.trace_id === o.tp.trace, + span_ok: o.map.appmap.metadata.parent_span_id === o.tp.span, + hdr_ok: summarize(o.map.appmap).servers[0]?.traceparent === o.tp.header, + outbound: o.gw.map((g) => g.traceparent), + outbound_ok: o.gw.every((g) => g.traceparent?.startsWith(`00-${o.tp.trace}-`) && g.traceparent.split('-')[2] !== o.tp.span), + recorded_outbound_ok: summarize(o.map.appmap).clients.every((c) => o.gw.some((g) => g.traceparent === c.traceparent)), + })); +const gateProblems = []; +if (gate.t1.newFiles !== 0) gateProblems.push(`T1 unstamped request wrote ${gate.t1.newFiles} file(s)`); +if (gate.t1.status !== 200) gateProblems.push(`T1 status ${gate.t1.status}`); +if (gate.t1.outboundTraceparents.some((x) => x)) gateProblems.push(`T5 unstamped request's outbound calls carried traceparent ${j(gate.t1.outboundTraceparents)}`); +for (const t of gate.t2) if (t.newFiles !== 0) gateProblems.push(`T2 malformed '${t.header}' wrote ${t.newFiles} file(s)`); +for (const t of t3) { + if (!t.trace_ok || !t.span_ok || !t.hdr_ok) gateProblems.push(`T3 ${t.id} metadata ids wrong ${j(t)}`); + if (!t.outbound_ok || !t.recorded_outbound_ok) gateProblems.push(`T4 ${t.id} outbound traceparent wrong ${j(t.outbound)}`); +} +save('C-traceparent-gate.json', { gate, t3 }); + +// structure: does the official sequence diagram nest the handler's work under the request? +const sd1 = path.join(EVID, 'sequence', 'run1'); +sequenceDiagrams(listMaps(run1.dir), sd1); +const structure = []; +for (const o of run1) { + if (!o.map) continue; + const f = path.join(sd1, path.basename(o.map.file).replace('.appmap.json', '.sequence.json')); + if (!fs.existsSync(f)) { structure.push({ id: o.req.id, roots: 'NO DIAGRAM' }); continue; } + const d = JSON.parse(fs.readFileSync(f, 'utf8')); + const roots = d.rootActions.map((a) => a.route ?? a.name); + const nested = d.rootActions.length === 1; + structure.push({ id: o.req.id, rootCount: d.rootActions.length, roots, serverChildren: d.rootActions[0]?.children?.length ?? 0, nested }); +} +save('C-structure.json', structure); +const badStructure = structure.filter((s) => s.rootCount > 1); + +verdict('C', cFail ? 'FAIL' : 'PASS', [ + ...cRows.map((r) => `${r.id} ${r.verdict}: ${r.problems?.length ? r.problems.join(' | ') : r.found.join(' | ')}`), + `anonymous Deno.serve handler (index.ts:68): recorded as the entry call in ${run1.filter((o) => o.map && summarize(o.map.appmap).functions[0]?.fn === 'index.handler').length}/${run1.filter((o) => o.map).length} recordings (EXPECTATIONS listed it as a gap)`, +]); +verdict('C-traceparent-gate', gateProblems.length ? 'FAIL' : 'PASS', gateProblems.length ? gateProblems : [ + `T1 unstamped: HTTP ${gate.t1.status}, ${gate.t1.newFiles} files, outbound traceparent ${j(gate.t1.outboundTraceparents)}`, + `T2 malformed: ${gate.t2.map((t) => `${t.header.slice(0, 12)}…→${t.newFiles} files`).join(', ')}`, + `T3/T4 stamped: ${t3.length}/${t3.length} maps carry caller trace_id+parent_span_id; outbound calls stamped with same trace id, new span`, +]); +verdict('C-structure', badStructure.length ? 'FAIL' : 'PASS', badStructure.length + ? badStructure.map((s) => `${s.id}: official sequence diagram has ${s.rootCount} disconnected roots ${j(s.roots)} (handler work not nested under the HTTP request)`) + : ['every request is a single tree rooted at the HTTP server request']); + +// ---------------------------------------------------------------- D noise +const pkgCount = {}; +for (const f of listMaps(run1.dir)) { + for (const e of JSON.parse(fs.readFileSync(f, 'utf8')).events) { + if (e.event !== 'call') continue; + const key = e.http_server_request ? 'http_server_request' : e.http_client_request ? `http_client_request ${new URL(e.http_client_request.url).host}` + : e.sql_query ? 'sql_query' : `function ${path.dirname(e.path ?? '?')}`; + pkgCount[key] = (pkgCount[key] ?? 0) + 1; + } +} +const foreign = Object.keys(pkgCount).filter((k) => k.startsWith('function ') && k !== `function ${path.dirname(ENTRY_REL)}`); +save('D-events-by-package.json', pkgCount); +verdict('D', foreign.length ? 'FAIL' : 'PASS', [`call events by package over run1: ${j(pkgCount)}`, ...foreign.map((f) => `foreign: ${f}`)]); + +// ---------------------------------------------------------------- E exception +const r7 = cRows.find((r) => r.id === 'R7'); +verdict('E', r7?.verdict === 'FOUND' ? 'PASS' : 'FAIL', r7 ? (r7.found.length && r7.verdict === 'FOUND' ? r7.found : r7.problems) : ['R7 missing']); + +// ---------------------------------------------------------------- G stability (run 2) +log('run2: same sequence again'); +const run2 = await runSequence('run2'); +fs.cpSync(run2.dir, path.join(EVID, 'recordings', 'run2'), { recursive: true }); +const sd2 = path.join(EVID, 'sequence', 'run2'); +sequenceDiagrams(listMaps(run2.dir), sd2); +const gDiffs = []; +for (const [i, o1] of run1.entries()) { + const o2 = run2[i]; + if (!o1.map || !o2.map) { gDiffs.push(`${o1.req.id}: recording missing in ${!o1.map ? 'run1' : 'run2'}`); continue; } + const f1 = path.join(sd1, path.basename(o1.map.file).replace('.appmap.json', '.sequence.json')); + const f2 = path.join(sd2, path.basename(o2.map.file).replace('.appmap.json', '.sequence.json')); + const d1 = normalizeDiagram(JSON.parse(fs.readFileSync(f1, 'utf8'))); + const d2 = normalizeDiagram(JSON.parse(fs.readFileSync(f2, 'utf8'))); + if (j(d1) !== j(d2)) { + const r1 = d1.rootActions.map((a) => `${a.route ?? a.name}#${a.subtreeDigest.slice(0, 8)}`); + const r2 = d2.rootActions.map((a) => `${a.route ?? a.name}#${a.subtreeDigest.slice(0, 8)}`); + gDiffs.push(`${o1.req.id}: run1 ${j(r1)} vs run2 ${j(r2)}`); + } + // also the event-level shape (ids/timings/random spans removed) + const shape = (m) => m.events.map((e) => `${e.event}:${e.thread_id}:${e.method_id ?? e.http_client_request?.url ?? e.http_server_request?.path_info ?? ''}:${e.parent_id ?? ''}`).join('|'); + if (shape(o1.map.appmap) !== shape(o2.map.appmap)) gDiffs.push(`${o1.req.id}: event order/thread shape differs`); +} +save('G-stability.json', gDiffs); +verdict('G', gDiffs.length ? 'FAIL' : 'PASS', gDiffs.length ? gDiffs : [`${run1.length}/${run1.length} requests: normalized official sequence-diagram JSON identical between run1 and run2 (elapsed/eventIds removed); event order/thread shape identical`]); + +// ---------------------------------------------------------------- H change detection +log('run3: H change applied on a scratch branch'); +const patch = path.join(ACC, 'h-change.patch'); +let hApplied = sh('git', ['-C', APPDIR, 'checkout', '-q', '-b', 'acceptance-h-change']); +hApplied = sh('git', ['-C', APPDIR, 'apply', patch]); +if (hApplied.code !== 0) throw new Error(`patch failed: ${hApplied.out}`); +save('H-app-diff.txt', sh('git', ['-C', APPDIR, 'diff']).out); +let run3; +try { + run3 = await runSequence('run3'); +} finally { + sh('git', ['-C', APPDIR, 'checkout', '-q', '--', '.']); + sh('git', ['-C', APPDIR, 'checkout', '-q', '-']); + sh('git', ['-C', APPDIR, 'branch', '-q', '-D', 'acceptance-h-change']); +} +fs.cpSync(run3.dir, path.join(EVID, 'recordings', 'run3-h-change'), { recursive: true }); +const sd3 = path.join(EVID, 'sequence', 'run3-h-change'); +sequenceDiagrams(listMaps(run3.dir), sd3); +const hOfficial = []; +const hChanged = []; +fs.mkdirSync(path.join(EVID, 'H-diff'), { recursive: true }); +for (const [i, o1] of run1.entries()) { + const o3 = run3[i]; + if (!o1.map || !o3.map) { hChanged.push(`${o1.req.id}: missing recording`); continue; } + const f1 = path.join(sd1, path.basename(o1.map.file).replace('.appmap.json', '.sequence.json')); + const f3 = path.join(sd3, path.basename(o3.map.file).replace('.appmap.json', '.sequence.json')); + const outDir = path.join(EVID, 'H-diff', o1.req.id); + const r = sh(APPMAP_CLI, ['sequence-diagram-diff', f1, f3, '--format', 'text', '--output-dir', outDir], { env: { ...process.env, APPMAP_TELEMETRY_DISABLED: 'true' } }); + const txtFiles = fs.existsSync(outDir) ? fs.readdirSync(outDir) : []; + const text = txtFiles.map((f) => fs.readFileSync(path.join(outDir, f), 'utf8')).join('\n'); + const same = j(normalizeDiagram(JSON.parse(fs.readFileSync(f1, 'utf8')))) === j(normalizeDiagram(JSON.parse(fs.readFileSync(f3, 'utf8')))); + hOfficial.push({ id: o1.req.id, exit: r.code, cli: r.out.trim().split('\n').filter((l) => !/telemetry/i.test(l)).slice(0, 5), diff: text.trim(), identical: same }); + if (!same) hChanged.push(o1.req.id); +} +save('H-official-diff.json', hOfficial); +// repo's own behavior-diff tracer +const traceOut = path.join(EVID, 'H-appmap-trace'); +const tr = sh(process.execPath, [path.join(ROOT, 'linker', 'bin', 'appmap-trace.mjs'), run3.dir, '--baseline', run1.dir, '--out', traceOut]); +save('H-appmap-trace-stdout.txt', tr.out); +const traceDel = fs.existsSync(traceOut) ? fs.readdirSync(traceOut).filter((f) => f.startsWith('DELETE') && f.endsWith('.md')) : []; +const traceDelText = traceDel.map((f) => fs.readFileSync(path.join(traceOut, f), 'utf8')).join('\n'); +const delOfficial = hOfficial.find((h) => h.id === 'R6'); +const delRec = run3.find((o) => o.req.id === 'R6')?.map; +const delClients = delRec ? summarize(delRec.appmap).clients.map((c) => `${c.method} ${c.url} ${c.status}`) : []; +const expectedDel = [`GET ${GW}/rest/v1/tasks?select=*&id=eq.2 200`, `DELETE ${GW}/rest/v1/tasks?id=eq.2 204`]; +const hProblems = []; +if (j(hChanged) !== j(['R6'])) hProblems.push(`requests whose diagram changed: ${j(hChanged)} (expected only R6)`); +if (j(delClients) !== j(expectedDel)) hProblems.push(`R6 after change has clients ${j(delClients)}, expected ${j(expectedDel)}`); +// The official diagram renders an HTTP client call from its `url`, which +// by the AppMap spec excludes the query (it is in `message`): the official +// diff can name the added GET but not its query. It must name the added +// GET; the query-level change must show in appmap-trace, which reads +// `message`. +if (!delOfficial?.diff || !delOfficial.diff.includes(`added HTTP client request \`GET ${GW}/rest/v1/tasks\``)) hProblems.push(`official sequence-diagram-diff text for R6 does not name the added GET: ${j(delOfficial?.diff?.slice(0, 400))}`); +if (!traceDelText.includes('New call restful-tasks→network: GET /rest/v1/tasks?select=*&id=eq.2')) hProblems.push(`appmap-trace --baseline for DELETE did not name the added call: ${j(traceDelText.slice(0, 400))}`); +const traceOthers = fs.existsSync(traceOut) ? fs.readdirSync(traceOut).filter((f) => f.endsWith('.md') && !f.startsWith('DELETE')) : []; +for (const f of traceOthers) if (!fs.readFileSync(path.join(traceOut, f), 'utf8').includes('No behavior change')) hProblems.push(`appmap-trace reports a change in ${f}`); +const traceCaption = (traceDelText.match(/^> (.*)$/m) ?? [])[1]; +verdict('H', hProblems.length ? 'FAIL' : 'PASS', [ + ...hProblems, + `official diff R6: ${j(delOfficial?.diff?.slice(0, 600))}`, + `appmap-trace DELETE caption: ${j(traceCaption)}; other ${traceOthers.length} interactions: "No behavior change"`, + `appmap-trace stdout: ${tr.out.trim().split('\n').slice(-1)[0]}`, +]); + +// ---------------------------------------------------------------- I concurrency +log('I: concurrent requests'); +const iRounds = []; +for (let round = 1; round <= 3; round++) { + resetDb(); + // seed 20 rows so every request touches its own id + const seedRes = await fetch(`${GW}/rest/v1/tasks`, { + method: 'POST', + headers: { Authorization: AUTH, apikey: ANON, 'Content-Type': 'application/json', Prefer: 'return=representation' }, + body: JSON.stringify(Array.from({ length: 30 }, (_, k) => ({ name: `conc-${k}`, status: 0 }))), + }); + const rows = await seedRes.json(); + const dir = path.join(REC, `concurrency-${round}`); + const server = await startServer({ recorded: true, entry: ENTRY_REL, cwd: APPDIR, port: APP_PORT, appmapDir: dir, app: 'restful-tasks' }); + const gwBefore = gatewayLines().length; + const reqs = []; + for (let k = 0; k < 20; k++) { + const id = rows[k].id; + const tp = traceparent(`c${round}`, k + 1); + const kind = k % 4; + const spec = kind === 0 ? { method: 'GET', path: `/restful-tasks/${id}`, fn: 'index.getTask', url: `?select=*&id=eq.${id}` } + : kind === 1 ? { method: 'PUT', path: `/restful-tasks/${id}`, body: { task: { name: `c-upd-${k}`, status: k } }, fn: 'index.updateTask', url: `?id=eq.${id}` } + : kind === 2 ? { method: 'DELETE', path: `/restful-tasks/${id}`, fn: 'index.deleteTask', url: `?id=eq.${id}` } + : { method: 'POST', path: '/restful-tasks', body: { task: { name: `c-new-${k}`, status: k } }, fn: 'index.createTask', url: '', marker: `c-new-${k}` }; + reqs.push({ k, id, tp, ...spec }); + } + // plus 10 unstamped requests interleaved, on their own rows (ids 21-30) + const unstamped = Array.from({ length: 10 }, (_, u) => ({ u, id: rows[20 + u].id })); + // rounds 1-2: one burst; round 3: staggered 25ms apart, so several + // recordings open and close while other requests are in flight. + const stagger = round === 3 ? 25 : 0; + const t0 = performance.now(); + const launch = (i, fn) => sleep(i * stagger).then(fn); + const responses = await Promise.all([ + ...reqs.map((r, i) => launch(i, () => request(APP_PORT, r.method, r.path, { body: r.body, tp: r.tp })).then((res) => ({ r, res }))), + ...unstamped.map((u, i) => launch(i * 2 + 0.5, () => request(APP_PORT, 'GET', `/restful-tasks/${u.id}`)).then((res) => ({ u, res }))), + ]); + const wall = performance.now() - t0; + await sleep(1500); + await server.stop(); + const gw = gatewayLines().slice(gwBefore); + const files = listMaps(dir); + const leaks = []; + const recorded = []; + for (const f of files) { + const m = JSON.parse(fs.readFileSync(f, 'utf8')); + const s = summarize(m); + const own = reqs.find((r) => r.tp.span === m.metadata.parent_span_id); + if (!own) { leaks.push(`${path.basename(f)}: no matching request`); continue; } + recorded.push(own.k); + const tag = `req#${own.k} ${own.method} ${own.path}`; + if (s.servers.length !== 1) leaks.push(`${tag}: ${s.servers.length} http_server_request events ${j(s.servers.map((x) => `${x.method} ${x.path}`))}`); + // This request's own entry call, the anonymous Deno.serve handler + // (index.ts:68, recorded since the requested fix), is not a leak: it is + // the first function call and is nested in no other function. Any other + // call, including a second handler call, is judged as before. + const first = s.functions[0]; + const entry = first && first.fn === 'index.handler' && first.lineno === 68 && !first.ancestors.some((a) => s.functions.some((g) => g.id === a)) ? first : undefined; + const appFns = s.functions.filter((fn) => fn !== entry); + const foreignFns = appFns.filter((fn) => fn.fn !== own.fn || (own.method !== 'POST' && fn.params[1] !== String(own.id)) || (own.method === 'POST' && !fn.params[1]?.includes(own.marker))); + if (foreignFns.length) leaks.push(`${tag}: ${foreignFns.length} foreign function call(s): ${j(foreignFns.slice(0, 4).map((x) => `${x.fn}(${x.params.slice(1).join(',')})`))}${foreignFns.length > 4 ? '…' : ''}`); + if (appFns.length - foreignFns.length !== 1) leaks.push(`${tag}: own handler function recorded ${appFns.length - foreignFns.length} times`); + if (entry && appFns.some((fn) => !fn.ancestors.includes(entry.id))) leaks.push(`${tag}: function call(s) outside this request's Deno.serve handler`); + const ownUrl = (c) => c.method === ({ GET: 'GET', PUT: 'PATCH', DELETE: 'DELETE', POST: 'POST' })[own.method] && c.url === `${GW}/rest/v1/tasks${own.url}`; + const foreignCl = s.clients.filter((c) => !ownUrl(c)); + if (foreignCl.length) leaks.push(`${tag}: ${foreignCl.length} foreign outbound call(s): ${j(foreignCl.slice(0, 4).map((c) => `${c.method} ${c.url.replace(GW, '')}`))}${foreignCl.length > 4 ? '…' : ''}`); + // gateway: outbound calls stamped with this recording's trace id + const stampedWithMine = gw.filter((g) => g.traceparent?.split('-')[1] === m.metadata.trace_id); + const foreignStamped = stampedWithMine.filter((g) => !ownUrl({ method: g.method, url: GW + g.url })); + if (foreignStamped.length) leaks.push(`${tag}: gateway saw ${foreignStamped.length} OTHER request(s)' outbound calls stamped with this recording's trace id: ${j(foreignStamped.slice(0, 3).map((g) => `${g.method} ${g.url}`))}`); + const synth = s.functions.filter((x) => x.synthetic).length + s.clients.filter((x) => x.synthetic).length; + if (s.truncated || synth) leaks.push(`${tag}: metadata.truncated=${s.truncated}, ${synth} synthetic return(s): other requests' calls were still open when this recording closed`); + } + const unstampedStamped = gw.filter((g) => g.traceparent && !reqs.some((r) => g.traceparent.split('-')[1] === r.tp.trace && g.url === `/rest/v1/tasks${r.url}`)).length; + const statuses = responses.map((x) => x.res.status); + iRounds.push({ round, staggerMs: stagger, wallMs: Math.round(wall), stampedSent: 20, unstampedSent: 10, files: files.length, recorded, statusesOk: statuses.every((s) => s === 200), leaks, outboundCallsWithAForeignTraceparent: unstampedStamped }); + fs.cpSync(dir, path.join(EVID, 'recordings', `concurrency-${round}`), { recursive: true }); +} +save('I-concurrency.json', iRounds); +const iLeaks = iRounds.flatMap((r) => r.leaks.map((l) => `round ${r.round}: ${l}`)); +verdict('I', iLeaks.length ? 'FAIL' : 'PASS', [ + ...iRounds.map((r) => `round ${r.round}${r.staggerMs ? ` (staggered ${r.staggerMs}ms)` : ' (one burst)'}: 20 stamped + 10 unstamped, all HTTP 200=${r.statusesOk} -> ${r.files} recording(s) (reqs ${j(r.recorded)}), ${r.leaks.length} leak finding(s), ${r.outboundCallsWithAForeignTraceparent} outbound call(s) carried another request's trace id`), + ...iLeaks.slice(0, 12), + ...(iLeaks.length > 12 ? [`… ${iLeaks.length - 12} more in evidence/I-concurrency.json`] : []), +]); + +// ---------------------------------------------------------------- waitUntil (synthetic probe) +log('W: waitUntil probe'); +const probeDir = path.join(REC, 'probe'); +const probeRel = path.relative(ROOT, PROBE); +const probeSrcBefore = fs.readFileSync(PROBE, 'utf8'); +const W = {}; +async function probeServer(dir) { + return startServer({ recorded: true, entry: probeRel, cwd: ROOT, port: PROBE_PORT, appmapDir: dir, app: 'probe', lock: false }); +} +resetDb(); +{ + const dir = path.join(probeDir, 'w1'); + const s = await probeServer(dir); + const tp = traceparent('w1', 1); + const t0 = Date.now(); + const res = await request(PROBE_PORT, 'POST', '/probe/ingest?n=1', { tp }); + const tResp = Date.now(); + const map = await waitForMap(dir, tp.span, 15000); + const tFile = Date.now(); + // W4: overlap: A then B during A's background window + const tpA = traceparent('w4', 2), tpB = traceparent('w4', 3); + const resA = await request(PROBE_PORT, 'POST', '/probe/ingest?n=2', { tp: tpA }); + await sleep(500); + const resB = await request(PROBE_PORT, 'POST', '/probe/ingest?n=3', { tp: tpB }); + const mapA = await waitForMap(dir, tpA.span, 15000); + await sleep(4000); + const mapB = listMaps(dir).find((f) => f.includes(`_${tpB.span}_`)); + await s.stop(); + const sum = map && summarize(map.appmap); + W.w1 = { status: res.status, responseMs: tResp - t0, fileAfterResponseMs: tFile - tResp, summary: sum, file: map?.file }; + const sA = mapA && summarize(mapA.appmap); + const bAppmap = mapB ? JSON.parse(fs.readFileSync(mapB, 'utf8')) : undefined; + W.w4 = { + statusA: resA.status, + statusB: resB.status, + aSummary: sA, + aTrace: mapA?.appmap.metadata.trace_id ?? null, + bFile: mapB ?? null, + bSummary: bAppmap ? summarize(bAppmap) : null, + bTrace: bAppmap?.metadata.trace_id ?? null, + tpA: tpA.header, + tpB: tpB.header, + }; + fs.cpSync(dir, path.join(EVID, 'recordings', 'probe-w1-w4'), { recursive: true }); +} +{ + const dir = path.join(probeDir, 'w2'); + const s = await probeServer(dir); + const tp = traceparent('w2', 4); + const res = await request(PROBE_PORT, 'POST', '/probe/fire-and-forget?n=4', { tp }); + const map = await waitForMap(dir, tp.span, 5000); + await sleep(4000); + await s.stop(); + W.w2 = { status: res.status, summary: map && summarize(map.appmap), files: listMaps(dir).length }; + fs.cpSync(dir, path.join(EVID, 'recordings', 'probe-w2'), { recursive: true }); +} +W.w3 = []; +for (const [how, n] of [['SIGKILL deno child', 5], ['SIGTERM runner', 6], ['SIGINT runner', 7]]) { + const dir = path.join(probeDir, `w3-${n}`); + const s = await probeServer(dir); + const tp = traceparent('w3', n); + const res = await request(PROBE_PORT, 'POST', `/probe/ingest?n=${n}`, { tp }); + await sleep(1500); // inside the background window (insert done, enrich pending) + const denoPid = s.denoPid(); + if (how.startsWith('SIGKILL')) process.kill(denoPid, 'SIGKILL'); + else s.proc.kill(how.startsWith('SIGTERM') ? 'SIGTERM' : 'SIGINT'); + await sleep(2500); + await s.stop('SIGKILL'); + const files = listMaps(dir); + const leftovers = fs.readdirSync(path.dirname(PROBE)).filter((f) => f.startsWith('.appmap.')); + W.w3.push({ how, status: res.status, files: files.map((f) => path.basename(f)), truncated: files.map((f) => !!JSON.parse(fs.readFileSync(f, 'utf8')).metadata.truncated), leftoverTransformedCopy: leftovers }); + for (const l of leftovers) fs.rmSync(path.join(path.dirname(PROBE), l)); +} +save('W-waituntil.json', W); +const w1p = []; +const s1 = W.w1.summary; +if (W.w1.status !== 202) w1p.push(`status ${W.w1.status}`); +if (W.w1.responseMs > 1000) w1p.push(`response took ${W.w1.responseMs}ms`); +if (!s1) w1p.push('no recording'); +else { + if (s1.servers[0]?.status !== 202) w1p.push(`server response ${s1.servers[0]?.status}`); + const cl = s1.clients.map((c) => `${c.method} ${c.url.replace(/^http:\/\/127\.0\.0\.1:\d+/, '')} ${c.status}`); + const exp = ['POST /rest/v1/tasks 201', 'GET /enrich?n=1 200', 'PATCH /rest/v1/tasks?name=eq.probe-1 204']; + if (j(cl) !== j(exp)) w1p.push(`clients ${j(cl)} != ${j(exp)}`); + if (!s1.functions.some((f) => f.fn === 'probe.ingest' && !f.synthetic)) w1p.push(`no completed probe.ingest call: ${j(s1.functions.map((f) => f.fn))}`); + if (W.w1.fileAfterResponseMs < 3000) w1p.push(`file appeared ${W.w1.fileAfterResponseMs}ms after the 202 (< 3000ms of background work)`); + if (s1.truncated || s1.unbalanced.length) w1p.push('truncated/unbalanced'); +} +verdict('W1-waitUntil-capture', w1p.length ? 'FAIL' : 'PASS', w1p.length ? w1p : [ + `202 in ${W.w1.responseMs}ms; file written ${W.w1.fileAfterResponseMs}ms after the response; clients ${j(s1.clients.map((c) => `${c.method} ${c.url.replace(/^http:\/\/127\.0\.0\.1:\d+/, '')} ${c.status}`))}; fns ${j(s1.functions.map((f) => f.fn))}; not truncated`, +]); +// Overlapping stamped requests each get their own recording (the +// requested fix): A and B must be two separate recordings, each holding +// exactly its own request's work and none of the other's. +const w4p = []; +const w4own = {}; +for (const [who, n, other, sum, trace, tp] of [ + ['A', 2, 3, W.w4.aSummary, W.w4.aTrace, W.w4.tpA], + ['B', 3, 2, W.w4.bSummary, W.w4.bTrace, W.w4.tpB], +]) { + if (!sum) { + w4p.push(`${who} (n=${n}) has no recording${who === 'B' ? ' (B arrived during A\'s background window)' : ''}`); + continue; + } + if (trace !== tp.split('-')[1]) w4p.push(`${who}'s recording has trace_id ${trace}, not its own request's`); + const cl = sum.clients.map((c) => `${c.method} ${c.url.replace(/^http:\/\/127\.0\.0\.1:\d+/, '')}`); + const fns = sum.functions.map((f) => `${f.fn}(${f.params[0]})`); + w4own[who] = { clients: cl, functions: fns }; + const foreign = cl.filter((u) => new RegExp(`probe-${other}\\b|n=${other}\\b`).test(u)); + if (foreign.length) w4p.push(`${who}'s recording contains the other request's background calls: ${j(foreign)}`); + const ingests = fns.filter((x) => x.startsWith('probe.ingest')); + if (j(ingests) !== j([`probe.ingest(${n})`])) w4p.push(`${who}'s recording has ingest calls ${j(ingests)}, expected only probe.ingest(${n})`); + const exp = ['POST /rest/v1/tasks', `GET /enrich?n=${n}`, `PATCH /rest/v1/tasks?name=eq.probe-${n}`]; + if (j(cl) !== j(exp)) w4p.push(`${who}'s outbound calls ${j(cl)} != ${j(exp)}`); +} +verdict('W4-waitUntil-overlap', w4p.length ? 'FAIL' : 'PASS', w4p.length ? w4p : [`A and B (overlapping) recorded separately, each with only its own calls: A ${j(w4own.A.clients)}; B ${j(w4own.B.clients)}`]); +const w2s = W.w2.summary; +const w2ok = w2s && w2s.truncated && w2s.unbalanced.length === 0 && w2s.functions.some((f) => f.fn === 'probe.ingest' && f.synthetic); +verdict('W2-self-heal-fire-and-forget', w2ok ? 'PASS' : 'FAIL', [ + w2s ? `truncated=${w2s.truncated}; functions ${j(w2s.functions.map((f) => `${f.fn}${f.synthetic ? '(synthetic return)' : ''}`))}; clients ${j(w2s.clients.map((c) => `${c.method} ${c.url.replace(/^http:\/\/127\.0\.0\.1:\d+/, '')} ${c.synthetic ? 'synthetic' : c.status}`))}; unbalanced=${w2s.unbalanced.length}` : 'no recording', +]); +const w3ok = W.w3.every((w) => w.files.length === 1 && w.truncated[0]); +verdict('W3-crash-self-heal', w3ok ? 'PASS' : 'FAIL', W.w3.map((w) => `${w.how} 1.5s into background: ${w.files.length} recording file(s) ${j(w.files)}; truncated ${j(w.truncated)}; leftover transformed copy ${j(w.leftoverTransformedCopy)}`)); +if (fs.readFileSync(PROBE, 'utf8') !== probeSrcBefore) throw new Error('probe source changed on disk'); + +// ---------------------------------------------------------------- J overhead +log('J: overhead'); +async function timeRun(recorded, stamped, rep) { + resetDb(); + const dir = path.join(REC, `overhead-${recorded}-${stamped}-${rep}`); + const s = await startServer({ recorded, entry: ENTRY_REL, cwd: APPDIR, port: APP_PORT, appmapDir: dir, app: 'restful-tasks' }); + // warm up + for (let i = 0; i < 10; i++) await request(APP_PORT, 'GET', '/restful-tasks/1'); + const t0 = performance.now(); + for (let i = 0; i < 200; i++) { + const p = i % 2 ? '/restful-tasks/1' : '/restful-tasks'; + await request(APP_PORT, 'GET', p, { tp: stamped ? traceparent('j', i + 1) : undefined }); + } + const ms = performance.now() - t0; + await sleep(500); + await s.stop(); + return { ms: Math.round(ms), files: listMaps(dir).length }; +} +const J = { plain: [], recordedUnstamped: [], recordedStamped: [] }; +for (let rep = 0; rep < 2; rep++) { + J.plain.push(await timeRun(false, false, rep)); + J.recordedUnstamped.push(await timeRun(true, false, rep)); + J.recordedStamped.push(await timeRun(true, true, rep)); +} +save('J-overhead.json', J); +const med = (a) => a.map((x) => x.ms).sort((x, y) => x - y)[0]; +verdict('J', 'PASS', [ + `200 sequential requests, best of 2: plain deno run ${med(J.plain)}ms; appmap-deno unstamped ${med(J.recordedUnstamped)}ms (${(med(J.recordedUnstamped) / med(J.plain)).toFixed(2)}x); appmap-deno all stamped ${med(J.recordedStamped)}ms (${(med(J.recordedStamped) / med(J.plain)).toFixed(2)}x), ${J.recordedStamped[0].files} maps written`, + 'no threshold is defined by the spec; reported as measured', +]); + +// ---------------------------------------------------------------- B validity (every recording) +log('B: validating every recording'); +const allMaps = [ + ...listMaps(path.join(REC, 'run1')), ...listMaps(path.join(REC, 'run2')), ...listMaps(path.join(REC, 'run3')), + ...[1, 2, 3].flatMap((r) => listMaps(path.join(REC, `concurrency-${r}`))), + ...fs.readdirSync(probeDir).flatMap((d) => listMaps(path.join(probeDir, d))), +]; +const B = { total: allMaps.length, cliValid: 0, cliMessages: {}, highestVersionSatisfied: {}, violations12: {} }; +for (const f of allMaps) { + const v = validateMap(f); + if (v.cliExit === 0) B.cliValid++; + const msg = v.cliOut.split('\n').slice(0, 3).join(' / '); + B.cliMessages[msg] = (B.cliMessages[msg] ?? 0) + 1; + const ok = Object.entries(v.perVersion).filter(([, errs]) => errs.length === 0).map(([ver]) => ver); + const hi = ok.length ? ok[ok.length - 1] : 'none'; + B.highestVersionSatisfied[hi] = (B.highestVersionSatisfied[hi] ?? 0) + 1; + for (const e of v.perVersion['1.12.0'] ?? []) B.violations12[e] = (B.violations12[e] ?? 0) + 1; + const d = diagnose(f); + const fix12 = Array.isArray(d['1.12.0']) ? d['1.12.0'].join(' + ') : d['1.12.0']; + B.missingFor112 ??= {}; + B.missingFor112[fix12] = (B.missingFor112[fix12] ?? 0) + 1; + const fixable = Object.entries(d).filter(([, x]) => Array.isArray(x)).map(([ver]) => ver); + B.oldestFixableVersion ??= {}; + B.oldestFixableVersion[fixable[0] ?? 'none'] = (B.oldestFixableVersion[fixable[0] ?? 'none'] ?? 0) + 1; +} +// sequence-diagram over everything +const sdAll = sequenceDiagrams(allMaps, path.join(WORK, 'sd-all')); +B.sequenceDiagramExit = sdAll.code; +B.sequenceDiagramErrors = sdAll.out.split('\n').filter((l) => /error|fail/i.test(l) && !/telemetry/i.test(l)).slice(0, 10); +save('B-validation.json', B); +verdict('B', B.cliValid === B.total ? 'PASS' : 'FAIL', [ + `official appmap-validate: ${B.cliValid}/${B.total} valid; messages ${j(B.cliMessages)}`, + `smallest set of missing items that would make each map valid 1.12.0 (count of maps): ${j(B.missingFor112)}`, + `oldest schema version each map could satisfy even after those additions (count of maps): ${j(B.oldestFixableVersion)}`, + `highest schema version fully satisfied (per map): ${j(B.highestVersionSatisfied)}`, + `appmap sequence-diagram over all ${B.total} maps: exit ${B.sequenceDiagramExit}`, +]); + +// ---------------------------------------------------------------- A setup / zero-touch +const status = sh('git', ['-C', APPDIR, 'status', '--porcelain', '--ignored']).out.trim(); +const head = sh('git', ['-C', APPDIR, 'rev-parse', 'HEAD']).out.trim(); +const srcAfter = fs.readFileSync(path.join(APPDIR, ENTRY_REL), 'utf8'); +const aProblems = []; +if (status) aProblems.push(`app clone not clean after runs: ${status}`); +if (srcAfter !== srcBefore) aProblems.push('app entry file changed'); +if (head !== '74a3be9aa8706755e05f7326f3d25472729cd977') aProblems.push(`app HEAD ${head}`); +if (!listMaps(run1.dir).length) aProblems.push('no recordings produced'); +verdict('A', aProblems.length ? 'FAIL' : 'PASS', [ + ...aProblems, + `app ${head}; command (cwd ${APPDIR}): ${run1.command}`, + 'env only: SUPABASE_URL, SUPABASE_ANON_KEY (local), APPMAP_DIR, DENO_SERVE_ADDRESS (port); no app source edits; git status clean after all runs', +]); +verdict('F', 'NOT RUN', ['the app has no tests; the Deno recorder has no test-recording mode (appmap-deno only wraps `deno run`)']); + +save('results.json', results); +gateway.kill(); +const failed = Object.entries(results).filter(([, r]) => r.status === 'FAIL').map(([k]) => k); +log(`FAILED: ${failed.join(', ') || 'none'}`); +process.exit(failed.length ? 1 : 0); diff --git a/acceptance/supabase-restful-tasks/infra/db.sh b/acceptance/supabase-restful-tasks/infra/db.sh new file mode 100755 index 0000000..4077498 --- /dev/null +++ b/acceptance/supabase-restful-tasks/infra/db.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Local PostgreSQL 16 + PostgREST for the restful-tasks acceptance run. +# usage: db.sh start|stop|reset (WORK dir from env, default /tmp/acc-deno-work) +set -euo pipefail +WORK=${WORK:-/tmp/acc-deno-work} +PGBIN=${PGBIN:-$(printf '%s\n' /usr/lib/postgresql/*/bin | sort -V | tail -1)} +PGPORT=${PGPORT:-54329} +PGRST_PORT=${PGRST_PORT:-54330} +POSTGREST=${POSTGREST:-/root/tools/postgrest} +JWT_SECRET=${JWT_SECRET:-acceptance-local-jwt-secret-at-least-32-chars} +PGDATA=$WORK/pgdata +RUNAS=() +if [ "$(id -u)" = 0 ]; then RUNAS=(runuser -u postgres --); fi + +schema_sql() { +cat </dev/null + fi + "${RUNAS[@]}" "$PGBIN/pg_ctl" -D "$PGDATA" -o "-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1" -l "$PGDATA/log" -w start >/dev/null + schema_sql | PGOPTIONS="-c client_min_messages=warning" psql -q -h 127.0.0.1 -p "$PGPORT" -U postgres -d postgres -v ON_ERROR_STOP=1 >/dev/null + cat > "$WORK/postgrest.conf" < "$WORK/postgrest.log" 2>&1 & + echo $! > "$WORK/postgrest.pid" + for _ in $(seq 1 50); do curl -sf "http://127.0.0.1:$PGRST_PORT/" >/dev/null && break; sleep 0.2; done + curl -sf "http://127.0.0.1:$PGRST_PORT/" >/dev/null || { echo "postgrest did not start"; cat "$WORK/postgrest.log"; exit 1; } + ;; + reset) + schema_sql | PGOPTIONS="-c client_min_messages=warning" psql -q -h 127.0.0.1 -p "$PGPORT" -U postgres -d postgres -v ON_ERROR_STOP=1 >/dev/null + # PostgREST caches the schema; ask it to reload. + psql -q -h 127.0.0.1 -p "$PGPORT" -U postgres -d postgres -c "notify pgrst, 'reload schema'" >/dev/null + sleep 0.5 + ;; + stop) + [ -f "$WORK/postgrest.pid" ] && kill "$(cat "$WORK/postgrest.pid")" 2>/dev/null || true + rm -f "$WORK/postgrest.pid" + [ -d "$PGDATA" ] && "${RUNAS[@]}" "$PGBIN/pg_ctl" -D "$PGDATA" -m fast stop >/dev/null 2>&1 || true + ;; + *) echo "usage: $0 start|stop|reset"; exit 2;; +esac diff --git a/acceptance/supabase-restful-tasks/infra/gateway.mjs b/acceptance/supabase-restful-tasks/infra/gateway.mjs new file mode 100644 index 0000000..2a9c932 --- /dev/null +++ b/acceptance/supabase-restful-tasks/infra/gateway.mjs @@ -0,0 +1,57 @@ +// Local stand-in for Supabase's API gateway (Kong): maps /rest/v1/* to a +// real PostgREST, and serves a tiny "enrich" stub used by the synthetic +// waitUntil probe. Every request is appended to GATEWAY_LOG as one JSON +// line {t, port, method, url, traceparent, status}; that log is the +// ground truth for the app's outbound calls. +import http from 'node:http'; +import fs from 'node:fs'; + +const GW_PORT = Number(process.env.GW_PORT ?? 54321); +const ENRICH_PORT = Number(process.env.ENRICH_PORT ?? 54399); +const PGRST = `http://127.0.0.1:${process.env.PGRST_PORT ?? 54330}`; +const LOG = process.env.GATEWAY_LOG ?? '/tmp/acc-deno-work/gateway.log'; + +const log = (rec) => fs.appendFileSync(LOG, JSON.stringify({ t: Date.now(), ...rec }) + '\n'); + +http + .createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const body = Buffer.concat(chunks); + const tp = req.headers['traceparent'] ?? null; + if (!req.url.startsWith('/rest/v1/')) { + res.writeHead(404).end(); + log({ port: GW_PORT, method: req.method, url: req.url, traceparent: tp, status: 404 }); + return; + } + const headers = { ...req.headers }; + delete headers.host; + delete headers['content-length']; + delete headers['connection']; + const upstream = await fetch(PGRST + req.url.slice('/rest/v1'.length), { + method: req.method, + headers, + body: ['GET', 'HEAD'].includes(req.method) ? undefined : body, + }); + const out = Buffer.from(await upstream.arrayBuffer()); + const h = {}; + upstream.headers.forEach((v, k) => { + if (!['content-encoding', 'transfer-encoding', 'content-length', 'connection'].includes(k)) h[k] = v; + }); + res.writeHead(upstream.status, h).end(out); + log({ port: GW_PORT, method: req.method, url: req.url, traceparent: tp, status: upstream.status }); + }) + .listen(GW_PORT, '127.0.0.1'); + +http + .createServer((req, res) => { + const tp = req.headers['traceparent'] ?? null; + const u = new URL(req.url, 'http://x'); + res.writeHead(200, { 'content-type': 'application/json' }).end( + JSON.stringify({ n: u.searchParams.get('n'), score: 42 }), + ); + log({ port: ENRICH_PORT, method: req.method, url: req.url, traceparent: tp, status: 200 }); + }) + .listen(ENRICH_PORT, '127.0.0.1'); + +console.log(`gateway :${GW_PORT} -> ${PGRST}, enrich stub :${ENRICH_PORT}, log ${LOG}`); diff --git a/acceptance/supabase-restful-tasks/lib/jwt.mjs b/acceptance/supabase-restful-tasks/lib/jwt.mjs new file mode 100644 index 0000000..487b34b --- /dev/null +++ b/acceptance/supabase-restful-tasks/lib/jwt.mjs @@ -0,0 +1,10 @@ +// HS256 JWT for the local PostgREST (role claim selects the DB role). +import crypto from 'node:crypto'; +const b64 = (o) => Buffer.from(typeof o === 'string' ? o : JSON.stringify(o)).toString('base64url'); +export function jwt(role, secret = process.env.JWT_SECRET ?? 'acceptance-local-jwt-secret-at-least-32-chars') { + const head = b64({ alg: 'HS256', typ: 'JWT' }); + const body = b64({ role, iss: 'acceptance', iat: 1700000000, exp: 4102444800 }); + const sig = crypto.createHmac('sha256', secret).update(`${head}.${body}`).digest('base64url'); + return `${head}.${body}.${sig}`; +} +if (import.meta.url === `file://${process.argv[1]}`) console.log(jwt(process.argv[2] ?? 'authenticated')); diff --git a/acceptance/supabase-restful-tasks/lib/schema-diagnose.mjs b/acceptance/supabase-restful-tasks/lib/schema-diagnose.mjs new file mode 100644 index 0000000..43129d6 --- /dev/null +++ b/acceptance/supabase-restful-tasks/lib/schema-diagnose.mjs @@ -0,0 +1,45 @@ +// For one recording: which minimal, named fixes make it pass each official +// schema version? Answers "which required fields are missing" precisely, +// instead of reading ajv's anyOf noise. Diagnostic only; never changes the file. +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +const TOOLS = process.env.APPMAP_TOOLS ?? '/root/appmap-tools'; +const req = createRequire(path.join(TOOLS, 'package.json')); +const Ajv = req('ajv'); +const ajv = new Ajv({ allErrors: true, strict: false }); +const dir = path.join(TOOLS, 'node_modules/@appland/appmap-validate/schema'); +const versions = fs.readdirSync(dir).filter((f) => /^1-\d+-\d+\.js$/.test(f)) + .map((f) => ({ v: f.slice(0, -3).replace(/-/g, '.'), check: ajv.compile(req(path.join(dir, f)).schema) })) + .sort((a, b) => a.v.localeCompare(b.v, undefined, { numeric: true })); + +const FIXES = { + 'metadata.language.version': (m) => { if (m.metadata.language) m.metadata.language.version = 'x'; }, + 'message[] on http_server_request/http_client_request calls': (m) => { + for (const e of m.events) if (e.http_server_request || e.http_client_request) e.message ??= []; + }, + 'exceptions[].object_id': (m) => { for (const e of m.events) for (const x of e.exceptions ?? []) x.object_id ??= 1; }, + 'parameter/return value <= 100 chars': (m) => { + for (const e of m.events) for (const p of [...(e.parameters ?? []), ...(e.return_value ? [e.return_value] : [])]) if (p.value?.length > 100) p.value = p.value.slice(0, 100); + }, +}; +export function diagnose(file) { + const orig = JSON.parse(fs.readFileSync(file, 'utf8')); + const out = {}; + for (const { v, check } of versions) { + const names = Object.keys(FIXES); + // smallest subset of fixes that makes it valid (4 fixes -> 16 subsets) + let best = null; + for (let mask = 0; mask < 1 << names.length; mask++) { + const m = structuredClone(orig); m.version = v; + const used = names.filter((_, i) => mask & (1 << i)); + used.forEach((n) => FIXES[n](m)); + if (check(m) && (!best || used.length < best.length)) best = used; + } + out[v] = best ?? 'not fixable by the named fixes'; + } + return out; +} +if (import.meta.url === `file://${process.argv[1]}`) { + for (const f of process.argv.slice(2)) console.log(path.basename(f), JSON.stringify(diagnose(f), null, 1)); +} diff --git a/acceptance/supabase-restful-tasks/lib/util.mjs b/acceptance/supabase-restful-tasks/lib/util.mjs new file mode 100644 index 0000000..20d2d0f --- /dev/null +++ b/acceptance/supabase-restful-tasks/lib/util.mjs @@ -0,0 +1,298 @@ +// Shared helpers for the restful-tasks acceptance harness. +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { jwt } from './jwt.mjs'; + +export const ACC = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +export const ROOT = path.resolve(ACC, '..', '..'); +export const WORK = process.env.WORK ?? '/tmp/acc-deno-work'; +export const APPDIR = path.join(WORK, 'supabase'); +export const ENTRY_REL = 'examples/edge-functions/supabase/functions/restful-tasks/index.ts'; +export const PROBE = path.join(ACC, 'probe', 'probe.ts'); +export const TOOLS = process.env.APPMAP_TOOLS ?? '/root/appmap-tools'; +export const APPMAP_CLI = path.join(TOOLS, 'node_modules', '.bin', 'appmap'); +export const VALIDATE_CLI = path.join(TOOLS, 'node_modules', '.bin', 'appmap-validate'); +export const DENO = process.env.DENO ?? 'deno'; +export const APP_PORT = Number(process.env.APP_PORT ?? 18000); +export const PROBE_PORT = Number(process.env.PROBE_PORT ?? 18001); +export const GW = `http://127.0.0.1:${process.env.GW_PORT ?? 54321}`; +export const GATEWAY_LOG = path.join(WORK, 'gateway.log'); +export const LOCK = path.join(ACC, 'deno.lock'); + +export const AUTH = `Bearer ${jwt('authenticated')}`; +export const ANON = jwt('anon'); + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export function appEnv(extra = {}) { + return { + ...process.env, + SUPABASE_URL: GW, + SUPABASE_ANON_KEY: ANON, + APPMAP_TELEMETRY_DISABLED: 'true', + ...extra, + }; +} + +/** Start the app (or the probe) either plain or under the zero-touch runner. + * Resolves once Deno prints "Listening on". */ +export async function startServer({ recorded, entry, cwd, port, appmapDir, app, extraEnv = {}, lock = true }) { + const env = appEnv({ DENO_SERVE_ADDRESS: `tcp:127.0.0.1:${port}`, PORT: String(port), ...extraEnv }); + if (appmapDir) env.APPMAP_DIR = appmapDir; + const lockArgs = lock ? [`--lock=${LOCK}`] : []; + let cmd, args; + if (recorded) { + cmd = process.execPath; + args = [ + '--no-warnings', + '--experimental-strip-types', + path.join(ROOT, 'deno', 'bin', 'appmap-deno.ts'), + ...(app ? ['--app', app] : []), + entry, + '--', + ...lockArgs, + ]; + } else { + cmd = DENO; + args = ['run', '-A', ...lockArgs, entry]; + } + const proc = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + const onData = (d) => (out += d.toString()); + proc.stdout.on('data', onData); + proc.stderr.on('data', onData); + const started = Date.now(); + while (!/Listening on/.test(out)) { + if (proc.exitCode !== null) throw new Error(`server exited early (${proc.exitCode}):\n${out}`); + if (Date.now() - started > 90_000) throw new Error(`server did not start:\n${out}`); + await sleep(50); + } + return { + proc, + command: `${cmd === process.execPath ? 'node' : cmd} ${args.join(' ')}`, + output: () => out, + denoPid: () => findDenoChild(proc.pid), + async stop(signal = 'SIGTERM') { + if (proc.exitCode !== null || proc.signalCode) return; + proc.kill(signal); + for (let i = 0; i < 100 && proc.exitCode === null && !proc.signalCode; i++) await sleep(50); + }, + }; +} + +function findDenoChild(pid) { + const r = spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' }); + const kids = r.stdout.trim().split('\n').filter(Boolean).map(Number); + return kids[0]; +} + +let spanSeq = 0; +/** Deterministic, unique W3C traceparent per call site key. */ +export function traceparent(tag, n) { + const hex = Buffer.from(`${tag}`).toString('hex').padEnd(16, '0').slice(0, 16); + const trace = (hex + String(n).padStart(16, '0')).slice(0, 32); + const span = (String(n).padStart(8, '0') + String(++spanSeq % 1e8).padStart(8, '0')).slice(0, 16); + return { header: `00-${trace}-${span}-01`, trace, span }; +} + +export async function request(port, method, p, { body, tp, auth = true, headers = {} } = {}) { + const h = { ...headers }; + if (auth) h.Authorization = AUTH; + if (tp) h.traceparent = typeof tp === 'string' ? tp : tp.header; + if (body !== undefined) h['Content-Type'] = 'application/json'; + const t0 = performance.now(); + const res = await fetch(`http://127.0.0.1:${port}${p}`, { + method, + headers: h, + body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, text, ms: performance.now() - t0 }; +} + +export function listMaps(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith('.appmap.json')) + .map((f) => path.join(dir, f)); +} + +export async function waitForMap(dir, span, timeoutMs = 8000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + const hit = listMaps(dir).find((f) => path.basename(f).includes(`_${span}_`)); + if (hit) { + // file is written in one writeTextFile call; make sure it parses + try { + return { file: hit, appmap: JSON.parse(fs.readFileSync(hit, 'utf8')), afterMs: Date.now() - t0 }; + } catch { + /* partially written, retry */ + } + } + await sleep(25); + } + return undefined; +} + +export function gatewayLines() { + if (!fs.existsSync(GATEWAY_LOG)) return []; + return fs + .readFileSync(GATEWAY_LOG, 'utf8') + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)); +} + +/** Flatten an AppMap into what the checks compare. */ +/** url + '?' + the event's message parameters (name=value, in order). */ +export function withQuery(url, message) { + const q = (message ?? []).map((m) => `${m.name}=${m.value}`).join('&'); + return q ? `${url}?${q}` : url; +} + +export function summarize(appmap) { + const byId = new Map(appmap.events.map((e) => [e.id, e])); + const returns = new Map(appmap.events.filter((e) => e.event === 'return').map((e) => [e.parent_id, e])); + const out = { + name: appmap.metadata?.name, + trace_id: appmap.metadata?.trace_id, + parent_span_id: appmap.metadata?.parent_span_id, + truncated: !!appmap.metadata?.truncated, + servers: [], + functions: [], + clients: [], + sql: [], + unbalanced: [], + }; + // Calls open around each event, per thread (a call sits between its + // parent's call and return): lets checks see nesting. + const open = new Map(); + const ancestorsOf = new Map(); + for (const e of appmap.events) { + const stack = open.get(e.thread_id) ?? []; + open.set(e.thread_id, stack); + if (e.event === 'call') { + ancestorsOf.set(e.id, [...stack]); + stack.push(e.id); + } else { + const i = stack.lastIndexOf(e.parent_id); + if (i >= 0) stack.length = i; + } + } + for (const e of appmap.events) { + if (e.event !== 'call') continue; + const r = returns.get(e.id); + if (!r) out.unbalanced.push(e.id); + if (e.http_server_request) { + out.servers.push({ + method: e.http_server_request.request_method, + path: e.http_server_request.path_info, + traceparent: e.http_server_request.headers?.traceparent, + status: r?.http_server_response?.status_code, + }); + } else if (e.http_client_request) { + out.clients.push({ + method: e.http_client_request.request_method, + // AppMap spec: http_client_request.url is the URL "excluding the query + // string"; the query parameters are the event's `message`. Rebuild + // path+query from both so URL comparisons see what went on the wire. + url: withQuery(e.http_client_request.url, e.message), + traceparent: e.http_client_request.headers?.traceparent, + status: r?.http_client_response?.status_code, + synthetic: r ? r.elapsed === undefined && !r.http_client_response : true, + }); + } else if (e.sql_query) { + out.sql.push(e.sql_query.sql); + } else if (e.method_id) { + out.functions.push({ + id: e.id, + ancestors: ancestorsOf.get(e.id) ?? [], + fn: `${e.defined_class}.${e.method_id}`, + path: e.path, + lineno: e.lineno, + params: (e.parameters ?? []).map((p) => p.value), + paramClasses: (e.parameters ?? []).map((p) => p.class), + exceptions: r?.exceptions, + returnClass: r?.return_value?.class, + synthetic: r ? r.elapsed === undefined && !r.return_value && !r.exceptions : true, + }); + } + } + void byId; + return out; +} + +export function sh(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...opts }); + return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? ''), stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +// --- official validation ------------------------------------------------- +const toolsRequire = createRequire(path.join(TOOLS, 'package.json')); +let schemaCache; +function schemas() { + if (schemaCache) return schemaCache; + const Ajv = toolsRequire('ajv'); + const ajv = new Ajv({ allErrors: true, strict: false }); + const dir = path.join(TOOLS, 'node_modules', '@appland', 'appmap-validate', 'schema'); + schemaCache = fs + .readdirSync(dir) + .filter((f) => /^1-\d+-\d+\.js$/.test(f)) + .map((f) => { + const v = f.replace('.js', '').replace(/-/g, '.'); + return { v, check: ajv.compile(toolsRequire(path.join(dir, f)).schema) }; + }) + .sort((a, b) => a.v.localeCompare(b.v, undefined, { numeric: true })); + return schemaCache; +} + +/** Official CLI verdict plus, per schema version shipped in the official + * validator, the leaf violations (allErrors) — so "highest version it + * actually satisfies" is computed, not guessed. */ +export function validateMap(file) { + const cli = sh(VALIDATE_CLI, [file]); + const appmap = JSON.parse(fs.readFileSync(file, 'utf8')); + const perVersion = {}; + for (const { v, check } of schemas()) { + const copy = { ...appmap, version: v }; + const ok = check(copy); + perVersion[v] = ok + ? [] + : [ + ...new Set( + check.errors + .filter((e) => !['anyOf', 'allOf', 'oneOf', 'if'].includes(e.keyword)) + .map((e) => `${e.instancePath.replace(/\/\d+/g, '/N')} ${e.message}`), + ), + ]; + } + return { cliExit: cli.code, cliOut: cli.out.trim(), perVersion }; +} + +export function sequenceDiagrams(files, outDir) { + fs.mkdirSync(outDir, { recursive: true }); + const r = sh(APPMAP_CLI, ['sequence-diagram', '--format', 'json', '--output-dir', outDir, ...files], { + env: { ...process.env, APPMAP_TELEMETRY_DISABLED: 'true' }, + }); + return r; +} + +/** Drop run-varying fields (timings, ids) from a sequence-diagram JSON. */ +export function normalizeDiagram(d) { + const strip = (n) => { + if (Array.isArray(n)) return n.map(strip); + if (n && typeof n === 'object') { + const o = {}; + for (const [k, v] of Object.entries(n)) { + if (['elapsed', 'eventIds'].includes(k)) continue; + o[k] = strip(v); + } + return o; + } + return n; + }; + return strip(d); +} diff --git a/acceptance/supabase-restful-tasks/probe/probe.ts b/acceptance/supabase-restful-tasks/probe/probe.ts new file mode 100644 index 0000000..7e624f8 --- /dev/null +++ b/acceptance/supabase-restful-tasks/probe/probe.ts @@ -0,0 +1,69 @@ +// SYNTHETIC PROBE: NOT THE APP UNDER TEST. +// +// The target app (supabase restful-tasks @ 74a3be9) has no +// EdgeRuntime.waitUntil. This file exists only to exercise the recorder's +// waitUntil capture (docs/design/11) with a Supabase-edge-function-shaped +// handler. It is plain Deno.serve with no appmap imports, and runs through the +// zero-touch runner like the app does. +// +// Plain `deno run` has no EdgeRuntime global (Supabase Edge Runtime provides +// it). The shim below keeps the isolate alive the same way: it holds a +// reference to the promise. It is used only when the host has no EdgeRuntime. + +declare global { + // deno-lint-ignore no-var + var EdgeRuntime: { waitUntil(p: Promise): void } | undefined; +} +if (typeof globalThis.EdgeRuntime === 'undefined') { + const held = new Set>(); + globalThis.EdgeRuntime = { + waitUntil(p: Promise) { + held.add(p); + p.finally(() => held.delete(p)); + }, + }; +} + +const REST = `${Deno.env.get('SUPABASE_URL') ?? 'http://127.0.0.1:54321'}/rest/v1`; +const ENRICH = Deno.env.get('ENRICH_URL') ?? 'http://127.0.0.1:54399'; +const KEY = Deno.env.get('SUPABASE_ANON_KEY') ?? ''; + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function ingest(n: string, auth: string) { + const headers = { apikey: KEY, Authorization: auth, 'Content-Type': 'application/json' }; + const ins = await fetch(`${REST}/tasks`, { + method: 'POST', + headers, + body: JSON.stringify({ name: `probe-${n}`, status: 0 }), + }); + await ins.body?.cancel(); + await sleep(1500); + const enrich = await fetch(`${ENRICH}/enrich?n=${n}`); + const { score } = await enrich.json(); + await sleep(1500); + const upd = await fetch(`${REST}/tasks?name=eq.probe-${n}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ status: score }), + }); + await upd.body?.cancel(); + return score; +} + +Deno.serve({ port: Number(Deno.env.get('PORT') ?? 8001) }, (req) => { + const url = new URL(req.url); + const n = url.searchParams.get('n') ?? '0'; + const auth = req.headers.get('Authorization') ?? ''; + if (req.method === 'POST' && url.pathname === '/probe/ingest') { + EdgeRuntime!.waitUntil(ingest(n, auth)); + return new Response(null, { status: 202 }); + } + if (req.method === 'POST' && url.pathname === '/probe/fire-and-forget') { + void ingest(n, auth); + return new Response(null, { status: 202 }); + } + return new Response('not found', { status: 404 }); +}); diff --git a/acceptance/supabase-restful-tasks/run.sh b/acceptance/supabase-restful-tasks/run.sh new file mode 100755 index 0000000..faa6c4d --- /dev/null +++ b/acceptance/supabase-restful-tasks/run.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# One command: clean clone of supabase/supabase at the pinned SHA, then every +# acceptance check for the AppMap Deno recorder against its restful-tasks +# edge function. Exits non-zero if any check FAILs. +# +# Needs: node >= 22, git, curl, PostgreSQL server binaries (initdb/pg_ctl/psql), +# network access to github.com, jsr.io and registry.npmjs.org for the one-time +# downloads. Everything the app talks to runs on 127.0.0.1. +# +# Env knobs: WORK (scratch dir, default /tmp/acc-deno-work), DENO (deno binary), +# POSTGREST (postgrest binary), APPMAP_TOOLS (dir with @appland/appmap and +# @appland/appmap-validate installed), PGBIN (postgres bin dir). +set -euo pipefail + +ACC=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$ACC/../.." && pwd) +export WORK=${WORK:-/tmp/acc-deno-work} +APP_REPO=https://github.com/supabase/supabase +APP_SHA=74a3be9aa8706755e05f7326f3d25472729cd977 +APP_PATH=examples/edge-functions/supabase/functions/restful-tasks +mkdir -p "$WORK/bin" +export PATH="$WORK/bin:$PATH" + +# --- tools ----------------------------------------------------------------- +if [ -n "${DENO:-}" ]; then ln -sf "$DENO" "$WORK/bin/deno"; fi +if ! command -v deno >/dev/null; then + echo "== installing deno (latest 2.x release binary)" + curl -fsSL -o "$WORK/deno.zip" https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip + python3 -c "import zipfile;zipfile.ZipFile('$WORK/deno.zip').extractall('$WORK/bin')" + chmod +x "$WORK/bin/deno" +fi +DENO=$(command -v deno) +export DENO + +if [ -z "${POSTGREST:-}" ]; then + if [ ! -x "$WORK/bin/postgrest" ]; then + echo "== downloading PostgREST v12.2.12" + curl -fsSL https://github.com/PostgREST/postgrest/releases/download/v12.2.12/postgrest-v12.2.12-linux-static-x86-64.tar.xz | tar xJ -C "$WORK/bin" + fi + export POSTGREST="$WORK/bin/postgrest" +fi + +if [ -z "${APPMAP_TOOLS:-}" ]; then + export APPMAP_TOOLS="$WORK/appmap-tools" + if [ ! -x "$APPMAP_TOOLS/node_modules/.bin/appmap-validate" ]; then + echo "== installing official AppMap CLI + validator" + mkdir -p "$APPMAP_TOOLS" + (cd "$APPMAP_TOOLS" && npm init -y >/dev/null && npm i --silent @appland/appmap@latest @appland/appmap-validate@latest ajv@8) + fi +fi +export APPMAP_TELEMETRY_DISABLED=true DENO_NO_UPDATE_CHECK=1 + +# recorder deps (babel for the build-time transform) +[ -d "$ROOT/node_modules/@babel/core" ] || (cd "$ROOT" && npm ci --silent) + +# --- app at the pinned SHA (clean clone every run) --------------------------- +rm -rf "$WORK/supabase" +git init -q "$WORK/supabase" +git -C "$WORK/supabase" remote add origin "$APP_REPO" +git -C "$WORK/supabase" config core.sparseCheckout true +echo "$APP_PATH/" > "$WORK/supabase/.git/info/sparse-checkout" +git -C "$WORK/supabase" fetch -q --depth 1 --filter=blob:none origin "$APP_SHA" +git -C "$WORK/supabase" -c advice.detachedHead=false checkout -q FETCH_HEAD +test "$(git -C "$WORK/supabase" rev-parse HEAD)" = "$APP_SHA" + +# --- local database: real Postgres + real PostgREST ------------------------ +"$ACC/infra/db.sh" stop || true +rm -rf "$WORK/pgdata" +"$ACC/infra/db.sh" start +trap '"$ACC/infra/db.sh" stop' EXIT + +echo "== versions" +deno --version | head -1 +node --version +"$POSTGREST" --version +"$(printf '%s\n' /usr/lib/postgresql/*/bin | sort -V | tail -1)/postgres" --version +"$APPMAP_TOOLS/node_modules/.bin/appmap" --version +echo "repo HEAD $(git -C "$ROOT" rev-parse HEAD); recorder code last changed in $(git -C "$ROOT" log -1 --format=%H -- deno recorder); app $APP_SHA" + +set +e +node "$ACC/harness.mjs" 2>&1 | tee "$WORK/harness.log" +rc=${PIPESTATUS[0]} +cp "$WORK/harness.log" "$ACC/evidence/run.log" +exit "$rc" diff --git a/deno/appmap.ts b/deno/appmap.ts index 7f6704a..69289cf 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): // @@ -13,30 +18,75 @@ // Deno.serve(withAppMap(handleRequest, { app: 'what2say' })); // // Run with: deno run -A --unstable-sloppy-imports -// (sloppy imports because the recorder core uses extensionless -// relative imports). Output: APPMAP_DIR (default tmp/appmap/requests), +// (sloppy imports because the recorder core imports its siblings as +// `./x.js` — what its Node build needs — and Deno maps those to the .ts +// sources only with sloppy 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 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 { AsyncLocalStorage } from 'node:async_hooks'; +import { Recording } from '../recorder/src/recording'; import { - Recording, - startRecording, - stopRecording, activeRecording, - autoInstrument, -} from '../recorder/src/index'; + closeScopedRecording, + installAsyncContext, + openScopedRecording, + runInRecording, + runUnrecorded, + type RecordingContext, +} from '../recorder/src/session'; +import { autoInstrument, instrumentHandler } from '../recorder/src/instrument'; + +// Per-request async context (docs/design/01, "Per-request async +// context"): every request runs in its own AsyncLocalStorage context, so +// concurrent requests each record only their own events, and an +// unrecorded request's code never sees (or stamps outbound calls with) +// another request's recording. node:async_hooks works under Deno. +installAsyncContext(new AsyncLocalStorage()); -// Re-export so this file can serve as the transform's `runtimeModule`. -export { autoInstrument }; +// Re-export so this file can serve as the transform's `runtimeModule`: +// the transform imports autoInstrument, plus instrumentHandler for +// closures nested inside instrumented functions. +export { autoInstrument, instrumentHandler }; declare const Deno: { env: { get(key: string): string | undefined }; + version?: { deno: string; typescript: string }; + pid?: number; mkdir(path: string, options?: { recursive?: boolean }): Promise; writeTextFile(path: string, text: string): Promise; + // Used for crash safety only; absent under the Node/Vitest stub. + mkdirSync?(path: string, options?: { recursive?: boolean }): void; + writeTextFileSync?(path: string, text: string): void; + rename?(from: string, to: string): Promise; + renameSync?(from: string, to: string): void; + remove?(path: string): Promise; + removeSync?(path: string): void; + readDirSync?(path: string): Iterable<{ name: string; isFile: boolean }>; + addSignalListener?(signal: string, handler: () => void): void; + removeSignalListener?(signal: string, handler: () => void): void; + kill?(pid: number, signal: string): void; + unrefTimer?(id: number): void; }; +// 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,24 +97,65 @@ export interface WithAppMapOptions { dir?: string; } +// Promises each open recording is waiting on before it closes — the +// background tasks its handler handed to EdgeRuntime.waitUntil +// (docs/design/11). EdgeRuntime.waitUntil is one shared global, so the +// patch looks up the recording of the *calling* async context; a +// recording is only in here while its handler is running, so the patch +// never touches waitUntil calls we aren't recording. +const pendingWaitUntil = new WeakMap[]>(); +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. + const recording = activeRecording(); + const pending = recording && pendingWaitUntil.get(recording); + if (pending) pending.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'); let seq = 0; + // A previous process killed outright (kill -9) left its last snapshot + // as a .part file: it is already a valid, truncated map — keep it. + if (!collector) healPartialRecordings(dir); return async (req: Request): Promise => { const match = TRACEPARENT.exec(req.headers.get('traceparent') ?? ''); - // 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. - if (!match || activeRecording()) return handler(req); + // Record only stamped requests (recording-driven semantics). Every + // stamped request gets its own recording, however many are in flight; + // an unstamped one runs in a context with no recording at all. + if (!match) return runUnrecorded(() => handler(req)); + patchWaitUntil(); + installCrashHandlers(); const url = new URL(req.url); - const recording = startRecording( + const recording = openScopedRecording( new Recording({ name: `${req.method} ${url.pathname}`, app: options.app, - language: { name: 'typescript', engine: 'deno' }, + // The spec requires language.version: the TypeScript version + // Deno compiles with (engine: deno). Unknown only under a stub. + language: { name: 'typescript', engine: 'deno', version: Deno.version?.typescript ?? 'unknown' }, client: { name: '@funwithappmap/react-recorder', url: 'https://github.com/getappmap/appmap-react', @@ -75,31 +166,137 @@ export function withAppMap(handler: Handler, options: WithAppMapOptions = {}): H // A backend request map carries the caller's ids, not its own. recording.metadata.trace_id = match[1]; recording.metadata.parent_span_id = match[2]; + const tracked = track(recording, collector ? undefined : recordingPath(recording, dir, ++seq)); + + const token = recording.httpServerRequest( + req.method, + url.pathname, + { traceparent: match[0] }, + undefined, + url.searchParams, + ); + // Collect waitUntil promises the handler registers during its (sync + // path to the) response. + const deferred: Promise[] = []; + pendingWaitUntil.set(recording, deferred); + + const finalize = (): void => { + pendingWaitUntil.delete(recording); + closeScopedRecording(recording); + void ship(tracked, collector); + }; - const token = recording.httpServerRequest(req.method, url.pathname, { - traceparent: match[0], - }); let status = 500; try { - const response = await handler(req); + const response = await runInRecording(recording, () => handler(req), token.callId); status = response.status; + // Record the real response now, at its true time — not after the + // background work. In the serialized map the background calls nest + // under the request (doc 12), so this return comes after them. + recording.httpServerResponse(token, status); + pendingWaitUntil.delete(recording); // 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; } }; } -async function ship( - recording: Recording, - dir: string, - collector: string | undefined, - seq: number, -): Promise { - const appmap = recording.toAppMap(); - const body = JSON.stringify(appmap, null, 2); +// --------------------------------------------------------------------------- +// Shipping, and crash safety (docs/design/11, "Crashes"). +// +// A recording is written once, when it closes. So that a process that +// dies first still leaves a map behind: +// +// - SIGINT / SIGTERM, an uncaught error or unhandled rejection, and a +// normal exit with recordings still open write every open recording +// synchronously, self-healed and marked truncated. A signal then takes +// its default course (the process ends as it would have) unless the +// app has its own listener for it. +// - kill -9 can't be caught, so a recording still open after +// SNAPSHOT_FIRST_MS is snapshotted to `.part` (written to a temp +// file, then renamed, so it is always a whole, valid map) every +// SNAPSHOT_EVERY_MS while it changes. The next withAppMap() in that +// directory — or the appmap-deno runner, when its child dies — renames +// a leftover .part to .appmap.json. It is at most one interval stale. +// +// Collector mode (APPMAP_COLLECTOR) posts over the network and has no +// file to fall back on; crash safety covers file output only. + +const SNAPSHOT_FIRST_MS = 250; +const SNAPSHOT_EVERY_MS = 500; + +interface Tracked { + recording: Recording; + /** Final file; undefined in collector mode. */ + path?: string; + timer?: ReturnType; + /** Event count at the last snapshot. */ + snapshotAt: number; + hasPart: boolean; + /** Serializes snapshot writes and the final write. */ + writing: Promise; + done: boolean; +} + +const openTracked = new Set(); + +function recordingPath(recording: Recording, dir: string, seq: number): string { + const name = String(recording.metadata.name) + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, ''); + return `${dir}/${name}_${recording.metadata.parent_span_id}_${String(seq).padStart(3, '0')}.appmap.json`; +} + +function track(recording: Recording, path: string | undefined): Tracked { + const t: Tracked = { recording, path, snapshotAt: -1, hasPart: false, writing: Promise.resolve(), done: false }; + openTracked.add(t); + if (path && typeof Deno.rename === 'function') scheduleSnapshot(t, SNAPSHOT_FIRST_MS); + return t; +} + +function scheduleSnapshot(t: Tracked, ms: number): void { + t.timer = setTimeout(() => { + if (t.done) return; + if (t.recording.events.length !== t.snapshotAt) { + t.snapshotAt = t.recording.events.length; + const body = JSON.stringify(t.recording.toAppMap(), null, 2); + const part = `${t.path}.part`; + t.writing = t.writing.then(async () => { + if (t.done) return; + try { + await Deno.mkdir(dirOf(part), { recursive: true }); + await Deno.writeTextFile(`${part}.tmp`, body); + await Deno.rename!(`${part}.tmp`, part); + t.hasPart = true; + } catch (err) { + console.warn('appmap: failed to write partial recording:', err); + } + }); + } + scheduleSnapshot(t, SNAPSHOT_EVERY_MS); + }, ms); + // Never keep the process alive just to snapshot. + if (typeof t.timer === 'number') Deno.unrefTimer?.(t.timer); + else (t.timer as { unref?: () => void } | undefined)?.unref?.(); +} + +async function ship(t: Tracked, collector: string | undefined): Promise { + clearTimeout(t.timer); + openTracked.delete(t); + await t.writing; + t.done = true; + const body = JSON.stringify(t.recording.toAppMap(), null, 2); try { if (collector) { await fetch(collector, { @@ -109,14 +306,100 @@ async function ship( }); return; } - const name = String(appmap.metadata.name) - .replace(/[^a-zA-Z0-9._-]+/g, '_') - .replace(/^_+|_+$/g, ''); - await Deno.mkdir(dir, { recursive: true }); - await Deno.writeTextFile( - `${dir}/${name}_${appmap.metadata.parent_span_id}_${String(seq).padStart(3, '0')}.appmap.json`, - body); + await Deno.mkdir(dirOf(t.path!), { recursive: true }); + await Deno.writeTextFile(t.path!, body); + if (t.hasPart) await Deno.remove?.(`${t.path}.part`).catch(() => {}); } catch (err) { console.warn('appmap: failed to ship recording:', err); } } + +/** Write every open recording now, synchronously: the process is about + * to end. Each is self-healed and marked truncated by toAppMap(). */ +function flushOpenRecordings(): void { + if (typeof Deno.writeTextFileSync !== 'function') return; + for (const t of openTracked) { + if (!t.path || t.done) continue; + t.done = true; + clearTimeout(t.timer); + try { + Deno.mkdirSync?.(dirOf(t.path), { recursive: true }); + Deno.writeTextFileSync(t.path, JSON.stringify(t.recording.toAppMap(), null, 2)); + if (t.hasPart) { + try { + Deno.removeSync?.(`${t.path}.part`); + } catch { + // already gone + } + } + } catch (err) { + console.warn('appmap: failed to flush recording:', err); + } + } + openTracked.clear(); +} + +// Signal listeners the app itself registered (Deno.addSignalListener is +// wrapped at module load, before app code runs), so a signal only takes +// its default course after the flush when nobody else handles it. +const appSignalListeners = new Map(); +const ownSignalHandlers = new Set<() => void>(); +if ( + typeof Deno !== 'undefined' && + typeof Deno.addSignalListener === 'function' && + typeof Deno.removeSignalListener === 'function' +) { + const add = Deno.addSignalListener.bind(Deno); + const remove = Deno.removeSignalListener.bind(Deno); + Deno.addSignalListener = (signal: string, handler: () => void) => { + if (!ownSignalHandlers.has(handler)) appSignalListeners.set(signal, (appSignalListeners.get(signal) ?? 0) + 1); + add(signal, handler); + }; + Deno.removeSignalListener = (signal: string, handler: () => void) => { + if (!ownSignalHandlers.has(handler)) appSignalListeners.set(signal, Math.max(0, (appSignalListeners.get(signal) ?? 0) - 1)); + remove(signal, handler); + }; +} +let crashHandlersInstalled = false; + +function installCrashHandlers(): void { + if (crashHandlersInstalled) return; + crashHandlersInstalled = true; + if (typeof Deno.addSignalListener === 'function') { + for (const signal of ['SIGINT', 'SIGTERM']) { + const onSignal = () => { + flushOpenRecordings(); + Deno.removeSignalListener?.(signal, onSignal); + // Re-raise with no listener left: the default action ends the + // process exactly as it would have without the recorder. + if (!appSignalListeners.get(signal) && Deno.pid !== undefined) Deno.kill?.(Deno.pid, signal); + }; + ownSignalHandlers.add(onSignal); + try { + Deno.addSignalListener(signal, onSignal); + } catch { + // signal not supported on this platform + } + } + } + const target = globalThis as { addEventListener?: (type: string, listener: () => void) => void }; + for (const type of ['error', 'unhandledrejection', 'unload']) target.addEventListener?.(type, flushOpenRecordings); +} + +function healPartialRecordings(dir: string): void { + if (typeof Deno.readDirSync !== 'function' || typeof Deno.renameSync !== 'function') return; + try { + for (const entry of Deno.readDirSync(dir)) { + if (entry.isFile && entry.name.endsWith('.appmap.json.part')) { + Deno.renameSync(`${dir}/${entry.name}`, `${dir}/${entry.name.slice(0, -'.part'.length)}`); + } + } + } catch { + // no directory yet: nothing to heal + } +} + +function dirOf(path: string): string { + const i = path.lastIndexOf('/'); + return i === -1 ? '.' : path.slice(0, i); +} diff --git a/deno/appmap_test.ts b/deno/appmap_test.ts new file mode 100644 index 0000000..1cd12e1 --- /dev/null +++ b/deno/appmap_test.ts @@ -0,0 +1,343 @@ +// 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 { autoInstrument, 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.ok(appmap.metadata.language.version, 'language.version is required by the spec'); + 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; + } +}); + +Deno.test('concurrent stamped requests each get their own recording; unstamped traffic is never stamped', async () => { + // docs/design/01, "Per-request async context". Stamped and unstamped + // requests overlap in time; every stamped one must end up in its own + // file holding only its own events, and no outbound call made for an + // unstamped request may carry a traceparent. + const dir = await Deno.makeTempDir({ prefix: 'appmap-deno-test-concurrent-' }); + const sent: { url: string; traceparent: string | null }[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + sent.push({ url: request.url, traceparent: request.headers.get('traceparent') }); + return Promise.resolve(Response.json({ ok: true })); + }; + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + try { + const lookup = autoInstrument( + async function lookup(id: string) { + await sleep(id.length * 3); + await fetch(`https://db.example/items/${id}`); + return id; + }, + { definedClass: 'items', methodId: 'lookup', path: 'items.ts', lineno: 1 }, + ['id'], + ); + const handler = async (req: Request) => { + const id = new URL(req.url).searchParams.get('id')!; + await sleep(5); + await lookup(id); + return Response.json({ id }); + }; + const wrapped = withAppMap(handler, { app: 'concurrent', dir }); + + const trace = (i: number) => `${String(i).padStart(2, '0')}${'e'.repeat(30)}`; + const span = (i: number) => `${String(i).padStart(2, '0')}${'f'.repeat(14)}`; + const stamped = Array.from({ length: 6 }, (_, i) => + wrapped( + new Request(`http://localhost/items?id=s${'x'.repeat(i)}`, { + headers: { traceparent: `00-${trace(i)}-${span(i)}-01` }, + }), + ), + ); + const unstamped = Array.from({ length: 4 }, (_, i) => + wrapped(new Request(`http://localhost/items?id=u${'y'.repeat(i)}`)), + ); + for (const res of await Promise.all([...stamped, ...unstamped])) assert.equal(res.status, 200); + + const deadline = Date.now() + 3000; + let files: string[] = []; + while (Date.now() < deadline) { + files = [...Deno.readDirSync(dir)].filter((e) => e.name.endsWith('.appmap.json')).map((e) => e.name); + if (files.length >= 6) break; + await sleep(10); + } + assert.equal(files.length, 6, `one file per stamped request, got ${files.join(', ')}`); + + for (let i = 0; i < 6; i++) { + const file = files.find((f) => f.includes(`_${span(i)}_`)); + assert.ok(file, `recording for stamped request ${i}`); + const appmap = JSON.parse(await Deno.readTextFile(`${dir}/${file}`)); + const calls = appmap.events.filter((e: { event: string }) => e.event === 'call'); + const lookups = calls.filter((e: { method_id?: string }) => e.method_id === 'lookup'); + assert.deepEqual( + lookups.map((e: { parameters: { value: string }[] }) => e.parameters[0].value), + [`s${'x'.repeat(i)}`], + `request ${i} recorded only its own lookup`, + ); + const clients = calls.filter((e: { http_client_request?: unknown }) => e.http_client_request); + assert.equal(clients.length, 1, `request ${i} recorded only its own outbound call`); + assert.ok(clients[0].http_client_request.headers.traceparent.startsWith(`00-${trace(i)}-`)); + assert.equal(calls.filter((e: { http_server_request?: unknown }) => e.http_server_request).length, 1); + assert.equal(appmap.metadata.truncated, undefined); + } + + for (const s of sent) { + const id = s.url.split('/').pop()!; + if (id.startsWith('u')) assert.equal(s.traceparent, null, `unstamped request's call to ${s.url} was stamped`); + else assert.ok(s.traceparent?.startsWith(`00-${trace(id.length - 1)}-`), `${s.url} stamped with its own trace`); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +Deno.test('a recorded request is one call tree rooted at its http_server_request, with language.version', async () => { + const dir = await Deno.makeTempDir({ prefix: 'appmap-deno-test-tree-' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(new Response(null, { status: 204 })); + try { + const deleteTask = autoInstrument( + async function deleteTask(id: string) { + await new Promise((r) => setTimeout(r, 1)); + await fetch(`http://db.example/rest/v1/tasks?id=eq.${id}`, { method: 'DELETE' }); + return Response.json({}); + }, + { definedClass: 'index', methodId: 'deleteTask', path: 'index.ts', lineno: 38 }, + ['id'], + ); + const wrapped = withAppMap((req) => deleteTask(new URL(req.url).pathname.split('/').pop()!), { dir }); + await wrapped( + new Request('http://localhost/restful-tasks/2?verbose=1', { + method: 'DELETE', + headers: { traceparent: `00-${'1'.repeat(32)}-${'2'.repeat(16)}-01` }, + }), + ); + const file = await waitForFile(dir); + const appmap = JSON.parse(await Deno.readTextFile(`${dir}/${file}`)); + assert.equal(appmap.metadata.language.version, Deno.version.typescript); + const shape = appmap.events.map((e: Record) => + `${e.event}:${e.thread_id}:${e.method_id ?? e.http_server_request?.path_info ?? e.http_client_request?.url ?? ''}`, + ); + assert.deepEqual(shape, [ + 'call:1:/restful-tasks/2', + 'call:1:deleteTask', + 'call:1:http://db.example/rest/v1/tasks', + 'return:1:', + 'return:1:', + 'return:1:', + ]); + assert.deepEqual(appmap.events[0].message, [{ name: 'verbose', class: 'String', value: '1' }]); + assert.deepEqual(appmap.events[2].message, [{ name: 'id', class: 'String', value: 'eq.2' }]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// Crash safety (docs/design/11, "Crashes"): a process that dies with a +// recording still open must leave a truncated-but-valid map behind. +async function crashChild(dir: string, mode: 'wait' | 'throw') { + const appmapModule = new URL('./appmap.ts', import.meta.url).href; + const script = `${dir}/child.ts`; + await Deno.writeTextFile( + script, + `import { autoInstrument, withAppMap } from ${JSON.stringify(appmapModule)}; +const dir = ${JSON.stringify(`${dir}/out`)}; +const ingest = autoInstrument(async function ingest(n: number) { + await new Promise(() => {}); +}, { definedClass: 'probe', methodId: 'ingest', path: 'probe.ts', lineno: 1 }, ['n']); +const wrapped = withAppMap(async () => { + void ingest(1); + ${mode === 'throw' ? "setTimeout(() => { throw new Error('boom'); }, 300);" : ''} + await new Promise((r) => setTimeout(r, 60_000)); + return new Response('ok'); +}, { dir }); +void wrapped(new Request('http://localhost/probe/ingest', { + method: 'POST', + headers: { traceparent: '00-${'7'.repeat(32)}-${'8'.repeat(16)}-01' }, +})); +console.log('ready'); +`, + ); + const child = new Deno.Command(Deno.execPath(), { + args: ['run', '-A', '--unstable-sloppy-imports', script], + stdout: 'piped', + stderr: 'null', + }).spawn(); + const reader = child.stdout.getReader(); + let out = ''; + while (!out.includes('ready')) { + const { value, done } = await reader.read(); + if (done) break; + out += new TextDecoder().decode(value); + } + reader.releaseLock(); + void child.stdout.cancel(); + return child; +} + +const mapsIn = (dir: string, suffix: string) => { + try { + return [...Deno.readDirSync(dir)].map((e) => e.name).filter((n) => n.endsWith(suffix)); + } catch { + return []; + } +}; + +for (const signal of ['SIGTERM', 'SIGINT'] as const) { + Deno.test(`${signal} while a recording is open writes it, truncated, and the process still ends`, async () => { + const dir = await Deno.makeTempDir({ prefix: `appmap-deno-test-${signal}-` }); + const child = await crashChild(dir, 'wait'); + await new Promise((r) => setTimeout(r, 100)); + child.kill(signal); + const status = await child.status; + assert.equal(status.signal, signal, 'the signal still ends the process'); + const files = mapsIn(`${dir}/out`, '.appmap.json'); + assert.equal(files.length, 1); + const appmap = JSON.parse(await Deno.readTextFile(`${dir}/out/${files[0]}`)); + assert.equal(appmap.metadata.truncated, true); + assert.ok(appmap.events.some((e: { method_id?: string }) => e.method_id === 'ingest')); + assert.deepEqual(mapsIn(`${dir}/out`, '.part'), []); + }); +} + +Deno.test('an uncaught error while a recording is open writes it, truncated', async () => { + const dir = await Deno.makeTempDir({ prefix: 'appmap-deno-test-throw-' }); + const child = await crashChild(dir, 'throw'); + const status = await child.status; + assert.equal(status.success, false); + const files = mapsIn(`${dir}/out`, '.appmap.json'); + assert.equal(files.length, 1); + assert.equal(JSON.parse(await Deno.readTextFile(`${dir}/out/${files[0]}`)).metadata.truncated, true); +}); + +Deno.test('kill -9 leaves a partial recording that the next start repairs', async () => { + const dir = await Deno.makeTempDir({ prefix: 'appmap-deno-test-sigkill-' }); + const child = await crashChild(dir, 'wait'); + await new Promise((r) => setTimeout(r, 900)); // past the first snapshots + child.kill('SIGKILL'); + await child.status; + assert.deepEqual(mapsIn(`${dir}/out`, '.appmap.json'), []); + assert.equal(mapsIn(`${dir}/out`, '.appmap.json.part').length, 1); + + withAppMap(() => new Response('ok'), { dir: `${dir}/out` }); // next start + const files = mapsIn(`${dir}/out`, '.appmap.json'); + assert.equal(files.length, 1); + assert.deepEqual(mapsIn(`${dir}/out`, '.part'), []); + const appmap = JSON.parse(await Deno.readTextFile(`${dir}/out/${files[0]}`)); + assert.equal(appmap.metadata.truncated, true); + assert.ok(appmap.events.some((e: { method_id?: string }) => e.method_id === 'ingest')); +}); diff --git a/deno/bin/appmap-deno.ts b/deno/bin/appmap-deno.ts index 31def74..0787ec4 100755 --- a/deno/bin/appmap-deno.ts +++ b/deno/bin/appmap-deno.ts @@ -14,7 +14,7 @@ // this way; see doc 06's Tier 2 for that gap, documented rather than // silently unsupported. -import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync } from 'node:fs'; import { spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -85,6 +85,30 @@ function cleanup() { } catch { // already gone — fine } + healPartialRecordings(); +} + +// A child killed outright (kill -9) can't flush its open recordings; the +// driver snapshots them as `.part` while they run (deno/appmap.ts, +// "crash safety"). Each is already a valid, truncated map: keep it. +function healPartialRecordings() { + if (process.env.APPMAP_COLLECTOR) return; + const dir = path.resolve(process.env.APPMAP_DIR ?? 'tmp/appmap/requests'); + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return; // nothing recorded + } + for (const name of names) { + if (!name.endsWith('.appmap.json.part')) continue; + try { + renameSync(path.join(dir, name), path.join(dir, name.slice(0, -'.part'.length))); + console.error(`appmap-deno: kept partial recording ${name.slice(0, -'.part'.length)} (truncated)`); + } catch { + // raced with something else — leave it + } + } } let exiting = false; @@ -99,6 +123,10 @@ process.on('SIGTERM', forwardSignal); child.on('exit', (code, signal) => { cleanup(); if (signal) { + // End the same way the child did; drop our forwarding handlers first + // or the re-raised signal would just be caught by them again. + process.removeListener('SIGINT', forwardSignal); + process.removeListener('SIGTERM', forwardSignal); process.kill(process.pid, signal); } else { process.exit(code ?? 1); diff --git a/deno/package.json b/deno/package.json index 95b93cd..d6af9b5 100644 --- a/deno/package.json +++ b/deno/package.json @@ -10,6 +10,7 @@ "test": "vitest run" }, "devDependencies": { + "@appland/appmap-validate": "^2.5.1", "vitest": "^3.1.3" } } 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/deno/test/withAppMap.test.ts b/deno/test/withAppMap.test.ts index 3f3d6ed..ec7bee2 100644 --- a/deno/test/withAppMap.test.ts +++ b/deno/test/withAppMap.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createRequire } from 'node:module'; import { withAppMap } from '../appmap.ts'; +const { validate } = createRequire(import.meta.url)('@appland/appmap-validate') as { + validate: (data: unknown) => void; +}; + // withAppMap (deno/appmap.ts) has real logic beyond the Recording // primitive already covered in examples/petclinic-react/test/ // serverRecording.test.ts: traceparent-gated routing, the one- @@ -76,6 +81,10 @@ describe('withAppMap', () => { event: 'return', http_server_response: { status_code: 200 }, }); + // Valid at the version it declares (official validator), query + // string in `message`. + expect(() => validate(appmap)).not.toThrow(); + expect(appmap.events[0].message).toEqual([{ name: 'x', class: 'String', value: '1' }]); }); it('ships to APPMAP_COLLECTOR via fetch instead of the filesystem when set', async () => { @@ -99,10 +108,10 @@ describe('withAppMap', () => { vi.unstubAllGlobals(); }); - it('leaves a second stamped request unrecorded while the first is still in flight', async () => { - // The ambient-session invariant (doc 01): one recording at a time, - // process-wide, across every withAppMap instance. A request arriving - // mid-recording must run unrecorded, not throw or corrupt state. + it('records a second stamped request in its own recording while the first is still in flight', async () => { + // Per-request async context (docs/design/01): overlapping stamped + // requests each get their own recording — the old one-at-a-time rule + // dropped the second one and let its events leak into the first. let releaseFirst!: () => void; const gate = new Promise((resolve) => { releaseFirst = resolve; @@ -113,18 +122,26 @@ describe('withAppMap', () => { }); const fastHandler = vi.fn(async () => new Response('second')); - const firstPromise = withAppMap(slowHandler, { app: 'test' })(stampedRequest('3'.repeat(16))); - const secondResponse = await withAppMap(fastHandler, { app: 'test' })(stampedRequest('4'.repeat(16))); + const firstPromise = withAppMap(slowHandler, { app: 'test' })(stampedRequest('3'.repeat(16), 'http://localhost/first')); + const secondResponse = await withAppMap(fastHandler, { app: 'test' })(stampedRequest('4'.repeat(16), 'http://localhost/second')); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(await secondResponse.text()).toBe('second'); expect(fastHandler).toHaveBeenCalledTimes(1); - expect(mkdir).not.toHaveBeenCalled(); // nothing shipped yet — the first request is still open + // The second request shipped on its own while the first is still open. + expect(writeTextFile).toHaveBeenCalledTimes(1); + expect(writeTextFile.mock.calls[0][0]).toContain('_4444444444444444_'); releaseFirst(); await firstPromise; await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mkdir).toHaveBeenCalledTimes(1); // only the first request's recording ships + expect(writeTextFile).toHaveBeenCalledTimes(2); + expect(writeTextFile.mock.calls[1][0]).toContain('_3333333333333333_'); + for (const [, body] of writeTextFile.mock.calls) { + const servers = JSON.parse(body).events.filter((e: { http_server_request?: unknown }) => e.http_server_request); + expect(servers).toHaveLength(1); + } }); it('increments the sequence number across successive requests on the same wrapped handler', async () => { 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..81a9768 100644 --- a/docs/design/01-recording-sessions-and-interaction-windows.md +++ b/docs/design/01-recording-sessions-and-interaction-windows.md @@ -88,8 +88,10 @@ 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 -are a flat list where each `return` names its `call` via `parent_id`. +**Why linearized events tolerate async overlap.** (Superseded for the +serialized form by doc 12, which emits the events as a call tree.) +AppMap 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 out of order. @@ -144,3 +146,90 @@ 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. + +## Amendment (2026-09-24): per-request async context + +Acceptance testing against a real Supabase edge function showed the +"one ambient session" design breaking exactly where this doc said it +would: on a server. With one module-global session, every instrumented +call and every `fetch` in the process was attributed to whichever +recording happened to be open — a burst of 20 stamped + 10 unstamped +requests produced **one** map holding all 30 requests' calls, and the +29 foreign outbound calls went out stamped with the open recording's +`traceparent`, so `appmap-link` would have joined them to the wrong map. +`withAppMap`'s "one at a time" check only stopped a second recording +from *starting*; it did nothing to stop other requests' events from +*entering* the open one. + +**Fix: mechanism 2 (AsyncLocalStorage) where the runtime has it.** +`recorder/src/session.ts` now has two kinds of recording: + +- **Scoped** (Deno, and any Node driver): the driver installs an + `AsyncLocalStorage` with `installAsyncContext()` and runs each unit + of work inside `runInRecording(recording, fn)`. `activeRecording()` + answers from the *current async context*, so each request sees only + its own recording; every stamped request gets its own map, however + many are in flight. Unstamped requests run inside `runUnrecorded()` + and see no recording at all — their code is never recorded and their + outbound calls are never stamped. A scoped recording is closed with + `closeScopedRecording()`; work still running in its context after + that (un-awaited background promises) is neither recorded nor + stamped. `node:async_hooks` works in both Deno and Node; session.ts + only uses it through a structural interface, so it never imports it + and stays loadable in a browser bundle. +- **Ambient** (`startRecording`/`stopRecording`), unchanged: test + recording and browser interaction windows. The browser still has no + async context, which is why doc 04's window scoping remains. + +The outbound-request patches stay installed while any recording, of +either kind, is open (reference counted). + +**Browser: overlapping interactions are marked, not split.** Without +async context the recorder cannot tell which of two overlapping +interactions a later event (a fetch response, a re-render) belongs to. +Splitting the window at the second trigger would present a guess as +fact — the first interaction's in-flight response would land in the +second map. So a window that absorbs a second trigger keeps going, but +the map says so: its name lists every interaction +(`click a "Users" + click a "Dashboard"`), `metadata.interactions` +holds them in order and `metadata.ambiguous` is `true`. Previously it +was silently named after the first click only. A trigger dispatched in +the same task as the one that opened the window (clicking a submit +button fires `click`, then `submit`) is the same user action and is not +counted as a second interaction. diff --git a/docs/design/02-cross-map-correlation-via-traceparent.md b/docs/design/02-cross-map-correlation-via-traceparent.md index 6a1ac40..fc8d243 100644 --- a/docs/design/02-cross-map-correlation-via-traceparent.md +++ b/docs/design/02-cross-map-correlation-via-traceparent.md @@ -3,9 +3,11 @@ Status: **accepted**, validated by the spike in [`../../linker`](../../linker) plus the stamping in [`../../recorder/src/fetchPatch.ts`](../../recorder/src/fetchPatch.ts) -— originally against **simulated** backend maps; since the 2026-06-12 -amendment below, also end to end against the **real** PetClinicGo -server emitting real backend maps. +— originally against **simulated** backend maps; from the 2026-06-12 +amendment to the 2026-09-24 one, against the real PetClinicGo server +(that test is now retired); since the 2026-09-24 amendment, end to end +against a real open-source React + Deno app +(`acceptance/supabase-edge-functions-app`). This is the project's headline goal landing deliberately early, on hand-instrumentation, before the build-time transform (doc 03) exists: @@ -55,7 +57,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: @@ -139,7 +141,7 @@ network conditions. That lands with the sibling follow-ups. backend maps; the simulator then becomes test fixture machinery only. -## Amendment 2026-06-12: end-to-end integration test, no simulator +## Amendment 2026-06-12: end-to-end integration test, no simulator (retired 2026-09-24, see below) The join now has a fully real automated proof: [`examples/petclinic-react/test/e2e/fullstack.test.tsx`](../../examples/petclinic-react/test/e2e/fullstack.test.tsx) @@ -172,3 +174,114 @@ simulator's synthetic maps (the diagram renderer now draws the DB lane only when sql_query events exist). That depth is precisely what the Go agent's own instrumentation roadmap delivers; when it does, this test upgrades for free. + +## Amendment (2026-09-24): XMLHttpRequest + +Stamping lived only in the `fetch` patch, so an app whose HTTP client is +axios (XHR under the hood) — bulletproof-react in the acceptance run — +produced no `http_client_request` events and sent no `traceparent` at +all: nothing to link. `recorder/src/xhrPatch.ts` now wraps +`XMLHttpRequest` the same way while a recording is open: `open()` +stamps the request, and the `loadstart`/`loadend` events record the +request/response pair. It observes the instance the app holds rather +than `XMLHttpRequest.prototype`, because request interceptors such as +MSW answer mocked requests without calling the real `send()`. The +interaction window's idle check counts these requests too, so a window +no longer closes before an XHR's response arrives. + +## Amendment 2026-09-24: the Go-backed e2e test is retired; the proof is a real OSS app + +`examples/petclinic-react/test/e2e/fullstack.test.tsx` (the 2026-06-12 +amendment above) has been removed. It was a weak proof: both ends were +this project's own novel tracers (the React recorder and the +experimental Go middleware) vouching for each other, it needed a +checkout of a private sibling repo, and it therefore skipped itself in +CI — so it never actually ran on a PR. + +The end-to-end proof of the join is now +[`acceptance/supabase-edge-functions-app`](../../acceptance/supabase-edge-functions-app), +run by CI on every push and PR: + +- the app is Supabase's own edge-functions example + (`supabase/supabase` @ `74a3be9`): a React app (the "Edge Functions + Test Client") that calls the `select-from-table-with-auth-rls` Deno + edge function via `supabase.functions.invoke`, which calls GoTrue and + queries Postgres through PostgREST under row-level security; +- everything runs on localhost from real parts (Postgres, the GoTrue + and PostgREST release binaries, the app's own migrations), driven by + real Chromium through Playwright, with no edits to the app's source; +- the checks are the shared acceptance spec (A–J, official validator + `@appland/appmap-validate`) plus the join itself: the browser's + request carries `traceparent`, the Deno map has the matching + `parent_span_id`, `appmap-link` joins them, and the stitched diagram + shows click → handler → backend → DB. + +Its `EXPECTATIONS.md` was written before any recording and its +`RESULTS.md` records what actually happened; its "Update" section says +what still fails and why (the recorder bugs it found are fixed). CI does +not hide a failing check. +`examples/petclinic-react/test/e2e/deno-fullstack.test.ts` (doc 09) +stays as a fast regression test, but both of its ends are this repo's +own code, so it is not the proof. + +## Amendment (2026-09-24): cross-origin requests — stamp only what the user lists + +The recorder stamped `traceparent` on every `fetch`/XHR made while a +recording was open. On a real app that broke the app: Supabase's +edge-functions example calls its function on another origin +(`localhost:54321` from a page on `:3300`), the function's CORS policy +allows `authorization, x-client-info, apikey, content-type` and not +`traceparent`, so the browser's preflight failed and every "Invoke +Function" click ended in `FunctionsFetchError` +(`acceptance/supabase-edge-functions-app`, bug 3). A recorder must never +break the app it records. + +The recorder now follows OpenTelemetry's browser model +(`propagateTraceHeaderCorsUrls`), in `recorder/src/propagation.ts`: + +- **same-origin requests** are always stamped (no preflight is involved); +- **cross-origin requests** are stamped only when their origin is listed in + the Vite plugin's `propagateTraceHeaderOrigins` option (or + `APPMAP_PROPAGATE_TRACE_HEADER_ORIGINS`, comma-separated): an origin, a + RegExp tested against the request URL, or `'*'`; +- **no page origin** (Node, Deno — server-side code, no CORS): every + outgoing request is stamped, as before. + +Every request is still recorded as `http_client_request`/`response`; +only the header is withheld. The option reaches the browser through the +injected interaction recorder (`installInteractionRecorder({ +propagateTraceHeaderOrigins })`) and Vitest test recording through the +environment (the plugin sets the variable before Vitest starts its +workers). `setPropagateTraceHeaderOrigins()` sets it at runtime. + +**What the user must configure to link a cross-origin backend** — the +one thing zero-touch cannot do for them: list the backend's origin, and +make the backend allow the header (`traceparent` in its +`Access-Control-Allow-Headers`). Supabase added `traceparent` to the +example functions' `corsHeaders` upstream (fc5db9bb); a function written +before that needs the same one-line change. + +Tests: `recorder/test/propagation.test.ts`, and in +`recorder/test/xhrPatch.test.ts` "a cross-origin backend whose CORS does +not allow traceparent" (jsdom enforces CORS for XHR: before this change +the request failed with "Headers traceparent forbidden"). + + +## Amendment (2026-09-24): the stitched diagram shows the frontend handler and the backend's outgoing calls + +On the Supabase edge-functions app the link held but the stitched diagram +was `click → POST /functions/v1/… → 200` and nothing else +(`acceptance/supabase-edge-functions-app`, bug 7): `diagram.mjs` drew no +frontend events at all, and on the backend only `sql_query` and function +calls — but an edge function reaches its database through PostgREST, over +HTTP, so its "DB" step is an `http_client_request`, which was not drawn. + +`appmap-link` now hands each interaction's own map to the renderer, which +draws its function calls in event order with each request where it was +made (`FE -> FE : App.invokeFunction`, then `FE -> BE0 : POST …`), and +draws a backend's outgoing HTTP calls to a `network` participant with the +query from the event's `message` (`BE0 -> NET : GET /rest/v1/users?select=*`). +Its summary line no longer counts a backend map that calls out as a +frontend map: `1 frontend map(s), 1 backend map(s) (1 of them also make +outgoing requests; 0 linked onward)`. Such maps are still linked onward, +so a middle tier works as before. Tests: `linker/test/link.test.mjs`. diff --git a/docs/design/03-build-time-instrumentation.md b/docs/design/03-build-time-instrumentation.md index ceb1a73..90dcde1 100644 --- a/docs/design/03-build-time-instrumentation.md +++ b/docs/design/03-build-time-instrumentation.md @@ -155,3 +155,45 @@ already Deno-compatible (`performance.now`, `crypto.getRandomValues`, (`Deno.writeTextFile` or POST to a collector) and a per-request session driver that copies the incoming traceparent into metadata, i.e. the Deno twin of the PetClinicGo middleware. + +## Amendment (2026-09-24): JSX in `.js` files; a file that does not parse is left alone + +The Vite plugin enabled Babel's JSX parser only for `.jsx`/`.tsx`, and +parsed everything with the TypeScript plugin. A Create React App keeps +its JSX in `.js` files (`acceptance/supabase-edge-functions-app`, bug 1): +every component failed with `Unexpected token`, Vite served a 500 and the +app did not render. The syntax now follows the extension +(`syntaxFor` in `recorder/src/transform.ts`): `.js`/`.mjs`/`.cjs`/`.jsx` +parse as JavaScript with JSX (Babel reads JSX-free JavaScript exactly as +before), `.ts`/`.mts`/`.cts` as TypeScript without JSX (so `x` type +assertions keep working), `.tsx` as both. Emitting the JSX is still the +app's toolchain's job; the transform only instruments and keeps it. + +And a recorder must never break the app: if the transform still cannot +parse a file, the plugin now serves it uninstrumented and prints +`appmap: not instrumenting : ` instead of failing the +request. Tests: `recorder/test/vitePluginSelection.test.ts`. + +## Amendment (2026-09-24): globs in `include`/`exclude`; test files excluded by default + +`exclude` took only directory prefixes. bulletproof-react co-locates its +tests (`src/**/__tests__/*.test.tsx`), so with `include: ['src']` the +functions its test files define (`renderDiscussion`, `TestDialog`, +`TestDrawer`) were recorded as app code, and the only way to keep them +out was to list every test directory by hand +(`acceptance/bulletproof-react`, bug 8). + +`include` and `exclude` entries are now either a directory prefix / file +(as before) or a glob matched against the project-relative path: `*` +within a segment, `**` across segments, `?`, `{a,b}` +(`recorder/src/pathMatch.ts`). And test code is excluded by default — +`DEFAULT_TEST_EXCLUDE`: + +``` +**/__tests__/** +**/__mocks__/** +**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs,mts,cts} +``` + +`defaultExclude: [...]` replaces that list; `defaultExclude: false` +instruments test files too. Tests: `recorder/test/vitePluginSelection.test.ts`. diff --git a/docs/design/04-collector-and-interaction-windows.md b/docs/design/04-collector-and-interaction-windows.md index 6d90d9d..2e9b056 100644 --- a/docs/design/04-collector-and-interaction-windows.md +++ b/docs/design/04-collector-and-interaction-windows.md @@ -73,8 +73,12 @@ tree-shake away. refinement path (transform-injected continuation passing). - Trigger set is `click`/`submit` only; route navigations and keyboard interactions are future units. -- One window at a time by construction; rapid-fire clicking merges - into the open window rather than erroring. +- One window at a time by construction. A trigger that fires while a + window is open joins it, and the map is marked: the name lists every + interaction, `metadata.interactions` holds them in order and + `metadata.ambiguous` is `true` ("Overlapping interactions" in doc + 01's per-request async context amendment explains why the window is + not split instead). ## Manual browser validation (the remaining step) diff --git a/docs/design/05-deno-edge-functions.md b/docs/design/05-deno-edge-functions.md index 44f94ae..d8b4a64 100644 --- a/docs/design/05-deno-edge-functions.md +++ b/docs/design/05-deno-edge-functions.md @@ -30,25 +30,27 @@ import { withAppMap } from './appmap.ts'; Deno.serve(withAppMap(handleRequest, { app: 'what2say' })); ``` -One `Recording` per request, gated the same way doc 01's ambient -session already is — module-level, one at a time, process-wide: - -- **No `traceparent` header, or a recording already active** → the - wrapped handler runs unrecorded and untouched. This is - recording-driven by design (same as the frontend side): production - traffic is silent unless something upstream chose to stamp it. -- **A stamped request, no recording active** → a `Recording` starts, - `metadata.trace_id`/`parent_span_id` are copied from the incoming - header (doc 02's join key — a backend request map carries the - *caller's* ids, never its own), `httpServerRequest`/ +One `Recording` per stamped request, each in its own async context +(doc 01, "Per-request async context" amendment): + +- **No `traceparent` header** → the wrapped handler runs unrecorded and + untouched, in a context with no recording at all, so even while other + requests are being recorded its code is not recorded and its outbound + calls are not stamped. This is recording-driven by design (same as + the frontend side): production traffic is silent unless something + upstream chose to stamp it. +- **A stamped request** → a `Recording` opens, scoped to that request's + async context. `metadata.trace_id`/`parent_span_id` are copied from + the incoming header (doc 02's join key — a backend request map + carries the *caller's* ids, never its own), `httpServerRequest`/ `httpServerResponse` bracket the call, and the finished map ships after the response is ready to send — never blocking it. -The one-recording-at-a-time invariant is enforced by `withAppMap` -itself checking `activeRecording()` before starting, not by -`startRecording`'s own defensive throw — a second stamped request -arriving mid-flight should degrade to unrecorded, not crash the -request. `deno/test/withAppMap.test.ts` pins this with two concurrent +Concurrent stamped requests each get their own map holding only their +own events. (This replaced an earlier one-recording-at-a-time rule that +let concurrent requests' events leak into whichever map was open.) +`deno/appmap_test.ts` pins this with overlapping stamped and unstamped +requests; `deno/test/withAppMap.test.ts` with two concurrent `withAppMap`-wrapped handlers and a held promise gate. ## Shipping: file or collector @@ -103,3 +105,12 @@ Everything Deno-specific lives in `deno/appmap.ts` alone. convention the linker's real-PetClinicGo integration test already uses for an optional external dependency. `deno/test/` covers the driver's own logic without needing the binary at all. + +## Amendment 2026-09-24: CI runs the real `deno run` spike + +The last consequence above no longer holds. CI installs Deno and runs +`examples/deno-edge`'s smoke test and the React ↔ Deno e2e test on +every push and PR, and both now **fail** instead of skipping when +`deno` is missing and `CI=true`. CI also runs the Deno acceptance +suites against real Supabase code (`acceptance/supabase-restful-tasks`, +and the full-stack `acceptance/supabase-edge-functions-app`). diff --git a/docs/design/06-zero-touch-deno.md b/docs/design/06-zero-touch-deno.md index 1b7d34f..ff8a6ce 100644 --- a/docs/design/06-zero-touch-deno.md +++ b/docs/design/06-zero-touch-deno.md @@ -113,3 +113,18 @@ a request's first argument to the developer's handler. Real address); it is silently dropped. Pre-existing, not introduced by this doc — noted here because the preload path makes it easier to hit by accident (a handler using `info` now "just works" until it doesn't). + +## Amendment (2026-09-24): the anonymous `Deno.serve` handler + +The transform wrapped only named top-level functions, so the most +common edge-function shape — `Deno.serve(async (req) => { … })` — never +recorded its real entry function: routing, body parsing and the error +path were invisible, and the named functions it called hung directly +off the request. The transform now also wraps the inline handler of a +top-level `Deno.serve(...)` call in all three shapes (`Deno.serve(fn)`, +`Deno.serve(options, fn)`, `Deno.serve({ handler: fn })`), recorded as +`.handler` (or the function expression's own name) at its line. +`deno/appmap.ts` also re-exports `instrumentHandler`, which the +transform imports for closures nested in instrumented functions — an +entry file with such a closure could not load under `appmap-deno` +before. diff --git a/docs/design/07-zero-touch-react.md b/docs/design/07-zero-touch-react.md index a531df7..9977b17 100644 --- a/docs/design/07-zero-touch-react.md +++ b/docs/design/07-zero-touch-react.md @@ -79,3 +79,60 @@ HTML/module pipeline): backend example requires an application-code change to record. Supabase Edge Functions remain the one named, unsolved exception (doc 06's Tier 2). + +## Amendment (2026-09-24): the injected import never loaded in a browser + +The checks above looked at the served HTML text and at the `/@id/` URL +separately — never at a browser loading the one from the other. In +real Chromium the page carried +`` +and the browser refused it (*"Cross origin requests are only supported +for protocol schemes: http, …"*), so zero-touch interaction recording +**never started** — on this repo's own example (Vite 6) and on +bulletproof-react (Vite 5). + +Why: a plain-function `transformIndexHtml` is a *normal-order* hook, +and Vite runs normal hooks after its own dev-HTML pass that rewrites +module imports to servable URLs. Our injected bare `virtual:` specifier +was added after that rewrite and reached the browser as-is. +(`@vitejs/plugin-react` gets away with the same hook because it injects +an already-servable URL, `@react-refresh`.) + +Fix: the plugin now does what plugin-react does and injects the URL +Vite itself serves the virtual module at — +`import "@id/__x00__virtual:appmap-interaction-recorder"` — which +goes through Vite's normal module pipeline (the module's own import of +the recorder is rewritten by Vite as usual). In a production build with +`force`, the recorder is emitted as its own chunk in `buildStart` and +the page links that chunk. + +Proof, this time in the pipeline a browser uses: + +- `recorder/test/vitePlugin.test.ts` starts a real Vite dev server + (bases `/` and `/sub/`), fetches the page, resolves the injected + specifier against the page URL exactly as a browser would, and + fetches it and the recorder it imports (fails before the fix: + the specifier resolves to a `virtual:` URL). It also runs a forced + production build and checks the linked chunk. +- Headless Chromium (Playwright) on the example app's dev server and on + bulletproof-react's: the page loads the recorder with no console + error, and a click writes an interaction map to the collector. + +## Amendment (2026-09-24): the page load gets its own window + +Windows open on `click`/`submit`, so what the page does while it loads — +before any interaction — was recorded nowhere, and its requests carried +no `traceparent` (bulletproof-react's `GET /auth/me` on every page load, +`acceptance/bulletproof-react` browser check). `installInteractionRecorder` +now takes `recordPageLoad`: when set, it opens a window as soon as it is +installed, named `load `, which closes on idle like any other. The +zero-touch injection sets it (the recorder script is in ``, so it +runs before the app's entry module). A click while that window is still +open is absorbed and marked `ambiguous`, like any overlapping interaction. +Off by default for callers of the API. Test: +`recorder/test/interactionRecording.test.ts` ("the page load"). + +Known limitation, unchanged: the browser has no async context, so two +interactions close together (e.g. two clicks 20 ms apart) land in one +window. The map is no longer silently named after the first one only: it +is marked `ambiguous: true` and lists both in `metadata.interactions`. 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/09-real-e2e-deno.md b/docs/design/09-real-e2e-deno.md index 8c551da..84b681b 100644 --- a/docs/design/09-real-e2e-deno.md +++ b/docs/design/09-real-e2e-deno.md @@ -50,3 +50,14 @@ yet does both against the same real backend in one test. That would mean reshaping which endpoint the app's own data client calls, or adding a page that calls `deno-edge`'s API — not done here, flagged as a real gap rather than implied to be covered. + +## Amendment 2026-09-24 + +`test/e2e/fullstack.test.tsx` (the Go half referred to above) has been +removed; see doc 02's amendment of the same date. This test stays, and +in CI (`CI=true`) it now fails instead of skipping when `deno` is +missing. Its backend (`examples/deno-edge`) is this repo's own example, +so it is a regression test, not the proof: the "user-driven flow +against a real backend" gap named above is what +`acceptance/supabase-edge-functions-app` covers, against a real +open-source React + Deno app in real Chromium. 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..3383e45 --- /dev/null +++ b/docs/design/10-behavior-diff-tracing-agent.md @@ -0,0 +1,162 @@ +# 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 backend maps used here are the simulated ones, which share the exact v1.2 +shape the real Go middleware will emit. (The PetClinicGo-backed e2e test this +paragraph used to point at was retired on 2026-09-24; the full-stack proof is +now `acceptance/supabase-edge-functions-app`, see doc 02.) + +## Amendment (2026-09-24): standalone request maps, XHR apps, query strings + +Three things acceptance runs on real apps showed: + +- **An edge function's request map was drawn as a frontend.** Any map + with an outgoing request counted as a frontend map, so a Deno request + map traced on its own (called directly, not from a recorded browser + interaction) ran in a lane called "frontend" and its + `http_server_request` was drawn as `undefined.undefined`. appmap-trace + now traces frontend maps (outgoing requests, no incoming one) plus + every backend request map no frontend map links to; the latter run in + their own app's lane (`client → restful-tasks → network`), with the + server request unwrapped into the interaction's title. A linked middle + tier's own outbound calls are drawn as calls to `network`, not `?.?`. + (`appmap-link`'s notion of a frontend map is unchanged: a middle tier + still links onward.) +- **An axios app traced 0 interactions** because its maps had no HTTP + events. The recorder now records XMLHttpRequest (doc 02 amendment), so + those maps are frontend maps like any other. +- **Query strings.** The recorder now keeps the query out of `url` and + in the event's `message`, as the spec says (doc 12). The trace shows + it again from `message`, so a request whose only change is a query + parameter still shows up in a behavior diff. + +Tests: `linker/test/trace-agent.test.mjs` ("a backend request map +traced on its own") and `linker/test/trace-cli.test.mjs`. + +## Amendment (2026-09-24, later): same-named interactions; what "changed" counts + +Two more things the acceptance runs showed: + +- **Same-named interactions were diffed against the wrong baseline.** + Every direct request to one edge function is named + `POST /`, and a button clicked twice gives two maps with the + same name. The baseline was a map keyed by name, so every such + interaction was diffed against the *last* baseline map of that name + (`acceptance/supabase-edge-functions-app` H: R1 and R2 were compared + with R3, which makes no outbound call, so their unchanged + `GET /auth/v1/user` showed as added). appmap-trace now pairs + same-named interactions by occurrence in recording order (the file + name's trailing sequence number), and writes them to `name`, + `name__2`, … instead of overwriting one file. +- **"1 added, 3 changed" for one inserted call.** The summary counted + every step that *contains* a change (the request, the handler, the + function around the call). It now counts a step as changed only when + its own outcome changed; steps that contain a change are still marked + `~` in the tree. A linked request's outcome now includes what the + backend answered (its `http_server_response` status), so a changed + backend status is a changed step. + +Tests: `linker/test/trace-cli.test.mjs`. diff --git a/docs/design/11-waituntil-background-work.md b/docs/design/11-waituntil-background-work.md new file mode 100644 index 0000000..2f346e8 --- /dev/null +++ b/docs/design/11-waituntil-background-work.md @@ -0,0 +1,151 @@ +# 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) is recorded at +its true time, before the background work. In the serialized map (doc +12) the background calls nest under the request — they were started by +its handler — so the `http_server_response` appears after them, closing +the request's tree; its `elapsed` still says when the response went +out. + +**Overlap.** Each stamped request records in its own async context +(doc 01, "Per-request async context" amendment), and `waitUntil` +promises are collected for the recording of the context that registered +them. A second stamped request arriving during a long background window +therefore gets its own map, and its own background work lands there — +not in the first request's map. (Before that amendment the ambient +session stayed held for the whole window, the second request ran +unrecorded, and its background work leaked into the first map.) + +**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 makes whatever *is* written balanced, +sanitizable and committable (if incomplete) instead of unusable. The +synthesis is a non-mutating snapshot: the recording's own event list is +untouched, so it composes with everything else. + +This section originally claimed that "even a genuinely killed isolate +yields a balanced, sanitizable, committable (if incomplete) map". It +did not: a recording was only ever written when it closed, so an +acceptance run that sent SIGKILL to deno, or SIGTERM/SIGINT to the +runner, 1.5 s into the background work found **no file at all** — the +self-heal never got to run. See "Crashes" below for what now makes the +claim true. + +### 3. Crashes (2026-09-24) + +`deno/appmap.ts` now makes sure a process that dies with recordings +open still leaves them behind, self-healed and `truncated: true`: + +- **SIGINT / SIGTERM** (sent to deno directly, or to the `appmap-deno` + runner, which forwards them): every open recording is written + synchronously, then the signal takes its default course — the + process ends exactly as it would have without the recorder — unless + the app registered its own listener for that signal, in which case + the app decides. +- **An uncaught error or unhandled rejection**, and a normal exit with + recordings still open: the same synchronous write. +- **`kill -9`** can't be caught, so a recording still open after 250 ms + is snapshotted to `.appmap.json.part` every 500 ms while it + changes (written to a temp file and renamed, so a `.part` is always a + whole, valid map). When the runner sees its child die it renames any + `.part` to `.appmap.json`; so does the next `withAppMap()` started on + that directory. What survives is at most one interval stale. + +Collector mode (`APPMAP_COLLECTOR`) has no file to fall back on and is +not covered. Tests: the SIGTERM, SIGINT, uncaught-error and kill -9 +cases in `deno/appmap_test.ts`, each against a real child process. + +## 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), plus up to 500 ms + of what ran before it (the snapshot interval); the rest survives, + flagged truncated. +- Attribution of concurrent background work to distinct recordings is + handled by per-request async context (doc 01's 2026-09-24 + amendment). diff --git a/docs/design/12-format-validity.md b/docs/design/12-format-validity.md new file mode 100644 index 0000000..1f019a7 --- /dev/null +++ b/docs/design/12-format-validity.md @@ -0,0 +1,81 @@ +# 12 — Format validity and the declared version + +**Status:** implemented. Every recording mode's output is checked with +the official validator, `@appland/appmap-validate` (getappmap/appmap-js +`packages/validate`, the validator the spec README links), in +`recorder/test/validity.test.ts` and `deno/test/withAppMap.test.ts`. + +## Why + +The recorder declared `"version": "1.12"` from day one, but nothing +checked it. An acceptance run on two real apps (bulletproof-react and a +Supabase edge function) validated every recording it produced with the +official validator: **0 of 29 and 0 of 49 were valid**, at 1.12 and at +every other version from 1.2 to 1.13.1. The label was unearned. + +## What was wrong, and what the recorder does now + +| Problem | Fix | +|---|---| +| `metadata.frameworks[]` had no `version` (required). | Test recordings fill each framework's version from the installed package (`vitest`, and `react` when installed); a framework that can't be found is left out rather than listed without a version. | +| `metadata.language.version` missing on Deno maps (required whenever `language` is present). | `{ name: 'typescript', engine: 'deno', version: Deno.version.typescript }`. Browser interaction maps still carry no `language` (optional). | +| Values capped at 1024 characters, plus a `…` appended *after* the cut (1025). Schemas 1.6+ allow 100. | `VALUE_SIZE_CAP` is 100 and the `…` counts toward it; a cut never splits a surrogate pair. `APPMAP_EVENT_VALUESIZE` still overrides it. | +| `exceptions[]` had no `object_id` (required). | Objects get their recording-wide `object_id`; thrown primitives get a fresh one. | +| `http_client_request` / `http_server_request` had no `message` (required from 1.5). `url` kept the query string, which the spec says it excludes. | `url` is origin + path; the query parameters are the event's `message` (`{ name, class: 'String', value }`), `[]` when there are none. The same for `http_server_request`. | +| A failed fetch was closed with `status_code: 0`; a self-healed (truncated) HTTP call got a bare synthetic return. Both are invalid: an HTTP call can only be closed by an `http_*_response` with a 100–599 status. | Such calls are left out of the event stream (their children move up to their parent) and listed in `metadata.unanswered_http_requests` with the reason (`network error` or `no response before the recording closed`). The caller still records the thrown error. | +| Calls didn't nest. Each HTTP event and every call made after an `await` landed on its own `thread_id`, so standard tools (which rebuild the tree positionally per thread) saw a flat set of roots: a Deno request's official sequence diagram had three disconnected roots — server request, handler, outbound call. And a sync caller returning while the async work it started was still running broke nesting on its thread (validator: `expected parent id of return event #56 to be 55 but got 51`). | Every call records the call it was made from: the synchronous caller, or — where the runtime has async context (Node, Deno) — the call whose async continuation is running (`session.ts` `runInCall`). `toAppMap()` emits the events as that tree on one thread, ids renumbered in tree order: each call sits between its parent's call and return. A Deno request is one tree rooted at its `http_server_request`; `onSubmit → search → findOwners → GET` nests even though `onSubmit` returned first. The live `events` list keeps its original order and thread assignment. | +| classMap entries were keyed by name only, so two same-named functions in one file (e.g. two inline `onClick` handlers) lost one entry and the validator reported a call "missing in classmap". | Keyed by name and location. | + +In the browser there is no async context, so a call made from an async +continuation there has no known parent and becomes a root (valid, just +flatter). + +## The declared version + +With these fixes every recording from both acceptance apps and this +repo's example passes the validator at every schema version from 1.6.0 +to 1.13.1 (1.2–1.5 need parameter `object_id`/`receiver` fields the +recorder does not always write). The recorder declares **1.12**: + +- 1.13.x differs from 1.12 only by an optional `timestamp` on events, + which the recorder does not emit, so claiming 1.13 would add nothing; +- 1.12 is also what the official Node agent (`appmap-node`) declares; +- `examples/petclinic-react/test/interactionRecorder.test.tsx` pins the + declared version at 1.12. + +`APPMAP_VERSION` in `recorder/src/recording.ts` is the single place the +version lives. If it changes, the validity tests check the new claim. + +## Credential redaction + +AppMaps get committed, attached to PRs and shared, so credentials must +not reach them. The acceptance runs found a plaintext password +(`"password":"secret-pw-1"`) in a React parameter value and a 191-char +`Authorization: Bearer …` token in a Deno one. `recorder/src/redact.ts` +now applies to every captured value (parameters, return values, +exception messages, HTTP headers and query parameters): + +- a parameter, object property, query parameter or header whose name + matches `password|secret|token|api[_-]?key` (case-insensitive) is + recorded as `"[REDACTED]"`; +- `Authorization`, `Proxy-Authorization`, `Cookie` and `Set-Cookie` + headers are always `"[REDACTED]"`; +- `Bearer ` inside any captured string becomes + `Bearer [REDACTED]`. + +Tested in `recorder/test/redaction.test.ts`. + +## Amendment (2026-09-24): an object's shape survives the value cap + +With values cut to 100 characters, a plain object argument lost its later +fields entirely: bulletproof-react's +`registerWithEmailAndPassword({ email, firstName, lastName, password, teamName })` +was recorded as `{"email":…,"firstName":…,"lastName":…,"…` — no +`teamName` anywhere. The schema's parameter `properties` (a list of +`{ name, class }`) is the spec's way to carry an object's shape, and the +recorder now writes it for plain objects (prototype `Object.prototype` or +null), on parameters and return values: every own enumerable *data* +property, read from its descriptor (no getter is run; accessor properties +are left out; at most 50). Class instances and arrays get none. Tests: +`recorder/test/valueCapture.test.ts` ("parameter properties"). + diff --git a/examples/deno-edge/README.md b/examples/deno-edge/README.md index bfbf028..6504988 100644 --- a/examples/deno-edge/README.md +++ b/examples/deno-edge/README.md @@ -16,8 +16,9 @@ below), sends one `traceparent`-stamped request and one plain request, and asserts exactly one AppMap was written — for the stamped request only — with the right trace/span ids and call events; and asserts the source file on disk was never touched. If `deno` isn't on `PATH`, it -reports itself as skipped rather than failing (the same convention -`linker`'s real-PetClinicGo integration test already uses). +reports itself as skipped rather than failing — except in CI +(`CI=true`), where a missing `deno` fails the run, so it cannot +silently skip on a PR. ## Do it by hand — zero-touch (recommended) diff --git a/examples/deno-edge/scripts/smoke.mjs b/examples/deno-edge/scripts/smoke.mjs index c12baa3..cb047b7 100644 --- a/examples/deno-edge/scripts/smoke.mjs +++ b/examples/deno-edge/scripts/smoke.mjs @@ -6,7 +6,9 @@ // written, with the right shape. Mirrors the linker's own "runs // automatically when the [external tool] is present, skips itself // otherwise" convention; no Deno binary means this reports itself as -// skipped rather than failing the suite. +// skipped rather than failing the suite -- except in CI (CI=true), +// where a missing Deno binary is a failure, so it can never silently +// skip on a PR run. import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, readFileSync, readdirSync, rmSync, existsSync, unlinkSync } from 'node:fs'; @@ -29,6 +31,10 @@ function hasDeno() { } if (!hasDeno()) { + if (process.env.CI === 'true' || process.env.CI === '1') { + console.error('deno-edge smoke test: `deno` not found on PATH and CI=true — failing instead of skipping.'); + process.exit(1); + } console.log('deno-edge smoke test: `deno` not found on PATH — skipping (this is not a failure).'); process.exit(0); } 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/deno-fullstack.test.ts b/examples/petclinic-react/test/e2e/deno-fullstack.test.ts index 6d41bc6..74641a7 100644 --- a/examples/petclinic-react/test/e2e/deno-fullstack.test.ts +++ b/examples/petclinic-react/test/e2e/deno-fullstack.test.ts @@ -5,10 +5,14 @@ // runs the real appmap-link CLI and asserts the frontend and backend // maps stitch by traceparent ids. // -// Unlike test/e2e/fullstack.test.tsx (the Go half), this needs no -// sibling repo — the Deno backend lives in this repo, at -// examples/deno-edge. Only requires the `deno` binary; skips itself -// cleanly otherwise, so plain `npm test` stays green everywhere. +// This needs no sibling repo — the Deno backend lives in this repo, at +// examples/deno-edge. Only requires the `deno` binary. Locally it skips +// itself when `deno` is missing; in CI (CI=true) a missing `deno` is a +// failure, so this test can never silently skip on a PR run. +// +// Both ends of this test are this repo's own code (recorder + example +// backend). The end-to-end proof against a real open-source app is +// acceptance/supabase-edge-functions-app (docs/design/02, 2026-09-24). import { mkdirSync, rmSync, copyFileSync, readdirSync, readFileSync, existsSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; @@ -40,6 +44,15 @@ const PET_LOOKUP_ENTRY = join(REPO_ROOT, 'examples', 'deno-edge', 'src', 'petLoo const E2E_DIR = join(EXAMPLE_ROOT, 'tmp', 'appmap', 'e2e-deno'); const denoAvailable = spawnSync('deno', ['--version']).status === 0; +const inCI = process.env.CI === 'true' || process.env.CI === '1'; + +if (!denoAvailable && inCI) { + describe('full-stack: React ↔ real deno-edge (zero-touch) ↔ appmap-link', () => { + it('requires `deno` on PATH in CI', () => { + throw new Error('`deno` is not on PATH and CI=true: this e2e test must run on every CI run, not skip'); + }); + }); +} describe.skipIf(!denoAvailable)('full-stack: React ↔ real deno-edge (zero-touch) ↔ appmap-link', () => { const frontendDir = join(E2E_DIR, 'frontend'); diff --git a/examples/petclinic-react/test/e2e/fullstack.test.tsx b/examples/petclinic-react/test/e2e/fullstack.test.tsx deleted file mode 100644 index 2642d7c..0000000 --- a/examples/petclinic-react/test/e2e/fullstack.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -// Full-stack integration test (docs/design/02, "the join demonstrated -// on real files" — now with NO simulator): boots the real PetClinicGo -// server with its AppMap middleware, drives the real React app against -// it over real HTTP, then runs the real appmap-link CLI and asserts -// the frontend and backend maps stitch by traceparent ids. -// -// Requires the Go toolchain and a checkout of the Go sibling repo -// (set PETCLINIC_GO_DIR, or keep the default side-by-side layout). -// Skips itself cleanly when either is missing, so plain `npm test` -// stays green everywhere. - -import { mkdirSync, rmSync, copyFileSync, readdirSync, readFileSync, existsSync } from 'node:fs'; -import { join, basename, dirname } from 'node:path'; -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; -import { createServer } from 'node:net'; -import { screen } from '@testing-library/react'; -import { render, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { stopRecording } from '@funwithappmap/react-recorder'; -import { - startTestRecording, - finishTestRecording, -} from '@funwithappmap/react-recorder/vitest'; -import { server as msw } from '../setup'; -import { ClinicProvider } from '../../src/context/ClinicContext'; -import { App } from '../../src/App'; - -// import.meta.url is not a file:// URL under the jsdom environment, so -// anchor on the workspace layout instead, starting from cwd. -function findExampleRoot(): string { - let dir = process.cwd(); - for (let i = 0; i < 6; i++) { - const pkg = join(dir, 'package.json'); - if (existsSync(pkg) && JSON.parse(readFileSync(pkg, 'utf8')).name === 'petclinic-react') { - return dir; - } - const nested = join(dir, 'examples', 'petclinic-react'); - if (existsSync(join(nested, 'package.json'))) return nested; - dir = dirname(dir); - } - throw new Error('cannot locate examples/petclinic-react from ' + process.cwd()); -} - -const EXAMPLE_ROOT = findExampleRoot(); -const REPO_ROOT = join(EXAMPLE_ROOT, '..', '..'); -const LINKER_CLI = join(REPO_ROOT, 'linker', 'bin', 'appmap-link.mjs'); -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'); - -const goAvailable = spawnSync('go', ['version']).status === 0; -const backendAvailable = goAvailable && existsSync(join(PETCLINIC_GO_DIR, 'main.go')); - -function freePort(): Promise { - return new Promise((resolve, reject) => { - const srv = createServer(); - srv.listen(0, '127.0.0.1', () => { - const { port } = srv.address() as { port: number }; - srv.close(() => resolve(port)); - }); - srv.on('error', reject); - }); -} - -describe.skipIf(!backendAvailable)('full-stack: React ↔ PetClinicGo ↔ appmap-link', () => { - const frontendDir = join(E2E_DIR, 'frontend'); - const backendDir = join(E2E_DIR, 'backend'); - const linksDir = join(E2E_DIR, 'links'); - let petclinic: ChildProcess | undefined; - let base: string; - - beforeAll(async () => { - msw.close(); // this file talks real HTTP - - rmSync(E2E_DIR, { recursive: true, force: true }); - mkdirSync(frontendDir, { recursive: true }); - - const binary = join(E2E_DIR, 'petclinic'); - const build = spawnSync('go', ['build', '-o', binary, '.'], { - cwd: PETCLINIC_GO_DIR, - encoding: 'utf8', - }); - if (build.status !== 0) throw new Error(`go build failed:\n${build.stderr}`); - - const port = await freePort(); - base = `http://127.0.0.1:${port}`; - petclinic = spawn( - binary, - ['-addr', `127.0.0.1:${port}`, '-db', join(E2E_DIR, 'petclinic.db'), '-appmap-dir', backendDir], - { stdio: ['ignore', 'inherit', 'inherit'] }, - ); - - await waitFor( - async () => { - const res = await fetch(`${base}/vets`); - expect(res.ok).toBe(true); - }, - { timeout: 15_000, interval: 250 }, - ); - }, 120_000); - - afterAll(() => { - petclinic?.kill('SIGTERM'); - }); - - it('stitches owner detail end to end: real fetches, real backend maps, real join', async () => { - // Drive a dedicated recording instead of the ambient per-test one, - // so we know exactly which file to link. - stopRecording(); - startTestRecording('e2e: owner detail', { app: 'petclinic-react' }); - - render( - - - - - , - ); - // Real seeded data from the real SQLite database. - expect(await screen.findByRole('heading', { name: 'George Franklin' })).toBeInTheDocument(); - expect(screen.getByText('Leo (cat)')).toBeInTheDocument(); - - const frontendMap = finishTestRecording('succeeded'); - copyFileSync(frontendMap, join(frontendDir, basename(frontendMap))); - - // The middleware writes after the response is flushed; allow for that. - await waitFor(() => expect(readdirSync(backendDir)).toHaveLength(2), { timeout: 5000 }); - - const link = spawnSync( - 'node', - [LINKER_CLI, frontendDir, backendDir, '--out', linksDir], - { encoding: 'utf8' }, - ); - expect(link.status, link.stderr).toBe(0); - expect(link.stdout).toContain('2/2 requests linked, 0 orphan backend map(s)'); - - const { links, orphan_backends } = JSON.parse( - readFileSync(join(linksDir, 'appmap-links.json'), 'utf8'), - ); - expect(orphan_backends).toEqual([]); - expect(links).toHaveLength(1); - - const requests = links[0].requests; - expect(requests).toHaveLength(2); - expect(requests.map((r: any) => r.backend?.name).sort()).toEqual(['GET /owners/1', 'GET /vets']); - expect(requests.every((r: any) => r.request.status === 200)).toBe(true); - - // The backend maps are REAL middleware output, not the simulator: - for (const req of requests) { - const backendMap = JSON.parse(readFileSync(req.backend.path, 'utf8')); - expect(backendMap.metadata.recorder.name).toBe('funwithappmap-go'); - expect(backendMap.metadata.parent_span_id).toBe(req.span_id); - expect(backendMap.metadata.trace_id).toBe(links[0].interaction.trace_id); - } - - // And the stitched diagram exists. - const puml = readdirSync(linksDir).find((f) => f.endsWith('.puml')); - expect(puml).toBeDefined(); - const diagram = readFileSync(join(linksDir, puml!), 'utf8'); - expect(diagram).toContain('FE -> BE0 : GET /owners/1'); - expect(diagram).toContain('FE -> BE0 : GET /vets'); - expect(diagram).toContain('BE0 --> FE : 200'); - }, 30_000); - - it('links the error path too: a 400 validation response', async () => { - stopRecording(); - startTestRecording('e2e: create owner validation error', { app: 'petclinic-react' }); - - const response = await fetch(`${base}/owners`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ firstName: 'Maria' }), // lastName missing - }); - expect(response.status).toBe(400); - - const frontendMap = finishTestRecording('succeeded'); - const errFrontendDir = join(E2E_DIR, 'frontend-err'); - mkdirSync(errFrontendDir, { recursive: true }); - copyFileSync(frontendMap, join(errFrontendDir, basename(frontendMap))); - - await waitFor(() => expect(readdirSync(backendDir).length).toBeGreaterThanOrEqual(3), { - timeout: 5000, - }); - - const outDir = join(E2E_DIR, 'links-err'); - const link = spawnSync('node', [LINKER_CLI, errFrontendDir, backendDir, '--out', outDir], { - encoding: 'utf8', - }); - expect(link.status, link.stderr).toBe(0); - - const { links } = JSON.parse(readFileSync(join(outDir, 'appmap-links.json'), 'utf8')); - const post = links[0].requests.find((r: any) => r.request.method === 'POST'); - expect(post.request.status).toBe(400); - expect(post.backend?.name).toBe('POST /owners'); - const backendMap = JSON.parse(readFileSync(post.backend.path, 'utf8')); - expect(backendMap.events.at(-1).http_server_response.status_code).toBe(400); - }, 30_000); -}); 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..bfae406 100644 --- a/examples/petclinic-react/vite.config.ts +++ b/examples/petclinic-react/vite.config.ts @@ -12,9 +12,22 @@ 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. - plugins: [appmapVitePlugin({ include: ['src'], app: 'petclinic-react' }), react()], + // injects interaction recording without application code changes. + // + // propagateTraceHeaderOrigins: in the browser the app calls its backend + // through the same-origin /api proxy below, which is always stamped with + // traceparent. The tests call cross-origin backends (MSW's + // http://localhost:8080, and the real deno-edge example on :8000 in + // test/e2e); cross-origin requests are stamped only for listed origins + // (docs/design/02, "Cross-origin requests"). + plugins: [ + appmapVitePlugin({ + include: ['src'], + app: 'petclinic-react', + propagateTraceHeaderOrigins: ['http://localhost:8080', 'http://localhost:8000'], + }), + react(), + ], resolve: { alias: [ { find: '@funwithappmap/react-recorder/vitest', replacement: recorderSrc('vitest.ts') }, diff --git a/linker/bin/appmap-link.mjs b/linker/bin/appmap-link.mjs index 29a7a17..4bd0b0c 100755 --- a/linker/bin/appmap-link.mjs +++ b/linker/bin/appmap-link.mjs @@ -38,6 +38,7 @@ writeFileSync( ); const backendByPath = new Map(backends.map((b) => [b.path, b.appmap])); +const frontendByPath = new Map(frontends.map((f) => [f.path, f.appmap])); let diagrams = 0; for (const link of links) { if (!link.requests.some((r) => r.backend)) continue; @@ -45,14 +46,24 @@ for (const link of links) { .split('/') .pop() .replace(/\.appmap\.json$/, ''); - writeFileSync(join(out, `${name}.puml`), renderSequenceDiagram(link, backendByPath)); + writeFileSync(join(out, `${name}.puml`), renderSequenceDiagram(link, backendByPath, frontendByPath.get(link.interaction.path))); diagrams++; } -const linked = links.reduce((n, l) => n + l.requests.filter((r) => r.backend).length, 0); -const total = links.reduce((n, l) => n + l.requests.length, 0); +// A backend request map that itself calls out (a middle tier, or an edge +// function calling PostgREST) is linked onward like a frontend map, but +// it is not an interaction: count it as a backend map only, and count +// only interactions' requests in the totals. +const interactions = links.filter((l) => !backendByPath.has(l.interaction.path)); +const linked = interactions.reduce((n, l) => n + l.requests.filter((r) => r.backend).length, 0); +const total = interactions.reduce((n, l) => n + l.requests.length, 0); +const middle = links.length - interactions.length; +const onward = links + .filter((l) => backendByPath.has(l.interaction.path)) + .reduce((n, l) => n + l.requests.filter((r) => r.backend).length, 0); console.log( - `${frontends.length} frontend map(s), ${backends.length} backend map(s): ` + - `${linked}/${total} requests linked, ${orphanBackends.length} orphan backend map(s)`, + `${interactions.length} frontend map(s), ${backends.length} backend map(s)` + + (middle ? ` (${middle} of them also make outgoing requests; ${onward} linked onward)` : '') + + `: ${linked}/${total} requests linked, ${orphanBackends.length} orphan backend map(s)`, ); console.log(`wrote ${relative('.', join(out, 'appmap-links.json'))} and ${diagrams} diagram(s)`); diff --git a/linker/bin/appmap-trace.mjs b/linker/bin/appmap-trace.mjs new file mode 100755 index 0000000..27e9a5e --- /dev/null +++ b/linker/bin/appmap-trace.mjs @@ -0,0 +1,170 @@ +#!/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, isBackendMap, outgoingRequests } 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); +} + +// The interactions to trace: every frontend map (outgoing requests, no +// incoming one), plus every backend request map no frontend map links to +// — a request traced on its own (an edge function called directly), +// drawn in its own app's lane rather than mislabeled "frontend". +function loadSet(scanDirs) { + const maps = scanAppMaps(scanDirs); + const spanToBackend = backendIndex(maps); + const frontends = maps.filter((m) => isFrontendMap(m.appmap) && !isBackendMap(m.appmap)); + const linked = new Set(); + for (const f of frontends) { + for (const r of outgoingRequests(f.appmap)) if (r.spanId && spanToBackend.has(r.spanId)) linked.add(r.spanId); + } + const standalone = maps.filter( + (m) => isBackendMap(m.appmap) && !linked.has(m.appmap.metadata?.parent_span_id), + ); + return { frontends: [...frontends, ...standalone], spanToBackend }; +} + +const current = loadSet(dirs); +const base = baseline ? loadSet([baseline]) : null; + +// Pair each interaction with its baseline by name. Several interactions can +// share a name (every direct request to one edge function is +// "POST /"; a button clicked twice gives two +// 'click button "…"' maps): pair them by occurrence, in recording order +// (the recorder's per-run sequence number, the file name's last _NNN). +// Keying a Map by name alone paired every one of them with the *last* +// baseline map of that name, so unchanged calls showed up as added. +const seqOf = (p) => Number(/_(\d+)\.appmap\.json$/.exec(p)?.[1] ?? Number.MAX_SAFE_INTEGER); +const inRecordingOrder = (list) => [...list].sort((a, b) => seqOf(a.path) - seqOf(b.path) || a.path.localeCompare(b.path)); +const occurrence = new Map(); // current path -> index among same-named maps +{ + const seen = new Map(); + for (const f of inRecordingOrder(current.frontends)) { + const name = f.appmap.metadata?.name; + occurrence.set(f.path, seen.get(name) ?? 0); + seen.set(name, (seen.get(name) ?? 0) + 1); + } +} +const baseByName = new Map(); // name -> baseline maps, in recording order +if (base) { + for (const f of inRecordingOrder(base.frontends)) { + const name = f.appmap.metadata?.name; + if (!baseByName.has(name)) baseByName.set(name, []); + baseByName.get(name).push(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(f.appmap.metadata?.name)?.[occurrence.get(f.path)] : undefined; + const result = renderInteraction(f.appmap, { + spanToBackend: current.spanToBackend, + baselineAppmap, + baselineSpanToBackend: base?.spanToBackend, + highlight, + }); + + rendered++; + // Same-named interactions get their own files: name, name__2, … + const n = occurrence.get(f.path) ?? 0; + const slug = sanitize(name) + (n ? `__${n + 1}` : ''); + + 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/diagram.mjs b/linker/src/diagram.mjs index 8b70086..2325448 100644 --- a/linker/src/diagram.mjs +++ b/linker/src/diagram.mjs @@ -1,5 +1,7 @@ // Stitched sequence diagram (PlantUML) for one linked interaction: -// actor → frontend → [backend lane] handler → service → SQL. The +// actor → frontend handler → [backend lane] handler → service → SQL, or +// the backend's own outgoing HTTP calls (an edge function reaches its +// database through PostgREST over HTTP, so that is its "DB" step). The // diagram is what makes linking visible; the maps stay separate files. const SQL_PREVIEW = 60; @@ -7,9 +9,12 @@ const SQL_PREVIEW = 60; /** * @param {object} link one entry of linkMaps().links * @param {Map} backendMapsByPath path → appmap + * @param {object} [frontendAppmap] the interaction's own map: when given, + * its function calls are drawn too, in event order, with each request + * where it was made (the handler that made it is visible) * @returns {string} PlantUML source */ -export function renderSequenceDiagram(link, backendMapsByPath) { +export function renderSequenceDiagram(link, backendMapsByPath, frontendAppmap) { const lines = [ '@startuml', `title ${escapeText(link.interaction.name ?? link.interaction.path)}`, @@ -19,6 +24,7 @@ export function renderSequenceDiagram(link, backendMapsByPath) { const backendApps = new Map(); // alias → display name let anySql = false; + let anyOutbound = false; for (const req of link.requests) { if (!req.backend) continue; const appmap = backendMapsByPath.get(req.backend.path); @@ -27,19 +33,23 @@ export function renderSequenceDiagram(link, backendMapsByPath) { backendApps.set(`BE${backendApps.size}`, app); } if (appmap?.events?.some((e) => e.sql_query)) anySql = true; + if (appmap?.events?.some((e) => e.http_client_request)) anyOutbound = true; } for (const [alias, app] of backendApps) { lines.push(`participant "${escapeText(app)}" as ${alias}`); } if (anySql) lines.push('database DB'); + // A backend's own outgoing HTTP calls: for an edge function this is how + // it reaches its database (PostgREST) and auth (GoTrue). + if (anyOutbound) lines.push('participant "network" as NET'); lines.push(`User -> FE : ${escapeText(link.interaction.name ?? 'interaction')}`); - for (const req of link.requests) { - const label = `${req.request.method} ${pathOf(req.request.url)}`; + const drawRequest = (req) => { + const label = `${req.request.method} ${pathOf(req.request.url, req.request.message)}`; if (!req.backend) { lines.push(`FE -> FE : ${escapeText(label)} (no backend map)`); - continue; + return; } const appmap = backendMapsByPath.get(req.backend.path); const app = appmap?.metadata?.app ?? 'backend'; @@ -47,6 +57,7 @@ export function renderSequenceDiagram(link, backendMapsByPath) { lines.push(`FE -> ${alias} : ${escapeText(label)}`); lines.push(`activate ${alias}`); + const statusOf = responses(appmap?.events ?? [], 'http_client_response'); for (const event of appmap?.events ?? []) { if (event.event !== 'call') continue; if (event.sql_query) { @@ -55,25 +66,58 @@ export function renderSequenceDiagram(link, backendMapsByPath) { `${alias} -> DB : ${escapeText(sql.length > SQL_PREVIEW ? sql.slice(0, SQL_PREVIEW) + '…' : sql)}`, ); lines.push(`DB --> ${alias}`); + } else if (event.http_client_request) { + const r = event.http_client_request; + lines.push(`${alias} -> NET : ${escapeText(`${r.request_method} ${pathOf(r.url, event.message)}`)}`); + lines.push(`NET --> ${alias} : ${statusOf.get(event.id) ?? '?'}`); } else if (event.defined_class) { lines.push(`${alias} -> ${alias} : ${escapeText(`${event.defined_class}.${event.method_id}`)}`); } } lines.push(`${alias} --> FE : ${req.request.status ?? '?'}`); lines.push(`deactivate ${alias}`); + }; + + if (frontendAppmap?.events) { + // outgoingRequests() lists requests in event order, and so does + // link.requests: pair them positionally. + let next = 0; + for (const event of frontendAppmap.events) { + if (event.event !== 'call') continue; + if (event.http_client_request) { + const req = link.requests[next++]; + if (req) drawRequest({ ...req, request: { ...req.request, message: event.message } }); + } else if (event.defined_class) { + lines.push(`FE -> FE : ${escapeText(`${event.defined_class}.${event.method_id}`)}`); + } + } + for (const req of link.requests.slice(next)) drawRequest(req); + } else { + for (const req of link.requests) drawRequest(req); } lines.push('@enduml'); return lines.join('\n') + '\n'; } -function pathOf(url) { +function responses(events, kind) { + const out = new Map(); + for (const e of events) if (e[kind]) out.set(e.parent_id, e[kind].status_code); + return out; +} + +/** Path plus query. Recorders following the spec keep the query out of + * `url` and in the event's `message`; older ones left it in `url`. */ +function pathOf(url, message) { + let path; try { const u = new URL(url); - return u.pathname + u.search; + path = u.pathname + u.search; } catch { - return url; + path = url; } + const query = (message ?? []).map((m) => `${m.name}=${m.value}`).join('&'); + return query && !path.includes('?') ? `${path}?${query}` : path; } function escapeText(text) { diff --git a/linker/src/trace-agent.mjs b/linker/src/trace-agent.mjs new file mode 100644 index 0000000..fc447ce --- /dev/null +++ b/linker/src/trace-agent.mjs @@ -0,0 +1,710 @@ +// 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, isBackendMap } 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; +} + +/** The query string of a request: from the url if it still has one, + * otherwise rebuilt from the event's `message` (spec-conformant + * recorders keep it out of `url`), so a query change stays visible. */ +function queryOf(url, message) { + try { + if (new URL(url).search) return ''; + } catch { + if (String(url).includes('?')) return ''; + } + if (!Array.isArray(message) || message.length === 0) return ''; + return '?' + message.map((p) => `${p.name}=${p.value}`).join('&'); +} + +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); + // A backend request map traced on its own (no frontend map links to it, + // e.g. an edge function called by curl) is its own interaction: its app + // is the lane its calls run in, and its caller is "client". Treating it + // as a frontend map drew the request as `undefined.undefined` in a + // lane named "frontend". + const backend = isBackendMap(frontendAppmap); + const lane = backend ? (frontendAppmap.metadata?.app ?? 'backend') : 'frontend'; + + const nodes = []; + for (const n of buildCallTree(frontendAppmap.events)) { + // The interaction label already names the request; unwrap it, as + // backendChildren does for linked backend maps. + if (n.event.http_server_request) nodes.push(...n.children); + else nodes.push(n); + } + const root = { + kind: 'interaction', + actor: backend ? 'client' : 'User', + target: lane, + label: frontendAppmap.metadata?.name ?? 'interaction', + labels: [], + detail: {}, + children: nodes.map((n) => frontendNodeToModel(n, labels, spanToBackend, lane)), + }; + return root; +} + +function frontendNodeToModel(node, labels, spanToBackend, lane = 'frontend') { + 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) : []; + // What the backend itself answered (its http_server_response), so a + // changed backend status is a changed outcome of this step. + const served = backend ? serverStatus(backend) : null; + const strayKids = node.children.map((c) => frontendNodeToModel(c, labels, spanToBackend, lane)); + return { + kind: 'fetch', + actor: lane, + target: app, + label: `${method} ${pathOf(url)}${queryOf(url, e.message)}`, + labels: ['http'], + detail: { status: status ?? null, linked: Boolean(backend), served }, + children: [...backendKids, ...strayKids], + }; + } + + const cls = e.defined_class; + const method = e.method_id; + const fnLabels = labels.get(`${cls}.${method}`) ?? []; + return { + kind: 'call', + actor: lane, + target: lane, + 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, lane)), + }; +} + +function serverStatus(appmap) { + const call = appmap.events?.find((e) => e.http_server_request); + const ret = call && appmap.events.find((e) => e.event === 'return' && e.parent_id === call.id); + return ret?.http_server_response?.status_code ?? null; +} + +/** 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: [], + }; + } + if (e.http_client_request) { + // A middle tier's own outbound call (an edge function calling + // PostgREST): not linked further here, but never drawn as "?.?". + return { + kind: 'fetch', + actor: app, + target: 'network', + label: `${e.http_client_request.request_method} ${pathOf(e.http_client_request.url)}${queryOf(e.http_client_request.url, e.message)}`, + labels: ['http'], + detail: { status: node.return?.http_client_response?.status_code ?? null, linked: false }, + children: node.children.map((c) => backendNodeToModel(c, app, labels)), + }; + } + 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}|served=${d.served ?? ''}`; + 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'; + // Count a step as changed only when its own outcome changed. A step that + // merely contains a change is still marked '~' in the tree, but counting + // it too made one inserted call read "1 added, 3 changed" (the request, + // the handler and the function around it). + if (outcomeChanged) summary.changed++; + else if (!childChanged) 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(tree.actor ?? 'User')}->>${alias(tree.target ?? '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(tree.target ?? '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(tree.actor ?? 'User'); + add(tree.target ?? '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/client/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 === 'client') return 'Client'; + 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/link.test.mjs b/linker/test/link.test.mjs index 6efb671..7d565ff 100644 --- a/linker/test/link.test.mjs +++ b/linker/test/link.test.mjs @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest'; import { parseTraceparent, outgoingRequests, linkMaps, isFrontendMap, isBackendMap } from '../src/link.mjs'; import { renderSequenceDiagram } from '../src/diagram.mjs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const LINK_CLI = fileURLToPath(new URL('../bin/appmap-link.mjs', import.meta.url)); const TRACE = 'a'.repeat(32); const SPAN_OWNER = '1'.repeat(16); @@ -155,3 +162,96 @@ describe('renderSequenceDiagram', () => { expect(puml).toContain('(no backend map)'); }); }); + +// acceptance/supabase-edge-functions-app (bug 7): the stitched diagram of a +// React app calling a Supabase edge function showed the click and the +// backend call only. The frontend handler was in the frontend map but the +// diagram drew no frontend events, and the function reaches its database +// through PostgREST over HTTP, which was not drawn either. And appmap-link +// counted the edge function's own maps (they call out) as frontend maps. +function interactionMap() { + return { + version: '1.12', + metadata: { name: 'click button "Invoke Function"', trace_id: TRACE }, + classMap: [], + events: [ + { id: 1, event: 'call', thread_id: 1, defined_class: 'App', method_id: 'invokeFunction', path: 'src/App.js', lineno: 16 }, + { + id: 2, + event: 'call', + thread_id: 1, + http_client_request: { + request_method: 'POST', + url: 'http://localhost:54321/functions/v1/select-from-table-with-auth-rls', + headers: { traceparent: `00-${TRACE}-${SPAN_OWNER}-01` }, + }, + message: [], + }, + { id: 3, event: 'return', thread_id: 1, parent_id: 2, http_client_response: { status_code: 200 } }, + { id: 4, event: 'return', thread_id: 1, parent_id: 1 }, + ], + }; +} +function edgeFunctionMap() { + return { + version: '1.12', + metadata: { name: 'POST /select-from-table-with-auth-rls', app: 'select-from-table-with-auth-rls', trace_id: TRACE, parent_span_id: SPAN_OWNER }, + classMap: [], + events: [ + { id: 1, event: 'call', thread_id: 1, http_server_request: { request_method: 'POST', path_info: '/select-from-table-with-auth-rls' }, message: [] }, + { id: 2, event: 'call', thread_id: 1, defined_class: 'index', method_id: 'handler', path: 'index.ts', lineno: 10 }, + { id: 3, event: 'call', thread_id: 1, http_client_request: { request_method: 'GET', url: 'http://127.0.0.1:54321/auth/v1/user' }, message: [] }, + { id: 4, event: 'return', thread_id: 1, parent_id: 3, http_client_response: { status_code: 200 } }, + { + id: 5, + event: 'call', + thread_id: 1, + http_client_request: { request_method: 'GET', url: 'http://127.0.0.1:54321/rest/v1/users' }, + message: [{ name: 'select', class: 'String', value: '*' }], + }, + { id: 6, event: 'return', thread_id: 1, parent_id: 5, http_client_response: { status_code: 200 } }, + { id: 7, event: 'return', thread_id: 1, parent_id: 2 }, + { id: 8, event: 'return', thread_id: 1, parent_id: 1, http_server_response: { status_code: 200 } }, + ], + }; +} + +describe('stitched diagram for a frontend handler → edge function → PostgREST', () => { + it('draws the frontend handler and the backend\'s outgoing (database) calls', () => { + const fe = { path: 'fe/invoke.appmap.json', appmap: interactionMap() }; + const be = { path: 'be/fn.appmap.json', appmap: edgeFunctionMap() }; + const { links } = linkMaps([fe], [be]); + const puml = renderSequenceDiagram(links[0], new Map([[be.path, be.appmap]]), fe.appmap); + const lines = puml.split('\n'); + const at = (s) => lines.indexOf(s); + expect(at('User -> FE : click button "Invoke Function"')).toBeGreaterThan(0); + expect(at('FE -> FE : App.invokeFunction')).toBeGreaterThan(at('User -> FE : click button "Invoke Function"')); + expect(at('FE -> BE0 : POST /functions/v1/select-from-table-with-auth-rls')).toBeGreaterThan(at('FE -> FE : App.invokeFunction')); + expect(at('BE0 -> BE0 : index.handler')).toBeGreaterThan(at('FE -> BE0 : POST /functions/v1/select-from-table-with-auth-rls')); + expect(at('BE0 -> NET : GET /auth/v1/user')).toBeGreaterThan(at('BE0 -> BE0 : index.handler')); + expect(at('BE0 -> NET : GET /rest/v1/users?select=*')).toBeGreaterThan(at('BE0 -> NET : GET /auth/v1/user')); + expect(at('NET --> BE0 : 200')).toBeGreaterThan(0); + expect(at('BE0 --> FE : 200')).toBeGreaterThan(at('BE0 -> NET : GET /rest/v1/users?select=*')); + expect(puml).toContain('participant "network" as NET'); + }); + + it('appmap-link counts an edge function map that calls out as a backend map, not a frontend map', () => { + const dir = mkdtempSync(join(tmpdir(), 'appmap-link-')); + try { + mkdirSync(join(dir, 'fe')); + mkdirSync(join(dir, 'be')); + writeFileSync(join(dir, 'fe', 'invoke.appmap.json'), JSON.stringify(interactionMap())); + writeFileSync(join(dir, 'be', 'fn.appmap.json'), JSON.stringify(edgeFunctionMap())); + const r = spawnSync(process.execPath, [LINK_CLI, join(dir, 'fe'), join(dir, 'be'), '--out', join(dir, 'links')], { encoding: 'utf8' }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain( + '1 frontend map(s), 1 backend map(s) (1 of them also make outgoing requests; 0 linked onward): 1/1 requests linked, 0 orphan backend map(s)', + ); + const puml = readFileSync(join(dir, 'links', 'invoke.puml'), 'utf8'); + expect(puml).toContain('FE -> FE : App.invokeFunction'); + expect(puml).toContain('BE0 -> NET : GET /rest/v1/users?select=*'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/linker/test/trace-agent.test.mjs b/linker/test/trace-agent.test.mjs new file mode 100644 index 0000000..5841814 --- /dev/null +++ b/linker/test/trace-agent.test.mjs @@ -0,0 +1,398 @@ +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); + }); +}); + +// A Deno edge function's request map traced on its own: an incoming +// request, a handler, and an outbound PostgREST call whose query string +// lives in `message` (the spec keeps it out of `url`). +function edgeRequestMap({ withLookup = false } = {}) { + 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); }; + const ret = (b = {}) => events.push({ id: ++id, event: 'return', thread_id: 1, parent_id: stack.pop(), ...b }); + call({ http_server_request: { request_method: 'DELETE', path_info: '/restful-tasks/2' }, message: [] }); + call({ defined_class: 'index', method_id: 'deleteTask', path: 'index.ts', lineno: 38, static: true }); + if (withLookup) { + call({ http_client_request: { request_method: 'GET', url: 'http://127.0.0.1:54321/rest/v1/tasks' }, message: [{ name: 'select', class: 'String', value: '*' }, { name: 'id', class: 'String', value: 'eq.2' }] }); + ret({ http_client_response: { status_code: 200 } }); + } + call({ http_client_request: { request_method: 'DELETE', url: 'http://127.0.0.1:54321/rest/v1/tasks' }, message: [{ name: 'id', class: 'String', value: 'eq.2' }] }); + ret({ http_client_response: { status_code: 204 } }); + ret({ return_value: { class: 'Response', value: '{}' } }); + ret({ http_server_response: { status_code: 200 } }); + return { version: '1.12', metadata: { name: 'DELETE /restful-tasks/2', app: 'restful-tasks', parent_span_id: '9'.repeat(16) }, classMap: [], events }; +} + +describe('a backend request map traced on its own', () => { + it('runs in its own app lane, never "frontend" or undefined.undefined', () => { + const model = buildInteractionModel(edgeRequestMap()); + expect(model).toMatchObject({ actor: 'client', target: 'restful-tasks', label: 'DELETE /restful-tasks/2' }); + const [handler] = model.children; + expect(handler).toMatchObject({ kind: 'call', actor: 'restful-tasks', label: 'index.deleteTask' }); + expect(handler.children[0]).toMatchObject({ + kind: 'fetch', + actor: 'restful-tasks', + target: 'network', + label: 'DELETE /rest/v1/tasks?id=eq.2', + }); + const { ascii, mermaid } = renderInteraction(edgeRequestMap()); + expect(ascii + mermaid).not.toMatch(/undefined|frontend/); + expect(mermaid).toContain('Client->>BE0: DELETE /restful-tasks/2'); + }); + + it('names an inserted outbound call, query included, in the diff caption', () => { + const { caption } = renderInteraction(edgeRequestMap({ withLookup: true }), { baselineAppmap: edgeRequestMap() }); + expect(caption).toContain('New call restful-tasks→network: GET /rest/v1/tasks?select=*&id=eq.2.'); + }); +}); diff --git a/linker/test/trace-cli.test.mjs b/linker/test/trace-cli.test.mjs new file mode 100644 index 0000000..af814bf --- /dev/null +++ b/linker/test/trace-cli.test.mjs @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// appmap-trace over real directory layouts: an edge function's request +// maps traced on their own (no frontend map) are interactions in their +// own lane, and a frontend map whose HTTP went through XMLHttpRequest +// (recorded like fetch) is traced too. + +const bin = fileURLToPath(new URL('../bin/appmap-trace.mjs', import.meta.url)); + +function requestMap(extraGet) { + const events = [ + { id: 1, event: 'call', thread_id: 1, http_server_request: { request_method: 'DELETE', path_info: '/restful-tasks/2' }, message: [] }, + { id: 2, event: 'call', thread_id: 1, defined_class: 'index', method_id: 'deleteTask', path: 'index.ts', lineno: 38, static: true }, + ]; + let id = 2; + if (extraGet) { + events.push({ id: ++id, event: 'call', thread_id: 1, http_client_request: { request_method: 'GET', url: 'http://127.0.0.1:54321/rest/v1/tasks' }, message: [{ name: 'select', class: 'String', value: '*' }, { name: 'id', class: 'String', value: 'eq.2' }] }); + events.push({ id: ++id, event: 'return', thread_id: 1, parent_id: id - 1, http_client_response: { status_code: 200 } }); + } + const del = ++id; + events.push({ id: del, event: 'call', thread_id: 1, http_client_request: { request_method: 'DELETE', url: 'http://127.0.0.1:54321/rest/v1/tasks' }, message: [{ name: 'id', class: 'String', value: 'eq.2' }] }); + events.push({ id: ++id, event: 'return', thread_id: 1, parent_id: del, http_client_response: { status_code: 204 } }); + events.push({ id: ++id, event: 'return', thread_id: 1, parent_id: 2, return_value: { class: 'Response', value: '{}' } }); + events.push({ id: ++id, event: 'return', thread_id: 1, parent_id: 1, http_server_response: { status_code: 200 } }); + return { version: '1.12', metadata: { name: 'DELETE /restful-tasks/2', app: 'restful-tasks', parent_span_id: '1'.repeat(16) }, classMap: [], events }; +} + +function write(dir, name, appmap) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.appmap.json`), JSON.stringify(appmap)); +} + +describe('appmap-trace CLI', () => { + it('traces standalone backend request maps as interactions and diffs them', () => { + const root = mkdtempSync(join(tmpdir(), 'appmap-trace-')); + write(join(root, 'before'), 'del', requestMap(false)); + write(join(root, 'after'), 'del', requestMap(true)); + const out = join(root, 'out'); + const stdout = execFileSync(process.execPath, [bin, join(root, 'after'), '--baseline', join(root, 'before'), '--out', out], { encoding: 'utf8' }); + expect(stdout).toContain('traced 1 interaction(s)'); + const md = readFileSync(join(out, 'DELETE_restful-tasks_2.md'), 'utf8'); + expect(md).toContain('New call restful-tasks→network: GET /rest/v1/tasks?select=*&id=eq.2'); + // One inserted call: not "1 added, 3 changed" (the request, the handler + // and deleteTask only contain the change). + expect(md).toContain('> Behavior changed — 1 added. New call'); + expect(md).not.toContain('undefined'); + }); + + it('traces a frontend map whose requests came from XMLHttpRequest', () => { + const root = mkdtempSync(join(tmpdir(), 'appmap-trace-xhr-')); + write(root, 'comments', { + version: '1.12', + metadata: { name: 'should render discussion', app: 'bulletproof-react' }, + classMap: [], + events: [ + { id: 1, event: 'call', thread_id: 1, defined_class: 'get-comments', method_id: 'getComments', path: 'src/get-comments.ts', static: true }, + { id: 2, event: 'call', thread_id: 1, http_client_request: { request_method: 'GET', url: 'https://api.example.test/comments' }, message: [{ name: 'discussionId', class: 'String', value: 'd1' }, { name: 'page', class: 'String', value: '1' }] }, + { id: 3, event: 'return', thread_id: 1, parent_id: 2, http_client_response: { status_code: 200 } }, + { id: 4, event: 'return', thread_id: 1, parent_id: 1 }, + ], + }); + const stdout = execFileSync(process.execPath, [bin, root, '--format', 'ascii'], { encoding: 'utf8' }); + expect(stdout).toContain('GET /comments?discussionId=d1&page=1'); + expect(stdout).toContain('traced 1 interaction(s)'); + }); + + // acceptance/supabase-edge-functions-app H: every direct request to one + // edge function is named "POST /". The baseline was keyed by + // name alone, so all of them were diffed against the last baseline map + // (R3, which makes no outbound call): R1 and R2 showed both of their calls + // as added, not the one query that changed. + it('pairs same-named interactions with their baselines by occurrence', () => { + const root = mkdtempSync(join(tmpdir(), 'appmap-trace-same-')); + const fn = (select, calls, seq, status = 200) => ({ + version: '1.12', + metadata: { name: 'POST /select-from-table-with-auth-rls', app: 'fn', parent_span_id: `${seq}`.padStart(16, '0') }, + classMap: [], + events: [ + { id: 1, event: 'call', thread_id: 1, http_server_request: { request_method: 'POST', path_info: '/select-from-table-with-auth-rls' }, message: [] }, + { id: 2, event: 'call', thread_id: 1, defined_class: 'index', method_id: 'handler', path: 'index.ts', lineno: 10, static: true }, + ...(calls + ? [ + { id: 3, event: 'call', thread_id: 1, http_client_request: { request_method: 'GET', url: 'http://127.0.0.1:54321/auth/v1/user' }, message: [] }, + { id: 4, event: 'return', thread_id: 1, parent_id: 3, http_client_response: { status_code: 200 } }, + { id: 5, event: 'call', thread_id: 1, http_client_request: { request_method: 'GET', url: 'http://127.0.0.1:54321/rest/v1/users' }, message: [{ name: 'select', class: 'String', value: select }] }, + { id: 6, event: 'return', thread_id: 1, parent_id: 5, http_client_response: { status_code: 200 } }, + ] + : []), + { id: 7, event: 'return', thread_id: 1, parent_id: 2 }, + { id: 8, event: 'return', thread_id: 1, parent_id: 1, http_server_response: { status_code: status } }, + ], + }); + // file names as the recorder writes them: __ + write(join(root, 'before'), 'POST_fn_0000000100000001_001', fn('*', true, 1)); + write(join(root, 'before'), 'POST_fn_0000000200000002_002', fn('*', true, 2)); + write(join(root, 'before'), 'POST_fn_0000000300000003_003', fn('*', false, 3, 400)); + write(join(root, 'after'), 'POST_fn_0000000100000007_001', fn('id', true, 7)); + write(join(root, 'after'), 'POST_fn_0000000200000008_002', fn('id', true, 8)); + write(join(root, 'after'), 'POST_fn_0000000300000009_003', fn('*', false, 9, 400)); + const out = join(root, 'out'); + execFileSync(process.execPath, [bin, join(root, 'after'), '--baseline', join(root, 'before'), '--out', out, '--format', 'ascii'], { encoding: 'utf8' }); + const r1 = readFileSync(join(out, 'POST_select-from-table-with-auth-rls.txt'), 'utf8'); + const r2 = readFileSync(join(out, 'POST_select-from-table-with-auth-rls__2.txt'), 'utf8'); + const r3 = readFileSync(join(out, 'POST_select-from-table-with-auth-rls__3.txt'), 'utf8'); + for (const t of [r1, r2]) { + expect(t).toContain('1 added, 1 removed'); + expect(t).toContain('+ → network: GET /rest/v1/users?select=id'); + expect(t).toContain('- → network: GET /rest/v1/users?select=*'); + expect(t).not.toContain('+ → network: GET /auth/v1/user'); + } + expect(r3).toContain('No behavior change'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 3c763b1..fbc1cef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "appmap-deno": "bin/appmap-deno.ts" }, "devDependencies": { + "@appland/appmap-validate": "^2.5.1", "vitest": "^3.1.3" } }, @@ -56,7 +57,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" @@ -69,6 +71,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@appland/appmap-validate": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@appland/appmap-validate/-/appmap-validate-2.5.1.tgz", + "integrity": "sha512-BkV91/XDb8H0/NFF7mQkH8a0WiuvtIPiD8DiPXzaVR3i76cTTWj53UbWVvT7IN9qp7Yhj1HYIx93U6ZwGwLbkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.6.0", + "ajv-error-tree": "^0.0.5", + "format-util": "^1.0.5", + "treeify": "^1.1.0" + }, + "bin": { + "appmap-validate": "bin/index.js" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1577,7 +1595,6 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -1591,7 +1608,6 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -1601,7 +1617,6 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -1612,7 +1627,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -1622,7 +1636,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 +1647,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 +1737,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 +1754,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 +1781,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 +1794,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 +1809,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 +1824,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 +1837,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", @@ -1844,6 +1858,30 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-error-tree": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/ajv-error-tree/-/ajv-error-tree-0.0.5.tgz", + "integrity": "sha512-xjuCZRuekW3ZPRCD2oatzLRh9Ix1gheiDQZTywHnwqk3dH5K8knGtNjUCqzq5Olvz+1khdv9YCzag5oYjtigew==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1886,7 +1924,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 +1979,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 +2009,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 +2026,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 +2167,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 +2225,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 +2283,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,12 +2293,19 @@ "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" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -2278,6 +2323,23 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -2292,7 +2354,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" @@ -2306,6 +2368,13 @@ } } }, + "node_modules/format-util": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz", + "integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2511,6 +2580,13 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2539,7 +2615,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 +2642,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 +2756,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 +2818,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 +2845,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 +2858,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", @@ -2932,6 +3008,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rettime": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", @@ -2943,7 +3029,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 +3126,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 +3146,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 +3156,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 +3173,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 +3228,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 +3241,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 +3268,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 +3299,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 +3309,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 +3319,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" @@ -3285,6 +3371,16 @@ "node": ">=18" } }, + "node_modules/treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/type-fest": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", @@ -3366,7 +3462,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 +3537,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 +3560,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 +3694,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", @@ -3733,16 +3829,25 @@ "name": "@funwithappmap/react-recorder", "version": "0.0.1", "dependencies": { - "@babel/core": "^7.26.0" + "@babel/core": "^7.26.0", + "@types/babel__core": "^7.20.5" }, "devDependencies": { - "@types/babel__core": "^7.20.5", - "vite": "^6.3.5" + "@appland/appmap-validate": "^2.5.1", + "jsdom": "^26.1.0", + "msw": "^2.7.5", + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vitest": "^3.1.3" }, "peerDependencies": { + "vite": ">=5", "vitest": ">=2" }, "peerDependenciesMeta": { + "vite": { + "optional": true + }, "vitest": { "optional": true } diff --git a/package.json b/package.json index 9581510..f743a06 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 1.12 (validated with the official @appland/appmap-validate) 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..ee6a27e 100644 --- a/recorder/package.json +++ b/recorder/package.json @@ -2,27 +2,58 @@ "name": "@funwithappmap/react-recorder", "private": true, "version": "0.0.1", - "description": "Hand-instrumentation AppMap recorder for React (spike for docs/design/01).", + "description": "AppMap recorder for React apps (Vite plugin, Vitest hooks, browser interaction recording): records AppMap 1.12, validated with the official @appland/appmap-validate.", "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { - ".": "./src/index.ts", - "./vitest": "./src/vitest.ts", - "./vite": "./src/vitePlugin.ts", - "./transform": "./src/transform.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./vitest": { + "types": "./dist/vitest.d.ts", + "default": "./dist/vitest.js" + }, + "./vite": { + "types": "./dist/vitePlugin.d.ts", + "default": "./dist/vitePlugin.js" + }, + "./transform": { + "types": "./dist/transform.d.ts", + "default": "./dist/transform.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepack": "npm run build", + "test": "vitest run" }, "dependencies": { - "@babel/core": "^7.26.0" + "@babel/core": "^7.26.0", + "@types/babel__core": "^7.20.5" }, "devDependencies": { - "@types/babel__core": "^7.20.5", - "vite": "^6.3.5" + "@appland/appmap-validate": "^2.5.1", + "jsdom": "^26.1.0", + "msw": "^2.7.5", + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vitest": "^3.1.3" }, "peerDependencies": { + "vite": ">=5", "vitest": ">=2" }, "peerDependenciesMeta": { + "vite": { + "optional": true + }, "vitest": { "optional": true } diff --git a/recorder/src/fetchPatch.ts b/recorder/src/fetchPatch.ts index adb699c..1056398 100644 --- a/recorder/src/fetchPatch.ts +++ b/recorder/src/fetchPatch.ts @@ -1,5 +1,6 @@ -import { activeRecording } from './session'; -import { randomHex } from './recording'; +import { activeRecording } from './session.js'; +import { randomHex } from './recording.js'; +import { shouldPropagateTraceHeader } from './propagation.js'; // Wraps globalThis.fetch while a recording is active, emitting // http_client_request / http_client_response events. Composes with MSW: @@ -10,13 +11,18 @@ import { randomHex } from './recording'; // traceparent header — the recording's trace-id plus a fresh span-id per // request. The backend agent copies it into its request AppMap's // metadata; the linker joins on span-id. Requests are only ever stamped -// while a recording is active, so production traffic is untouched. +// while a recording is active, so production traffic is untouched, and +// in a browser only same-origin requests and origins the user listed are +// stamped (propagation.ts): a header the backend's CORS doesn't allow +// would make the browser block the request. Every request is recorded +// either way. XMLHttpRequest (axios) gets the same treatment in +// xhrPatch.ts. let originalFetch: typeof globalThis.fetch | undefined; /** Headers worth capturing on events; everything else is noise at this stage. */ -const CAPTURED_REQUEST_HEADERS = ['content-type', 'accept', 'traceparent']; -const CAPTURED_RESPONSE_HEADERS = ['content-type']; +export const CAPTURED_REQUEST_HEADERS = ['content-type', 'accept', 'traceparent']; +export const CAPTURED_RESPONSE_HEADERS = ['content-type']; export function patchFetch(): void { if (originalFetch) return; @@ -31,7 +37,9 @@ export function patchFetch(): void { if (!recording) return underlying(input, init); const request = new Request(input, init); - request.headers.set('traceparent', `00-${recording.traceId}-${randomHex(8)}-01`); + if (shouldPropagateTraceHeader(request.url)) { + request.headers.set('traceparent', `00-${recording.traceId}-${randomHex(8)}-01`); + } const token = recording.httpClientRequest( request.method, request.url, diff --git a/recorder/src/index.ts b/recorder/src/index.ts index c3d2447..0c8896c 100644 --- a/recorder/src/index.ts +++ b/recorder/src/index.ts @@ -1,17 +1,40 @@ -export type * from './types'; -export { Recording, formatValue, VALUE_SIZE_CAP, type CallToken } from './recording'; -export { startRecording, stopRecording, activeRecording } from './session'; +export type * from './types.js'; +export { + Recording, + formatValue, + VALUE_SIZE_CAP, + setValueSizeCap, + type CallToken, + type ObjectIdTracker, +} from './recording.js'; +import { setValueSizeCap as __setValueSizeCap } from './recording.js'; + +// 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.js'; +export { + setPropagateTraceHeaderOrigins, + shouldPropagateTraceHeader, + type OriginPattern, +} from './propagation.js'; export { instrument, instrumentComponent, instrumentHook, instrumentHandler, autoInstrument, -} from './instrument'; +} from './instrument.js'; export { installInteractionRecorder, COLLECTOR_PATH, type InteractionRecorderOptions, -} from './interactionRecording'; +} from './interactionRecording.js'; // Test recording (node:fs) deliberately lives behind the './vitest' // entry point: this module must stay loadable in a browser bundle. diff --git a/recorder/src/instrument.ts b/recorder/src/instrument.ts index ebca96b..60e730d 100644 --- a/recorder/src/instrument.ts +++ b/recorder/src/instrument.ts @@ -1,5 +1,5 @@ -import type { FunctionInfo } from './types'; -import { activeRecording } from './session'; +import type { FunctionInfo } from './types.js'; +import { activeRecording, runInCall } from './session.js'; // Hand-written instrumentation wrappers. Each is exactly the prologue / // epilogue the build-time transform (docs/design/03) will inject: @@ -27,8 +27,15 @@ export function instrument(fn: F, info: FunctionInfo, argNames? })); const token = recording.enter(info, captured); try { - const result = fn.apply(this, args as never[]); + // Run the body as this call, so calls it makes from async + // continuations (after an await, in a timer) still nest under it + // where the runtime has async context (session.ts). + const result = runInCall(recording, token.callId, () => 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 +78,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/interactionRecording.ts b/recorder/src/interactionRecording.ts index 9e00c49..f819134 100644 --- a/recorder/src/interactionRecording.ts +++ b/recorder/src/interactionRecording.ts @@ -1,6 +1,7 @@ -import type { Metadata } from './types'; -import { Recording } from './recording'; -import { startRecording, stopRecording, activeRecording } from './session'; +import type { Metadata } from './types.js'; +import { Recording } from './recording.js'; +import { startRecording, stopRecording, activeRecording } from './session.js'; +import { setPropagateTraceHeaderOrigins, type OriginPattern } from './propagation.js'; // Interaction-window recording (docs/design/01 §design, spiked for // docs/design/04): one AppMap per user interaction. The window opens at @@ -24,6 +25,17 @@ export interface InteractionRecorderOptions { maxMs?: number; /** DOM event types that open a window. */ triggers?: string[]; + /** Cross-origin targets whose requests get a `traceparent` header + * (same-origin requests always do): origins such as + * 'https://api.example.com', RegExps tested against the URL, or '*'. + * The backend's CORS must allow the `traceparent` request header, or + * the browser blocks the request. See propagation.ts. */ + propagateTraceHeaderOrigins?: OriginPattern[]; + /** Also open a window when the recorder is installed, named + * `load `, so the requests and renders of the initial page load + * (before any click) are recorded and stamped too. The Vite plugin's + * zero-touch injection turns this on. Default false. */ + recordPageLoad?: boolean; } export const COLLECTOR_PATH = '/__appmap/interactions'; @@ -37,17 +49,32 @@ export function installInteractionRecorder(options: InteractionRecorderOptions = maxMs = 10_000, triggers = ['click', 'submit'], } = options; + if (options.propagateTraceHeaderOrigins) setPropagateTraceHeaderOrigins(options.propagateTraceHeaderOrigins); let timer: ReturnType | undefined; + let open: { recording: Recording; interactions: string[]; sameTask: boolean } | undefined; const onTrigger = (event: Event) => { - // An open window absorbs further triggers (interaction-window - // scoping); a window opened elsewhere (e.g. a test recording) is - // respected the same way. - if (activeRecording()) return; + const active = activeRecording(); + if (active && active === open?.recording) { + // A trigger dispatched in the same task as the one that opened the + // window is part of the same user action (clicking a submit button + // fires click, then submit). + if (open.sameTask) return; + // Otherwise the open window absorbs it, but records that it did: + // the map now covers more than one interaction. + open.interactions.push(describeInteraction(event)); + return; + } + // A window opened elsewhere (e.g. a test recording) is respected. + if (active) return; + openWindow(describeInteraction(event)); + }; + const openWindow = (description: string) => { + const interactions = [description]; const metadata: Metadata = { - name: describeInteraction(event), + name: interactions[0], app, client: { name: '@funwithappmap/react-recorder', @@ -56,6 +83,9 @@ export function installInteractionRecorder(options: InteractionRecorderOptions = recorder: { name: 'funwithappmap-react', type: 'requests' }, }; const recording = startRecording(new Recording(metadata)); + const opened = { recording, interactions, sameTask: true }; + open = opened; + setTimeout(() => (opened.sameTask = false), 0); const openedAt = Date.now(); let lastCount = -1; // force at least one full idle interval @@ -68,19 +98,31 @@ export function installInteractionRecorder(options: InteractionRecorderOptions = lastCount = recording.events.length; if (idle || Date.now() - openedAt >= maxMs) { clearInterval(timer); + open = undefined; + if (interactions.length > 1) { + recording.metadata.name = interactions.join(' + '); + recording.metadata.interactions = interactions; + recording.metadata.ambiguous = true; + } ship(stopRecording(), collectorUrl); } }, idleMs); }; for (const type of triggers) document.addEventListener(type, onTrigger, { capture: true }); + if (options.recordPageLoad && !activeRecording()) { + const where = (globalThis as { location?: { pathname?: string } }).location?.pathname ?? '/'; + openWindow(`load ${where}`); + } return () => { for (const type of triggers) document.removeEventListener(type, onTrigger, { capture: true }); if (timer) clearInterval(timer); }; } -/** http_client_request events whose response has not arrived yet. */ +/** http_client_request events whose response has not arrived yet — + * fetch and XMLHttpRequest alike (both patches record into the same + * events), so the window stays open until XHR responses land too. */ function pendingRequests(recording: Recording): number { let pending = 0; const open = new Set(); @@ -96,8 +138,8 @@ function pendingRequests(recording: Recording): number { } function ship(recording: Recording, collectorUrl: string): void { - // stopRecording() has already unpatched fetch, so this request is - // never recorded or stamped. + // stopRecording() has already unpatched fetch (and XMLHttpRequest), + // so this request is never recorded or stamped. void fetch(collectorUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/recorder/src/pathMatch.ts b/recorder/src/pathMatch.ts new file mode 100644 index 0000000..e94bb7e --- /dev/null +++ b/recorder/src/pathMatch.ts @@ -0,0 +1,95 @@ +// include / exclude matching for the Vite plugin (the appmap.yml +// `packages:` / `exclude:` equivalent). +// +// An entry without glob characters is a directory prefix or an exact file +// ('src', 'src/testing', 'src/main.tsx'), as before. An entry with glob +// characters is matched against the whole project-relative path: +// * any characters within one path segment +// ** any number of segments, including none ('**/__tests__/**') +// ? one character within a segment +// {a,b} alternatives ('**/*.{test,spec}.tsx') + +/** Test code the plugin leaves uninstrumented unless told otherwise + * (option `defaultExclude`). Tests are not the app: a function defined in + * a test file showing up in an AppMap is noise. */ +export const DEFAULT_TEST_EXCLUDE: readonly string[] = [ + '**/__tests__/**', + '**/__mocks__/**', + '**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs,mts,cts}', +]; + +export type PathMatcher = (relPath: string) => boolean; + +const GLOB_CHARS = /[*?{]/; + +export function pathMatcher(entries: readonly string[]): PathMatcher { + const prefixes: string[] = []; + const patterns: RegExp[] = []; + for (const raw of entries) { + const entry = raw.replace(/^\.\//, '').replace(/\/+$/, ''); + if (!entry) continue; + if (GLOB_CHARS.test(entry)) patterns.push(...expandBraces(entry).map(globToRegExp)); + else prefixes.push(entry); + } + return (rel) => { + const p = rel.split('\\').join('/'); + return prefixes.some((d) => p === d || p.startsWith(d + '/')) || patterns.some((re) => re.test(p)); + }; +} + +function expandBraces(glob: string): string[] { + const open = glob.indexOf('{'); + if (open === -1) return [glob]; + let depth = 0; + for (let i = open; i < glob.length; i++) { + if (glob[i] === '{') depth++; + else if (glob[i] === '}' && --depth === 0) { + const alternatives: string[] = []; + let start = open + 1; + let d = 0; + for (let k = open + 1; k < i; k++) { + if (glob[k] === '{') d++; + else if (glob[k] === '}') d--; + else if (glob[k] === ',' && d === 0) { + alternatives.push(glob.slice(start, k)); + start = k + 1; + } + } + alternatives.push(glob.slice(start, i)); + const head = glob.slice(0, open); + const tail = glob.slice(i + 1); + return alternatives.flatMap((alt) => expandBraces(head + alt + tail)); + } + } + return [glob]; // unbalanced: literal +} + +function globToRegExp(glob: string): RegExp { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + const atSegmentStart = i === 0 || glob[i - 1] === '/'; + const atSegmentEnd = i + 2 === glob.length || glob[i + 2] === '/'; + if (atSegmentStart && atSegmentEnd) { + if (i + 2 === glob.length) { + re += '.*'; + i += 1; + } else { + re += '(?:.*/)?'; + i += 2; // '**/' + } + continue; + } + i += 1; + } + re += '[^/]*'; + } else if (c === '?') { + re += '[^/]'; + } else { + re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp(`^${re}$`); +} diff --git a/recorder/src/propagation.ts b/recorder/src/propagation.ts new file mode 100644 index 0000000..783e5b4 --- /dev/null +++ b/recorder/src/propagation.ts @@ -0,0 +1,113 @@ +// Which outgoing requests get a `traceparent` header (docs/design/02, +// amendment "Cross-origin requests"). +// +// A recorder must never break the app. In a browser, adding a header that +// is not CORS-safelisted to a cross-origin request makes the browser send +// a preflight, and if the server's Access-Control-Allow-Headers does not +// list `traceparent` (Supabase functions' default CORS, most APIs written +// before trace propagation was considered) the browser blocks the request +// and the app's feature stops working. So this follows OpenTelemetry's +// browser model (`propagateTraceHeaderCorsUrls`): +// +// - same-origin requests are always stamped; +// - cross-origin requests are stamped only when their origin is listed in +// `propagateTraceHeaderOrigins` (an origin string such as +// 'https://api.example.com', a RegExp tested against the full URL, or +// '*' for every origin); +// - where there is no page origin at all (Node, Deno: server-side code, +// no CORS) every outgoing request is stamped, as OpenTelemetry's +// server-side instrumentations do. +// +// Linking a frontend map to a cross-origin backend therefore needs two +// things: the backend's origin in this list, and the backend's CORS +// allowing the `traceparent` request header. +// +// Configuration, in increasing precedence: +// 1. APPMAP_PROPAGATE_TRACE_HEADER_ORIGINS (comma-separated; `/re/flags` +// entries are regular expressions), read where `process.env` exists — +// the Vite plugin sets it from its option so Vitest workers see it; +// 2. setPropagateTraceHeaderOrigins(), which installInteractionRecorder +// calls with its `propagateTraceHeaderOrigins` option (the Vite +// plugin passes its option there for zero-touch browser recording). + +export type OriginPattern = string | RegExp; + +export const PROPAGATE_ENV = 'APPMAP_PROPAGATE_TRACE_HEADER_ORIGINS'; + +let patterns: OriginPattern[] = readEnv(); + +/** Replace the list of cross-origin targets that get `traceparent`. */ +export function setPropagateTraceHeaderOrigins(list: readonly OriginPattern[] | undefined): void { + patterns = [...(list ?? [])]; +} + +export function propagateTraceHeaderOrigins(): readonly OriginPattern[] { + return patterns; +} + +/** Should a request to `url` carry the recording's traceparent? */ +export function shouldPropagateTraceHeader(url: string): boolean { + const page = pageOrigin(); + if (page === undefined) return true; + let target: URL; + try { + target = new URL(url, page); + } catch { + return false; + } + if (target.origin === page) return true; + return patterns.some((p) => + typeof p === 'string' ? p === '*' || normalizeOrigin(p) === target.origin : p.test(target.href), + ); +} + +/** Serialize patterns for an env var / define (RegExp as `/source/flags`). */ +export function serializeOriginPatterns(list: readonly OriginPattern[]): string { + return list.map((p) => (typeof p === 'string' ? p : `/${p.source}/${p.flags}`)).join(','); +} + +export function parseOriginPatterns(raw: string | undefined): OriginPattern[] { + if (!raw) return []; + const out: OriginPattern[] = []; + for (const entry of raw.split(',').map((s) => s.trim()).filter(Boolean)) { + const re = /^\/(.+)\/([a-z]*)$/.exec(entry); + if (re) { + try { + out.push(new RegExp(re[1], re[2])); + continue; + } catch { + // not a valid regex: treat as a literal origin below + } + } + out.push(entry); + } + return out; +} + +function normalizeOrigin(value: string): string { + try { + return new URL(value).origin; + } catch { + return value; + } +} + +/** The page's origin in a browser (or jsdom); undefined server-side. + * Deno throws on `location` unless run with --location, hence the try. */ +function pageOrigin(): string | undefined { + try { + const origin = (globalThis as { location?: { origin?: string } }).location?.origin; + return origin && origin !== 'null' ? origin : undefined; + } catch { + return undefined; + } +} + +function readEnv(): OriginPattern[] { + try { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + return parseOriginPatterns(env?.[PROPAGATE_ENV]); + } catch { + return []; + } +} diff --git a/recorder/src/recording.ts b/recorder/src/recording.ts index 63e7632..5497709 100644 --- a/recorder/src/recording.ts +++ b/recorder/src/recording.ts @@ -7,48 +7,217 @@ import type { Metadata, PackageEntry, ParameterValue, -} from './types'; + ParameterProperty, +} from './types.js'; +import { className, functionName, readDataProperty, safeStringify } from './stringify.js'; +import { currentCallId } from './session.js'; +import { isSensitiveName, redactHeaders, redactString, REDACTED } from './redact.js'; -/** Maximum captured length of any single value string, like - * APPMAP_EVENT_VALUESIZE in the .NET agent. */ -export const VALUE_SIZE_CAP = 1024; +/** The AppMap version this recorder declares. Checked against the + * official validator (@appland/appmap-validate) in + * recorder/test/validity.test.ts; see docs/design/12. */ +export const APPMAP_VERSION = '1.12'; -export function formatValue(v: unknown): { class: string; value: string } { - let cls: string; - if (v === null) cls = 'null'; - else if (v === undefined) cls = 'undefined'; - else if (typeof v === 'object' || typeof v === 'function') { - cls = (v as object).constructor?.name ?? typeof v; - } else { - cls = typeof v; - } +/** Default maximum captured length of any single value string, like + * APPMAP_EVENT_VALUESIZE in the .NET agent: 100, the length the AppMap + * spec says values are trimmed to (schemas 1.6+ enforce it). The cap + * includes the "…" marking a cut. Overridable at runtime via + * setValueSizeCap (wired to the APPMAP_EVENT_VALUESIZE env var by + * testRecording.ts and vitePlugin.ts). */ +export const VALUE_SIZE_CAP = 100; + +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, + name?: string, +): { class: string; value: string; size?: number; object_id?: number; properties?: ParameterProperty[] } { + // Never JSON.stringify / String() an unknown value: that runs its + // getters and toJSON and can change what the app does (stringify.ts). + const cls = v === undefined ? 'undefined' : className(v); let str: string; + if (isSensitiveName(name)) str = REDACTED; + else if (typeof v === 'string') str = redactString(v); + else if (typeof v === 'function') str = `[function ${functionName(v) || 'anonymous'}]`; + else if (v === undefined) str = 'undefined'; + else if (typeof v === 'symbol') str = v.toString(); + else if (typeof v === 'bigint') str = String(v); + else { + try { + str = safeStringify(v, currentValueSizeCap); + } catch { + str = `[${cls}]`; + } + } + str = capValue(str); + + const result: { class: string; value: string; size?: number; object_id?: number; properties?: ParameterProperty[] } = { + class: cls, + value: str, + }; + const properties = plainObjectProperties(v); + if (properties) result.properties = properties; + + if (v !== null && typeof v === 'object') { + result.size = Array.isArray(v) ? ownLength(v) : 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; +} + +const MAX_PROPERTIES = 50; + +/** The spec's parameter `properties` for a plain object (prototype + * Object.prototype or null): each own enumerable data property's name and + * class, read from descriptors only (no getter is run; accessors are left + * out). A value is cut to 100 characters, so without this an argument like + * `{ email, firstName, lastName, password, teamName }` lost its last + * fields entirely. */ +function plainObjectProperties(v: unknown): ParameterProperty[] | undefined { + if (v === null || typeof v !== 'object' || Array.isArray(v)) return undefined; + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) return undefined; + const out: ParameterProperty[] = []; + for (const key of Object.keys(v as object)) { + if (out.length >= MAX_PROPERTIES) break; + const d = Object.getOwnPropertyDescriptor(v, key); + if (!d || !('value' in d)) continue; + out.push({ name: key, class: d.value === undefined ? 'undefined' : className(d.value) }); + } + return out.length ? out : undefined; +} + +/** Cut a value string to the cap, "…" included (no off-by-one), never + * splitting a surrogate pair. */ +export function capValue(str: string): string { + if (str.length <= currentValueSizeCap) return str; + let end = Math.max(0, currentValueSizeCap - 1); + const code = str.charCodeAt(end - 1); + if (code >= 0xd800 && code <= 0xdbff) end--; + return str.slice(0, end) + '…'; +} + +/** Split a URL into what http_*_request events carry: the URL without + * its query string (and fragment), and the query parameters as a + * `message` array. */ +export function splitUrl(raw: string): { url: string; message: ParameterValue[] } { + let u: URL; try { - if (typeof v === 'string') str = v; - else if (typeof v === 'function') str = `[function ${(v as Function).name || 'anonymous'}]`; - else str = JSON.stringify(v) ?? String(v); + u = new URL(raw); } catch { - str = String(v); + const q = raw.indexOf('?'); + const bare = raw.split('#')[0]; + return q === -1 ? { url: bare, message: [] } : { url: bare.slice(0, q), message: queryMessage(new URLSearchParams(bare.slice(q + 1))) }; + } + return { url: `${u.origin}${u.pathname}`, message: queryMessage(u.searchParams) }; +} + +export function queryMessage(params: URLSearchParams): ParameterValue[] { + const message: ParameterValue[] = []; + for (const [name, value] of params) { + message.push({ name, class: 'String', value: isSensitiveName(name) ? REDACTED : capValue(redactString(value)) }); } - if (str.length > VALUE_SIZE_CAP) str = str.slice(0, VALUE_SIZE_CAP) + '…'; - return { class: cls, value: str }; + return message; +} + +/** Class of a thrown value: its constructor's name for objects (a + * thrown plain object is an "Object", not "object"), typeof otherwise. */ +function exceptionClass(e: unknown): string { + return e !== null && (typeof e === 'object' || typeof e === 'function') ? className(e) : typeof e; +} + +/** Message of a thrown value, read without running app code: an own or + * inherited `message` data property if it is a string (Error, and the + * plain error objects libraries like supabase-js throw), otherwise the + * value itself rendered as a parameter would be — never String(e), + * which printed "[object Object]" and runs toString. */ +function exceptionMessage(e: unknown): string { + const message = readDataProperty(e, 'message'); + if (typeof message === 'string') return redactString(message); + if (e !== null && typeof e === 'object') return formatValue(e).value; + return redactString(String(e)); +} + +function ownLength(a: unknown[]): number { + const d = Object.getOwnPropertyDescriptor(a, 'length'); + return d && typeof d.value === 'number' ? d.value : 0; } /** 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; +} + +/** An http_*_request that never got a response the format can express + * (network error, or still in flight when the recording closed). Kept + * out of the event stream — an http_*_response needs a real 100-599 + * status — and listed in metadata instead (docs/design/12). */ +export interface UnansweredHttpRequest { + event: 'http_client_request' | 'http_server_request'; + request_method: string; + url: string; + reason: 'network error' | 'no response before the recording closed'; } export class Recording { + /** The live event stream, in the order things happened. `toAppMap()` + * serializes it as a call tree (see there). */ readonly events: Event[] = []; readonly metadata: Metadata; - private nextId = 1; private functions = new Map(); + private objectIds: ObjectIdTracker = { ids: new WeakMap(), next: 1 }; + // callId -> the call it was made from (undefined: a root). Sync nesting + // from `syncStack`; async continuations from the async context + // (session.ts currentCallId), where the runtime has one. + private parentOf = new Map(); + + // Thread assignment (docs/design/01, 2026 amendment) for the *live* + // event stream: 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. The serialized map re-threads everything as one + // tree (toAppMap). + 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,53 +225,120 @@ 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!; } + /** The call a new call would be made from right now: the innermost + * synchronously executing call, else the call whose async + * continuation is running (where the runtime has async context). */ + currentParent(): number | undefined { + return this.syncStack[this.syncStack.length - 1] ?? currentCallId(this); + } + + 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(parent: number | undefined): { callId: number; threadId: number } { + const callId = this.nextId++; + const threadId = this.allocateThread(); + this.parentOf.set(callId, parent); + 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}`; + const key = `${fn.path}:${fn.lineno ?? ''}:${fn.definedClass}.${fn.methodId}`; if (!this.functions.has(key)) this.functions.set(key, fn); + const parent = this.currentParent(); const id = this.nextId++; + const threadId = this.allocateThread(); + this.parentOf.set(id, parent); + 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, a.name), })); 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: [ { - class: e instanceof Error ? e.constructor.name : typeof e, - message: e instanceof Error ? e.message : String(e), + class: exceptionClass(e), + message: exceptionMessage(e), + object_id: this.objectId(e), }, ], }); @@ -110,87 +346,208 @@ 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++; + /** object_id for any value: shared by repeated references to the same + * object, fresh for primitives (the spec requires one on exceptions). */ + private objectId(v: unknown): number { + if (v === null || (typeof v !== 'object' && typeof v !== 'function')) return this.objectIds.next++; + let id = this.objectIds.ids.get(v); + if (id === undefined) { + id = this.objectIds.next++; + this.objectIds.ids.set(v, id); + } + return id; + } + + /** `url` may carry a query string; it is split off into `message`, as + * the spec wants. `parent` overrides the call it was made from (for + * requests whose event is recorded later than the call that made + * them, e.g. XMLHttpRequest). */ + httpClientRequest( + method: string, + url: string, + headers?: Record, + parent: number | undefined = this.currentParent(), + ): CallToken { + const { callId: id, threadId } = this.openDangling(parent); + const split = splitUrl(url); 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 } : {}), + url: split.url, + ...(headers && Object.keys(headers).length ? { headers: redactHeaders(headers) } : {}), }, + message: split.message, }); - return { callId: id, startMs: performance.now() }; + return { callId: id, startMs: performance.now(), threadId }; } + /** statusCode 0 means there was no response (network error, abort). */ 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: { status_code: statusCode, - ...(headers && Object.keys(headers).length ? { headers } : {}), + ...(headers && Object.keys(headers).length ? { headers: redactHeaders(headers) } : {}), }, }); } /** Server-side twin of httpClientRequest, for recorders running inside - * a backend request handler (e.g. the Deno driver in deno/appmap.ts). */ + * a backend request handler (e.g. the Deno driver in deno/appmap.ts). + * `query` becomes the event's `message`. */ httpServerRequest( method: string, pathInfo: string, headers?: Record, normalizedPathInfo?: string, + query?: URLSearchParams, ): CallToken { - const id = this.nextId++; + const { callId: id, threadId } = this.openDangling(this.currentParent()); this.events.push({ id, event: 'call', - thread_id: 1, + thread_id: threadId, http_server_request: { request_method: method, path_info: pathInfo, ...(normalizedPathInfo ? { normalized_path_info: normalizedPathInfo } : {}), - ...(headers && Object.keys(headers).length ? { headers } : {}), + ...(headers && Object.keys(headers).length ? { headers: redactHeaders(headers) } : {}), }, + message: query ? queryMessage(query) : [], }); - 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 (APPMAP_VERSION). The classMap contains exactly + * the functions that produced events, grouped package-per-directory + * like appmap-agent-js. + * + * Events are emitted as a call tree (docs/design/12): every call sits + * between its parent's call and return — the parent being the call it + * was made from, synchronously or from an async continuation — with + * ids renumbered in that order and one thread_id. Standard tooling + * (the official validator, `appmap sequence-diagram`) rebuilds the + * tree positionally per thread, so this is what makes a request one + * tree rooted at its http_server_request, and keeps an async call's + * children under it after its caller has already returned. The live + * `events` list is untouched. + * + * Self-healing (docs/design/11): any function call still open at this + * point gets a synthesized `return`, so the tree is always balanced — + * an unbalanced list makes downstream tools that reconstruct the call + * stack throw ("failed trying to compute event stack, call.id: N"). + * Synthetic returns carry no elapsed/return_value and are flagged + * collectively by metadata.truncated. HTTP calls without a response + * the format can express are listed in metadata.unanswered_http_requests + * instead (their children, if any, move up to their parent). */ toAppMap(overrides?: Partial): AppMap { + const calls = this.events.filter((e) => e.event === 'call'); + const returns = new Map(); + for (const e of this.events) if (e.event === 'return') returns.set(e.parent_id, e); + const known = new Set(calls.map((c) => c.id)); + const children = new Map(); + for (const call of calls) { + let parent = this.parentOf.get(call.id); + if (parent !== undefined && !known.has(parent)) parent = undefined; + const list = children.get(parent) ?? []; + list.push(call); + children.set(parent, list); + } + + const out: Event[] = []; + const unanswered: UnansweredHttpRequest[] = []; + let truncated = false; + let nextId = 1; + const threadId = 1; + const emit = (call: Event): void => { + const ret = returns.get(call.id); + if ('http_client_request' in call || 'http_server_request' in call) { + const status = + ret && 'http_client_response' in ret + ? ret.http_client_response.status_code + : ret && 'http_server_response' in ret + ? ret.http_server_response.status_code + : undefined; + if (status === undefined || status < 100 || status > 599) { + if (!ret) truncated = true; + unanswered.push( + 'http_client_request' in call + ? { + event: 'http_client_request', + request_method: call.http_client_request.request_method, + url: call.http_client_request.url, + reason: ret ? 'network error' : 'no response before the recording closed', + } + : { + event: 'http_server_request', + request_method: call.http_server_request.request_method, + url: call.http_server_request.path_info, + reason: 'no response before the recording closed', + }, + ); + for (const child of children.get(call.id) ?? []) emit(child); + return; + } + } + const id = nextId++; + out.push({ ...call, id, thread_id: threadId } as Event); + for (const child of children.get(call.id) ?? []) emit(child); + if (ret) { + out.push({ ...ret, id: nextId++, thread_id: threadId, parent_id: id } as Event); + } else { + truncated = true; + out.push({ id: nextId++, event: 'return', thread_id: threadId, parent_id: id }); + } + }; + for (const root of children.get(undefined) ?? []) emit(root); + return { - version: '1.2', - metadata: { ...this.metadata, ...overrides }, + version: APPMAP_VERSION, + metadata: { + ...this.metadata, + ...(truncated ? { truncated: true } : {}), + ...(unanswered.length ? { unanswered_http_requests: unanswered } : {}), + ...overrides, + }, classMap: buildClassMap([...this.functions.values()]), - events: this.events, + events: out, }; } } @@ -225,11 +582,14 @@ function buildClassMap(functions: FunctionInfo[]): ClassMapEntry[] { cls = { type: 'class', name: fn.definedClass, children: [] }; pkg.children.push(cls); } - if (!cls.children.some((c) => c.type === 'function' && c.name === fn.methodId)) { + const location = fn.lineno ? `${fn.path}:${fn.lineno}` : fn.path; + if (!cls.children.some((c) => c.type === 'function' && c.name === fn.methodId && c.location === location)) { cls.children.push({ type: 'function', name: fn.methodId, - location: fn.lineno ? `${fn.path}:${fn.lineno}` : fn.path, + location, + // 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/redact.ts b/recorder/src/redact.ts new file mode 100644 index 0000000..63de834 --- /dev/null +++ b/recorder/src/redact.ts @@ -0,0 +1,36 @@ +// Credentials never go into a recording in plaintext. AppMaps get +// committed, attached to PRs and shared; the acceptance runs found +// plaintext passwords in React parameter values and a bearer token in a +// Deno one. +// +// - A parameter, property, query parameter or header whose *name* +// matches SENSITIVE_NAME has its value replaced by "[REDACTED]". +// - Authorization, Cookie and Set-Cookie headers are always redacted. +// - "Bearer " anywhere inside a captured string keeps the word +// Bearer and loses the token. +// - A JWT anywhere inside a captured string is redacted whatever it is +// called: a Supabase client object carries its key as `supabaseKey`, +// which no name rule catches. + +export const REDACTED = '[REDACTED]'; + +const SENSITIVE_NAME = /password|secret|token|api[_-]?key/i; +const SENSITIVE_HEADER = /^(authorization|proxy-authorization|cookie|set-cookie)$/i; +const BEARER = /\b(bearer)\s+[A-Za-z0-9\-._~+/]+=*/gi; +const JWT = /\beyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g; + +export function isSensitiveName(name: string | undefined): boolean { + return !!name && SENSITIVE_NAME.test(name); +} + +export function redactString(s: string): string { + return s.replace(BEARER, `$1 ${REDACTED}`).replace(JWT, REDACTED); +} + +export function redactHeaders(headers: Record): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(headers)) { + out[name] = SENSITIVE_HEADER.test(name) || isSensitiveName(name) ? REDACTED : redactString(value); + } + return out; +} diff --git a/recorder/src/session.ts b/recorder/src/session.ts index e8f32a2..a6ede83 100644 --- a/recorder/src/session.ts +++ b/recorder/src/session.ts @@ -1,14 +1,80 @@ -import { Recording } from './recording'; -import { patchFetch, unpatchFetch } from './fetchPatch'; +import type { Recording } from './recording.js'; +import { patchFetch, unpatchFetch } from './fetchPatch.js'; +import { patchXhr, unpatchXhr } from './xhrPatch.js'; -// Session resolution — the doc 01 decision. One ambient session at a time: -// a module-level global, not AsyncLocalStorage / Zone.js. Test recording -// (one AppMap per RTL test, tests run sequentially per worker) and browser -// interaction windows (single-threaded, one window open at a time) both -// satisfy the invariant. See docs/design/01-recording-sessions-and- -// interaction-windows.md for why the async gap is deferred, not solved here. +// Session resolution — the doc 01 decision, amended (docs/design/01, +// "Per-request async context"). Two ways a recording can be "the one +// events go to": +// +// - Ambient: one module-level recording at a time (startRecording / +// stopRecording). Test recording (one AppMap per RTL test, tests run +// sequentially per worker) and browser interaction windows use it. The +// browser has no async context, so this is all it can have. +// +// - Scoped: where an AsyncLocalStorage exists (Deno, Node), a driver +// installs it with installAsyncContext() and runs each recorded unit of +// work inside runInRecording(). activeRecording() then answers from the +// *current async context*, so concurrent requests each see only their +// own recording, and code running for an unrecorded request +// (runUnrecorded) sees none at all — it is never recorded or stamped, +// even while other recordings are open. +// +// The same context also carries the innermost instrumented call, so a +// call made from an async continuation (after an await, in a timer, in +// background work) is attributed to the call that started it — see +// currentCallId / runInCall. + +/** What the async context holds. */ +export interface RecordingContext { + /** True inside runInRecording / runUnrecorded: the context alone + * decides which recording (if any) is active; the ambient one is not + * consulted. */ + scoped: boolean; + recording?: Recording; + /** Innermost instrumented call open in this async context… */ + callId?: number; + /** …and the recording that call belongs to. */ + callRecording?: Recording; +} + +/** The subset of node:async_hooks' AsyncLocalStorage the recorder uses. + * Kept structural so this module never imports node:async_hooks itself + * and stays loadable in a browser bundle. */ +export interface AsyncContextStorage { + getStore(): RecordingContext | undefined; + run(store: RecordingContext, fn: () => R): R; +} let current: Recording | undefined; +let storage: AsyncContextStorage | undefined; +// Recordings currently open, ambient or scoped. The outbound-request +// patches stay installed while any is open. +let openCount = 0; +const closed = new WeakSet(); + +function retainPatches(): void { + if (openCount++ === 0) { + patchFetch(); + patchXhr(); + } +} + +function releasePatches(): void { + if (openCount > 0 && --openCount === 0) { + unpatchFetch(); + unpatchXhr(); + } +} + +/** Install the async-context store (an AsyncLocalStorage). Idempotent: + * the first store installed wins, so every driver shares one. */ +export function installAsyncContext(store: AsyncContextStorage): void { + if (!storage) storage = store; +} + +export function hasAsyncContext(): boolean { + return storage !== undefined; +} export function startRecording(recording: Recording): Recording { if (current) { @@ -17,7 +83,7 @@ export function startRecording(recording: Recording): Recording { ); } current = recording; - patchFetch(); + retainPatches(); return recording; } @@ -25,10 +91,60 @@ export function stopRecording(): Recording { if (!current) throw new Error('no active recording'); const finished = current; current = undefined; - unpatchFetch(); + releasePatches(); return finished; } +/** Open a scoped recording. Events reach it only from code run inside + * runInRecording(recording, …). Requires installAsyncContext(). */ +export function openScopedRecording(recording: Recording): Recording { + if (!storage) throw new Error('openScopedRecording needs installAsyncContext() first'); + retainPatches(); + return recording; +} + +/** Close a scoped recording. Work still running in its context (e.g. + * un-awaited background promises) is from now on neither recorded nor + * stamped. Idempotent. */ +export function closeScopedRecording(recording: Recording): void { + if (closed.has(recording)) return; + closed.add(recording); + releasePatches(); +} + +/** Run fn (and every async continuation it spawns) with `recording` as + * the active recording; `callId` makes the calls fn makes children of + * that call (e.g. the http_server_request of a recorded request). */ +export function runInRecording(recording: Recording, fn: () => R, callId?: number): R { + if (!storage) throw new Error('runInRecording needs installAsyncContext() first'); + return storage.run({ scoped: true, recording, callId, callRecording: recording }, fn); +} + +/** Run fn with no active recording, whatever else is open. */ +export function runUnrecorded(fn: () => R): R { + return storage ? storage.run({ scoped: true }, fn) : fn(); +} + export function activeRecording(): Recording | undefined { + const ctx = storage?.getStore(); + if (ctx?.scoped) return ctx.recording && !closed.has(ctx.recording) ? ctx.recording : undefined; return current; } + +/** The innermost instrumented call of `recording` open in the current + * async context, if the runtime has async context. */ +export function currentCallId(recording: Recording): number | undefined { + const ctx = storage?.getStore(); + return ctx?.callRecording === recording ? ctx.callId : undefined; +} + +/** Run fn as the body of call `callId`, so calls made from it — + * synchronously or from its async continuations — nest under it. */ +export function runInCall(recording: Recording, callId: number, fn: () => R): R { + if (!storage) return fn(); + const ctx = storage.getStore(); + return storage.run( + { scoped: ctx?.scoped ?? false, recording: ctx?.recording, callId, callRecording: recording }, + fn, + ); +} diff --git a/recorder/src/stringify.ts b/recorder/src/stringify.ts new file mode 100644 index 0000000..ae398e7 --- /dev/null +++ b/recorder/src/stringify.ts @@ -0,0 +1,182 @@ +// Side-effect-free value capture (the observer-effect fix). +// +// The recorder must never change what the app does. JSON.stringify does: +// it calls every getter and every toJSON on the value it walks. React +// Query's *tracked* query result, for one, is an object of getters that +// subscribe the component to whichever fields are read — serializing it +// subscribed components to every field and made them re-render on +// changes they otherwise ignore (a real app's test went from passing to +// failing only under the recorder). react-hook-form's formState works +// the same way. +// +// So values are rendered by reading property *descriptors* only: +// +// - own enumerable data properties are rendered, JSON-style, so plain +// data prints exactly as JSON.stringify would print it; +// - accessor properties are never invoked: they render as "[getter]"; +// - toJSON, toString, valueOf and friends are never called on unknown +// objects (Date is the one exception: Date.prototype.toISOString is a +// builtin, called on a real Date, never user code); +// - functions render as "[function name]" instead of disappearing; +// - depth, key count and output length are bounded, so a huge or +// cyclic object costs no more than the cap it is cut to. +// +// Proxies remain the one thing JavaScript can't detect: reading their +// keys and descriptors goes through their traps. + +import { isSensitiveName, redactString, REDACTED } from './redact.js'; + +const MAX_DEPTH = 4; +const MAX_KEYS = 50; + +/** Class name of a value, read without invoking any accessor. */ +export function className(v: unknown): string { + if (v === null) return 'null'; + if (typeof v !== 'object' && typeof v !== 'function') return typeof v; + const own = Object.getOwnPropertyDescriptor(v, 'constructor'); + let ctor: unknown = own && 'value' in own ? own.value : undefined; + if (!own) { + const proto = Object.getPrototypeOf(v); + if (proto === null) return 'Object'; + const inherited = findDescriptor(proto, 'constructor'); + ctor = inherited && 'value' in inherited ? inherited.value : undefined; + } + const name = typeof ctor === 'function' ? functionName(ctor) : ''; + return name || (typeof v === 'function' ? 'Function' : 'Object'); +} + +/** A function's own `name`, read from its descriptor. */ +export function functionName(fn: unknown): string { + if (typeof fn !== 'function') return ''; + const d = Object.getOwnPropertyDescriptor(fn, 'name'); + return d && typeof d.value === 'string' ? d.value : ''; +} + +/** Read a data property without invoking accessors; undefined if the + * property is missing or is an accessor. */ +export function readDataProperty(obj: unknown, key: string): unknown { + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) return undefined; + const d = findDescriptor(obj as object, key); + return d && 'value' in d ? d.value : undefined; +} + +function findDescriptor(obj: object, key: string): PropertyDescriptor | undefined { + for (let o: object | null = obj; o; o = Object.getPrototypeOf(o)) { + const d = Object.getOwnPropertyDescriptor(o, key); + if (d) return d; + } + return undefined; +} + +/** + * Render a value as a string of at most roughly `budget` characters + * (callers cut it to their exact cap). Properties with a sensitive name + * render as "[REDACTED]" and bearer tokens inside strings are removed + * (redact.ts). + */ +export function safeStringify(v: unknown, budget: number): string { + let out = ''; + const full = () => out.length > budget; + const ancestors: object[] = []; + + const emitString = (s: string) => { + out += JSON.stringify(redactString(s)); + }; + + const walk = (value: unknown, depth: number, inArray: boolean): void => { + if (full()) return; + switch (typeof value) { + case 'string': + emitString(value); + return; + case 'number': + out += Number.isFinite(value) ? String(value) : 'null'; + return; + case 'boolean': + out += String(value); + return; + case 'bigint': + out += `${value}n`; + return; + case 'undefined': + out += inArray ? 'null' : 'undefined'; + return; + case 'symbol': + emitString(value.toString()); + return; + case 'function': + emitString(`[function ${functionName(value) || 'anonymous'}]`); + return; + } + if (value === null) { + out += 'null'; + return; + } + const obj = value as object; + if (ancestors.includes(obj)) { + emitString('[Circular]'); + return; + } + if (obj instanceof Date) { + let iso: string; + try { + iso = Date.prototype.toISOString.call(obj); + } catch { + iso = 'Invalid Date'; + } + emitString(iso); + return; + } + const isArray = Array.isArray(obj); + if (depth >= MAX_DEPTH) { + emitString(isArray ? '[Array]' : `[${className(obj)}]`); + return; + } + ancestors.push(obj); + try { + if (isArray) { + const d = Object.getOwnPropertyDescriptor(obj, 'length'); + const length = d && typeof d.value === 'number' ? d.value : 0; + out += '['; + for (let i = 0; i < length && !full(); i++) { + if (i > 0) out += ','; + if (i >= MAX_KEYS) { + emitString(`[${length - i} more]`); + break; + } + const item = Object.getOwnPropertyDescriptor(obj, String(i)); + if (!item) out += 'null'; + else if ('value' in item) walk(item.value, depth + 1, true); + else emitString('[getter]'); + } + out += ']'; + return; + } + out += '{'; + let n = 0; + for (const key of Object.keys(obj)) { + if (full()) break; + const d = Object.getOwnPropertyDescriptor(obj, key); + if (!d) continue; + // JSON.stringify leaves out undefined-valued keys; so do we. + if ('value' in d && d.value === undefined) continue; + if (n > 0) out += ','; + if (n >= MAX_KEYS) { + emitString('…'); + break; + } + n++; + out += `${JSON.stringify(key)}:`; + if (isSensitiveName(key)) emitString(REDACTED); + else if ('value' in d) walk(d.value, depth + 1, false); + else emitString('[getter]'); + } + out += '}'; + } finally { + ancestors.pop(); + } + }; + + walk(v, 0, false); + return out; +} diff --git a/recorder/src/testRecording.ts b/recorder/src/testRecording.ts index 9c4de56..81323ff 100644 --- a/recorder/src/testRecording.ts +++ b/recorder/src/testRecording.ts @@ -1,8 +1,9 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import type { Metadata } from './types'; -import { Recording } from './recording'; -import { startRecording, stopRecording } from './session'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { Metadata } from './types.js'; +import { Recording, setValueSizeCap } from './recording.js'; +import { installAsyncContext, startRecording, stopRecording, type RecordingContext } from './session.js'; // Test recording: one AppMap per test, written to tmp/appmap/tests/. // This is milestone 1 (docs/design/01): RTL under Vitest runs in @@ -11,6 +12,16 @@ 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); + +// Node has async context: with it, a call made from an async +// continuation (after an await) nests under the call that started it +// instead of floating to the top of the tree (session.ts, runInCall). +installAsyncContext(new AsyncLocalStorage()); + export interface TestRecordingOptions { app?: string; sourceLocation?: string; @@ -27,7 +38,7 @@ export function startTestRecording(name: string, options: TestRecordingOptions = url: 'https://github.com/getappmap/appmap-react', }, recorder: { name: 'funwithappmap-react', type: 'tests' }, - frameworks: options.frameworks ?? [{ name: 'vitest' }], + frameworks: frameworksWithVersions(options.frameworks ?? [{ name: 'vitest' }, { name: 'react' }]), source_location: options.sourceLocation, }; return startRecording(new Recording(metadata)); @@ -46,3 +57,38 @@ export function finishTestRecording(status: 'succeeded' | 'failed'): string { function sanitize(name: string): string { return name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 200); } + +/** The spec requires a version on every framework entry. Fill missing + * versions from the installed package; leave out a framework that isn't + * installed rather than claim one. */ +function frameworksWithVersions( + frameworks: { name: string; version?: string }[], +): { name: string; version: string }[] { + const out: { name: string; version: string }[] = []; + for (const f of frameworks) { + const version = f.version ?? installedVersion(f.name); + if (version) out.push({ name: f.name, version }); + } + return out; +} + +const versionCache = new Map(); + +function installedVersion(pkg: string): string | undefined { + if (versionCache.has(pkg)) return versionCache.get(pkg); + let version: string | undefined; + for (let dir = process.cwd(); ; dir = dirname(dir)) { + const manifest = join(dir, 'node_modules', pkg, 'package.json'); + if (existsSync(manifest)) { + try { + version = JSON.parse(readFileSync(manifest, 'utf8')).version; + } catch { + // unreadable manifest: treat as unknown + } + break; + } + if (dirname(dir) === dir) break; + } + versionCache.set(pkg, version); + return version; +} diff --git a/recorder/src/transform.ts b/recorder/src/transform.ts index 60c9753..aecf585 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\./ }, ]; @@ -90,7 +79,39 @@ function detectBuiltinLabels(fnPath: NodePath, code: string): string[] { return [...found]; } +/** The inline handler of a top-level `Deno.serve(...)` call, in any of + * its three shapes: `Deno.serve(handler)`, `Deno.serve(options, handler)`, + * `Deno.serve({ ..., handler })`. */ +function denoServeHandler( + expr: NodePath, +): NodePath | undefined { + if (!expr.isCallExpression()) return undefined; + const callee = expr.node.callee; + if ( + !t.isMemberExpression(callee) || + callee.computed || + !t.isIdentifier(callee.object, { name: 'Deno' }) || + !t.isIdentifier(callee.property, { name: 'serve' }) + ) { + return undefined; + } + const isFn = (p: NodePath | undefined): p is NodePath => + !!p && (p.isArrowFunctionExpression() || p.isFunctionExpression()) && !(p.node as t.FunctionExpression).generator; + const [first, second] = expr.get('arguments'); + if (isFn(first)) return first; + if (isFn(second)) return second; + if (first?.isObjectExpression()) { + for (const prop of first.get('properties')) { + if (!prop.isObjectProperty() || !t.isIdentifier(prop.node.key, { name: 'handler' })) continue; + const value = prop.get('value'); + if (isFn(value)) return value; + } + } + return undefined; +} + const RUNTIME_NAME = '__appmap_instrument__'; +const HANDLER_RUNTIME_NAME = '__appmap_instrument_handler__'; export const DEFAULT_RUNTIME_MODULE = '@funwithappmap/react-recorder'; export interface TransformOptions { @@ -98,12 +119,27 @@ export interface TransformOptions { relPath: string; /** Filename handed to Babel (diagnostics, sourcemap source). */ filename?: string; - /** Parse JSX (for .tsx/.jsx sources). */ + /** Parse JSX. */ jsx?: boolean; + /** Parse TypeScript syntax. Default true. */ + typescript?: boolean; /** Import specifier for the recorder runtime. */ runtimeModule?: string; } +/** The syntax of a source file, decided by its extension the way Vite + * decides it, except that JSX is also accepted in plain JavaScript files + * (.js/.mjs/.cjs), the Create React App convention: Babel's JSX parser + * reads any JS without JSX exactly as before. TypeScript files (.ts/.mts/ + * .cts) never get JSX, since `x` is a type assertion there; .tsx gets + * both. */ +export function syntaxFor(file: string): { jsx: boolean; typescript: boolean } { + const ext = /\.([mc]?[jt]sx?)$/.exec(file.split('?')[0])?.[1] ?? ''; + const typescript = ext.includes('t'); + const jsx = ext.endsWith('x') || !typescript; + return { jsx, typescript }; +} + export async function transformSource( code: string, options: TransformOptions, @@ -113,7 +149,12 @@ export async function transformSource( babelrc: false, configFile: false, sourceMaps: true, - parserOpts: { plugins: options.jsx ? ['typescript', 'jsx'] : ['typescript'] }, + parserOpts: { + plugins: [ + ...(options.typescript === false ? [] : (['typescript'] as const)), + ...(options.jsx ? (['jsx'] as const) : []), + ], + }, plugins: [ instrumentBabelPlugin(options.relPath, options.runtimeModule ?? DEFAULT_RUNTIME_MODULE, code), ], @@ -122,11 +163,12 @@ export async function transformSource( return { code: result.code, map: result.map }; } -export function instrumentBabelPlugin(relPath: string, runtimeModule: string, code: string): PluginObj { - const definedClass = basename(relPath).replace(/\.[jt]sx?$/, ''); +export function instrumentBabelPlugin(relPath: string, runtimeModule: string, code = ''): PluginObj { + const definedClass = basename(relPath).replace(/\.[mc]?[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 +203,81 @@ 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), also + // spelled React.useCallback / React.useMemo — 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 + : t.isMemberExpression(callee) && !callee.computed && t.isIdentifier(callee.property) + ? callee.property.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 +293,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. @@ -188,6 +306,18 @@ export function instrumentBabelPlugin(relPath: string, runtimeModule: string, co ), ), ); + } else if (decl.isExpressionStatement()) { + // Deno.serve(async (req) => …): the request's real entry + // function is an anonymous argument, not a declaration. + const handler = denoServeHandler(decl.get('expression')); + if (handler) { + const name = (t.isFunctionExpression(handler.node) && handler.node.id?.name) || 'handler'; + const labels = [...new Set([...commentLabels, ...detectBuiltinLabels(handler, code)])]; + instrumentNested(handler); + handler.replaceWith( + wrapCall(handler.node, name, handler.node.params, handler.node.loc?.start.line, labels), + ); + } } else if (decl.isVariableDeclaration()) { for (const d of decl.get('declarations')) { const init = d.get('init'); @@ -195,6 +325,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 +341,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..65ed751 100644 --- a/recorder/src/types.ts +++ b/recorder/src/types.ts @@ -1,8 +1,9 @@ -// AppMap data format v1.2 — the subset this agent emits. +// AppMap data format — the subset this agent emits (declared version: +// APPMAP_VERSION in recording.ts, checked with the official validator). // https://github.com/getappmap/appmap (appmap.json spec) export interface AppMap { - version: '1.2'; + version: string; metadata: Metadata; classMap: ClassMapEntry[]; events: Event[]; @@ -23,6 +24,26 @@ 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; + // Interaction maps only (docs/design/04): set when more than one + // interaction fired while the window was open. The browser has no + // async context to attribute events by, so the map holds the work of + // all of them; `interactions` lists them in order. + interactions?: string[]; + ambiguous?: boolean; + // HTTP calls that never got a response the format can express (see + // UnansweredHttpRequest in recording.ts). + unanswered_http_requests?: { + event: 'http_client_request' | 'http_server_request'; + request_method: string; + url: string; + reason: string; + }[]; } export type ClassMapEntry = PackageEntry | ClassEntry | FunctionEntry; @@ -60,6 +81,18 @@ 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; + /** A plain object's fields (name + class), per the spec's parameter + * `properties`: the shape survives the 100-character value cap. */ + properties?: ParameterProperty[]; +} + +export interface ParameterProperty { + name: string; + class: string; } export interface CallEvent { @@ -80,8 +113,8 @@ export interface ReturnEvent { thread_id: number; parent_id: number; elapsed?: number; - return_value?: { class: string; value: string }; - exceptions?: { class: string; message: string }[]; + return_value?: { class: string; value: string; size?: number; object_id?: number; properties?: ParameterProperty[] }; + exceptions?: { class: string; message: string; object_id: number }[]; } export interface HttpClientRequestEvent { @@ -93,6 +126,8 @@ export interface HttpClientRequestEvent { url: string; headers?: Record; }; + /** Query parameters (the spec keeps them out of `url`). */ + message: ParameterValue[]; } export interface HttpClientResponseEvent { @@ -117,6 +152,8 @@ export interface HttpServerRequestEvent { normalized_path_info?: string; headers?: Record; }; + /** Query parameters. */ + message: ParameterValue[]; } export interface HttpServerResponseEvent { diff --git a/recorder/src/vitePlugin.ts b/recorder/src/vitePlugin.ts index 34960ec..651fad6 100644 --- a/recorder/src/vitePlugin.ts +++ b/recorder/src/vitePlugin.ts @@ -1,12 +1,19 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { relative, join } from 'node:path'; -import type { Plugin } from 'vite'; -import { transformSource } from './transform'; +import type { IndexHtmlTransformContext, Plugin } from 'vite'; +import { syntaxFor, transformSource } from './transform.js'; +import { DEFAULT_TEST_EXCLUDE, pathMatcher } from './pathMatch.js'; +import { + PROPAGATE_ENV, + parseOriginPatterns, + serializeOriginPatterns, + type OriginPattern, +} from './propagation.js'; 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,57 +24,99 @@ 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 - * appmap.yml `packages:` equivalent), e.g. ['src']. */ + /** What to instrument (the appmap.yml `packages:` equivalent), relative + * to the project root: directory prefixes or files ('src'), or globs + * ('src/**\/*.tsx'). See pathMatch.ts. */ include: string[]; - /** Directory prefixes to skip within include. */ + /** What to skip within include, in the same forms ('src/testing', + * '**\/*.stories.tsx'). Applied on top of `defaultExclude`. */ exclude?: string[]; + /** Test code excluded unless you say otherwise: `__tests__/` and + * `__mocks__/` directories and `*.test.*` / `*.spec.*` files + * (DEFAULT_TEST_EXCLUDE). Pass your own list to replace it, or `false` + * to instrument test files too. */ + defaultExclude?: readonly string[] | false; /** 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; + /** Cross-origin backends whose requests get a `traceparent` header, so + * their AppMaps can be linked to the frontend's: origins + * ('https://api.example.com'), RegExps tested against the request URL, + * or '*'. Same-origin requests are always stamped; other cross-origin + * requests never are, because a header the backend's CORS does not + * allow makes the browser block the request. A listed backend must + * list `traceparent` in its Access-Control-Allow-Headers. Also read + * from APPMAP_PROPAGATE_TRACE_HEADER_ORIGINS (comma-separated). Applies + * to browser interaction recording and to Vitest test recording. */ + propagateTraceHeaderOrigins?: OriginPattern[]; } +export { DEFAULT_TEST_EXCLUDE }; + export function appmapVitePlugin(options: AppMapPluginOptions): Plugin { let root = process.cwd(); + let base = '/'; let enabled = true; + let building = false; + const included = pathMatcher(options.include); + const excluded = pathMatcher([ + ...(options.defaultExclude === false ? [] : (options.defaultExclude ?? DEFAULT_TEST_EXCLUDE)), + ...(options.exclude ?? []), + ]); const selected = (id: string): string | undefined => { const file = id.split('?')[0]; - if (!/\.[jt]sx?$/.test(file) || file.includes('/node_modules/')) return undefined; - const rel = relative(root, file); + if (!/\.([mc]?[jt]s|[jt]sx)$/.test(file) || file.includes('/node_modules/')) return undefined; + const rel = relative(root, file).split('\\').join('/'); if (rel.startsWith('..')) return undefined; - if (!options.include.some((dir) => rel === dir || rel.startsWith(dir + '/'))) return undefined; - if (options.exclude?.some((dir) => rel === dir || rel.startsWith(dir + '/'))) return undefined; + if (!included(rel) || excluded(rel)) return undefined; return rel; }; + const propagateOrigins = [ + ...(options.propagateTraceHeaderOrigins ?? []), + ...parseOriginPatterns(process.env[PROPAGATE_ENV]), + ]; return { name: 'appmap-instrument', enforce: 'pre', + config() { + // Vitest runs tests in worker processes spawned after the config is + // resolved; they inherit this, and the recorder reads it at load + // (propagation.ts). The browser gets it through the injected + // interaction recorder instead (load() below). + if (propagateOrigins.length) process.env[PROPAGATE_ENV] = serializeOriginPatterns(propagateOrigins); + // 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; + base = config.base || '/'; + building = config.command === 'build' && !config.build?.ssr; enabled = options.force || config.mode !== 'production'; }, + buildStart() { + // Production build with `force`: bundle the interaction recorder as + // its own entry chunk; transformIndexHtml links it below. + if (building && enabled && options.app) { + this.emitFile({ type: 'chunk', id: INTERACTION_RECORDER_VIRTUAL_ID, name: 'appmap-interaction-recorder' }); + } + }, // The collector (docs/design/04): browsers can't write tmp/appmap/, // so the in-page recorder POSTs finished interaction AppMaps here — // the remote recording protocol with roles reversed. @@ -110,11 +159,14 @@ export function appmapVitePlugin(options: AppMapPluginOptions): Plugin { const rel = selected(id); if (!rel) return null; - return transformSource(code, { - relPath: rel, - filename: id, - jsx: /\.[jt]sx$/.test(id.split('?')[0]), - }); + try { + return await transformSource(code, { relPath: rel, filename: id, ...syntaxFor(id) }); + } catch (err) { + // A recorder must never break the app: a file the transform cannot + // parse is served uninstrumented, with a warning. + this.warn(`appmap: not instrumenting ${rel}: ${(err as Error).message.split('\n')[0]}`); + return null; + } }, resolveId(id) { if (!enabled || !options.app) return; @@ -122,18 +174,47 @@ export function appmapVitePlugin(options: AppMapPluginOptions): Plugin { }, load(id) { if (id !== RESOLVED_INTERACTION_RECORDER_VIRTUAL_ID) return; + const strings = propagateOrigins.filter((p): p is string => typeof p === 'string'); + const regexps = propagateOrigins + .filter((p): p is RegExp => typeof p !== 'string') + .map((p) => `new RegExp(${JSON.stringify(p.source)}, ${JSON.stringify(p.flags)})`); + const json = JSON.stringify({ app: options.app, recordPageLoad: true, propagateTraceHeaderOrigins: strings }); + const recorderOptions = regexps.length + ? `Object.assign(${json}, { propagateTraceHeaderOrigins: ${JSON.stringify(strings)}.concat([${regexps.join(', ')}]) })` + : json; return [ `import { installInteractionRecorder } from '@funwithappmap/react-recorder';`, - `installInteractionRecorder(${JSON.stringify({ app: options.app })});`, + `installInteractionRecorder(${recorderOptions});`, ].join('\n'); }, - transformIndexHtml() { + // Zero-touch interaction recording (docs/design/07). A plain-function + // transformIndexHtml runs *after* Vite's own dev-HTML import + // rewriting, so a bare `import "virtual:…"` injected here would reach + // the browser un-rewritten — and the browser refuses the `virtual:` + // scheme, so recording never started. Inject the URL Vite itself + // serves the virtual module at instead (`@id/__x00__`, the + // same thing @vitejs/plugin-react does for its preamble); in a + // production build (`force`), link the chunk emitted in buildStart. + transformIndexHtml(_html?: string, ctx?: IndexHtmlTransformContext) { if (!enabled || !options.app) return; + if (ctx?.bundle) { + const chunk = Object.values(ctx.bundle).find( + (c) => c.type === 'chunk' && c.facadeModuleId === RESOLVED_INTERACTION_RECORDER_VIRTUAL_ID, + ); + if (!chunk) return; + return [ + { + tag: 'script', + attrs: { type: 'module', src: `${base}${chunk.fileName}` }, + injectTo: 'head' as const, + }, + ]; + } return [ { tag: 'script', attrs: { type: 'module' }, - children: `import ${JSON.stringify(INTERACTION_RECORDER_VIRTUAL_ID)};`, + children: `import ${JSON.stringify(`${base}@id/__x00__${INTERACTION_RECORDER_VIRTUAL_ID}`)};`, injectTo: 'head' as const, }, ]; diff --git a/recorder/src/vitest.ts b/recorder/src/vitest.ts index 5897ab9..81a6ac7 100644 --- a/recorder/src/vitest.ts +++ b/recorder/src/vitest.ts @@ -1,15 +1,15 @@ import { afterEach, beforeEach } from 'vitest'; -import { activeRecording } from './session'; +import { activeRecording } from './session.js'; import { startTestRecording, finishTestRecording, type TestRecordingOptions, -} from './testRecording'; +} from './testRecording.js'; // Vitest-only module (imports 'vitest' and, via testRecording, node:fs); // deliberately separate from index.ts so the core recorder stays // loadable in a browser. -export { startTestRecording, finishTestRecording, type TestRecordingOptions } from './testRecording'; +export { startTestRecording, finishTestRecording, type TestRecordingOptions } from './testRecording.js'; /** Install beforeEach/afterEach hooks that record every test in the * importing project. Call from a Vitest setup file. */ diff --git a/recorder/src/xhrPatch.ts b/recorder/src/xhrPatch.ts new file mode 100644 index 0000000..83f9056 --- /dev/null +++ b/recorder/src/xhrPatch.ts @@ -0,0 +1,167 @@ +import { activeRecording } from './session.js'; +import { randomHex, type CallToken, type Recording } from './recording.js'; +import { CAPTURED_REQUEST_HEADERS, CAPTURED_RESPONSE_HEADERS } from './fetchPatch.js'; +import { shouldPropagateTraceHeader } from './propagation.js'; + +// The XMLHttpRequest twin of fetchPatch.ts. axios (and every other +// XHR-based client) never calls fetch, so without this an axios app's +// HTTP traffic was invisible: no http_client_request events, no +// traceparent on the wire (so no full-stack linking), and interaction +// windows closed before the response because the idle check had no +// pending request to wait for. +// +// While a recording is open, globalThis.XMLHttpRequest is wrapped so +// every new instance is observed: +// +// - open(): if a recording is active, remember it and stamp the request +// with a traceparent (the recording's trace id, a fresh span id) when +// propagation.ts allows it for the request's origin; +// - setRequestHeader(): capture the headers worth keeping; +// - the `loadstart` / `loadend` events: record http_client_request / +// http_client_response. +// +// Hooks are installed on the *instance the app holds*, not on +// XMLHttpRequest.prototype, and the request is detected from events +// rather than by wrapping send(): request interceptors such as MSW's +// (bulletproof-react's tests) replace XMLHttpRequest with a proxy that +// answers mocked requests without ever calling the real send(), and only +// fire listeners registered through that proxy. + +// Structural types: this module is also type-checked under Deno, whose +// lib has no XMLHttpRequest (the patch is a no-op there). +interface Xhr { + open(...args: unknown[]): void; + setRequestHeader(name: string, value: string): void; + addEventListener(type: string, listener: () => void): void; + getResponseHeader(name: string): string | null; + readonly status: number; +} +type XhrCtor = new (...args: unknown[]) => Xhr; +const scope = globalThis as unknown as { XMLHttpRequest?: XhrCtor }; + +let original: XhrCtor | undefined; +let originalDescriptor: PropertyDescriptor | undefined; +let wrapper: XhrCtor | undefined; + +interface Pending { + recording: Recording; + method: string; + url: string; + headers: Record; + /** The call that opened the request: its event is recorded later. */ + parent: number | undefined; + token?: CallToken; +} + +export function patchXhr(): void { + if (original || typeof scope.XMLHttpRequest !== 'function') return; + // Interceptors (MSW) install XMLHttpRequest with defineProperty, often + // read-only; go through defineProperty too, and restore it exactly. + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'XMLHttpRequest'); + if (descriptor && !descriptor.configurable && !descriptor.writable) return; + original = scope.XMLHttpRequest; + originalDescriptor = descriptor; + wrapper = new Proxy(original, { + construct(target, args, newTarget) { + const xhr = Reflect.construct(target, args, newTarget) as Xhr; + observe(xhr); + return xhr; + }, + }); + setGlobal(wrapper); +} + +function setGlobal(value: XhrCtor): void { + const d = Object.getOwnPropertyDescriptor(globalThis, 'XMLHttpRequest'); + if (!d || d.configurable) { + Object.defineProperty(globalThis, 'XMLHttpRequest', { + value, + writable: true, + configurable: true, + enumerable: d?.enumerable ?? false, + }); + } else { + scope.XMLHttpRequest = value; + } +} + +export function unpatchXhr(): void { + if (!original) return; + // Only undo our own wrapper; if something wrapped it after us, leave + // the chain alone (instances keep checking activeRecording()). + if (scope.XMLHttpRequest === wrapper) { + if (originalDescriptor?.configurable) Object.defineProperty(globalThis, 'XMLHttpRequest', originalDescriptor); + else setGlobal(original); + } + original = undefined; + originalDescriptor = undefined; + wrapper = undefined; +} + +function observe(xhr: Xhr): void { + let pending: Pending | undefined; + const open = xhr.open; + const setRequestHeader = xhr.setRequestHeader; + + xhr.open = function (...args: unknown[]) { + pending = undefined; + const result = open.apply(xhr, args); + const recording = activeRecording(); + if (recording) { + const [method, url] = args as [string, string | URL]; + pending = { + recording, + method: String(method).toUpperCase(), + url: absoluteUrl(url), + headers: {}, + parent: recording.currentParent(), + }; + if (shouldPropagateTraceHeader(pending.url)) { + xhr.setRequestHeader('traceparent', `00-${recording.traceId}-${randomHex(8)}-01`); + } + } + return result; + }; + + xhr.setRequestHeader = function (name: string, value: string) { + if (pending && CAPTURED_REQUEST_HEADERS.includes(name.toLowerCase())) { + pending.headers[name.toLowerCase()] = value; + } + return setRequestHeader.call(xhr, name, value); + }; + + const start = () => { + if (pending && !pending.token) { + pending.token = pending.recording.httpClientRequest(pending.method, pending.url, pending.headers, pending.parent); + } + }; + xhr.addEventListener('loadstart', start); + xhr.addEventListener('loadend', () => { + if (!pending) return; + start(); + const { recording, token } = pending; + pending = undefined; + let status = 0; + const headers: Record = {}; + try { + status = xhr.status; + for (const name of CAPTURED_RESPONSE_HEADERS) { + const value = xhr.getResponseHeader(name); + if (value !== null) headers[name] = value; + } + } catch { + // aborted / network error: no response to read + } + // status 0: network error, abort or timeout — no response. + recording.httpClientResponse(token!, status, headers); + }); +} + +function absoluteUrl(url: string | URL): string { + const base = (globalThis as { document?: { baseURI?: string } }).document?.baseURI; + try { + return new URL(String(url), base).href; + } catch { + return String(url); + } +} 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/exceptions.test.ts b/recorder/test/exceptions.test.ts new file mode 100644 index 0000000..3bd93e6 --- /dev/null +++ b/recorder/test/exceptions.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { Recording } from '../src/recording'; + +// Thrown values that are not Errors — supabase-js throws the plain +// parsed PostgREST error object — must keep their message. + +function thrown(value: unknown) { + const recording = new Recording({ + name: 'exceptions', + client: { name: 'test', url: 'https://example.invalid' }, + recorder: { name: 'test', type: 'tests' }, + }); + const token = recording.enter({ definedClass: 'index', methodId: 'getTask', path: 'index.ts', lineno: 18 }); + recording.exit(token, { exception: value }); + return (recording.toAppMap().events[1] as any).exceptions[0]; +} + +describe('exception capture', () => { + it('records a thrown plain object by its class and its message property', () => { + const e = thrown({ code: '22P02', details: null, hint: null, message: 'invalid input syntax for type bigint: "not-a-number"' }); + expect(e).toMatchObject({ class: 'Object', message: 'invalid input syntax for type bigint: "not-a-number"' }); + expect(Number.isInteger(e.object_id)).toBe(true); + }); + + it('records a plain object without a message as its value, not "[object Object]"', () => { + expect(thrown({ code: 42 })).toMatchObject({ class: 'Object', message: '{"code":42}' }); + }); + + it('keeps Error subclasses and primitives as before', () => { + class ApiError extends Error {} + expect(thrown(new ApiError('nope'))).toMatchObject({ class: 'ApiError', message: 'nope' }); + expect(thrown('boom')).toMatchObject({ class: 'string', message: 'boom' }); + }); + + it('never calls a message getter or toString on the thrown value', () => { + let calls = 0; + const sneaky = { + get message() { + calls++; + return 'x'; + }, + toString() { + calls++; + return 'y'; + }, + }; + thrown(sneaky); + expect(calls).toBe(0); + }); +}); diff --git a/recorder/test/interactionRecording.test.ts b/recorder/test/interactionRecording.test.ts new file mode 100644 index 0000000..0efddf4 --- /dev/null +++ b/recorder/test/interactionRecording.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { installInteractionRecorder } from '../src/interactionRecording'; + +// Overlapping interactions (docs/design/04): the browser has no async +// context, so two interactions fired close together cannot be told +// apart. They must not be merged *silently* under the first one's name. + +describe('interaction recorder: overlapping interactions', () => { + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + }); + + it('marks a window that absorbed a second interaction as ambiguous and names both', async () => { + const shipped: any[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + shipped.push(JSON.parse(String(init.body))); + return new Response(null, { status: 204 }); + }), + ); + document.body.innerHTML = 'UsersDashboard'; + const uninstall = installInteractionRecorder({ app: 'test', idleMs: 20 }); + try { + document.getElementById('users')!.click(); + await new Promise((r) => setTimeout(r, 5)); + document.getElementById('dashboard')!.click(); + await vi.waitFor(() => expect(shipped).toHaveLength(1), { timeout: 2000 }); + } finally { + uninstall(); + } + + const { metadata } = shipped[0]; + expect(metadata.ambiguous).toBe(true); + expect(metadata.interactions).toEqual(['click a "Users"', 'click a "Dashboard"']); + expect(metadata.name).toBe('click a "Users" + click a "Dashboard"'); + }); + + it('leaves a single-interaction window unmarked', async () => { + const shipped: any[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + shipped.push(JSON.parse(String(init.body))); + return new Response(null, { status: 204 }); + }), + ); + document.body.innerHTML = ''; + const uninstall = installInteractionRecorder({ app: 'test', idleMs: 20 }); + try { + document.querySelector('button')!.click(); + await vi.waitFor(() => expect(shipped).toHaveLength(1), { timeout: 2000 }); + } finally { + uninstall(); + } + expect(shipped[0].metadata.name).toBe('click button "Save"'); + expect(shipped[0].metadata.ambiguous).toBeUndefined(); + expect(shipped[0].metadata.interactions).toBeUndefined(); + }); + + it('treats the submit a click causes as the same interaction', async () => { + const shipped: any[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + shipped.push(JSON.parse(String(init.body))); + return new Response(null, { status: 204 }); + }), + ); + document.body.innerHTML = '
'; + const submits: Event[] = []; + document.querySelector('form')!.addEventListener('submit', (e) => { + e.preventDefault(); + submits.push(e); + }); + const uninstall = installInteractionRecorder({ app: 'test', idleMs: 20 }); + try { + document.querySelector('button')!.click(); + await vi.waitFor(() => expect(shipped).toHaveLength(1), { timeout: 2000 }); + } finally { + uninstall(); + } + expect(submits).toHaveLength(1); + expect(shipped[0].metadata.name).toBe('click button "Find Owner"'); + expect(shipped[0].metadata.ambiguous).toBeUndefined(); + }); +}); + +describe('interaction recorder: the page load', () => { + afterEach(() => vi.unstubAllGlobals()); + + // A request the page makes while loading, before any click (e.g. + // bulletproof-react's GET /auth/me), had no window: it was neither + // recorded nor stamped. + it('recordPageLoad records the initial load in its own window, stamped', async () => { + const shipped: any[] = []; + const sent: Request[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith('/__appmap/interactions')) shipped.push(JSON.parse(String(init!.body))); + else sent.push(new Request(input, init)); + return new Response('{}', { status: 200 }); + }), + ); + const uninstall = installInteractionRecorder({ app: 'test', idleMs: 20, recordPageLoad: true }); + try { + await fetch('http://localhost:3000/api/auth/me'); // the app, loading (same origin as the page) + await vi.waitFor(() => expect(shipped).toHaveLength(1), { timeout: 2000 }); + } finally { + uninstall(); + } + expect(shipped[0].metadata.name).toBe('load /'); + const call = shipped[0].events.find((e: any) => e.http_client_request); + expect(call.http_client_request.url).toBe('http://localhost:3000/api/auth/me'); + expect(sent[0].headers.get('traceparent')).toBe(call.http_client_request.headers.traceparent); + }); + + it('is off by default', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))); + const uninstall = installInteractionRecorder({ app: 'test', idleMs: 20 }); + try { + await new Promise((r) => setTimeout(r, 80)); + expect(fetch).not.toHaveBeenCalled(); + } finally { + uninstall(); + } + }); +}); diff --git a/recorder/test/package.test.ts b/recorder/test/package.test.ts new file mode 100644 index 0000000..7a71b00 --- /dev/null +++ b/recorder/test/package.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The package must be usable by another app exactly as documented: +// `npm pack` → install → import '@funwithappmap/react-recorder', +// '/vite', '/vitest' with no workarounds. It used to ship two files +// (package.json + src/index.ts), point its exports at .ts sources Node +// refuses to load from node_modules, and build a dist whose +// extensionless relative imports Node ESM could not resolve. + +const recorderRoot = fileURLToPath(new URL('..', import.meta.url)); +const work = join(recorderRoot, 'tmp', 'package-test'); +// Inside the repo, so the consumer resolves the package's own +// dependencies (@babel/core, vite, vitest) from the workspace root. +const consumer = join(work, 'consumer'); +const installed = join(consumer, 'node_modules', '@funwithappmap', 'react-recorder'); + +let files: string[] = []; + +beforeAll(() => { + rmSync(work, { recursive: true, force: true }); + mkdirSync(installed, { recursive: true }); + const out = execFileSync('npm', ['pack', '--json', '--pack-destination', work], { + cwd: recorderRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const [info] = JSON.parse(out.slice(out.indexOf('['))); + files = info.files.map((f: { path: string }) => f.path); + execFileSync('tar', ['-xzf', join(work, info.filename), '-C', installed, '--strip-components=1']); + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name: 'consumer', type: 'module', private: true })); +}, 120_000); + +afterAll(() => rmSync(work, { recursive: true, force: true })); + +describe('npm pack output', () => { + it('ships the built entry points', () => { + for (const f of ['dist/index.js', 'dist/vitePlugin.js', 'dist/vitest.js', 'dist/transform.js', 'dist/index.d.ts']) { + expect(files).toContain(f); + } + expect(files.some((f) => f.startsWith('test/'))).toBe(false); + }); + + it('imports by its documented specifiers in plain Node ESM (as a vite.config.ts does)', () => { + const script = ` + const vite = await import('@funwithappmap/react-recorder/vite'); + const core = await import('@funwithappmap/react-recorder'); + const transform = await import('@funwithappmap/react-recorder/transform'); + const plugin = vite.appmapVitePlugin({ include: ['src'] }); + console.log(JSON.stringify({ + plugin: plugin.name, + core: typeof core.autoInstrument + typeof core.Recording + typeof core.installInteractionRecorder, + transform: typeof transform.transformSource, + })); + `; + const out = execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: consumer, + encoding: 'utf8', + }); + expect(JSON.parse(out)).toEqual({ + plugin: 'appmap-instrument', + core: 'functionfunctionfunction', + transform: 'function', + }); + }); + + it('records a consumer app\'s tests with the plugin and the /vitest hooks, as the README documents', () => { + mkdirSync(join(consumer, 'src'), { recursive: true }); + mkdirSync(join(consumer, 'test'), { recursive: true }); + writeFileSync( + join(consumer, 'vite.config.ts'), + [ + "import { defineConfig } from 'vite';", + "import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite';", + 'export default defineConfig({', + " plugins: [appmapVitePlugin({ include: ['src'], app: 'consumer' })],", + " test: { setupFiles: ['./test/setup.ts'] },", + '});', + ].join('\n'), + ); + writeFileSync( + join(consumer, 'test', 'setup.ts'), + "import { registerAppMapHooks } from '@funwithappmap/react-recorder/vitest';\nregisterAppMapHooks({ app: 'consumer' });\n", + ); + writeFileSync(join(consumer, 'src', 'greet.ts'), "export function greet(name: string) {\n return `hi ${name}`;\n}\n"); + writeFileSync( + join(consumer, 'test', 'greet.test.ts'), + "import { test, expect } from 'vitest';\nimport { greet } from '../src/greet';\ntest('greets', () => expect(greet('Ada')).toBe('hi Ada'));\n", + ); + const vitestBin = join(recorderRoot, '..', 'node_modules', 'vitest', 'vitest.mjs'); + execFileSync(process.execPath, [vitestBin, 'run'], { cwd: consumer, encoding: 'utf8', stdio: 'pipe' }); + const appmap = JSON.parse(readFileSync(join(consumer, 'tmp', 'appmap', 'tests', 'greets.appmap.json'), 'utf8')); + expect(appmap.metadata.app).toBe('consumer'); + const call = appmap.events.find((e: { method_id?: string }) => e.method_id === 'greet'); + expect(call).toMatchObject({ path: 'src/greet.ts', parameters: [{ name: 'name', value: 'Ada' }] }); + }, 120_000); + + it('resolves the documented specifiers to type declarations', () => { + writeFileSync( + join(consumer, 'check.ts'), + [ + "import { appmapVitePlugin } from '@funwithappmap/react-recorder/vite';", + "import { registerAppMapHooks } from '@funwithappmap/react-recorder/vitest';", + "import { autoInstrument, type AppMap } from '@funwithappmap/react-recorder';", + "const p: { name: string } = appmapVitePlugin({ include: ['src'] });", + 'const r: (o?: { app?: string }) => void = registerAppMapHooks;', + 'const a: typeof autoInstrument = autoInstrument;', + 'let m: AppMap | undefined;', + 'export { p, r, a, m };', + ].join('\n'), + ); + for (const resolution of ['bundler', 'nodenext']) { + const tsc = join(recorderRoot, '..', 'node_modules', 'typescript', 'bin', 'tsc'); + const moduleKind = resolution === 'bundler' ? 'esnext' : 'nodenext'; + execFileSync( + process.execPath, + [tsc, '--noEmit', '--strict', '--skipLibCheck', '--module', moduleKind, '--moduleResolution', resolution, '--target', 'es2022', 'check.ts'], + { cwd: consumer, encoding: 'utf8' }, + ); + } + expect(readdirSync(consumer)).toContain('check.ts'); + }); +}); diff --git a/recorder/test/propagation.test.ts b/recorder/test/propagation.test.ts new file mode 100644 index 0000000..263b22b --- /dev/null +++ b/recorder/test/propagation.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + shouldPropagateTraceHeader, + setPropagateTraceHeaderOrigins, + parseOriginPatterns, + serializeOriginPatterns, +} from '../src/propagation'; + +// Which requests get traceparent (docs/design/02, "Cross-origin requests"): +// OpenTelemetry's browser model. Same origin always; cross origin only when +// listed; server-side (no page origin) always. + +afterEach(() => { + vi.unstubAllGlobals(); + setPropagateTraceHeaderOrigins([]); +}); + +describe('shouldPropagateTraceHeader', () => { + it('stamps every request where there is no page origin (Node, Deno)', () => { + expect(globalThis.location).toBeUndefined(); + expect(shouldPropagateTraceHeader('https://api.example.com/x')).toBe(true); + }); + + it('in a page: same origin yes, other origins only when listed', () => { + vi.stubGlobal('location', { origin: 'http://127.0.0.1:3300' }); + expect(shouldPropagateTraceHeader('http://127.0.0.1:3300/api/x?y=1')).toBe(true); + expect(shouldPropagateTraceHeader('/relative')).toBe(true); + expect(shouldPropagateTraceHeader('http://localhost:54321/functions/v1/fn')).toBe(false); + expect(shouldPropagateTraceHeader('http://127.0.0.1:8080/api')).toBe(false); + + setPropagateTraceHeaderOrigins(['http://localhost:54321/']); + expect(shouldPropagateTraceHeader('http://localhost:54321/functions/v1/fn')).toBe(true); + expect(shouldPropagateTraceHeader('http://localhost:54322/functions/v1/fn')).toBe(false); + + setPropagateTraceHeaderOrigins([/\/functions\/v1\//]); + expect(shouldPropagateTraceHeader('http://localhost:54321/functions/v1/fn')).toBe(true); + expect(shouldPropagateTraceHeader('http://localhost:54321/rest/v1/users')).toBe(false); + + setPropagateTraceHeaderOrigins(['*']); + expect(shouldPropagateTraceHeader('https://anything.example/x')).toBe(true); + }); + + it('treats a throwing location getter (Deno without --location) as server-side', () => { + Object.defineProperty(globalThis, 'location', { + configurable: true, + get() { + throw new ReferenceError('Access to "location", run again with --location .'); + }, + }); + try { + expect(shouldPropagateTraceHeader('https://api.example.com/x')).toBe(true); + } finally { + delete (globalThis as { location?: unknown }).location; + } + }); +}); + +describe('APPMAP_PROPAGATE_TRACE_HEADER_ORIGINS', () => { + it('round-trips origins and regular expressions', () => { + const list = ['http://localhost:54321', /\.example\.com\//i]; + const parsed = parseOriginPatterns(serializeOriginPatterns(list)); + expect(parsed[0]).toBe('http://localhost:54321'); + expect(parsed[1]).toBeInstanceOf(RegExp); + expect((parsed[1] as RegExp).test('https://API.example.com/x')).toBe(true); + expect(parseOriginPatterns(' a , ,b ')).toEqual(['a', 'b']); + expect(parseOriginPatterns(undefined)).toEqual([]); + }); + + it('is read when the module loads (how Vitest workers get the Vite plugin option)', async () => { + vi.stubEnv('APPMAP_PROPAGATE_TRACE_HEADER_ORIGINS', 'http://localhost:8080'); + vi.resetModules(); + const fresh = await import('../src/propagation'); + vi.stubGlobal('location', { origin: 'http://localhost:3000' }); + expect(fresh.shouldPropagateTraceHeader('http://localhost:8080/owners')).toBe(true); + expect(fresh.shouldPropagateTraceHeader('http://localhost:8081/owners')).toBe(false); + vi.unstubAllEnvs(); + }); +}); diff --git a/recorder/test/recording.test.ts b/recorder/test/recording.test.ts new file mode 100644 index 0000000..e3acc3b --- /dev/null +++ b/recorder/test/recording.test.ts @@ -0,0 +1,157 @@ +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, the spec\'s 100 characters', () => { + expect(VALUE_SIZE_CAP).toBe(100); + const { value } = formatValue('a'.repeat(VALUE_SIZE_CAP + 50)); + // The truncation ellipsis counts toward the cap: the spec (and the + // official validator) allow 100 characters, not 101. + expect(value.length).toBe(VALUE_SIZE_CAP); + expect(value.endsWith('…')).toBe(true); + expect(formatValue('a'.repeat(VALUE_SIZE_CAP)).value).toBe('a'.repeat(VALUE_SIZE_CAP)); + }); + + it('honors setValueSizeCap (wired to APPMAP_EVENT_VALUESIZE)', () => { + setValueSizeCap(10); + const { value } = formatValue('a'.repeat(50)); + expect(value.length).toBe(10); + }); + + it('never splits a surrogate pair when cutting', () => { + setValueSizeCap(10); + const { value } = formatValue('aaaaaaaa😀😀😀'); + expect(value).toBe('aaaaaaaa…'); + }); +}); + +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 }); + // Every framework entry carries the version the spec requires, read + // from the installed package. + expect(metadata.frameworks).toContainEqual({ name: 'vitest', version: expect.stringMatching(/^\d+\.\d+\.\d+/) }); + for (const f of metadata.frameworks!) expect(f.version).toMatch(/^\d+\.\d+\.\d+/); + 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/test/redaction.test.ts b/recorder/test/redaction.test.ts new file mode 100644 index 0000000..ca24802 --- /dev/null +++ b/recorder/test/redaction.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Recording, formatValue, setValueSizeCap, VALUE_SIZE_CAP } from '../src/recording'; +import { autoInstrument } from '../src/instrument'; +import { activeRecording, startRecording, stopRecording } from '../src/session'; + +// Credentials never reach a recording in plaintext. + +const JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.c2lnbmF0dXJl'; + +function record(run: () => unknown) { + const recording = startRecording(new Recording({ + name: 'redaction', + client: { name: 'test', url: 'https://example.invalid' }, + recorder: { name: 'test', type: 'tests' }, + })); + try { + run(); + } finally { + stopRecording(); + } + return recording.toAppMap(); +} + +describe('credential redaction', () => { + afterEach(() => { + setValueSizeCap(VALUE_SIZE_CAP); + if (activeRecording()) stopRecording(); + vi.unstubAllGlobals(); + }); + + it('redacts parameters and nested properties with a sensitive name, case-insensitively', () => { + const login = autoInstrument( + (data: unknown, apiKey: string, _page: number) => ({ ok: true, session: { accessToken: 'tok-123', user: 'ada' } }), + { definedClass: 'auth', methodId: 'login', path: 'src/auth.ts', lineno: 1 }, + ['data', 'apiKey', 'page'], + ); + setValueSizeCap(1000); // see the whole value + const appmap = record(() => + login({ email: 'ada@example.com', password: 'secret-pw-1', nested: { API_KEY: 'k', 'x-api-key': 'k2', clientSecret: 's' } }, 'ak-1', 2), + ); + const call = appmap.events.find((e: any) => e.method_id === 'login') as any; + expect(call.parameters[0].value).toBe( + '{"email":"ada@example.com","password":"[REDACTED]","nested":{"API_KEY":"[REDACTED]","x-api-key":"[REDACTED]","clientSecret":"[REDACTED]"}}', + ); + expect(call.parameters[1].value).toBe('[REDACTED]'); + expect(call.parameters[2].value).toBe('2'); + const ret = appmap.events.find((e: any) => e.return_value) as any; + expect(ret.return_value.value).toBe('{"ok":true,"session":{"accessToken":"[REDACTED]","user":"ada"}}'); + expect(JSON.stringify(appmap)).not.toMatch(/secret-pw-1|ak-1|tok-123/); + }); + + it('removes bearer tokens from any captured string', () => { + expect(formatValue(`Bearer ${JWT}`).value).toBe('Bearer [REDACTED]'); + expect(formatValue({ auth: `bearer ${JWT}` }).value).toBe('{"auth":"bearer [REDACTED]"}'); + }); + + it('redacts a JWT under any name (a Supabase client carries its key as supabaseKey)', () => { + expect(formatValue({ supabaseUrl: 'http://127.0.0.1:54321', supabaseKey: JWT }).value).toBe( + '{"supabaseUrl":"http://127.0.0.1:54321","supabaseKey":"[REDACTED]"}', + ); + }); + + it('redacts Authorization/Cookie/Set-Cookie headers and sensitive query parameters on HTTP events', () => { + const recording = new Recording({ + name: 'http', + client: { name: 'test', url: 'https://example.invalid' }, + recorder: { name: 'test', type: 'requests' }, + }); + const token = recording.httpClientRequest('GET', 'https://api.example.test/items?page=1&access_token=abc&apiKey=def', { + authorization: `Bearer ${JWT}`, + cookie: 'sid=abc', + accept: 'application/json', + }); + recording.httpClientResponse(token, 200, { 'set-cookie': 'sid=new' }); + const appmap = recording.toAppMap(); + const [call, ret] = appmap.events as any[]; + expect(call.http_client_request.headers).toEqual({ + authorization: '[REDACTED]', + cookie: '[REDACTED]', + accept: 'application/json', + }); + expect(call.message).toEqual([ + { name: 'page', class: 'String', value: '1' }, + { name: 'access_token', class: 'String', value: '[REDACTED]' }, + { name: 'apiKey', class: 'String', value: '[REDACTED]' }, + ]); + expect(ret.http_client_response.headers).toEqual({ 'set-cookie': '[REDACTED]' }); + expect(JSON.stringify(appmap)).not.toMatch(new RegExp(`${JWT}|sid=|abc|def`)); + }); +}); diff --git a/recorder/test/transform.test.ts b/recorder/test/transform.test.ts new file mode 100644 index 0000000..f564ad4 --- /dev/null +++ b/recorder/test/transform.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { transformSource } from '../src/transform'; + +// Callbacks passed to useCallback/useMemo are instrumented whichever way +// the hook is spelled: bulletproof-react writes React.useCallback, and its +// toggle/open/close/checkAccess callbacks were never recorded. + +const source = ` +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; + +export const useDisclosure = (initial = false) => { + const [isOpen, setIsOpen] = React.useState(initial); + const open = React.useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const label = React.useMemo(() => (isOpen ? 'open' : 'closed'), [isOpen]); + const same = useMemo(function compute() { return 1; }, []); + const notAHook = React.useState(() => 0); + return { isOpen, open, close, label, same, notAHook }; +}; +`; + +describe('transform: useCallback / useMemo callbacks', () => { + it('wraps React.useCallback / React.useMemo callbacks like the bare hooks', async () => { + const result = await transformSource(source, { relPath: 'src/hooks/use-disclosure.ts' }); + const code = result!.code; + for (const [hook, name] of [ + ['React.useCallback', 'open'], + ['useCallback', 'close'], + ['React.useMemo', 'label'], + ['useMemo', 'same'], + ]) { + const wrapped = new RegExp( + `const ${name} = ${hook.replace('.', '\\.')}\\(__appmap_instrument_handler__\\([\\s\\S]*?methodId: "${name}"`, + ); + expect(code, `${hook} callback for ${name}`).toMatch(wrapped); + } + // Other React.* calls are left alone. + expect(code).toMatch(/const notAHook = React\.useState\(\(\) => 0\)/); + }); +}); + +describe('transform: the Deno.serve handler', () => { + for (const [shape, call] of [ + ['Deno.serve(handler)', 'Deno.serve(async (req) => new Response(req.url));'], + ['Deno.serve(options, handler)', 'Deno.serve({ port: 8000 }, async (req) => new Response(req.url));'], + ['Deno.serve({ handler })', 'Deno.serve({ port: 8000, handler: async (req) => new Response(req.url) });'], + ]) { + it(`instruments an anonymous handler: ${shape}`, async () => { + const result = await transformSource(`function helper() {}\n\n${call}\n`, { relPath: 'functions/tasks/index.ts' }); + expect(result!.code).toMatch( + /__appmap_instrument__\(async req => new Response\(req\.url\), \{[\s\S]*?methodId: "handler",[\s\S]*?lineno: 3[\s\S]*?\}, \["req"\]\)/, + ); + }); + } + + it('leaves other top-level calls alone', async () => { + const result = await transformSource('console.log(() => 1);\nfoo.serve(async () => 1);\n', { relPath: 'x.ts' }); + expect(result!.code).not.toContain('__appmap_instrument__'); + }); +}); diff --git a/recorder/test/validity.test.ts b/recorder/test/validity.test.ts new file mode 100644 index 0000000..7ed77dc --- /dev/null +++ b/recorder/test/validity.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { Recording, APPMAP_VERSION } from '../src/recording'; +import { autoInstrument } from '../src/instrument'; +import { + activeRecording, + closeScopedRecording, + openScopedRecording, + runInRecording, + stopRecording, +} from '../src/session'; +import { startTestRecording, finishTestRecording } from '../src/testRecording'; +import type { AppMap, Event } from '../src/types'; + +// Every recording mode's output must pass the official validator +// (@appland/appmap-validate, getappmap/appmap-js packages/validate) at +// the version it declares — schema and semantics: per-thread call/return +// nesting, event ids, classMap consistency — and calls must form a tree. + +const { validate } = createRequire(import.meta.url)('@appland/appmap-validate') as { + validate: (data: unknown, options?: { version?: string }) => void; +}; + +const fn = (methodId: string, lineno: number, f: (...a: never[]) => unknown, args: string[] = []) => + autoInstrument(f as never, { definedClass: 'app', methodId, path: 'src/app.ts', lineno }, args) as ( + ...a: unknown[] + ) => any; + +const tick = () => new Promise((r) => setTimeout(r, 1)); + +/** call → children as nested arrays, rebuilt the way standard tooling + * does: positionally, per thread. */ +function tree(events: Event[]): unknown[] { + const roots: unknown[] = []; + const stack: { name: string; children: unknown[] }[] = []; + for (const e of events) { + if (e.event === 'call') { + const name = + 'method_id' in e ? e.method_id : 'http_client_request' in e ? `${e.http_client_request.request_method} ${e.http_client_request.url}` : `SERVER ${(e as any).http_server_request.path_info}`; + const node = { name, children: [] as unknown[] }; + (stack.length ? stack[stack.length - 1].children : roots).push(node); + stack.push(node); + } else stack.pop(); + } + const simplify = (n: any): unknown => (n.children.length ? { [n.name]: n.children.map(simplify) } : n.name); + return roots.map(simplify); +} + +describe(`recordings are valid AppMap ${APPMAP_VERSION}`, () => { + afterEach(() => { + if (activeRecording()) stopRecording(); + vi.unstubAllGlobals(); + }); + + it('test recording: async nesting, fetch with a query, network error, exceptions, long values', async () => { + const underlying = vi.fn(async (input: RequestInfo | URL) => { + const url = new Request(input).url; + if (url.includes('down')) throw new TypeError('fetch failed'); + return Response.json({ ok: true }); + }); + vi.stubGlobal('fetch', underlying); + + const findOwners = fn('findOwners', 10, async (lastName: string) => { + await tick(); + const res = await fetch(`https://api.example.test/owners?lastName=${lastName}&page=1`); + return res.json(); + }, ['lastName']); + const search = fn('search', 20, async (lastName: string) => { + await tick(); + return findOwners(lastName); // called from an async continuation + }, ['lastName']); + // Returns synchronously while the search it started is still running. + const onSubmit = fn('onSubmit', 30, (lastName: string) => { + void search(lastName); + }, ['lastName']); + const both = fn('both', 40, async () => Promise.all([findOwners('a'), findOwners('b')])); + const failing = fn('failing', 50, async () => { + await fetch('https://down.example.test/x'); + }); + const throwsPlain = fn('throwsPlain', 60, () => { + throw { code: '22P02', message: 'invalid input' }; + }); + const echo = fn('echo', 70, (s: string) => s, ['s']); + + startTestRecording('validity test', { sourceLocation: 'test/validity.test.ts' }); + onSubmit('Davis'); + await vi.waitFor(() => expect(underlying).toHaveBeenCalledTimes(1)); + await tick(); + await both(); + await expect(failing()).rejects.toThrow('fetch failed'); + expect(() => throwsPlain()).toThrow(); + expect(() => fn('throwsString', 80, () => { throw 'nope'; })()).toThrow(); + echo('x'.repeat(500)); + const file = finishTestRecording('succeeded'); + const appmap: AppMap = JSON.parse(readFileSync(file, 'utf8')); + + expect(appmap.version).toBe(APPMAP_VERSION); + expect(() => validate(appmap)).not.toThrow(); + for (const f of appmap.metadata.frameworks!) expect(f.version).toBeTruthy(); + + // One thread, and the async chain nests: onSubmit → search → + // findOwners → GET, though onSubmit returned long before. + expect(new Set(appmap.events.map((e) => e.thread_id))).toEqual(new Set([1])); + const t = tree(appmap.events); + expect(t[0]).toEqual({ + onSubmit: [{ search: [{ findOwners: ['GET https://api.example.test/owners'] }] }], + }); + expect(t[1]).toEqual({ + both: [ + { findOwners: ['GET https://api.example.test/owners'] }, + { findOwners: ['GET https://api.example.test/owners'] }, + ], + }); + + // The query string lives in `message`, not in `url`. + const get = appmap.events.find((e) => 'http_client_request' in e) as any; + expect(get.http_client_request.url).toBe('https://api.example.test/owners'); + expect(get.message).toEqual([ + { name: 'lastName', class: 'String', value: 'Davis' }, + { name: 'page', class: 'String', value: '1' }, + ]); + // The failed request has no response the format can express: it is + // listed in metadata, not left as an invalid event. + expect(appmap.metadata.unanswered_http_requests).toEqual([ + { event: 'http_client_request', request_method: 'GET', url: 'https://down.example.test/x', reason: 'network error' }, + ]); + const exceptions = appmap.events.flatMap((e: any) => e.exceptions ?? []); + expect(exceptions.every((x: any) => Number.isInteger(x.object_id))).toBe(true); + const long = appmap.events.find((e: any) => e.method_id === 'echo') as any; + expect(long.parameters[0].value).toHaveLength(100); + }); + + it('server request (scoped recording): one tree rooted at the http_server_request', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))); + const deleteTask = fn('deleteTask', 38, async (id: string) => { + await tick(); + await fetch(`http://127.0.0.1:54321/rest/v1/tasks?id=eq.${id}`, { method: 'DELETE' }); + return new Response('{}'); + }, ['id']); + + const recording = openScopedRecording(new Recording({ + name: 'DELETE /restful-tasks/2', + language: { name: 'typescript', engine: 'deno', version: '5.8.3' }, + client: { name: 'test', url: 'https://example.invalid' }, + recorder: { name: 'test', type: 'requests' }, + })); + const token = recording.httpServerRequest('DELETE', '/restful-tasks/2', {}, undefined, new URLSearchParams('x=1')); + const res = await runInRecording(recording, () => deleteTask('2'), token.callId); + recording.httpServerResponse(token, res.status); + closeScopedRecording(recording); + const appmap = recording.toAppMap(); + + expect(() => validate(appmap)).not.toThrow(); + expect(tree(appmap.events)).toEqual([ + { 'SERVER /restful-tasks/2': [{ deleteTask: ['DELETE http://127.0.0.1:54321/rest/v1/tasks'] }] }, + ]); + expect((appmap.events[0] as any).message).toEqual([{ name: 'x', class: 'String', value: '1' }]); + expect((appmap.events[2] as any).message).toEqual([{ name: 'id', class: 'String', value: 'eq.2' }]); + }); + + it('truncated recording (calls and requests still open) is balanced and valid', async () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))); + const hang = fn('hang', 90, async () => { + await fetch('https://slow.example.test/never'); + }); + const recording = openScopedRecording(new Recording({ + name: 'POST /probe/ingest', + client: { name: 'test', url: 'https://example.invalid' }, + recorder: { name: 'test', type: 'requests' }, + })); + const token = recording.httpServerRequest('POST', '/probe/ingest'); + void runInRecording(recording, () => hang(), token.callId); + await tick(); + const appmap = recording.toAppMap(); + closeScopedRecording(recording); + + expect(appmap.metadata.truncated).toBe(true); + expect(() => validate(appmap)).not.toThrow(); + expect(tree(appmap.events)).toEqual(['hang']); + expect(appmap.metadata.unanswered_http_requests?.map((u) => u.event)).toEqual([ + 'http_server_request', + 'http_client_request', + ]); + }); +}); diff --git a/recorder/test/valueCapture.test.ts b/recorder/test/valueCapture.test.ts new file mode 100644 index 0000000..193829c --- /dev/null +++ b/recorder/test/valueCapture.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { Recording, formatValue } from '../src/recording'; +import { instrument } from '../src/instrument'; +import { startRecording, stopRecording } from '../src/session'; +import { createRequire } from 'node:module'; + +const { validate } = createRequire(import.meta.url)('@appland/appmap-validate') as { + validate: (data: unknown) => void; +}; + +// The observer-effect fix: capturing a value must never run app code. +// React Query's tracked query result is an object of getters that +// subscribe the component to each field read; the recorder used to +// JSON.stringify hook return values, read every getter, and so changed +// how often components re-rendered. + +function tracked() { + const reads: string[] = []; + const result = {}; + for (const key of ['data', 'isFetching', 'status']) { + Object.defineProperty(result, key, { + enumerable: true, + configurable: false, + get: () => { + reads.push(key); + return key === 'data' ? 1 : false; + }, + }); + } + return { result, reads }; +} + +describe('value capture has no side effects', () => { + it('never invokes getters or toJSON on a captured value', () => { + const { result, reads } = tracked(); + let toJSONCalls = 0; + const withToJSON = { a: 1, toJSON: () => (toJSONCalls++, 'x') }; + formatValue(result); + formatValue({ nested: { deeper: result } }); + formatValue([result]); + formatValue(withToJSON); + expect(reads).toEqual([]); + expect(toJSONCalls).toBe(0); + }); + + it('does not read a tracked hook result returned through an instrumented hook', () => { + const { result, reads } = tracked(); + const useThing = instrument(() => result, { definedClass: 'x', methodId: 'useThing', path: 'x.ts' }); + const recording = startRecording(new Recording({ + name: 'observer effect', + client: { name: 'test', url: 'https://example.invalid' }, + recorder: { name: 'test', type: 'tests' }, + })); + try { + expect(useThing()).toBe(result); + } finally { + stopRecording(); + } + expect(reads).toEqual([]); + const ret = recording.events.find((e) => e.event === 'return') as { return_value: { value: string } }; + expect(ret.return_value.value).toBe('{"data":"[getter]","isFetching":"[getter]","status":"[getter]"}'); + }); + + it('still renders plain data exactly like JSON', () => { + const value = { a: 1, b: [1, 'x', null, true], c: { d: 'e' }, n: -2.5 }; + expect(formatValue(value).value).toBe(JSON.stringify(value)); + expect(formatValue([{ id: 1 }, { id: 2 }]).value).toBe('[{"id":1},{"id":2}]'); + expect(formatValue('plain').value).toBe('plain'); + expect(formatValue(42).value).toBe('42'); + expect(formatValue(null).value).toBe('null'); + }); + + it('keeps function-valued properties visible instead of dropping them', () => { + function onSuccess() {} + expect(formatValue({ onSuccess }).value).toBe('{"onSuccess":"[function onSuccess]"}'); + }); + + it('survives cycles and huge values within the cap', () => { + const cyclic: Record = { name: 'loop' }; + cyclic.self = cyclic; + expect(formatValue(cyclic).value).toBe('{"name":"loop","self":"[Circular]"}'); + const huge = Array.from({ length: 100_000 }, (_, i) => ({ i })); + expect(formatValue(huge).value.length).toBeLessThanOrEqual(1025); + }); + + it('reads the class name without invoking a constructor getter', () => { + let reads = 0; + const odd = Object.create({ + get constructor() { + reads++; + return Object; + }, + }); + expect(formatValue(odd).class).toBe('Object'); + expect(reads).toBe(0); + class Owner { + id = 1; + } + expect(formatValue(new Owner()).class).toBe('Owner'); + }); +}); + +// A value is cut to 100 characters (the AppMap schema's cap), so a plain +// object argument lost its later fields entirely: bulletproof-react's +// registerWithEmailAndPassword({ email, firstName, lastName, password, +// teamName }) was recorded without teamName. The spec's parameter +// `properties` keep the object's shape (name + class of each field). +describe('parameter properties (the shape survives the value cap)', () => { + it('lists a plain object\'s fields, even those cut from the value', () => { + const data = { + email: 'andrzej_wafula365@virgilio.info', + firstName: 'Yaakv.Baker11', + lastName: 'Stephen.Mahto', + password: 'secret-pw-1', + teamName: 'Acceptance Team', + nested: { a: 1 }, + }; + const f = formatValue(data, undefined, 'data'); + expect(f.value.length).toBeLessThanOrEqual(100); + expect(f.value).not.toContain('teamName'); + expect(f.properties).toEqual([ + { name: 'email', class: 'string' }, + { name: 'firstName', class: 'string' }, + { name: 'lastName', class: 'string' }, + { name: 'password', class: 'string' }, + { name: 'teamName', class: 'string' }, + { name: 'nested', class: 'Object' }, + ]); + }); + + it('runs no getter, and leaves class instances and arrays alone', () => { + const { result, reads } = tracked(); + expect(formatValue(result).properties).toBeUndefined(); // only accessors + expect(reads).toEqual([]); + class Point { + x = 1; + } + expect(formatValue(new Point()).properties).toBeUndefined(); + expect(formatValue([1, 2]).properties).toBeUndefined(); + expect(formatValue('s').properties).toBeUndefined(); + }); + + it('is recorded on call parameters and return values', () => { + const recording = startRecording(new Recording({ name: 't', client: { name: 't', url: 'u' }, recorder: { name: 't', type: 'tests' } })); + const register = instrument((data: object) => ({ ok: true, data }), { definedClass: 'auth', methodId: 'register', path: 'src/lib/auth.tsx' }, ['data']); + register({ email: 'a@b.c', teamName: 'T' }); + stopRecording(); + const call = recording.events.find((e: any) => e.method_id === 'register') as any; + expect(call.parameters[0].properties).toEqual([ + { name: 'email', class: 'string' }, + { name: 'teamName', class: 'string' }, + ]); + const ret = recording.events.find((e: any) => e.event === 'return' && e.parent_id === call.id) as any; + expect(ret.return_value.properties.map((p: any) => p.name)).toEqual(['ok', 'data']); + expect(() => validate(recording.toAppMap())).not.toThrow(); + }); +}); diff --git a/recorder/test/vitePlugin.test.ts b/recorder/test/vitePlugin.test.ts new file mode 100644 index 0000000..db71b92 --- /dev/null +++ b/recorder/test/vitePlugin.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build, createServer, type ViteDevServer } from 'vite'; +import { appmapVitePlugin } from '../src/vitePlugin'; + +// Zero-touch interaction recording (docs/design/07) against a real Vite +// dev server. The injected script must be something a browser can +// actually load: before the fix, the page carried +// `import "virtual:appmap-interaction-recorder"`, a URL scheme browsers +// refuse, so interaction recording never started in a real browser. + +const recorderRoot = fileURLToPath(new URL('..', import.meta.url)); +const fixture = join(recorderRoot, 'tmp', 'vite-plugin-fixture'); + +function injectedSpecifier(html: string): string { + const match = /', + ); + writeFileSync(join(fixture, 'src', 'main.ts'), 'export const x = 1;\n'); + afterAll(() => rmSync(fixture, { recursive: true, force: true })); + + for (const base of ['/', '/sub/']) { + it(`serves a page whose injected recorder import the browser can load (base ${base})`, async () => { + const { server, origin } = await devServer(base); + try { + const pageUrl = `${origin}${base}`; + const html = await (await fetch(pageUrl)).text(); + const specifier = injectedSpecifier(html); + // What the browser would request for this import. + const moduleUrl = new URL(specifier, pageUrl); + expect(moduleUrl.protocol).toBe('http:'); + const res = await fetch(moduleUrl); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/javascript/); + const code = await res.text(); + expect(code).toContain('installInteractionRecorder('); + expect(code).toContain('"app":"fixture-app"'); + // …and its own import of the recorder resolves too. + const recorderImport = /from\s+"([^"]+)"/.exec(code)![1]; + const recorderRes = await fetch(new URL(recorderImport, moduleUrl)); + expect(recorderRes.status).toBe(200); + expect(await recorderRes.text()).toContain('installInteractionRecorder'); + } finally { + await server.close(); + } + }); + } + + it('links the bundled recorder in a forced production build', async () => { + const outDir = join(fixture, 'dist'); + await build({ + root: fixture, + configFile: false, + logLevel: 'silent', + mode: 'production', + build: { outDir, emptyOutDir: true }, + resolve: { + alias: [{ find: '@funwithappmap/react-recorder', replacement: join(recorderRoot, 'src', 'index.ts') }], + }, + plugins: [appmapVitePlugin({ include: ['src'], app: 'fixture-app', force: true })], + }); + const html = readFileSync(join(outDir, 'index.html'), 'utf8'); + const src = /