diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d7781c5f43f..228f3fbdf88e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -502,6 +502,8 @@ jobs: node-version-file: 'package.json' - name: Set up Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' - name: Restore caches uses: ./.github/actions/restore-cache with: @@ -884,6 +886,8 @@ jobs: node-version-file: 'package.json' - name: Set up Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' - name: Restore caches uses: ./.github/actions/restore-cache with: @@ -925,9 +929,8 @@ jobs: - name: Run integration tests env: NODE_VERSION: ${{ matrix.node }} - run: | - cd packages/remix - yarn test:integration:ci + working-directory: packages/remix + run: yarn test:integration job_build_tarballs: name: Build tarballs @@ -1012,6 +1015,8 @@ jobs: contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "hono-4"]'), matrix.test-application) uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' - name: Set up AWS SAM if: matrix.test-application == 'aws-serverless' || matrix.test-application == 'aws-serverless-layer' uses: aws-actions/setup-sam@v2 diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 000000000000..2eada66b2014 --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,43 @@ +name: 'Nightly: Lighthouse' + +# Nightly: builds the Lighthouse fixture app and uploads bundles to the +# lighthouse.sentry.gg lab. Never blocks merges. + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +concurrency: + group: lighthouse-${{ github.run_id }} + cancel-in-progress: false + +env: + CACHED_DEPENDENCY_PATHS: | + ${{ github.workspace }}/node_modules + ${{ github.workspace }}/packages/*/node_modules + ${{ github.workspace }}/dev-packages/*/node_modules + +jobs: + build-and-upload: + name: Bundle and upload Lighthouse cells + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + LIGHTHOUSE_LAB_URL: ${{ secrets.LIGHTHOUSE_LAB_URL }} + LIGHTHOUSE_UPLOAD_TOKEN: ${{ secrets.LIGHTHOUSE_UPLOAD_TOKEN }} + VITE_E2E_TEST_DSN: 'https://username@domain/123' + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 9.15.9 + - uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + - uses: ./.github/actions/install-dependencies + - run: yarn build:ci + - run: yarn build:tarball + - run: yarn test:prepare + working-directory: dev-packages/e2e-tests + - run: node dev-packages/lighthouse-bundle/bundle-and-upload.mjs diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/index.html b/dev-packages/e2e-tests/test-applications/lighthouse-react/index.html new file mode 100644 index 000000000000..dbddfb003021 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/index.html @@ -0,0 +1,13 @@ + + + + + + + Lighthouse Fixture + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json b/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json new file mode 100644 index 000000000000..64ca94f7d244 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/package.json @@ -0,0 +1,32 @@ +{ + "name": "lighthouse-react-test-app", + "private": true, + "type": "module", + "sentryTest": { + "skip": true + }, + "scripts": { + "build": "vite build", + "build:no-sentry": "vite build --mode no-sentry", + "build:init-only": "vite build --mode init-only", + "build:tracing-replay": "vite build --mode tracing-replay", + "preview": "vite preview", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && pnpm build" + }, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.7.0", + "typescript": "~5.4.0", + "vite": "^5.4.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/public/logo.svg b/dev-packages/e2e-tests/test-applications/lighthouse-react/public/logo.svg new file mode 100644 index 000000000000..a1ee6ba8d243 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/public/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/App.tsx b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/App.tsx new file mode 100644 index 000000000000..95eb8a9391b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/App.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; + +const CARDS = [ + { title: 'Performance', body: 'Measure and optimize your app render times and interaction latency.' }, + { title: 'Accessibility', body: 'Ensure your interface is usable by everyone, including assistive technologies.' }, + { title: 'Best Practices', body: 'Follow modern web development patterns for secure and maintainable code.' }, + { title: 'SEO', body: 'Optimize discoverability with semantic markup and structured metadata.' }, + { title: 'PWA', body: 'Add offline support and installability via service workers and manifests.' }, + { title: 'Security', body: 'Protect users with CSP headers, HTTPS, and input validation.' }, +]; + +export default function App() { + const [count, setCount] = useState(0); + + useEffect(() => { + document.title = `Lighthouse Fixture (${count})`; + }, [count]); + + return ( +
+
+ +
+ +
+ Lighthouse logo +

Lighthouse Fixture

+

+ This app exists to measure JavaScript bundle size and runtime cost across three Sentry instrumentation + configurations. Each build mode ships a different level of SDK integration. +

+
+ +
+ {CARDS.map(card => ( +
+

{card.title}

+

{card.body}

+
+ ))} +
+ +
{ + e.preventDefault(); + setCount(c => c + 1); + }} + > + + + + +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx new file mode 100644 index 000000000000..8ba6db3c8685 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx @@ -0,0 +1,20 @@ +import { createRoot } from 'react-dom/client'; +import App from './App'; + +async function bootstrap() { + const mode = import.meta.env.MODE; + if (mode === 'init-only') { + const { initSentry } = await import('./sentry/init-only'); + initSentry(); + } else if (mode === 'tracing-replay') { + const { initSentry } = await import('./sentry/tracing-replay'); + initSentry(); + } + // 'no-sentry' mode: do not import any sentry module — the dynamic-import + // branches above are unreachable and Vite drops them from the bundle. + + const root = createRoot(document.getElementById('root')!); + root.render(); +} + +void bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/init-only.ts b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/init-only.ts new file mode 100644 index 000000000000..572d2c93e106 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/init-only.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/react'; + +export function initSentry(): void { + // enabled: false makes the SDK a guaranteed no-op (no transport allocation, + // no DSN warning). We're measuring pure SDK-loading + tree-shaking cost. + Sentry.init({ enabled: false }); +} diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/no-sentry.ts b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/no-sentry.ts new file mode 100644 index 000000000000..5610cfa61cfc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/no-sentry.ts @@ -0,0 +1,3 @@ +export function initSentry(): void { + // no-op: this mode intentionally excludes all Sentry imports +} diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/tracing-replay.ts b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/tracing-replay.ts new file mode 100644 index 000000000000..137636604e59 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/src/sentry/tracing-replay.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/react'; + +export function initSentry(): void { + Sentry.init({ + dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined, + release: 'lighthouse-fixture', + environment: 'qa', + integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], + tracesSampleRate: 1.0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.json b/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.json new file mode 100644 index 000000000000..3934b8f6d676 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.node.json new file mode 100644 index 000000000000..42872c59f5b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/lighthouse-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/lighthouse-react/vite.config.ts new file mode 100644 index 000000000000..4f312acb1a84 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/lighthouse-react/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/dev-packages/lighthouse-bundle/bundle-and-upload.mjs b/dev-packages/lighthouse-bundle/bundle-and-upload.mjs new file mode 100644 index 000000000000..305188b2650e --- /dev/null +++ b/dev-packages/lighthouse-bundle/bundle-and-upload.mjs @@ -0,0 +1,161 @@ +/** + * Bundle the `lighthouse-react` test app for each mode (no-sentry, init-only, + * tracing-replay) and POST the three tarballs to the Sentry Lighthouse lab + * (https://lighthouse.sentry.gg). The lab runs Lighthouse asynchronously and + * ships results to Sentry on its own schedule — this script exits as soon as + * the upload succeeds. + * + * Single-app static matrix: 1 app × 3 modes = 3 cells. + * See plan scratchpad #182 for design details. + * + * Wire protocol: ~/Projects/sentry-lhci/docs/sentry-javascript-handoff.md + * + * Zero runtime dependencies — uses Node 22 builtins (fetch, FormData, Blob) and + * the system `tar`. Every external command is invoked via `execFileSync` with + * an argv array so no shell interpolation happens — needed both for safety + * (CodeQL flags any env-derived string concatenated into a shell command, even + * when the inputs are controlled) and to keep paths with spaces working. + */ + +/* eslint-disable no-console */ + +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const LAB_URL = process.env.LIGHTHOUSE_LAB_URL; +const TOKEN = process.env.LIGHTHOUSE_UPLOAD_TOKEN; +if (!LAB_URL || !TOKEN) { + throw new Error('LIGHTHOUSE_LAB_URL and LIGHTHOUSE_UPLOAD_TOKEN must be set'); +} + +const WORKSPACE = process.env.GITHUB_WORKSPACE ?? process.cwd(); +const RUNNER_TEMP = process.env.RUNNER_TEMP ?? path.join(WORKSPACE, '.tmp'); +const PACKED_DIR = path.join(WORKSPACE, 'dev-packages/e2e-tests/packed'); +const E2E_DIR = path.join(WORKSPACE, 'dev-packages/e2e-tests'); + +const APP = 'lighthouse-react'; +const APP_DIR = 'lighthouse-react'; +const MODES = ['no-sentry', 'init-only', 'tracing-replay']; +const STATIC_DIR = 'dist'; + +async function run() { + // Fail fast if the lab is down so we don't waste minutes building bundles. + console.log(`Liveness check: ${LAB_URL}/healthz`); + const health = await fetch(`${LAB_URL}/healthz`); + if (!health.ok) { + throw new Error(`Lab healthcheck failed: ${health.status} ${await health.text()}`); + } + console.log('Lab is reachable.'); + + await mkdir(path.join(RUNNER_TEMP, 'bundles'), { recursive: true }); + const bundles = []; + + for (const mode of MODES) { + const fieldName = `bundle-${bundles.length}`; + console.log(`\n=== Preparing ${APP} (${mode}) → ${fieldName} ===`); + bundles.push(await prepareCell(mode, fieldName)); + } + + console.log(`\n=== Uploading ${bundles.length} bundles to ${LAB_URL}/api/builds ===`); + const buildResp = await uploadBundles(bundles); + console.log(`Build queued: ${buildResp.buildId}`); + console.log(`Dashboard: ${LAB_URL}${buildResp.dashboardUrl}`); + console.log(`API: ${LAB_URL}${buildResp.buildUrl}`); + console.log('\nUpload succeeded. The lab runs Lighthouse asynchronously — track results in the Sentry dashboard.'); +} + +/** + * Build a single (mode) cell: + * 1. Copy the app to a unique temp dir. + * 2. Apply pnpm overrides (existing helper). + * 3. Run `pnpm install` then `pnpm build:`. + * 4. Tar the `dist/` output dir. + * 5. Return cell metadata for the upload. + */ +async function prepareCell(mode, fieldName) { + const tempApp = path.join(RUNNER_TEMP, `app-${APP}-${mode}`); + await rm(tempApp, { recursive: true, force: true }); + + // Copy app to temp (fixes file:/link: deps to workspace-absolute paths) + execFileSync('yarn', ['ci:copy-to-temp', `./test-applications/${APP_DIR}`, tempApp], { + cwd: E2E_DIR, + stdio: 'inherit', + }); + + // Add pnpm overrides (workspace-absolute paths pointing at packed dir) + execFileSync('yarn', ['ci:pnpm-overrides', tempApp, PACKED_DIR], { + cwd: E2E_DIR, + stdio: 'inherit', + }); + + // Install deps + execFileSync('pnpm', ['install'], { cwd: tempApp, stdio: 'inherit' }); + + // Build with the Vite mode — mode selection lives inside the per-mode npm + // script, no extra env vars needed for routing. VITE_E2E_TEST_DSN is passed + // so the tracing-replay build's Sentry.init has a DSN at build time. + execFileSync('pnpm', [`build:${mode}`], { + cwd: tempApp, + stdio: 'inherit', + env: { + ...process.env, + VITE_E2E_TEST_DSN: process.env.VITE_E2E_TEST_DSN ?? 'https://username@domain/123', + }, + }); + + const tarPath = path.join(RUNNER_TEMP, 'bundles', `${APP}-${mode}.tar.gz`); + execFileSync('tar', ['-czf', tarPath, '-C', tempApp, STATIC_DIR], { stdio: 'inherit' }); + console.log(`Static bundle: ${tarPath} (${await formatSize(tarPath)})`); + + return { + fieldName, + tarPath, + cell: { app: APP, mode, bundleField: fieldName, serve: 'static', staticDir: STATIC_DIR }, + }; +} + +/** + * POST the multipart form. Returns the parsed 202 response body. + */ +async function uploadBundles(bundles) { + const metadata = { + commit: process.env.GITHUB_SHA ?? 'unknown', + branch: process.env.GITHUB_REF_NAME ?? 'unknown', + triggeredBy: 'github-actions', + workflowRunUrl: + process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` + : undefined, + cells: bundles.map(b => b.cell), + }; + + const form = new FormData(); + form.append('metadata', JSON.stringify(metadata)); + for (const b of bundles) { + const buf = await readFile(b.tarPath); + form.append(b.fieldName, new Blob([buf], { type: 'application/gzip' }), path.basename(b.tarPath)); + } + + const res = await fetch(`${LAB_URL}/api/builds`, { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}` }, + body: form, + }); + if (!res.ok) { + throw new Error(`Upload failed: ${res.status} ${await res.text()}`); + } + return res.json(); +} + +async function formatSize(filePath) { + const { size } = await stat(filePath); + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +run().catch(err => { + console.error(err.stack || err.message); + process.exit(1); +}); diff --git a/dev-packages/lighthouse-bundle/package.json b/dev-packages/lighthouse-bundle/package.json new file mode 100644 index 000000000000..14480a2ebb34 --- /dev/null +++ b/dev-packages/lighthouse-bundle/package.json @@ -0,0 +1,12 @@ +{ + "name": "@sentry-internal/lighthouse-bundle", + "version": "0.0.0", + "private": true, + "type": "module", + "volta": { + "extends": "../../package.json" + }, + "scripts": { + "bundle-and-upload": "node bundle-and-upload.mjs" + } +} diff --git a/package.json b/package.json index bca64a9f863f..ddd85333d3f3 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,8 @@ "dev-packages/clear-cache-gh-action", "dev-packages/external-contributor-gh-action", "dev-packages/rollup-utils", - "dev-packages/bundler-tests" + "dev-packages/bundler-tests", + "dev-packages/lighthouse-bundle" ], "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7",