diff --git a/.github/workflows/claude-manager.yml b/.github/workflows/claude-manager.yml index e157b470d..5bad5c459 100644 --- a/.github/workflows/claude-manager.yml +++ b/.github/workflows/claude-manager.yml @@ -9,9 +9,14 @@ on: types: [created] jobs: + # Reviews and comments only. It never merges: a human maintainer performs + # every merge (CLAUDE.md, "PR Automation"). The token can't merge anyway + # (contents: read), and drafts are skipped so work in progress isn't + # reviewed. scripts/ci/check-pr-automation.mjs enforces all of this. claude-pr-review: if: | github.event_name == 'pull_request' && + github.event.pull_request.draft == false && (github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR') @@ -30,7 +35,7 @@ jobs: - name: Wait for other checks to settle (30s) run: sleep 30 - - name: Claude PR Review & Auto-merge + - name: Claude PR Review (comments only, never merges) uses: anthropics/claude-code-action@beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -46,6 +51,10 @@ jobs: Title: ${{ github.event.pull_request.title }} Base branch: ${{ github.event.pull_request.base.ref }} + You review and comment only. Never merge, squash-merge, rebase-merge, approve or enable + auto-merge for this pull request, never push commits to it, and never mark it ready for + review. A human maintainer performs all merges. + Steps: 1. Fetch all CI check statuses for this PR using the GitHub API. 2. Check the Vercel deployment check status specifically. @@ -57,7 +66,7 @@ jobs: - cn() not converted to template literals - React hooks not placed after conditional returns - No new video-processing dependencies added - 5. If ALL checks pass and code is correct: squash-merge the PR and post a comment explaining the merge. + 5. If ALL checks pass and code is correct: post a comment summarising what you checked and that it is ready for a maintainer to review and merge. Do NOT merge it yourself. 6. If ANY check failed or code has issues: post a detailed comment tagging @${{ github.event.pull_request.user.login }} with exactly what failed and how to fix it. Do NOT merge. claude-issue-triage: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 829740651..da6254b7f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,6 +8,8 @@ jobs: steps: - uses: actions/checkout@v6 + # No workflow or instruction may merge or push to a PR on its own. + - run: node scripts/ci/check-pr-automation.mjs - uses: oven-sh/setup-bun@v2 - run: bun install - run: bun run lint \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 44b72996e..6103cafb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,50 +6,39 @@ This file instructs Claude Code (and the Claude GitHub App) how to behave as an ## PR Automation -When triggered on a pull request: +**Claude never merges pull requests. A human maintainer performs every merge.** Claude reviews, reports CI status and posts feedback. It must not merge, squash-merge, rebase-merge, approve, or enable auto-merge on a pull request, push commits to a pull request's branch, or mark a draft ready for review, however a request is phrased. -**Step 1 — Resolve merge conflicts first (if any).** -Before reviewing code or checking CI, check whether the PR has merge conflicts. If it does, resolve them automatically as described in the Merge Conflict Resolution section below. Only proceed to steps 2–3 after the branch is clean. +When triggered on a pull request that is not a draft: + +**Step 1 — Check for merge conflicts.** +If the PR conflicts with `main`, say so in your comment, list the conflicting files, and explain how they should be resolved using the Merge Conflict Guidance below. Don't resolve them yourself. **Step 2 — Check CI.** -Check whether **ALL** checks have passed — including tests and the Vercel deployment check. +Report whether **ALL** checks have passed, including tests and the deployment preview. **Step 3 — Review the code.** Review for correctness, security issues, and adherence to the standards in this file. -**If ALL checks pass AND the code looks good** → merge the PR immediately using a squash merge. Leave a short comment explaining what was merged and why it passed. +**If ALL checks pass AND the code looks good** → post a short comment saying what you checked and that the PR is ready for a maintainer to review and merge. Do not merge it. -**If ANY check failed OR the code has issues** → do NOT merge. Post a comment on the PR tagging the author (`@username`) with: +**If ANY check failed OR the code has issues** → post a comment on the PR tagging the author (`@username`) with: - A clear summary of what failed - Specific steps they need to take to fix it - A friendly but direct tone -Never merge a PR if any CI check or Vercel deployment check is still pending or has failed. - --- -## Merge Conflict Resolution +## Merge Conflict Guidance -When a PR has merge conflicts, resolve them automatically **before** doing anything else (before code review, before checking CI). +When a PR conflicts with `main`, explain the conflict in a comment so that the author or a maintainer can resolve it. Never resolve conflicts by committing or pushing to the PR's branch. ### Steps: -1. **Detect conflicts** — check the PR's mergeability via the GitHub API (`mergeable` field). If `mergeable: false`, proceed with resolution. +1. **Detect conflicts** — check the PR's mergeability via the GitHub API (`mergeable` field). -2. **Clone and resolve locally:** - ```bash - git clone https://github.com/reframe-oss/reframe.git - cd reframe - git fetch origin - git checkout - git merge origin/main - # resolve conflicts (see rules below) - git add . - git commit -m "chore: resolve merge conflicts with main" - git push origin - ``` +2. **List the conflicting files** and, for each one, what the resolution should be. -3. **Conflict resolution rules — apply these in order:** +3. **Conflict resolution rules — recommend these, in order:** - **`src/app/page.tsx`** — Always keep the `main` version exactly. This file has a known stable structure; contributor changes that add a `
` wrapper or duplicate JSX must be discarded in favour of main. - **`bun.lock` / `package-lock.json`** — Keep `bun.lock` from `main`. Delete any `package-lock.json` the PR introduced. @@ -58,17 +47,17 @@ When a PR has merge conflicts, resolve them automatically **before** doing anyth - **`.github/workflows/`** — Keep `main` versions of all workflow files. Only accept new workflow files from the PR if they don't conflict. - **All other files** — Use standard semantic merge: keep both sets of changes if they don't logically conflict. If two edits touch the same line and both are meaningful, apply the PR's change on top of `main`'s version (i.e., prefer preserving contributor intent where safe). -4. **Post a comment** on the PR after pushing the resolved branch, tagging the author: +4. **Post a comment** tagging the author, for example: - > Hey @username! This PR had merge conflicts with `main` — I've resolved them automatically and pushed the result. Here's what was changed: + > Hey @username! This PR has merge conflicts with `main`. Here's how to resolve them: > - > - **`src/app/page.tsx`**: kept the `main` version (your `
` wrapper was removed — this file has a fixed structure) - > - **`bun.lock`**: regenerated from `main` (removed `package-lock.json`) - > - _(list each file and what decision was made)_ + > - **`src/app/page.tsx`**: keep the `main` version (this file has a fixed structure) + > - **`bun.lock`**: take `main`'s version and delete `package-lock.json` + > - _(list each file and the recommended decision)_ > - > CI has been re-triggered. If anything looks wrong, feel free to push additional changes. + > Run `git merge origin/main` on your branch, resolve these, and push. A maintainer will review it afterwards. -5. **Do not resolve conflicts that require domain judgement** — if two branches both meaningfully modify the same function body in `src/lib/ffmpeg.ts` or a complex component, do not guess. Instead post a comment asking the author to rebase manually and explain exactly which lines conflict. +5. **Flag conflicts that need domain judgement** — if two branches both meaningfully modify the same function body in `src/lib/ffmpeg.ts` or a complex component, don't guess a resolution. Explain exactly which lines conflict and ask the author to resolve them. --- @@ -198,7 +187,7 @@ Every PR must have: ## General Rules -- Always take real actions via the GitHub API — post comments, merge PRs, assign issues, apply labels. +- Always take real actions via the GitHub API — post comments, assign issues, apply labels. Never merge PRs, push to PR branches, or enable auto-merge; a human maintainer merges. - Follow the code style and architecture patterns visible in the existing codebase. - Tag contributors by their GitHub username when addressing them in comments. - If uncertain about something, post a comment asking for clarification rather than guessing. diff --git a/bun.lock b/bun.lock index 74ba36aec..c91ebf99a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,9 +14,9 @@ "jszip": "^3.10.1", "lottie-web": "^5.12.2", "lucide-react": "^0.577.0", - "next": "16.3.4", - "react": "19.2.8", - "react-dom": "19.2.8", + "next": "16.4.1", + "react": "19.3.0", + "react-dom": "19.3.0", "reframe": ".", "tailwind-merge": "^3.6.0", "wasm-feature-detect": "^1.8.0", @@ -35,7 +35,7 @@ "@types/react-dom": "19.2.5", "chromatic": "^18.5.0", "eslint": "9.39.5", - "eslint-config-next": "16.3.4", + "eslint-config-next": "16.4.1", "eslint-plugin-jsx-a11y": "^6.10.2", "jsdom": "^29.1.1", "postcss": "^8", @@ -390,57 +390,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -458,25 +458,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], - "@next/env": ["@next/env@16.3.4", "", {}, "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q=="], + "@next/env": ["@next/env@16.4.1", "", {}, "sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g=="], - "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.4", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA=="], + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.4.1", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-2io6M+wAqe7nT5mt2gEVy6FOzye2DmwhjaYCpSvHfrIKL11SXNTwSARxpPHJacRmfgSBSDnNmnEXSp0DaJ23BQ=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1130,7 +1130,7 @@ "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], - "eslint-config-next": ["eslint-config-next@16.3.4", "", { "dependencies": { "@next/eslint-plugin-next": "16.3.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA=="], + "eslint-config-next": ["eslint-config-next@16.4.1", "", { "dependencies": { "@next/eslint-plugin-next": "16.4.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-6AsojbCfRdpWkP6BIUnMBv3MH4OvtfmH9p4hSfLh8ZgsW7nfhu7DpUMNBB7leisDyyMz+YYxTc+NAdBOQfovlA=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], @@ -1526,7 +1526,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next": ["next@16.3.4", "", { "dependencies": { "@next/env": "16.3.4", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.4", "@next/swc-darwin-x64": "16.3.4", "@next/swc-linux-arm64-gnu": "16.3.4", "@next/swc-linux-arm64-musl": "16.3.4", "@next/swc-linux-x64-gnu": "16.3.4", "@next/swc-linux-x64-musl": "16.3.4", "@next/swc-win32-arm64-msvc": "16.3.4", "@next/swc-win32-x64-msvc": "16.3.4", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA=="], + "next": ["next@16.4.1", "", { "dependencies": { "@next/env": "16.4.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.1", "@next/swc-darwin-x64": "16.4.1", "@next/swc-linux-arm64-gnu": "16.4.1", "@next/swc-linux-arm64-musl": "16.4.1", "@next/swc-linux-x64-gnu": "16.4.1", "@next/swc-linux-x64-musl": "16.4.1", "@next/swc-win32-arm64-msvc": "16.4.1", "@next/swc-win32-x64-msvc": "16.4.1", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], @@ -1680,13 +1680,13 @@ "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], - "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react-docgen": ["react-docgen@8.0.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w=="], "react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="], - "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1758,7 +1758,7 @@ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], @@ -1774,7 +1774,7 @@ "sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="], - "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], diff --git a/docs/DATADOG.md b/docs/DATADOG.md new file mode 100644 index 000000000..38f011b21 --- /dev/null +++ b/docs/DATADOG.md @@ -0,0 +1,203 @@ +# Datadog RUM: export reliability + +Reframe reports, from the browser, whether each export succeeded, failed or was +cancelled. Datadog RUM adds the browser, operating system and device type on +its own, so we can see which browser and device combinations fail. + +- SDK setup: `src/components/DatadogInit.tsx` +- Helpers, environment rules and scrubbing: `src/lib/telemetry.ts` +- Export and engine events: `src/lib/exportTelemetry.ts` (the only place they are sent) +- Importable dashboard: [`docs/datadog/export-reliability.dashboard.json`](datadog/export-reliability.dashboard.json) + +## Privacy + +- Session replay stays off (`sessionReplaySampleRate: 0`). +- Custom events carry only fixed enums, booleans, coarse buckets and + millisecond durations. They never carry file names, captions, paths or exact + values. `exportTelemetry.test.ts` fails if any other property appears. +- `sanitizeRumEvent` (used as `beforeSend`) removes query strings and hashes + from URLs, drops `blob:`, `data:` and `file:` resources, and replaces the + current file names, caption text, local paths and media file names in + errors and action names with `[redacted]`. +- `enablePrivacyForActionName: true` masks auto-named clicks, which could + otherwise carry the file name. + +## Environments + +`resolveTelemetryEnv` fails closed: + +- RUM never starts on localhost, `*.local`, loopback or private-LAN addresses, + or in automated browsers (`navigator.webdriver`), even though `.env.local` + holds real keys. +- `production` needs the host's own deploy context to say so (Netlify's + `CONTEXT` or Vercel's `VERCEL_ENV`, baked into `NEXT_PUBLIC_DEPLOY_CONTEXT` + by `next.config.ts`), or an explicit `NEXT_PUBLIC_DATADOG_ENV=production`. +- Netlify deploy previews and branch deploys, and Vercel previews, report + `preview` and are never relabelled `production`. +- To see real events from your machine, build with + `NEXT_PUBLIC_DATADOG_ENV=local-perf` and use a normal browser. Set it to + `off` to disable RUM anywhere. + +## Events + +| Event | When | Properties | +|---|---|---| +| `export_started` | Export clicked and settings validated | `format`, `resolution`, `input_size`, `clip_length`, `has_text`, `has_music`, `has_image_overlay`, `is_retry`, `retry_after` (retries only: the previous failure's category) | +| `export_succeeded` | Terminal | the context above, plus `engine`, `durationMs` (click to done), `encode_ms`, `output_size` | +| `export_failed` | Terminal | the context, plus `engine`, `durationMs`, `stage` (`engine` / `encode`), `category` | +| `export_cancelled` | Terminal: the user cancelled | the context, plus `engine`, `durationMs`, `stage` | +| `ffmpeg_load_success` | A worker loaded the engine (once per worker) | `engine` (`multi` / `single`, what actually loaded), `engine_requested`, `single_reason` (`not_isolated` / `after_stall` / `mt_load_failed`, single-threaded only), `load_number` (`first` / `repeat`), `durationMs` | +| `ffmpeg_load_failure` | A worker failed to load (not sent when the user cancelled) | `engine_requested`, `load_number`, `category` (`network` / `integrity` / `wasm` / `worker_script` / `unknown`), `durationMs` | +| `export` (duration vital) | With `export_succeeded`; encoding time only | the context, plus `engine` | +| `ffmpeg_init` (duration vital) | With `ffmpeg_load_success` | `engine`, `load_number` | +| Global context | Every event | `cross_origin_isolated`, `shared_array_buffer`, `cpu_cores`, `device_memory_gb`, `reduced_motion` | + +`engine` is `none` when the export ended before the engine was ready. + +Buckets: + +- `resolution` (short side of the output): `<=480p`, `720p`, `1080p`, `1440p`, `2160p+` +- `input_size`, `output_size`: `<25MB`, `25-100MB`, `100-500MB`, `500MB-1GB`, `>1GB` +- `clip_length`: `<15s`, `15-60s`, `1-5min`, `5-20min`, `>20min`, `unknown` + +Changing a bucket boundary breaks comparisons over time, so treat the buckets +as part of the schema. + +`export_failed.category`: + +| Category | Meaning | +|---|---| +| `engine_load` | The engine never became ready (any failure in the `engine` stage) | +| `timeout` | No progress for 60 s; the next attempt uses the single-threaded engine | +| `unsupported_format` | FFmpeg couldn't decode the input | +| `out_of_memory` | An allocation failed, or a `RangeError` | +| `encoder_crash` | A WASM trap in the encoder (`memory access out of bounds`, `unreachable`) | +| `network` | A network error after the engine started | +| `encode_error` | Any other FFmpeg failure | + +### Guarantees (covered by tests) + +- Every `export_started` is followed by at most one terminal event. A + cancelled encode that rejects later, or a second Cancel click, adds nothing. +- A user cancellation is `export_cancelled`, never `export_failed`. +- After a failure, the next attempt on the same video has `is_retry: true` and + `retry_after: `. Choosing a new video, "Edit another" or clearing + saved data resets this. +- Handled failures are logged with the `[reframe:handled]` prefix, and + `beforeSend` drops RUM's automatic console-error copy, so a failure is + counted once. Uncaught exceptions still reach RUM. +- An engine that can't start produces both `ffmpeg_load_failure` (engine + reliability) and `export_failed` with `category: engine_load` (the export's + outcome). They answer different questions; don't add them together. + +### Older event names + +Builds before this change send `export_start`, `export_success` and +`export_failure` (with a `reason` property instead of `category`), and +`ffmpeg_load_success` / `ffmpeg_load_failure` without `engine`. During the +transition, query both, for example +`@action.target.name:(export_success OR export_succeeded)`. + +## Querying in Datadog + +Custom actions sent with `addAction` are searched by +**`@action.target.name`**, not `@action.name`. For example: + +``` +@type:action service:reframe env:production @action.target.name:export_failed +``` + +Custom properties and the global context are under `@context.`, for +example `@context.category:timeout` or `@context.format:gif`. Browser, OS and +device come from RUM itself: `@browser.name`, `@os.name`, `@device.type`. +Vitals are `@type:vital @vital.name:export`, with durations in nanoseconds. + +The `@context.*` paths were checked against real events through the RUM +search API on 2026-11-03. Grouping by a `@context.*` attribute works in +queries and dashboards without creating a facet first; facets only make the +attribute show up in the RUM Explorer's side panel. + +## What exists in Datadog + +Created through the API on 2026-11-03 (site `datadoghq.com`): + +| Item | ID | +|---|---| +| Dashboard "Reframe — export reliability" | [`2tw-pge-qkk`](https://app.datadoghq.com/dashboard/2tw-pge-qkk) | +| Monitor: exports failing in production (> 10 `export_failed` in 1 h; warning at 5) | `340137974` | +| Monitor: FFmpeg engine failing to load (> 5 `ffmpeg_load_failure` in 1 h) | `340138002` | +| Monitor: cross-origin isolation missing (> 3 loads with `single_reason:not_isolated` in 1 h) | `340138004` | +| Monitor: frontend errors spike (> 25 uncaught errors in 1 h) | `340138012` | + +The monitors only watch `env:production`, never page anyone, and email the +maintainer. Their thresholds are absolute counts, chosen for today's low +traffic; revisit them once there are a few weeks of data. + +The management keys live in the `DD_API_KEY` and `DD_APP_KEY` GitHub secrets +(and in a maintainer's gitignored `.env.local`). They are separate from the +browser's send-only client token. Never commit them. + +## The dashboard + +`docs/datadog/export-reliability.dashboard.json` is the source of the +dashboard above. If you change it, update the live dashboard to match (see +below, with `PUT /api/v1/dashboard/2tw-pge-qkk`). + +What it shows (all for the `$env` template variable, default `production`): + +- Export success rate, `succeeded / (succeeded + failed)`, with cancellations + excluded, and the number of finished exports it is based on +- Outcomes over time, including cancellations +- Success rate by browser, device type, operating system, output format, + output resolution and engine (multi- or single-threaded) +- GIF exports: success rate by device type +- Failure categories overall, by browser and by device type +- Where exports fail (engine start or encoding) +- How often a retry recovers +- Engine start failures by cause, and why loads ended single-threaded + +Every success-rate table shows the sample size next to the rate. Ignore groups +with only a handful of finished exports: a browser with three exports and one +failure is not a 67% browser. + +### Update it + +To push a changed definition to the live dashboard, with the keys in your +environment (an application key with the `dashboards_write` scope): + +```bash +curl -X PUT "https://api.datadoghq.com/api/v1/dashboard/2tw-pge-qkk" \ + -H "Content-Type: application/json" \ + -H "DD-API-KEY: $DD_API_KEY" \ + -H "DD-APPLICATION-KEY: $DD_APP_KEY" \ + --data @docs/datadog/export-reliability.dashboard.json +``` + +Without keys, you can paste the file into Dashboards → New Dashboard → the +settings cog → **Import dashboard JSON** instead. + +For filtering in the RUM Explorer, you can also create facets for +`context.format`, `context.resolution`, `context.engine`, `context.category`, +`context.stage`, `context.is_retry` and `context.single_reason` (open an +`export_failed` event → "Create facet"). The dashboard doesn't need them. + +## Testing locally + +- `bun run test` runs with the SDK mocked, and `vitest.setup.ts` fails any + test that tries to contact Datadog. +- `DatadogInit.local.test.tsx` checks that RUM never starts on localhost, + even with keys and `NEXT_PUBLIC_DATADOG_ENV=production`. + `DatadogInit.production.test.tsx` and `DatadogInit.preview.test.tsx` load the + page as the Netlify production and deploy-preview hosts. +- `exportTelemetry.hook.test.ts` drives the editor through failures, retries + and cancellations and checks the events it sends. + +## Known limitations + +- A tab closed mid-export sends no terminal event. Those attempts show up only + as `export_started` minus the three terminal events, and RUM may not flush a + closing page's last events. +- Requests made inside the FFmpeg worker are invisible to RUM. Engine-load + categories come from Reframe's own error text, so new browser wording can + land in `unknown`. +- Rates depend on RUM's sampling (`sessionSampleRate: 100` today). diff --git a/docs/datadog/export-reliability.dashboard.json b/docs/datadog/export-reliability.dashboard.json new file mode 100644 index 000000000..3ebee75c5 --- /dev/null +++ b/docs/datadog/export-reliability.dashboard.json @@ -0,0 +1,1014 @@ +{ + "title": "Reframe — export reliability", + "description": "Export outcomes from Reframe's browser RUM events (src/lib/exportTelemetry.ts). Success rate = export_succeeded / (export_succeeded + export_failed); cancellations are excluded. Treat groups with few finished exports as noise. Setup and facets: docs/DATADOG.md.", + "layout_type": "ordered", + "template_variables": [ + { + "name": "env", + "prefix": "env", + "available_values": [ + "production", + "preview" + ], + "default": "production" + } + ], + "widgets": [ + { + "definition": { + "title": "Export success rate (cancellations excluded)", + "type": "query_value", + "precision": 1, + "custom_unit": "%", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Finished exports (sample size)", + "type": "query_value", + "precision": 0, + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [] + } + ], + "formulas": [ + { + "formula": "succeeded + failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Export outcomes over time", + "type": "timeseries", + "show_legend": true, + "requests": [ + { + "response_format": "timeseries", + "display_type": "bars", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [] + }, + { + "data_source": "rum", + "name": "cancelled", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_cancelled" + }, + "group_by": [] + } + ], + "formulas": [ + { + "formula": "succeeded", + "alias": "Succeeded" + }, + { + "formula": "failed", + "alias": "Failed" + }, + { + "formula": "cancelled", + "alias": "Cancelled by the user" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Success rate by browser", + "type": "query_table", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [ + { + "facet": "@browser.name", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@browser.name", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)", + "alias": "Success rate (%)" + }, + { + "formula": "succeeded + failed", + "alias": "Finished exports (sample size)" + }, + { + "formula": "failed", + "alias": "Failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Success rate by device type", + "type": "query_table", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [ + { + "facet": "@device.type", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@device.type", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)", + "alias": "Success rate (%)" + }, + { + "formula": "succeeded + failed", + "alias": "Finished exports (sample size)" + }, + { + "formula": "failed", + "alias": "Failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Success rate by operating system", + "type": "query_table", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [ + { + "facet": "@os.name", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@os.name", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)", + "alias": "Success rate (%)" + }, + { + "formula": "succeeded + failed", + "alias": "Finished exports (sample size)" + }, + { + "formula": "failed", + "alias": "Failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Success rate by output format", + "type": "query_table", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [ + { + "facet": "@context.format", + "limit": 4, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@context.format", + "limit": 4, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)", + "alias": "Success rate (%)" + }, + { + "formula": "succeeded + failed", + "alias": "Finished exports (sample size)" + }, + { + "formula": "failed", + "alias": "Failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Success rate by output resolution", + "type": "query_table", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [ + { + "facet": "@context.resolution", + "limit": 5, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@context.resolution", + "limit": 5, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)", + "alias": "Success rate (%)" + }, + { + "formula": "succeeded + failed", + "alias": "Finished exports (sample size)" + }, + { + "formula": "failed", + "alias": "Failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Success rate by engine (multi- vs single-threaded)", + "type": "query_table", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded" + }, + "group_by": [ + { + "facet": "@context.engine", + "limit": 3, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@context.engine", + "limit": 3, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)", + "alias": "Success rate (%)" + }, + { + "formula": "succeeded + failed", + "alias": "Finished exports (sample size)" + }, + { + "formula": "failed", + "alias": "Failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "GIF exports: success rate by device type", + "type": "query_table", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "succeeded", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded @context.format:gif" + }, + "group_by": [ + { + "facet": "@device.type", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + }, + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed @context.format:gif" + }, + "group_by": [ + { + "facet": "@device.type", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "100 * succeeded / (succeeded + failed)", + "alias": "Success rate (%)" + }, + { + "formula": "succeeded + failed", + "alias": "Finished exports (sample size)" + }, + { + "formula": "failed", + "alias": "Failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Failure categories", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@context.category", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Failure categories by browser", + "type": "query_table", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@browser.name", + "limit": 8, + "sort": { + "aggregation": "count", + "order": "desc" + } + }, + { + "facet": "@context.category", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "failed", + "alias": "Failed exports" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Failure categories by device type", + "type": "query_table", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@device.type", + "limit": 5, + "sort": { + "aggregation": "count", + "order": "desc" + } + }, + { + "facet": "@context.category", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "failed", + "alias": "Failed exports" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Where exports fail: engine start vs encoding", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed" + }, + "group_by": [ + { + "facet": "@context.stage", + "limit": 2, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Retries: how often a retry recovers", + "type": "query_value", + "precision": 1, + "custom_unit": "%", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "retry_ok", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_succeeded @context.is_retry:true" + }, + "group_by": [] + }, + { + "data_source": "rum", + "name": "retry_failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:export_failed @context.is_retry:true" + }, + "group_by": [] + } + ], + "formulas": [ + { + "formula": "100 * retry_ok / (retry_ok + retry_failed)" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Engine start failures by cause", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "load_failed", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:ffmpeg_load_failure" + }, + "group_by": [ + { + "facet": "@context.category", + "limit": 6, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "load_failed" + } + ] + } + ] + } + }, + { + "definition": { + "title": "Engine loads that ended single-threaded, by reason", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "data_source": "rum", + "name": "loads", + "indexes": [ + "*" + ], + "compute": { + "aggregation": "count" + }, + "search": { + "query": "@type:action service:reframe $env @action.target.name:ffmpeg_load_success @context.engine:single" + }, + "group_by": [ + { + "facet": "@context.single_reason", + "limit": 4, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "loads" + } + ] + } + ] + } + } + ] +} diff --git a/next.config.ts b/next.config.ts index 7f1f28a17..d027b6918 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,21 @@ import type { NextConfig } from "next"; +/** + * The hosting platform's own deploy context, read at build time: Netlify's + * CONTEXT (production / deploy-preview / branch-deploy / dev) or Vercel's + * VERCEL_ENV (production / preview / development). Empty for local builds. + * Telemetry only reports as production when this says "production" + * (src/lib/telemetry.ts, resolveTelemetryEnv). + */ +function deployContext(): string { + return (process.env.CONTEXT || process.env.VERCEL_ENV || "").trim().toLowerCase(); +} + const nextConfig: NextConfig = { output: "export", + env: { + NEXT_PUBLIC_DEPLOY_CONTEXT: deployContext(), + }, experimental: { scrollRestoration: true, }, diff --git a/package.json b/package.json index caacdf975..8cf6b3057 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "lucide-react": "^0.577.0", "next": "16.4.1", "react": "19.3.0", - "react-dom": "19.2.8", + "react-dom": "19.3.0", "reframe": ".", "tailwind-merge": "^3.6.0", "wasm-feature-detect": "^1.8.0" @@ -44,7 +44,7 @@ "@types/react-dom": "19.2.5", "chromatic": "^18.5.0", "eslint": "9.39.5", - "eslint-config-next": "16.3.4", + "eslint-config-next": "16.4.1", "eslint-plugin-jsx-a11y": "^6.10.2", "jsdom": "^29.1.1", "postcss": "^8", diff --git a/scripts/ci/check-pr-automation.mjs b/scripts/ci/check-pr-automation.mjs new file mode 100644 index 000000000..a304bd90e --- /dev/null +++ b/scripts/ci/check-pr-automation.mjs @@ -0,0 +1,113 @@ +// Guards against PR automation that merges or pushes on its own. A human +// maintainer performs every merge (CLAUDE.md, "PR Automation"). +// +// Fails when: +// 1. any workflow runs a merge: `gh pr merge`, the REST merge endpoint, +// GraphQL mergePullRequest / enablePullRequestAutoMerge, merge_method; +// 2. a job using anthropics/claude-code-action can write repository +// contents (`contents: write`, `write-all`), which merging and pushing need; +// 3. such a job can write to pull requests but doesn't skip drafts +// (`github.event.pull_request.draft == false` in its `if`); +// 4. a Claude prompt or CLAUDE.md tells the agent to merge, squash-merge, +// enable auto-merge, or push to a PR branch, without a negation +// ("never", "do not", …) on the same line. +// +// Usage: node scripts/ci/check-pr-automation.mjs (exit 0 ok, 1 problems) +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const MERGE_COMMANDS = [ + /\bgh\s+pr\s+merge\b/i, + /\/pulls\/[^\s/]+\/merge\b/i, + /\bmergePullRequest\b/, + /\benablePullRequestAutoMerge\b/, + /\bmerge_method\b/, + /\bgh\s+pr\s+ready\b/i, +]; + +// Affirmative instructions that would make an agent merge or push. +const MERGE_INSTRUCTION = + /\b(squash[- ]?merge|rebase[- ]?merge|auto[- ]?merge|merge (it|the pr|the pull request|this pr|this pull request|prs|pull requests)|git push)\b/i; +const NEGATION = /\b(never|not|don't|do not|no|must not|cannot|can't)\b/i; + +/** Splits a workflow's text into { name, text } blocks per job (2-space keys under `jobs:`). */ +export function jobsOf(workflowText) { + const lines = workflowText.split("\n"); + const start = lines.findIndex((l) => /^jobs:\s*$/.test(l)); + if (start < 0) return []; + const jobs = []; + let current = null; + for (const line of lines.slice(start + 1)) { + if (/^\S/.test(line) && line.trim()) break; + const m = line.match(/^ {2}([A-Za-z0-9_-]+):\s*$/); + if (m) { + current = { name: m[1], lines: [] }; + jobs.push(current); + } else if (current) { + current.lines.push(line); + } + } + return jobs.map((j) => ({ name: j.name, text: j.lines.join("\n") })); +} + +/** Lines that tell an agent to merge or push, ignoring negated ones. */ +export function affirmativeMergeLines(text) { + return text + .split("\n") + .map((line, i) => ({ line: line.trim(), n: i + 1 })) + .filter(({ line }) => MERGE_INSTRUCTION.test(line) && !NEGATION.test(line)); +} + +export function checkWorkflow(file, text) { + const problems = []; + text.split("\n").forEach((line, i) => { + if (line.trim().startsWith("#")) return; + for (const re of MERGE_COMMANDS) if (re.test(line)) problems.push(`${file}:${i + 1}: runs a merge operation (${re})`); + }); + if (/^permissions:\s*write-all/m.test(text)) problems.push(`${file}: workflow-level permissions: write-all`); + + for (const job of jobsOf(text)) { + if (!/anthropics\/claude-code-action/.test(job.text)) continue; + const where = `${file} › ${job.name}`; + if (/^\s+contents:\s*write\b/m.test(job.text) || /permissions:\s*write-all/.test(job.text)) { + problems.push(`${where}: Claude job can write repository contents`); + } + const prWrite = /^\s+pull-requests:\s*write\b/m.test(job.text); + const prTriggered = /pull_request/.test(job.text) || /^\s+if:[\s\S]*?pull_request/m.test(job.text); + if (prWrite && prTriggered && !/github\.event\.pull_request\.draft\s*==\s*false/.test(job.text)) { + problems.push(`${where}: can write to pull requests but doesn't skip drafts`); + } + for (const { line, n } of affirmativeMergeLines(job.text)) { + problems.push(`${where} (job line ${n}): instruction to merge or push: "${line.slice(0, 100)}"`); + } + } + return problems; +} + +export function checkInstructions(file, text) { + return affirmativeMergeLines(text).map(({ line, n }) => `${file}:${n}: instruction to merge or push: "${line.slice(0, 100)}"`); +} + +export function checkRepository(root) { + const problems = []; + const dir = path.join(root, ".github/workflows"); + for (const name of fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !/ \d+\.ya?ml$/.test(f))) { + problems.push(...checkWorkflow(`.github/workflows/${name}`, fs.readFileSync(path.join(dir, name), "utf8"))); + } + for (const name of ["CLAUDE.md", ".github/CLAUDE.md"]) { + const p = path.join(root, name); + if (fs.existsSync(p)) problems.push(...checkInstructions(name, fs.readFileSync(p, "utf8"))); + } + return problems; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + const problems = checkRepository(root); + if (problems.length) { + console.error("PR automation safety check failed:\n" + problems.map((p) => ` - ${p}`).join("\n")); + process.exit(1); + } + console.log("PR automation safety check passed: no workflow or instruction merges or pushes on its own."); +} diff --git a/src/components/DatadogInit.tsx b/src/components/DatadogInit.tsx index 4d8ed0574..c5d2ef42f 100644 --- a/src/components/DatadogInit.tsx +++ b/src/components/DatadogInit.tsx @@ -1,35 +1,69 @@ "use client"; import { useEffect } from "react"; -import { datadogRum } from "@datadog/browser-rum"; -import { reactPlugin } from "@datadog/browser-rum-react"; +import { capabilityContext, currentTelemetryEnv, registerRum, sanitizeRumEvent } from "@/lib/telemetry"; export default function DatadogInit() { useEffect(() => { const applicationId = process.env.NEXT_PUBLIC_DATADOG_APPLICATION_ID; const clientToken = process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN; - if (!applicationId || !clientToken) { + // null on localhost, LAN addresses and automated browsers unless + // NEXT_PUBLIC_DATADOG_ENV names a non-production env (see telemetry.ts). + const env = currentTelemetryEnv(); + if (!applicationId || !clientToken || !env) { return; } - datadogRum.init({ - applicationId, - clientToken, - site: "datadoghq.com", - service: "reframe", - env: "production", - version: "0.1.0", - sessionSampleRate: 100, - // Reframe advertises itself as 100% private client-side video editing — - // session replay would visually record the editor UI, which cuts - // against that claim. Metrics/errors only. - sessionReplaySampleRate: 0, - plugins: [reactPlugin()], - trackUserInteractions: true, - trackResources: true, - trackLongTasks: true, - }); + let cancelled = false; + + // The SDK isn't needed for first paint, so it loads as a separate chunk + // once the browser is idle. RUM still captures the initial view's + // FCP/LCP/CLS from buffered performance entries. + const start = () => { + Promise.all([import("@datadog/browser-rum"), import("@datadog/browser-rum-react")]) + .then(([{ datadogRum }, { reactPlugin }]) => { + if (cancelled) return; + datadogRum.init({ + applicationId, + clientToken, + site: "datadoghq.com", + service: "reframe", + env, + version: "0.1.0", + sessionSampleRate: 100, + // Reframe advertises itself as 100% private client-side video editing — + // session replay would visually record the editor UI, which cuts + // against that claim. Metrics/errors only. + sessionReplaySampleRate: 0, + plugins: [reactPlugin()], + trackUserInteractions: true, + // Auto-named click actions use the clicked element's text, which can + // include the uploaded file's name. Mask them; custom actions keep names. + enablePrivacyForActionName: true, + trackResources: true, + trackLongTasks: true, + // Strips the recipe query string, blob: previews, file names, + // captions and local paths from automatically collected events. + beforeSend: (event) => sanitizeRumEvent(event as Parameters[0]), + }); + datadogRum.setGlobalContext(capabilityContext()); + registerRum(datadogRum); + }) + .catch(() => { + // Telemetry is optional; never surface a failure to the user. + }); + }; + + const idle = window.requestIdleCallback + ? window.requestIdleCallback(start, { timeout: 4000 }) + : window.setTimeout(start, 1500); + + return () => { + cancelled = true; + if (window.cancelIdleCallback) window.cancelIdleCallback(idle); + else window.clearTimeout(idle); + }; }, []); return null; diff --git a/src/components/__tests__/DatadogInit.local.test.tsx b/src/components/__tests__/DatadogInit.local.test.tsx new file mode 100644 index 000000000..27cf43c1b --- /dev/null +++ b/src/components/__tests__/DatadogInit.local.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { rumInit, renderDatadogInit } from "./datadogInitHarness"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("DatadogInit on localhost (this test run)", () => { + it("never starts RUM or contacts Datadog, even with real-looking keys", async () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "pub0000"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", ""); + const requests = await renderDatadogInit(); + expect(rumInit).not.toHaveBeenCalled(); + expect(requests.filter((u) => /datadoghq|browser-intake|ddog/.test(u))).toEqual([]); + }); + + it("refuses an explicit production env locally", async () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "pub0000"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", "production"); + await renderDatadogInit(); + expect(rumInit).not.toHaveBeenCalled(); + }); + + it("does nothing, and breaks nothing, without configuration", async () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", ""); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", ""); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", "unit-test"); + await expect(renderDatadogInit()).resolves.toEqual([]); + expect(rumInit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/DatadogInit.preview.test.tsx b/src/components/__tests__/DatadogInit.preview.test.tsx new file mode 100644 index 000000000..ab83961d9 --- /dev/null +++ b/src/components/__tests__/DatadogInit.preview.test.tsx @@ -0,0 +1,17 @@ +// @vitest-environment-options {"url": "https://deploy-preview-1802--reframe-os.netlify.app/"} +import { describe, it, expect, vi, afterEach } from "vitest"; +import { rumInit, renderDatadogInit } from "./datadogInitHarness"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("DatadogInit on a Netlify deploy preview", () => { + it("tags events preview, never production", async () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "pub0000"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", ""); + // Even if a preview were built with the production context. + vi.stubEnv("NEXT_PUBLIC_DEPLOY_CONTEXT", "production"); + await renderDatadogInit(); + expect(rumInit.mock.calls[0]![0]).toMatchObject({ env: "preview" }); + }); +}); diff --git a/src/components/__tests__/DatadogInit.production.test.tsx b/src/components/__tests__/DatadogInit.production.test.tsx new file mode 100644 index 000000000..b1324bd97 --- /dev/null +++ b/src/components/__tests__/DatadogInit.production.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment-options {"url": "https://reframe-os.netlify.app/"} +import { describe, it, expect, vi, afterEach } from "vitest"; +import { rumInit, renderDatadogInit } from "./datadogInitHarness"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("DatadogInit on the production host", () => { + it("starts RUM tagged production, with session replay off", async () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "pub0000"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", ""); + vi.stubEnv("NEXT_PUBLIC_VERCEL_ENV", ""); + // Netlify's CONTEXT=production, baked in by next.config.ts at build time. + vi.stubEnv("NEXT_PUBLIC_DEPLOY_CONTEXT", "production"); + await renderDatadogInit(); + expect(rumInit).toHaveBeenCalledTimes(1); + expect(rumInit.mock.calls[0]![0]).toMatchObject({ + env: "production", + service: "reframe", + sessionReplaySampleRate: 0, + enablePrivacyForActionName: true, + }); + }); + + it("stays off on the production host when nothing confirms a production build", async () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "pub0000"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", ""); + vi.stubEnv("NEXT_PUBLIC_VERCEL_ENV", ""); + vi.stubEnv("NEXT_PUBLIC_DEPLOY_CONTEXT", ""); + const requests = await renderDatadogInit(); + expect(rumInit).not.toHaveBeenCalled(); + expect(requests).toEqual([]); + }); +}); diff --git a/src/components/__tests__/datadogInitHarness.tsx b/src/components/__tests__/datadogInitHarness.tsx new file mode 100644 index 000000000..ec588fe19 --- /dev/null +++ b/src/components/__tests__/datadogInitHarness.tsx @@ -0,0 +1,38 @@ +// Shared by the DatadogInit tests, which differ only in the page URL (set per +// file with @vitest-environment-options, since jsdom's location is fixed). +import { render } from "@testing-library/react"; +import { vi } from "vitest"; + +export const rumInit = vi.fn(); + +vi.mock("@datadog/browser-rum", () => ({ + datadogRum: { init: rumInit, setGlobalContext: vi.fn(), addAction: vi.fn(), addDurationVital: vi.fn(), addTiming: vi.fn() }, +})); +vi.mock("@datadog/browser-rum-react", () => ({ reactPlugin: () => ({ name: "react" }) })); + +/** Renders DatadogInit and waits past its idle-time start. Returns network calls seen. */ +export async function renderDatadogInit() { + rumInit.mockClear(); + const requests: string[] = []; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + requests.push(String(input instanceof Request ? input.url : input)); + return new Response(""); + }); + const beacon = vi.fn((url: string) => { + requests.push(url); + return true; + }); + Object.defineProperty(navigator, "sendBeacon", { configurable: true, value: beacon }); + const open = vi.spyOn(XMLHttpRequest.prototype, "open").mockImplementation(function (_m: string, url: string | URL) { + requests.push(String(url)); + }); + + const { default: DatadogInit } = await import("../DatadogInit"); + const view = render(); + // DatadogInit starts on requestIdleCallback, or after 1.5 s without it. + await new Promise((r) => setTimeout(r, 1700)); + view.unmount(); + fetchSpy.mockRestore(); + open.mockRestore(); + return requests; +} diff --git a/src/hooks/tests/exportTelemetry.hook.test.ts b/src/hooks/tests/exportTelemetry.hook.test.ts new file mode 100644 index 000000000..1b09798e0 --- /dev/null +++ b/src/hooks/tests/exportTelemetry.hook.test.ts @@ -0,0 +1,140 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import type { DatadogRum } from "@datadog/browser-rum"; +import { useVideoEditor } from "../useVideoEditor"; +import { loadFFmpeg, exportVideo, FFmpegLoadError } from "@/lib/ffmpeg"; +import { HANDLED_ERROR_PREFIX, registerRum, resetTelemetryForTests } from "@/lib/telemetry"; + +// Real error classes, mocked engine. +vi.mock("@/lib/ffmpeg", async (importOriginal) => ({ + ...(await importOriginal()), + loadFFmpeg: vi.fn(), + exportVideo: vi.fn(), + terminateFFmpeg: vi.fn(), + getEngineMode: vi.fn(() => "multi"), +})); +vi.mock("@/lib/sessionDB", () => ({ + saveSessionFile: vi.fn().mockResolvedValue(undefined), + loadSessionFile: vi.fn().mockResolvedValue(null), + clearSessionFile: vi.fn().mockResolvedValue(undefined), +})); + +const okResult = { blobUrl: "blob:result", blob: new Blob(), size: 1000, width: 1920, height: 1080, format: "mp4" as const, dispose() {} }; + +// jsdom has no media pipeline: report metadata as soon as a src is set. +beforeAll(() => { + Object.defineProperty(HTMLMediaElement.prototype, "src", { + configurable: true, + get: () => "", + set(this: HTMLVideoElement) { + setTimeout(() => this.onloadedmetadata?.(new Event("loadedmetadata")), 0); + }, + }); + Object.defineProperty(HTMLVideoElement.prototype, "videoWidth", { configurable: true, get: () => 1920 }); + Object.defineProperty(HTMLVideoElement.prototype, "videoHeight", { configurable: true, get: () => 1080 }); + Object.defineProperty(HTMLMediaElement.prototype, "duration", { configurable: true, get: () => 12 }); + URL.createObjectURL = vi.fn(() => "blob:test"); + URL.revokeObjectURL = vi.fn(); +}); + +let rum: { addAction: ReturnType; addDurationVital: ReturnType }; +const lifecycle = () => + rum.addAction.mock.calls + .map(([name, ctx]) => ({ name: name as string, ctx: ctx as Record })) + .filter((e) => e.name.startsWith("export_")); + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState(null, "", "/"); + vi.mocked(loadFFmpeg).mockResolvedValue(undefined); + resetTelemetryForTests(); + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "token"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", "unit-test"); + rum = { addAction: vi.fn(), addDurationVital: vi.fn() }; + registerRum(rum as unknown as DatadogRum); +}); +afterEach(() => { + vi.unstubAllEnvs(); + resetTelemetryForTests(); +}); + +const mp4 = () => + new File([new Uint8Array([0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d])], "holiday-clip.mp4", { type: "video/mp4" }); + +async function editorWithFile() { + const hook = renderHook(() => useVideoEditor()); + act(() => hook.result.current.handleFileSelect(mp4())); + await waitFor(() => expect(hook.result.current.file).not.toBeNull()); + return hook; +} + +describe("export reliability telemetry in the editor", () => { + it("reports each export's outcome with bounded context, and whether a retry recovered", async () => { + const { result } = await editorWithFile(); + vi.mocked(exportVideo).mockRejectedValueOnce(new Error("RuntimeError: memory access out of bounds")); + await act(() => result.current.handleExport()); + vi.mocked(exportVideo).mockResolvedValueOnce(okResult); + await act(() => result.current.handleExport()); + + expect(lifecycle().map((e) => [e.name, e.ctx.is_retry, e.ctx.category ?? e.ctx.retry_after ?? null])).toEqual([ + ["export_started", false, null], + ["export_failed", false, "encoder_crash"], + ["export_started", true, "encoder_crash"], + ["export_succeeded", true, null], + ]); + expect(lifecycle()[1]!.ctx).toMatchObject({ format: "mp4", resolution: "1080p", engine: "multi", stage: "encode" }); + // Never the file name or caption text. + expect(JSON.stringify(rum.addAction.mock.calls)).not.toContain("holiday-clip"); + }); + + it("a cancel followed by a late rejection is one cancellation, not a failure", async () => { + const { result } = await editorWithFile(); + let reject!: (e: unknown) => void; + vi.mocked(exportVideo).mockImplementationOnce(() => new Promise((_, r) => (reject = r))); + let done!: Promise; + act(() => { + done = result.current.handleExport(); + }); + await waitFor(() => expect(result.current.status).toBe("exporting")); + act(() => result.current.cancelExport()); + act(() => result.current.cancelExport()); + await act(async () => { + reject(new Error("ffmpeg exited with code 255")); + await done; + }); + expect(lifecycle().map((e) => e.name)).toEqual(["export_started", "export_cancelled"]); + expect(lifecycle()[1]!.ctx).toMatchObject({ stage: "encode" }); + }); + + it("cancelling while the engine loads is reported at the engine stage", async () => { + const { result } = await editorWithFile(); + vi.mocked(loadFFmpeg).mockImplementationOnce(() => new Promise(() => {})); + act(() => { + void result.current.handleExport(); + }); + await waitFor(() => expect(result.current.status).toBe("loading-engine")); + act(() => result.current.cancelExport()); + expect(lifecycle().map((e) => [e.name, e.ctx.stage ?? null])).toEqual([ + ["export_started", null], + ["export_cancelled", "engine"], + ]); + }); + + it("an engine that can't start is an engine_load failure", async () => { + const { result } = await editorWithFile(); + vi.mocked(loadFFmpeg).mockRejectedValueOnce(new FFmpegLoadError("Failed to fetch engine file (HTTP 503)")); + await act(() => result.current.handleExport()); + expect(lifecycle().at(-1)).toMatchObject({ name: "export_failed", ctx: { stage: "engine", category: "engine_load" } }); + }); + + it("logs handled failures with the prefix RUM's console collection drops", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { result } = await editorWithFile(); + vi.mocked(exportVideo).mockRejectedValueOnce(new Error("ffmpeg exited with code 1")); + await act(() => result.current.handleExport()); + expect(spy.mock.calls.some(([first]) => String(first).startsWith(HANDLED_ERROR_PREFIX))).toBe(true); + spy.mockRestore(); + }); +}); diff --git a/src/hooks/tests/useVideoEditor.test.ts b/src/hooks/tests/useVideoEditor.test.ts index 27eaa4a71..8e164d314 100644 --- a/src/hooks/tests/useVideoEditor.test.ts +++ b/src/hooks/tests/useVideoEditor.test.ts @@ -8,6 +8,7 @@ vi.mock("@/lib/ffmpeg", () => ({ loadFFmpeg: vi.fn().mockResolvedValue(true), exportVideo: vi.fn().mockResolvedValue({ blobUrl: "blob:test" }), terminateFFmpeg: vi.fn(), + getEngineMode: vi.fn(() => null), FFmpegLoadError: class extends Error {}, })); diff --git a/src/hooks/tests/useVideoEditorProject.test.ts b/src/hooks/tests/useVideoEditorProject.test.ts index 43c9eaefb..c0b2c0e9a 100644 --- a/src/hooks/tests/useVideoEditorProject.test.ts +++ b/src/hooks/tests/useVideoEditorProject.test.ts @@ -17,6 +17,7 @@ vi.mock("@/lib/ffmpeg", () => ({ loadFFmpeg: vi.fn().mockResolvedValue(true), exportVideo: vi.fn().mockResolvedValue({ blobUrl: "blob:out", size: 1, width: 1, height: 1 }), terminateFFmpeg: vi.fn(), + getEngineMode: vi.fn(() => "multi"), formatBytes: (n: number) => `${n} B`, FFmpegLoadError: class extends Error {}, })); diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index ee698aaa4..020d334a8 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -30,8 +30,15 @@ import { } from "@/lib/project"; import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants"; import { getPresetById } from "@/lib/presets"; -import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; -import { trackAction } from "@/lib/telemetry"; +import { loadFFmpeg, exportVideo, terminateFFmpeg, getEngineMode, FFmpegLoadError } from "@/lib/ffmpeg"; +import { + addTelemetryRedaction, + durationBucket, + HANDLED_ERROR_PREFIX, + resolutionBucket, + sizeBucket, +} from "@/lib/telemetry"; +import { ExportAttempt, type ExportFailureCategory } from "@/lib/exportTelemetry"; import { suggestPreset } from "@/lib/presetSuggestion"; import { validateDimensions, getDownscaledDimensions } from "@/utils/video-validation"; import { @@ -309,6 +316,11 @@ export function useVideoEditor() { const [exportStartedAt, setExportStartedAt] = useState(null); const exportAbortControllerRef = useRef(null); const exportCancelledRef = useRef(false); + /** The export attempt in progress, which reports its single outcome. */ + const exportAttemptRef = useRef(null); + // Category of the last failed export for this source, so the next attempt + // is reported as a retry; cleared by a success or a new source video. + const lastFailureRef = useRef(null); const videoRef = useRef(null); const [musicFile, setMusicFile] = useState(null); @@ -640,6 +652,7 @@ export function useVideoEditor() { setDuration(dur); setVideoMetadata({ width, height, duration: dur }); setFile(selectedFile); + lastFailureRef.current = null; setStorageStatus((prev) => ({ ...prev, sourceCopy: "saving" })); saveSessionFile(selectedFile) .then(() => setStorageStatus((prev) => ({ ...prev, sourceCopy: "saved" }))) @@ -690,8 +703,26 @@ export function useVideoEditor() { exportAbortControllerRef.current = abortController; exportCancelledRef.current = false; + const preset = recipe.preset === "custom" ? null : getPresetById(recipe.preset); + const outW = preset ? preset.width : recipe.customWidth; + const outH = preset ? preset.height : recipe.customHeight; + const clipSeconds = ((recipe.trimEnd ?? duration) - recipe.trimStart) / recipe.speed; + // Low-cardinality only: never the file name, captions or exact values. + const attempt = new ExportAttempt( + { + format: recipe.format, + resolution: resolutionBucket(outW, outH), + input_size: sizeBucket(file.size), + clip_length: durationBucket(clipSeconds), + has_text: (recipe.textOverlays?.length ?? 0) > 0, + has_music: Boolean(musicFile), + has_image_overlay: Boolean(overlayFile), + }, + lastFailureRef.current + ); + exportAttemptRef.current = attempt; + try { - trackAction("export_start"); setStatus("loading-engine"); setProgress(0); setError(null); @@ -703,6 +734,7 @@ export function useVideoEditor() { if (exportCancelledRef.current) return; const startedAt = Date.now(); + attempt.encoding(getEngineMode()); setExportStartedAt(startedAt); setStatus("exporting"); @@ -731,12 +763,18 @@ export function useVideoEditor() { exportDurationMs: Date.now() - startedAt, }); setStatus("done"); - trackAction("export_success", { durationMs: Date.now() - startedAt }); + attempt.succeeded(sizeBucket(exportResult.size)); + lastFailureRef.current = null; } catch (err) { + // A cancellation was already reported by cancelExport; a settled + // attempt ignores further outcomes, so a late rejection adds nothing. if (exportCancelledRef.current) return; - trackAction("export_failure", { reason: err instanceof Error ? err.name : "unknown" }); + const category = attempt.failed(err); + if (category) lastFailureRef.current = category; - console.error("export failed:", err); + // Reported above as export_failed; the prefix keeps RUM's automatic + // console-error collection from reporting it a second time. + console.error(`${HANDLED_ERROR_PREFIX} export failed:`, err); if (err instanceof FFmpegLoadError) { setError(err.message); } else if (err instanceof Error && err.message.includes("network")) { @@ -752,6 +790,9 @@ export function useVideoEditor() { if (exportAbortControllerRef.current === abortController) { exportAbortControllerRef.current = null; } + if (exportAttemptRef.current === attempt) { + exportAttemptRef.current = null; + } } }, [ duration, @@ -863,7 +904,21 @@ export function useVideoEditor() { updateEdit((prev) => toEditState(DEFAULT_RECIPE, prev.imageOverlay)); }, [updateEdit]); + // Keep user-specific strings (file names, captions) out of anything the + // RUM SDK collects on its own, such as console errors. + useEffect(() => { + addTelemetryRedaction( + file?.name, + musicFile?.name, + overlayFile?.name, + recovery?.source.name, + ...(recipe.textOverlays ?? []).map((overlay) => overlay.text) + ); + }, [file, musicFile, overlayFile, recovery, recipe.textOverlays]); + const cancelExport = useCallback(() => { + exportAttemptRef.current?.cancelled(); + exportAttemptRef.current = null; exportCancelledRef.current = true; exportAbortControllerRef.current?.abort(); exportAbortControllerRef.current = null; @@ -875,6 +930,7 @@ export function useVideoEditor() { }, []); const reset = useCallback(() => { + lastFailureRef.current = null; if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setFile(null); setVideoMetadata(null); @@ -900,6 +956,7 @@ export function useVideoEditor() { /** Removes everything this editor saved in the browser and starts over. */ const deleteSavedData = useCallback(() => { + lastFailureRef.current = null; if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); pendingSaveRef.current = null; clearSavedEditorData(); diff --git a/src/lib/exportTelemetry.ts b/src/lib/exportTelemetry.ts new file mode 100644 index 000000000..6d0397d0e --- /dev/null +++ b/src/lib/exportTelemetry.ts @@ -0,0 +1,210 @@ +/** + * Export and engine-load telemetry with fixed semantics: + * - every export_started is followed by at most one terminal event + * (export_succeeded, export_failed or export_cancelled); + * - a user cancellation is never an export_failed; + * - failures carry one category from a closed list, never the raw message. + * Event names and properties are documented in docs/DATADOG.md. + */ +import { failureCategory, trackAction, trackVital } from "./telemetry"; + +/** Which FFmpeg core the worker loaded. */ +export type EngineMode = "multi" | "single"; + +export type ExportStage = "engine" | "encode"; + +export type ExportFailureCategory = + | "engine_load" + | "timeout" + | "unsupported_format" + | "out_of_memory" + | "encoder_crash" + | "network" + | "encode_error"; + +export type EngineLoadFailureCategory = "network" | "integrity" | "wasm" | "worker_script" | "unknown"; + +/** Why a load ended up single-threaded. */ +export type SingleThreadReason = "not_isolated" | "after_stall" | "mt_load_failed"; + +/** Bounded, non-identifying description of an export. */ +export interface ExportContext { + format: string; + resolution: string; + input_size: string; + clip_length: string; + has_text: boolean; + has_music: boolean; + has_image_overlay: boolean; +} + +const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + +const isAbort = (err: unknown) => + (err instanceof DOMException || err instanceof Error) && err.name === "AbortError"; + +export function exportFailureCategory(err: unknown, stage: ExportStage): ExportFailureCategory { + const name = err instanceof Error ? err.name : ""; + if (name === "ExportStalledError") return "timeout"; + if (stage === "engine" || name === "FFmpegLoadError") return "engine_load"; + switch (failureCategory(err)) { + case "memory": + return "out_of_memory"; + case "crash": + return "encoder_crash"; + case "codec": + return "unsupported_format"; + case "network": + return "network"; + default: + return "encode_error"; + } +} + +/** + * Classifies an engine start-up failure from Reframe's own error text (the + * worker's messages, never user input). `source` is set by the caller when it + * knows more, e.g. the worker script itself failed to load. + */ +export function engineLoadFailureCategory(err: unknown): EngineLoadFailureCategory { + const source = (err as { source?: unknown } | null)?.source; + if (source === "worker_script") return "worker_script"; + const msg = err instanceof Error ? `${err.name} ${err.message}`.toLowerCase() : String(err ?? "").toLowerCase(); + if (/integrity|digest|\bsri\b/.test(msg)) return "integrity"; + if (/webassembly|compileerror|linkerror|\bwasm\b|instantiate/.test(msg)) return "wasm"; + if (/fetch|network|http \d{3}|load failed/.test(msg)) return "network"; + if (/syntaxerror|importscripts|\bworker\b/.test(msg)) return "worker_script"; + return "unknown"; +} + +type Outcome = "succeeded" | "failed" | "cancelled"; + +/** One export attempt, from the Export click to its single outcome. */ +export class ExportAttempt { + stage: ExportStage = "engine"; + private outcome: Outcome | null = null; + private readonly startedAt = now(); + private encodeStartedAt: number | null = null; + private engine: EngineMode | "none" = "none"; + + constructor( + private readonly context: ExportContext, + private readonly retryOf: ExportFailureCategory | null = null + ) { + trackAction("export_started", { + ...context, + is_retry: retryOf !== null, + ...(retryOf ? { retry_after: retryOf } : {}), + }); + } + + get settled(): boolean { + return this.outcome !== null; + } + + /** The engine is ready and encoding begins. */ + encoding(engine: EngineMode | null): void { + this.stage = "encode"; + this.engine = engine ?? "single"; + this.encodeStartedAt = now(); + } + + private common() { + return { + ...this.context, + engine: this.engine, + is_retry: this.retryOf !== null, + durationMs: Math.round(now() - this.startedAt), + }; + } + + /** Returns false if this attempt already had an outcome. */ + succeeded(outputSize: string): boolean { + if (this.settled) return false; + this.outcome = "succeeded"; + const encodeStartedAt = this.encodeStartedAt ?? this.startedAt; + // The duration vital measures encoding only; engine start-up is ffmpeg_init. + trackVital("export", encodeStartedAt, { ...this.context, engine: this.engine }); + trackAction("export_succeeded", { + ...this.common(), + encode_ms: Math.round(now() - encodeStartedAt), + output_size: outputSize, + }); + return true; + } + + /** + * Records a failure, or a cancellation if `err` is an AbortError. Returns + * the category, or null if this attempt already had an outcome. + */ + failed(err: unknown): ExportFailureCategory | null { + if (this.settled) return null; + if (isAbort(err)) { + this.cancelled(); + return null; + } + this.outcome = "failed"; + const category = exportFailureCategory(err, this.stage); + trackAction("export_failed", { ...this.common(), stage: this.stage, category }); + return category; + } + + /** Returns false if this attempt already had an outcome. */ + cancelled(): boolean { + if (this.settled) return false; + this.outcome = "cancelled"; + trackAction("export_cancelled", { ...this.common(), stage: this.stage }); + return true; + } +} + +// ── Engine start-up ────────────────────────────────────────────────────────── + +let loadsThisPage = 0; + +/** + * One engine load (one worker). Reports exactly one of ffmpeg_load_success or + * ffmpeg_load_failure, or nothing if the load was abandoned (a cancellation, + * which the export attempt reports). + */ +export function startEngineLoad(requested: EngineMode, singleReason: SingleThreadReason | null = null) { + const startedAt = now(); + // "repeat" loads usually reuse the HTTP-cached core: a different cost profile. + const load_number = loadsThisPage++ === 0 ? "first" : "repeat"; + let done = false; + return { + succeeded(engine: EngineMode) { + if (done) return; + done = true; + // The worker falls back to the single-threaded core by itself when the + // multi-threaded one fails to load. + const reason = engine === "single" ? (requested === "multi" ? "mt_load_failed" : singleReason) : null; + trackVital("ffmpeg_init", startedAt, { engine, load_number }); + trackAction("ffmpeg_load_success", { + engine, + engine_requested: requested, + ...(reason ? { single_reason: reason } : {}), + load_number, + durationMs: Math.round(now() - startedAt), + }); + }, + failed(err: unknown) { + if (done) return; + done = true; + trackAction("ffmpeg_load_failure", { + engine_requested: requested, + load_number, + category: engineLoadFailureCategory(err), + durationMs: Math.round(now() - startedAt), + }); + }, + abandoned() { + done = true; + }, + }; +} + +/** Test helper. */ +export function resetEngineLoadCountForTests() { + loadsThisPage = 0; +} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 1251ac076..497646131 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,9 +1,16 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions, MAX_FILE_SIZE } from "./types"; import { getPresetById } from "./presets"; import { buildTextFilter } from "./text-overlay"; -import { trackAction, trackTiming } from "./telemetry"; +import { trackTiming } from "./telemetry"; +import { startEngineLoad, type EngineMode } from "./exportTelemetry"; -export class FFmpegLoadError extends Error {} +export class FFmpegLoadError extends Error { + name = "FFmpegLoadError"; + /** Set when the worker script itself failed, for telemetry classification. */ + constructor(message: string, readonly source?: "worker_script") { + super(message); + } +} type SerializedFile = { @@ -24,7 +31,7 @@ type WorkerExportRequest = { overlayOptions?: ImageOverlayOptions; }; -type WorkerLoadResponse = { type: "ready" }; +type WorkerLoadResponse = { type: "ready"; mode?: EngineMode }; type WorkerProgressResponse = { type: "progress"; percent: number }; type WorkerResultResponse = { type: "result"; @@ -57,6 +64,11 @@ let pendingExport: { } | null = null; let pendingProgress: ((percent: number) => void) | null = null; +/** The core the current worker loaded, once it is ready. */ +let engineMode: EngineMode | null = null; +/** Telemetry for the engine load in progress, if any (one outcome per worker). */ +let engineLoad: ReturnType | null = null; + function createWorker(): Worker { if (typeof window === "undefined") { throw new Error("Web Workers are not available in this environment."); @@ -68,7 +80,9 @@ function createWorker(): Worker { ffmpegWorker.onmessage = handleWorkerMessage; ffmpegWorker.onerror = (event) => { const message = event.message || "FFmpeg worker error"; - const error = new FFmpegLoadError(message); + const error = new FFmpegLoadError(message, "worker_script"); + if (workerReadyReject) engineLoad?.failed(error); + engineLoad = null; workerReadyReject?.(error); pendingExport?.reject(error); resetWorker(); @@ -83,6 +97,10 @@ function createWorker(): Worker { } function resetWorker() { + // A load that never finished was abandoned (cancelled), not failed. + engineLoad?.abandoned(); + engineLoad = null; + engineMode = null; ffmpegWorker = null; workerReady = null; workerReadyResolve = null; @@ -95,6 +113,9 @@ function handleWorkerMessage(event: MessageEvent) { const data = event.data; if (data.type === "ready") { + engineMode = data.mode ?? "single"; + engineLoad?.succeeded(engineMode); + engineLoad = null; workerReadyResolve?.(); workerReadyResolve = null; workerReadyReject = null; @@ -137,7 +158,10 @@ function handleWorkerMessage(event: MessageEvent) { return; } - workerReadyReject?.(new FFmpegLoadError(data.message)); + const loadError = new FFmpegLoadError(data.message); + if (workerReadyReject) engineLoad?.failed(loadError); + engineLoad = null; + workerReadyReject?.(loadError); workerReady = null; workerReadyResolve = null; workerReadyReject = null; @@ -177,6 +201,8 @@ export async function loadFFmpeg( // 2. Use the captured flag to securely trigger the worker's internal load phase if (isFirstLoad) { + const isolated = typeof crossOriginIsolated !== "undefined" && crossOriginIsolated; + engineLoad = startEngineLoad(isolated ? "multi" : "single", isolated ? null : "not_isolated"); ffmpegWorker!.postMessage({ type: "load" }); } @@ -199,20 +225,11 @@ export async function loadFFmpeg( signal?.addEventListener("abort", onAbort, { once: true }); - const loadStart = performance.now(); try { + // ffmpeg_load_success / ffmpeg_load_failure are reported once per worker + // by the message handlers, however many callers wait on the same load. await workerReady; - const durationMs = Math.round(performance.now() - loadStart); - trackAction("ffmpeg_load_success", { durationMs }); - trackTiming("ffmpeg_ready"); - } catch (err) { - if (!(err instanceof DOMException && err.name === "AbortError")) { - trackAction("ffmpeg_load_failure", { - durationMs: Math.round(performance.now() - loadStart), - reason: err instanceof Error ? err.name : "unknown", - }); - } - throw err; + if (isFirstLoad) trackTiming("ffmpeg_ready"); } finally { cleanup(); } @@ -340,6 +357,11 @@ async function getVideoDuration(file: File): Promise { }); } +/** The core the worker loaded ("multi" or "single"), or null before it is ready. */ +export function getEngineMode(): EngineMode | null { + return engineMode; +} + export function terminateFFmpeg() { if (ffmpegWorker) { ffmpegWorker.postMessage({ type: "terminate" }); diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts index b0e33a683..52fbed3e0 100644 --- a/src/lib/ffmpeg.worker.ts +++ b/src/lib/ffmpeg.worker.ts @@ -52,7 +52,8 @@ type WorkerCommand = LoadRequest | ExportRequest | CancelRequest | TerminateRequ type ProgressPayload = { type: "progress"; percent: number }; -type ReadyPayload = { type: "ready" }; +/** `mode`: which core loaded, multi-threaded (cross-origin isolated) or single. */ +type ReadyPayload = { type: "ready"; mode: "multi" | "single" }; type ResultPayload = { type: "result"; @@ -73,6 +74,7 @@ type WorkerResponse = ProgressPayload | ReadyPayload | ResultPayload | ErrorPayl let ffmpeg: FFmpeg | null = null; let ffmpegLoaded = false; +let engineMode: "multi" | "single" = "single"; let activeExportAbortController: AbortController | null = null; let activeExportId: string | null = null; @@ -345,6 +347,7 @@ async function loadCore(onProgress?: (percent: number) => void): Promise { const isIsolated = typeof self !== "undefined" && self.crossOriginIsolated; const baseURL = isIsolated ? MT_CORE_BASE_URL : CORE_BASE_URL; + engineMode = isIsolated ? "multi" : "single"; const handleProgress = ({ progress }: { progress: number }) => { onProgress?.(Math.round(progress * 100)); @@ -654,7 +657,7 @@ async function handleCommand(message: WorkerCommand) { case "load": { try { await loadCore(); - postMessage({ type: "ready" }); + postMessage({ type: "ready", mode: engineMode }); } catch (error) { postMessage({ type: "error", message: (error as Error).message }); } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index a3eb8a8f3..5496763b8 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,26 +1,335 @@ -import { datadogRum } from "@datadog/browser-rum"; +import type { DatadogRum as RumPublicApi } from "@datadog/browser-rum"; // Custom Datadog RUM actions. Only coarse, non-identifying metrics are sent — -// never file names, contents, or recipe values (Reframe is privacy-first). -// No-op when RUM isn't configured (see DatadogInit). -const enabled = () => +// never file names, contents, captions or recipe values (Reframe is +// privacy-first). No-op when RUM isn't configured (see DatadogInit). +// +// Event names and their properties are defined in docs/DATADOG.md +// ("Events"); export and engine-load events are emitted only through +// src/lib/exportTelemetry.ts, which guarantees one outcome per attempt. +// ffmpeg_load_success / ffmpeg_load_failure +// export_started → export_succeeded | export_failed | export_cancelled +// Duration vitals (RUM → Vitals): ffmpeg_init, export. +// +// The SDK is loaded lazily by DatadogInit (it is not needed for first paint). +// Calls made before it registers are queued (bounded) and replayed. +export const telemetryConfigured = () => typeof window !== "undefined" && - Boolean(process.env.NEXT_PUBLIC_DATADOG_APPLICATION_ID && process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN); + Boolean(process.env.NEXT_PUBLIC_DATADOG_APPLICATION_ID && process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN) && + currentTelemetryEnv() !== null; -export function trackAction(name: string, context?: Record) { - if (!enabled()) return; +// ── Environment ──────────────────────────────────────────────────────────── +// Local builds read .env.local, which holds the real Datadog keys. Without a +// guard, `next dev`, local production builds and the perf scripts all +// reported as env:production. + +// Netlify serves deploy previews and branch deploys at +// `--.netlify.app`; only `.netlify.app` (or a +// custom domain) is production. The hostname check backs up the build-time +// deploy context, which a hand-made draft deploy doesn't have. +const NETLIFY_NON_PRODUCTION_HOST = /^[^.]+--[^.]+\.netlify\.app$/i; + +const LOCAL_HOST = + /^(localhost|.+\.localhost|.+\.local|0\.0\.0\.0|127(\.\d+){3}|\[?::1\]?|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2})$/i; + +export interface TelemetryEnvInput { + /** NEXT_PUBLIC_DATADOG_ENV — explicit override; "off" disables RUM. */ + explicitEnv?: string; + /** + * NEXT_PUBLIC_DEPLOY_CONTEXT — the host's own deploy context, baked in at + * build time by next.config.ts: Netlify's CONTEXT (production / + * deploy-preview / branch-deploy / dev) or Vercel's VERCEL_ENV. + */ + deployContext?: string; + /** NEXT_PUBLIC_VERCEL_ENV — Vercel's own copy of VERCEL_ENV. */ + vercelEnv?: string; + hostname?: string; + /** navigator.webdriver — true under Puppeteer, Playwright and Lighthouse CI. */ + webdriver?: boolean; +} + +const PRODUCTION = new Set(["production", "prod"]); +const PREVIEW_CONTEXTS = new Set(["deploy-preview", "branch-deploy", "preview"]); +const DEV_CONTEXTS = new Set(["dev", "development"]); + +/** + * The Datadog `env` tag to report under, or null to not start RUM at all. + * Fails closed: + * - local hosts and automated browsers never report, unless an explicit + * non-production env is set (e.g. NEXT_PUBLIC_DATADOG_ENV=local-perf); + * - preview hosts and preview/dev deploy contexts never report as production; + * - "production" needs the host's deploy context to say production, or an + * explicit NEXT_PUBLIC_DATADOG_ENV=production. A build with neither (a + * local build deployed by hand, an unknown host) sends nothing. + */ +export function resolveTelemetryEnv({ explicitEnv, deployContext, vercelEnv, hostname, webdriver }: TelemetryEnvInput): string | null { + const explicit = explicitEnv?.trim().toLowerCase() || undefined; + if (explicit === "off" || explicit === "none" || explicit === "disabled") return null; + const explicitNonProd = explicit && !PRODUCTION.has(explicit) ? explicit : undefined; + + const local = !hostname || LOCAL_HOST.test(hostname) || webdriver === true; + if (local) return explicitNonProd ?? null; + + const context = (deployContext || vercelEnv || "").trim().toLowerCase(); + if (NETLIFY_NON_PRODUCTION_HOST.test(hostname) || PREVIEW_CONTEXTS.has(context)) return explicitNonProd ?? "preview"; + if (DEV_CONTEXTS.has(context)) return explicitNonProd ?? "development"; + if (explicitNonProd) return explicitNonProd; + if (context === "production" || (explicit && PRODUCTION.has(explicit))) return "production"; + return null; +} + +export function currentTelemetryEnv(): string | null { + if (typeof window === "undefined") return null; + return resolveTelemetryEnv({ + explicitEnv: process.env.NEXT_PUBLIC_DATADOG_ENV, + deployContext: process.env.NEXT_PUBLIC_DEPLOY_CONTEXT, + vercelEnv: process.env.NEXT_PUBLIC_VERCEL_ENV, + hostname: window.location?.hostname, + webdriver: typeof navigator !== "undefined" ? navigator.webdriver : undefined, + }); +} + +export type TelemetryContext = Record; + +type RumCall = (rum: RumPublicApi) => void; +const MAX_QUEUED = 50; +let rum: RumPublicApi | null = null; +let queue: RumCall[] = []; + +function run(call: RumCall) { + if (!telemetryConfigured()) return; + if (!rum) { + if (queue.length < MAX_QUEUED) queue.push(call); + return; + } try { - datadogRum.addAction(name, context); + call(rum); } catch { // Telemetry must never break the editor. } } +/** Called once by DatadogInit after the SDK is initialised. */ +export function registerRum(instance: RumPublicApi) { + rum = instance; + const pending = queue; + queue = []; + pending.forEach(run); +} + +/** Test helper: forget the registered SDK and queued calls. */ +export function resetTelemetryForTests() { + rum = null; + queue = []; + redactions.clear(); +} + +export function trackAction(name: string, context?: TelemetryContext) { + run((r) => r.addAction(name, context)); +} + export function trackTiming(name: string) { - if (!enabled()) return; - try { - datadogRum.addTiming(name); - } catch { - // ignore + // addTiming is relative to the current view; capture the time now, not at replay. + const time = typeof performance !== "undefined" ? Math.round(performance.now()) : undefined; + run((r) => r.addTiming(name, time)); +} + +/** + * Records a RUM duration vital only, for percentiles in Datadog. Use this when + * a custom action already carries the same duration, so nothing is counted twice. + * `startedAt` is a `performance.now()` timestamp. + */ +export function trackVital(name: string, startedAt: number, context?: TelemetryContext) { + const durationMs = Math.round(performance.now() - startedAt); + const startTime = Math.round(performance.timeOrigin + startedAt); + run((r) => r.addDurationVital(name, { startTime, duration: durationMs, context })); +} + +/** + * Records a duration both as a RUM duration vital (for percentiles in + * Datadog) and as an action carrying `durationMs` (for event search). + * `startedAt` is a `performance.now()` timestamp. + */ +export function trackDuration(name: string, startedAt: number, context?: TelemetryContext) { + const durationMs = Math.round(performance.now() - startedAt); + trackAction(name, { ...context, durationMs }); + const startTime = Math.round(performance.timeOrigin + startedAt); + run((r) => r.addDurationVital(name, { startTime, duration: durationMs, context })); +} + +// ── Low-cardinality buckets ──────────────────────────────────────────────── +// Raw sizes/durations are identifying in aggregate and explode cardinality, +// so only coarse ranges are reported. + +export function sizeBucket(bytes: number): string { + const mb = bytes / (1024 * 1024); + if (mb < 25) return "<25MB"; + if (mb < 100) return "25-100MB"; + if (mb < 500) return "100-500MB"; + if (mb < 1024) return "500MB-1GB"; + return ">1GB"; +} + +export function durationBucket(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "unknown"; + if (seconds < 15) return "<15s"; + if (seconds < 60) return "15-60s"; + if (seconds < 300) return "1-5min"; + if (seconds < 1200) return "5-20min"; + return ">20min"; +} + +export function resolutionBucket(width: number, height: number): string { + const p = Math.min(width, height); + if (p <= 480) return "<=480p"; + if (p <= 720) return "720p"; + if (p <= 1080) return "1080p"; + if (p <= 1440) return "1440p"; + return "2160p+"; +} + +/** Maps an export error to a small fixed set of categories (never the raw message). */ +export function failureCategory(err: unknown): string { + if (err instanceof DOMException && err.name === "AbortError") return "cancelled"; + if (err instanceof Error && err.name === "ExportStalledError") return "stalled"; + const msg = err instanceof Error ? err.message.toLowerCase() : ""; + const name = err instanceof Error ? err.name : ""; + // Word boundaries: "NetworkError" contains "worker". + if (name === "FFmpegLoadError" || /\bengine\b|\bworker\b/.test(msg)) return "engine"; + // A wasm trap inside an encoder, not the tab running out of memory. + if (msg.includes("memory access out of bounds") || msg.includes("unreachable") || msg.includes("runtimeerror")) return "crash"; + if (msg.includes("out of memory") || msg.includes("allocation failed") || /\boom\b/.test(msg) || name === "RangeError") return "memory"; + if (msg.includes("network") || msg.includes("fetch")) return "network"; + if (msg.includes("codec") || msg.includes("decod")) return "codec"; + return "ffmpeg"; +} + +/** + * Coarse device/browser capabilities, attached once as RUM global context so + * every event can be sliced by them. All values are bucketed booleans/ranges. + */ +export function capabilityContext(): TelemetryContext { + if (typeof window === "undefined") return {}; + const nav = navigator as Navigator & { deviceMemory?: number }; + const cores = nav.hardwareConcurrency ?? 0; + return { + cross_origin_isolated: Boolean(window.crossOriginIsolated), + shared_array_buffer: typeof SharedArrayBuffer !== "undefined", + cpu_cores: cores >= 8 ? "8+" : cores >= 4 ? "4-7" : cores > 0 ? "1-3" : "unknown", + device_memory_gb: nav.deviceMemory ? (nav.deviceMemory >= 8 ? "8+" : String(nav.deviceMemory)) : "unknown", + reduced_motion: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, + }; +} + +// ── Scrubbing automatically collected events ────────────────────────────── +// Custom actions above only carry buckets, but the SDK also collects errors +// (including console.error), resources and view URLs on its own. Those can +// contain the uploaded file's name, a local path or the recipe in the query +// string, so they pass through sanitizeRumEvent in DatadogInit's beforeSend. + +const MEDIA_EXT = "mp4|m4v|mov|qt|webm|mkv|avi|wmv|flv|3gp|gif|mp3|wav|aac|m4a|ogg|oga|opus|flac|png|jpe?g|webp|avif|heic|bmp|svg|srt|vtt"; +const FILE_NAME = new RegExp(`[^\\s"'<>/\\\\:|*?]+\\.(?:${MEDIA_EXT})\\b`, "gi"); +const LOCAL_PATH = /(?:[A-Za-z]:\\|\/(?:Users|home|private|var|tmp|mnt|media|Volumes|storage|sdcard|data)\/)[^\s"'<>):]*/g; +const MAX_TEXT = 1000; + +const redactions = new Set(); +const MAX_REDACTIONS = 200; + +/** + * Registers user-specific strings (file names, captions) to strip from any + * automatically collected event text. Kept in memory only, never sent. + */ +export function addTelemetryRedaction(...values: (string | null | undefined)[]) { + for (const value of values) { + const v = value?.trim(); + if (!v || v.length < 3) continue; + redactions.add(v); + const stem = v.replace(/\.[^.]+$/, ""); + if (stem !== v && stem.length >= 3) redactions.add(stem); + } + // Captions are registered as they're typed; keep the most recent entries. + while (redactions.size > MAX_REDACTIONS) { + redactions.delete(redactions.values().next().value!); + } +} + +export function clearTelemetryRedactions() { + redactions.clear(); +} + +function stripUrlQuery(text: string): string { + return text.replace(/(\b(?:https?|wss?):\/\/[^\s?#"'<>]*)[?#][^\s"'<>]*/gi, "$1"); +} + +/** Removes file names, local paths, blob/file URLs and query strings from free text. */ +export function scrubText(text: string): string { + let out = text; + // Longest first so "My clip.mp4" goes before its stem "My clip". + for (const value of [...redactions].sort((a, b) => b.length - a.length)) { + out = out.split(value).join("[redacted]"); + } + out = out + .replace(/\bblob:[^\s"'<>)]+/gi, "blob:[redacted]") + .replace(/\bfile:\/\/[^\s"'<>)]+/gi, "file:[redacted]") + .replace(/\bdata:[^\s"'<>)]{24,}/gi, "data:[redacted]"); + out = stripUrlQuery(out).replace(LOCAL_PATH, "[path]").replace(FILE_NAME, "[file]"); + return out.length > MAX_TEXT ? `${out.slice(0, MAX_TEXT)}…` : out; +} + +export function stripQuery(url: string | undefined): string | undefined { + if (!url) return url; + const i = url.search(/[?#]/); + return i === -1 ? url : url.slice(0, i); +} + +/** + * Prefix for console.error calls about failures that are already reported as + * a custom event (e.g. export_failed). sanitizeRumEvent drops RUM's automatic + * copy of them so one failure is counted once. + */ +export const HANDLED_ERROR_PREFIX = "[reframe:handled]"; + +type MutableRumEvent = { + type?: string; + view?: { url?: string; referrer?: string; name?: string }; + resource?: { url?: string }; + error?: { + source?: string; + message?: string; + stack?: string; + resource?: { url?: string }; + causes?: { message?: string; stack?: string }[]; + }; + action?: { target?: { name?: string } }; +}; + +/** + * DatadogInit's beforeSend. Returns false to drop the event. Mutates in + * place because the SDK only reads back modified fields of the same object. + */ +export function sanitizeRumEvent(event: MutableRumEvent): boolean { + if (event.view) { + event.view.url = stripQuery(event.view.url); + event.view.referrer = stripQuery(event.view.referrer); + if (event.view.name) event.view.name = scrubText(event.view.name); + } + if (event.type === "resource" && event.resource?.url) { + // Previews and exports are local blob: URLs; never report them. + if (/^(blob|data|file):/i.test(event.resource.url)) return false; + event.resource.url = stripQuery(event.resource.url) ?? event.resource.url; + } + if (event.error) { + if (event.error.source === "console" && event.error.message?.startsWith(HANDLED_ERROR_PREFIX)) return false; + if (event.error.message) event.error.message = scrubText(event.error.message); + if (event.error.stack) event.error.stack = scrubText(event.error.stack); + if (event.error.resource?.url) event.error.resource.url = stripQuery(event.error.resource.url) ?? ""; + event.error.causes?.forEach((cause) => { + if (cause.message) cause.message = scrubText(cause.message); + if (cause.stack) cause.stack = scrubText(cause.stack); + }); + } + if (event.action?.target?.name) { + event.action.target.name = scrubText(event.action.target.name); } + return true; } diff --git a/src/lib/tests/exportTelemetry.test.ts b/src/lib/tests/exportTelemetry.test.ts new file mode 100644 index 000000000..52916ac3c --- /dev/null +++ b/src/lib/tests/exportTelemetry.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + ExportAttempt, + engineLoadFailureCategory, + exportFailureCategory, + resetEngineLoadCountForTests, + startEngineLoad, + type ExportContext, +} from "../exportTelemetry"; +import { HANDLED_ERROR_PREFIX, registerRum, resetTelemetryForTests, sanitizeRumEvent } from "../telemetry"; +import { FFmpegLoadError } from "../ffmpeg"; + +// main has no stall watchdog yet; an error with its name must still map to "timeout". +class ExportStalledError extends Error { + name = "ExportStalledError"; + constructor(readonly mode: "multi" | "single") { + super("Export stopped responding"); + } +} +import type { DatadogRum } from "@datadog/browser-rum"; + +const context: ExportContext = { + format: "mp4", + resolution: "1080p", + input_size: "<25MB", + clip_length: "<15s", + has_text: false, + has_music: false, + has_image_overlay: false, +}; + +// Every property any of these events may carry. Anything else is a bug. +const ALLOWED_KEYS = new Set([ + ...Object.keys(context), + "engine", + "engine_requested", + "is_retry", + "retry_after", + "durationMs", + "encode_ms", + "output_size", + "stage", + "category", + "load_number", + "single_reason", +]); + +let rum: { addAction: ReturnType; addDurationVital: ReturnType }; +const events = () => rum.addAction.mock.calls.map(([name, ctx]) => ({ name: name as string, ctx: ctx as Record })); +const names = () => events().map((e) => e.name); + +beforeEach(() => { + resetTelemetryForTests(); + resetEngineLoadCountForTests(); + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "token"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", "unit-test"); + rum = { addAction: vi.fn(), addDurationVital: vi.fn() }; + registerRum(rum as unknown as DatadogRum); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("export attempt outcomes", () => { + it("reports a success once, with engine, monotonic durations and buckets only", () => { + const attempt = new ExportAttempt(context); + attempt.encoding("multi"); + expect(attempt.succeeded("<25MB")).toBe(true); + expect(attempt.succeeded("<25MB")).toBe(false); + attempt.failed(new Error("late")); + attempt.cancelled(); + + expect(names()).toEqual(["export_started", "export_succeeded"]); + const done = events()[1]!.ctx; + expect(done).toMatchObject({ engine: "multi", is_retry: false, output_size: "<25MB" }); + expect(done.durationMs).toBeGreaterThanOrEqual(0); + expect(done.encode_ms).toBeGreaterThanOrEqual(0); + expect(rum.addDurationVital).toHaveBeenCalledWith("export", expect.objectContaining({ context: { ...context, engine: "multi" } })); + }); + + it("never reports a user cancellation as a failure", () => { + const attempt = new ExportAttempt(context); + attempt.encoding("single"); + expect(attempt.cancelled()).toBe(true); + // The aborted encode later rejects; it must not become a second outcome. + expect(attempt.failed(new DOMException("Aborted", "AbortError"))).toBeNull(); + expect(attempt.failed(new Error("ffmpeg exited"))).toBeNull(); + expect(names()).toEqual(["export_started", "export_cancelled"]); + expect(events()[1]!.ctx).toMatchObject({ stage: "encode", engine: "single" }); + }); + + it("treats an AbortError as a cancellation even without cancelled()", () => { + const attempt = new ExportAttempt(context); + expect(attempt.failed(new DOMException("Aborted", "AbortError"))).toBeNull(); + expect(names()).toEqual(["export_started", "export_cancelled"]); + expect(events()[1]!.ctx).toMatchObject({ stage: "engine", engine: "none" }); + }); + + it("reports a failure once with a controlled category, never the message", () => { + const attempt = new ExportAttempt(context); + attempt.encoding("multi"); + const secret = "/Users/alice/holiday clip.mp4: Invalid data found"; + expect(attempt.failed(new Error(secret))).toBe("encode_error"); + expect(attempt.failed(new Error("again"))).toBeNull(); + expect(names()).toEqual(["export_started", "export_failed"]); + expect(JSON.stringify(rum.addAction.mock.calls)).not.toContain("alice"); + expect(events()[1]!.ctx).toMatchObject({ stage: "encode", category: "encode_error" }); + }); + + it("marks retries, and whether the retry recovered", () => { + new ExportAttempt(context, "timeout").succeeded("<25MB"); + expect(events()[0]!.ctx).toMatchObject({ is_retry: true, retry_after: "timeout" }); + expect(events()[1]!.ctx).toMatchObject({ is_retry: true }); + }); + + it("only ever sends documented, bounded properties", () => { + const a = new ExportAttempt(context, "engine_load"); + a.encoding("multi"); + a.failed(new Error("x")); + new ExportAttempt(context).cancelled(); + new ExportAttempt(context).succeeded("25-100MB"); + const load = startEngineLoad("multi"); + load.failed(new Error("Failed to fetch engine file (HTTP 503)")); + startEngineLoad("single").succeeded("single"); + + for (const { name, ctx } of events()) { + for (const [key, value] of Object.entries(ctx ?? {})) { + expect(ALLOWED_KEYS.has(key), `${name}.${key}`).toBe(true); + expect(["string", "number", "boolean"]).toContain(typeof value); + if (typeof value === "string") expect(value.length).toBeLessThan(24); + } + } + }); +}); + +describe("failure categories", () => { + it.each([ + [new ExportStalledError("multi"), "encode", "timeout"], + [new FFmpegLoadError("boom"), "encode", "engine_load"], + [new Error("anything"), "engine", "engine_load"], + [new RangeError("Array buffer allocation failed"), "encode", "out_of_memory"], + [new Error("RuntimeError: memory access out of bounds"), "encode", "encoder_crash"], + [new Error("Decoder (codec h265) not found"), "encode", "unsupported_format"], + [new Error("NetworkError when attempting to fetch"), "encode", "network"], + [new Error("ffmpeg exited with code 1"), "encode", "encode_error"], + ["not an error", "encode", "encode_error"], + ] as const)("%s during %s → %s", (err, stage, expected) => { + expect(exportFailureCategory(err, stage)).toBe(expected); + }); + + it.each([ + [new FFmpegLoadError("Uncaught SyntaxError", "worker_script"), "worker_script"], + [new FFmpegLoadError("Failed to fetch engine file (HTTP 404)"), "network"], + [new TypeError("Failed to fetch"), "network"], + [new FFmpegLoadError("Failed to find a valid digest in the 'integrity' attribute"), "integrity"], + [new FFmpegLoadError("WebAssembly.instantiate(): Out of memory"), "wasm"], + [new FFmpegLoadError("something else"), "unknown"], + ])("engine load %s → %s", (err, expected) => { + expect(engineLoadFailureCategory(err)).toBe(expected); + }); +}); + +describe("engine load events", () => { + it("records why a load ended up single-threaded", () => { + startEngineLoad("multi").succeeded("multi"); + startEngineLoad("multi").succeeded("single"); + startEngineLoad("single", "not_isolated").succeeded("single"); + startEngineLoad("single", "after_stall").succeeded("single"); + expect(events().map((e) => e.ctx.single_reason ?? null)).toEqual([null, "mt_load_failed", "not_isolated", "after_stall"]); + expect(events()[1]!.ctx).toMatchObject({ engine: "single", engine_requested: "multi" }); + }); + + it("reports one outcome per load and labels later loads as repeats", () => { + const first = startEngineLoad("multi"); + first.succeeded("multi"); + first.succeeded("multi"); + first.failed(new Error("late")); + const second = startEngineLoad("single"); + second.failed(new Error("Failed to fetch")); + + expect(names()).toEqual(["ffmpeg_load_success", "ffmpeg_load_failure"]); + expect(events()[0]!.ctx).toMatchObject({ engine: "multi", load_number: "first" }); + expect(events()[1]!.ctx).toMatchObject({ engine_requested: "single", load_number: "repeat", category: "network" }); + expect(rum.addDurationVital).toHaveBeenCalledTimes(1); + }); + + it("reports nothing for an abandoned (cancelled) load", () => { + const load = startEngineLoad("multi"); + load.abandoned(); + load.failed(new Error("terminated")); + expect(names()).toEqual([]); + }); +}); + +describe("handled errors", () => { + it("drops RUM's automatic copy of a failure that was already reported", () => { + const handled = { type: "error", error: { source: "console", message: `${HANDLED_ERROR_PREFIX} export failed: Error` } }; + const unhandled = { type: "error", error: { source: "console", message: "Unhandled application error" } }; + const thrown = { type: "error", error: { source: "source", message: `${HANDLED_ERROR_PREFIX} looks handled` } }; + expect(sanitizeRumEvent(handled)).toBe(false); + expect(sanitizeRumEvent(unhandled)).toBe(true); + // Only console copies are dropped; a real exception always reaches RUM. + expect(sanitizeRumEvent(thrown)).toBe(true); + }); +}); diff --git a/src/lib/tests/prAutomationSafety.test.ts b/src/lib/tests/prAutomationSafety.test.ts new file mode 100644 index 000000000..3acfa7cde --- /dev/null +++ b/src/lib/tests/prAutomationSafety.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { checkInstructions, checkRepository, checkWorkflow } from "../../../scripts/ci/check-pr-automation.mjs"; + +const root = path.resolve(__dirname, "../../.."); + +const reviewJob = (body: { if?: string; perms?: string; prompt?: string; name?: string } = {}) => `name: Test +on: + pull_request: + types: [opened, synchronize] +jobs: + review: + if: | +${body.if ?? " github.event_name == 'pull_request' &&\n github.event.pull_request.draft == false"} + runs-on: ubuntu-latest + permissions: +${body.perms ?? " contents: read\n pull-requests: write"} + steps: + - name: ${body.name ?? "Review"} + uses: anthropics/claude-code-action@beta + with: + direct_prompt: | +${body.prompt ?? " Review the PR and post a comment. Never merge it."} +`; + +describe("PR automation safety check", () => { + it("passes this repository's workflows and CLAUDE.md", () => { + expect(checkRepository(root)).toEqual([]); + }); + + it("accepts a comment-only, draft-skipping review job", () => { + expect(checkWorkflow("w.yml", reviewJob())).toEqual([]); + }); + + it.each([ + ["an instruction to squash-merge", { prompt: " 5. If all checks pass: squash-merge the PR." }, /instruction to merge/], + ["an instruction to merge the PR", { prompt: " Then merge the PR and comment." }, /instruction to merge/], + ["enabling auto-merge", { prompt: " Enable auto-merge when green." }, /instruction to merge/], + ["pushing to the PR branch", { prompt: " git push origin the-branch" }, /instruction to merge or push/], + ["an auto-merge step name", { name: "Review & Auto-merge" }, /instruction to merge/], + ["contents: write", { perms: " contents: write\n pull-requests: write" }, /write repository contents/], + ["write-all", { perms: " write-all" }, /write repository contents/], + ["no draft guard", { if: " github.event_name == 'pull_request'" }, /doesn't skip drafts/], + ])("rejects %s", (_label, body, message) => { + expect(checkWorkflow("w.yml", reviewJob(body)).join("\n")).toMatch(message); + }); + + it.each([ + "run: gh pr merge --squash ${{ github.event.pull_request.number }}", + "run: gh api -X PUT repos/o/r/pulls/1/merge", + "run: gh pr ready 12", + "query: mutation { enablePullRequestAutoMerge(input: {}) { clientMutationId } }", + ])("rejects a merge operation in any workflow: %s", (line) => { + const wf = `name: X\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - ${line}\n`; + expect(checkWorkflow("x.yml", wf).join("\n")).toMatch(/merge operation/); + }); + + it("flags affirmative merge instructions in CLAUDE.md but allows prohibitions", () => { + expect(checkInstructions("CLAUDE.md", "If checks pass, merge the PR immediately using a squash merge.")).toHaveLength(1); + expect(checkInstructions("CLAUDE.md", "Never merge PRs or enable auto-merge; a maintainer merges.")).toEqual([]); + expect(checkInstructions("CLAUDE.md", "Uses `clsx` + `tailwind-merge`.")).toEqual([]); + }); +}); diff --git a/src/lib/tests/telemetry.test.ts b/src/lib/tests/telemetry.test.ts new file mode 100644 index 000000000..0a42d3e27 --- /dev/null +++ b/src/lib/tests/telemetry.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + trackAction, + trackDuration, + registerRum, + resetTelemetryForTests, + sizeBucket, + durationBucket, + resolutionBucket, + failureCategory, + capabilityContext, + resolveTelemetryEnv, + currentTelemetryEnv, + telemetryConfigured, + scrubText, + sanitizeRumEvent, + addTelemetryRedaction, +} from "../telemetry"; +import type { DatadogRum } from "@datadog/browser-rum"; + +function fakeRum() { + return { + addAction: vi.fn(), + addTiming: vi.fn(), + addDurationVital: vi.fn(), + } as unknown as DatadogRum & { + addAction: ReturnType; + addDurationVital: ReturnType; + }; +} + +describe("telemetry", () => { + beforeEach(() => { + resetTelemetryForTests(); + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "token"); + // jsdom runs on localhost, which is disabled unless an env is explicit. + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", "unit-test"); + }); + afterEach(() => vi.unstubAllEnvs()); + + it("queues events until the lazily-loaded SDK registers, then replays them in order", () => { + trackAction("export_start", { format: "mp4" }); + trackAction("export_success"); + const rum = fakeRum(); + registerRum(rum); + expect(rum.addAction.mock.calls.map((c) => c[0])).toEqual(["export_start", "export_success"]); + trackAction("export_cancel"); + expect(rum.addAction).toHaveBeenLastCalledWith("export_cancel", undefined); + }); + + it("is a no-op when RUM isn't configured", () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", ""); + const rum = fakeRum(); + registerRum(rum); + trackAction("export_start"); + expect(rum.addAction).not.toHaveBeenCalled(); + }); + + it("never throws into the editor when the SDK fails", () => { + const rum = fakeRum(); + rum.addAction.mockImplementation(() => { + throw new Error("sdk broke"); + }); + registerRum(rum); + expect(() => trackAction("export_start")).not.toThrow(); + }); + + it("records durations as both an action and a duration vital", () => { + const rum = fakeRum(); + registerRum(rum); + trackDuration("export", performance.now() - 1500, { format: "webm" }); + expect(rum.addAction).toHaveBeenCalledWith("export", expect.objectContaining({ format: "webm", durationMs: expect.any(Number) })); + expect(rum.addDurationVital).toHaveBeenCalledWith("export", expect.objectContaining({ duration: expect.any(Number) })); + const ms = rum.addAction.mock.calls[0]![1].durationMs; + expect(ms).toBeGreaterThanOrEqual(1500); + }); + + it("buckets values so no exact sizes, lengths or messages are sent", () => { + expect(sizeBucket(10 * 1024 * 1024)).toBe("<25MB"); + expect(sizeBucket(3 * 1024 ** 3)).toBe(">1GB"); + expect(durationBucket(42)).toBe("15-60s"); + expect(durationBucket(NaN)).toBe("unknown"); + expect(resolutionBucket(1080, 1920)).toBe("1080p"); + expect(failureCategory(new Error("Failed to fetch core"))).toBe("network"); + expect(failureCategory(new DOMException("x", "AbortError"))).toBe("cancelled"); + expect(failureCategory(new Error("/Users/me/secret-video.mp4: Invalid data"))).toBe("ffmpeg"); + expect(failureCategory(new Error("RuntimeError: memory access out of bounds"))).toBe("crash"); + expect(failureCategory(new Error("Aborted(OOM)"))).toBe("memory"); + expect(failureCategory(Object.assign(new Error("x"), { name: "ExportStalledError" }))).toBe("stalled"); + }); + + it("capability context contains only coarse, non-identifying values", () => { + const ctx = capabilityContext(); + for (const value of Object.values(ctx)) { + expect(["string", "boolean"]).toContain(typeof value); + } + expect(Object.keys(ctx).sort()).toEqual( + ["cpu_cores", "cross_origin_isolated", "device_memory_gb", "reduced_motion", "shared_array_buffer"].sort() + ); + }); +}); + +describe("telemetry environment", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("never reports local or automated runs as production", () => { + for (const hostname of ["localhost", "127.0.0.1", "[::1]", "192.168.1.20", "10.0.0.5", "reframe.local", "app.localhost", ""]) { + expect(resolveTelemetryEnv({ hostname })).toBeNull(); + expect(resolveTelemetryEnv({ hostname, explicitEnv: "production" })).toBeNull(); + } + expect(resolveTelemetryEnv({ hostname: "reframe-lilac.vercel.app", webdriver: true })).toBeNull(); + }); + + it("lets local perf runs opt in under a non-production env", () => { + expect(resolveTelemetryEnv({ hostname: "localhost", explicitEnv: "local-perf" })).toBe("local-perf"); + expect(resolveTelemetryEnv({ hostname: "localhost", webdriver: true, explicitEnv: "local-perf" })).toBe("local-perf"); + }); + + it("reports production only when the host's deploy context or an explicit env says so", () => { + expect(resolveTelemetryEnv({ hostname: "reframe-os.netlify.app", deployContext: "production" })).toBe("production"); + expect(resolveTelemetryEnv({ hostname: "reframe-lilac.vercel.app", vercelEnv: "production" })).toBe("production"); + expect(resolveTelemetryEnv({ hostname: "reframe.example", explicitEnv: "production" })).toBe("production"); + }); + + it("fails closed when nothing confirms production", () => { + // A local build deployed by hand, or any host without a deploy context. + expect(resolveTelemetryEnv({ hostname: "reframe-os.netlify.app" })).toBeNull(); + expect(resolveTelemetryEnv({ hostname: "reframe-lilac.vercel.app", deployContext: "" })).toBeNull(); + expect(resolveTelemetryEnv({ hostname: "reframe-os.netlify.app", deployContext: "something-new" })).toBeNull(); + }); + + it("never tags previews or development as production", () => { + expect(resolveTelemetryEnv({ hostname: "reframe-abc.vercel.app", vercelEnv: "preview" })).toBe("preview"); + expect(resolveTelemetryEnv({ hostname: "x.netlify.app", deployContext: "deploy-preview" })).toBe("preview"); + expect(resolveTelemetryEnv({ hostname: "x.netlify.app", deployContext: "branch-deploy" })).toBe("preview"); + expect(resolveTelemetryEnv({ hostname: "x.netlify.app", deployContext: "dev" })).toBe("development"); + // Even an explicit "production" can't relabel a preview. + expect(resolveTelemetryEnv({ hostname: "deploy-preview-9--reframe-os.netlify.app", explicitEnv: "production", deployContext: "production" })).toBe("preview"); + expect(resolveTelemetryEnv({ hostname: "x.vercel.app", explicitEnv: "production", vercelEnv: "preview" })).toBe("preview"); + // An explicit non-production env is honoured. + expect(resolveTelemetryEnv({ hostname: "example.netlify.app", explicitEnv: "staging" })).toBe("staging"); + }); + + it("tags Netlify deploy previews and branch deploys as preview", () => { + expect(resolveTelemetryEnv({ hostname: "reframe-os.netlify.app", deployContext: "production" })).toBe("production"); + expect(resolveTelemetryEnv({ hostname: "deploy-preview-1802--reframe-os.netlify.app" })).toBe("preview"); + expect(resolveTelemetryEnv({ hostname: "6718a2c0f1--reframe-os.netlify.app" })).toBe("preview"); + expect(resolveTelemetryEnv({ hostname: "feat-x--reframe-os.netlify.app" })).toBe("preview"); + // A lookalike domain isn't a Netlify preview, and without a context it's nothing. + expect(resolveTelemetryEnv({ hostname: "a--b.netlify.app.evil.example" })).toBeNull(); + }); + + it("can be switched off explicitly", () => { + expect(resolveTelemetryEnv({ hostname: "reframe-lilac.vercel.app", explicitEnv: "off" })).toBeNull(); + }); + + it("in this jsdom (localhost) run, RUM stays off without an explicit env", () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "token"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", ""); + expect(currentTelemetryEnv()).toBeNull(); + expect(telemetryConfigured()).toBe(false); + resetTelemetryForTests(); + const rum = fakeRum(); + registerRum(rum); + trackAction("export_start"); + expect(rum.addAction).not.toHaveBeenCalled(); + }); +}); + +describe("RUM event sanitising", () => { + beforeEach(() => resetTelemetryForTests()); + + it("removes registered file names and captions, including the bare stem", () => { + addTelemetryRedaction("Family trip 2026.mov", "Happy birthday Sam!"); + const out = scrubText("Failed to restore 'Family trip 2026.mov' (Family trip 2026) caption=Happy birthday Sam!"); + expect(out).not.toMatch(/Family|birthday|Sam/); + }); + + it("removes media file names, local paths, blob/file URLs and query strings it was never told about", () => { + const out = scrubText( + "Error at /Users/alex/Movies/secret.mp4 C:\\Users\\alex\\clip.webm blob:https://x.app/1234-abcd " + + "file:///home/alex/a.mkv https://reframe.app/?brightness=0.4&text=hi#t holiday.MOV" + ); + expect(out).not.toMatch(/alex|secret|clip\.webm|1234-abcd|brightness|holiday/i); + expect(out).toContain("https://reframe.app/"); + }); + + it("keeps ordinary stack frames readable", () => { + const frame = "at exportVideo (https://reframe.app/_next/static/chunks/123-abc.js:1:2345)"; + expect(scrubText(frame)).toBe(frame); + }); + + it("caps long messages", () => { + expect(scrubText("x".repeat(5000)).length).toBeLessThanOrEqual(1001); + }); + + it("sanitises views, resources, errors and action names, and drops blob resources", () => { + addTelemetryRedaction("wedding.mp4"); + const view = { type: "view", view: { url: "https://reframe.app/?contrast=1.2", referrer: "https://ref.example/?q=1" } }; + expect(sanitizeRumEvent(view)).toBe(true); + expect(view.view).toEqual({ url: "https://reframe.app/", referrer: "https://ref.example/" }); + + expect(sanitizeRumEvent({ type: "resource", resource: { url: "blob:https://reframe.app/abc" } })).toBe(false); + const resource = { type: "resource", resource: { url: "https://cdn.jsdelivr.net/core.wasm?v=1" } }; + sanitizeRumEvent(resource); + expect(resource.resource.url).toBe("https://cdn.jsdelivr.net/core.wasm"); + + const error = { + type: "error", + error: { + message: "console error: Failed to restore video session: wedding.mp4", + stack: "Error: wedding.mp4\n at x (https://reframe.app/_next/static/chunks/a.js:1:1)", + resource: { url: "https://reframe.app/?speed=2" }, + causes: [{ message: "nested wedding.mp4", stack: "" }], + }, + }; + sanitizeRumEvent(error); + expect(JSON.stringify(error)).not.toMatch(/wedding|speed=2/); + + const action = { type: "action", action: { target: { name: "Remove wedding.mp4" } } }; + sanitizeRumEvent(action); + expect(action.action.target.name).not.toContain("wedding"); + }); +}); diff --git a/vitest.setup.ts b/vitest.setup.ts index d2973f28b..9d61fc0d5 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -14,3 +14,14 @@ Object.defineProperty(window, 'matchMedia', { dispatchEvent: () => false, }), }) + +// No test may reach Datadog. Tests mock the SDK; this catches any that don't. +const DATADOG_HOST = /datadoghq\.|browser-intake-|ddog-gov\./ +const realFetch = globalThis.fetch +if (realFetch) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input) + if (DATADOG_HOST.test(url)) throw new Error(`Test tried to contact Datadog: ${url.split('?')[0]}`) + return realFetch(input, init) + }) as typeof fetch +}