Files
next.js/test/production/app-dir/upload-trace/upload-trace.test.ts
Tim NeutkensandGitHub 0895413804 Add next internal upload-trace (#92191)
### What?

Adds `next internal upload-trace` CLI command for uploading CPU profiles
and Turbopack traces to Vercel Blob storage.

### Why?

When investigating performance issues, the Next.js team often needs CPU
profiles and Turbopack traces from contributors and bug reporters.
Currently there's no streamlined way to collect these files. This
command provides a single step to upload all profiling output after a
build.

### How?

- **`.next-profiles` directory**: CPU profiles
(`--experimental-cpu-prof`) and Turbopack traces (`--internal-trace`)
now write to `.next-profiles/` instead of separate locations,
consolidating all profiling output.
- **`next internal upload-trace [directory]`**: New CLI command that
uploads `.cpuprofile` files and `trace-turbopack` from `.next-profiles/`
to Vercel Blob via a secure handshake with an external API endpoint.
- Validates file headers before upload (JSON for `.cpuprofile`, binary
for `trace-turbopack`)
  - Skips empty files
  - Shows progress bar in TTY environments
  - Groups all files under a single server-assigned session ID
  - Only logs the session ID to the user (no internal blob URLs exposed)
- **`@vercel/blob` compiled**: Vendored into
`packages/next/src/compiled/@vercel/blob/` via ncc.
- **`--internal-trace` CLI flag**: Added to `next build` and `next dev`
to enable Turbopack tracing to `.next-profiles/trace-turbopack`.
- **Security**: The server controls the storage path (client only sends
filename), session continuity uses HMAC-signed tokens (stateless across
serverless instances), `allowOverwrite: false` prevents replacing
existing uploads, and filenames are validated against an allowlist.
- **Tests**: Added production test suite with a mock HTTP server.

Usage:

```bash
next build --experimental-cpu-prof --internal-trace
next internal upload-trace
```
2026-04-09 11:14:05 +02:00

148 lines
4.2 KiB
TypeScript

import { nextTestSetup, isNextStart } from 'e2e-utils'
import { runNextCommand } from 'next-test-utils'
import http from 'http'
import path from 'path'
import fs from 'fs'
describe('upload-trace', () => {
if (!isNextStart) {
it('skipped for non-start mode', () => {})
return
}
const { next, isTurbopack, skipped } = nextTestSetup({
files: __dirname,
skipStart: true,
skipDeployment: true,
buildCommand: 'pnpm next build --experimental-cpu-prof --internal-trace',
})
if (skipped) return
it('should upload profiles and trace to the mock endpoint after build', async () => {
const buildResult = await next.build()
expect(buildResult.exitCode).toBe(0)
const profilesDir = path.join(next.testDir, '.next-profiles')
expect(fs.existsSync(profilesDir)).toBe(true)
const allFiles = fs.readdirSync(profilesDir)
const cpuProfiles = allFiles.filter((f: string) =>
f.endsWith('.cpuprofile')
)
expect(cpuProfiles.length).toBeGreaterThan(0)
if (isTurbopack) {
expect(allFiles).toContain('trace-turbopack')
}
const uploadableFiles = allFiles.filter(
(f: string) => f.endsWith('.cpuprofile') || f === 'trace-turbopack'
)
const expectedUploadCount = uploadableFiles.length
const handshakeRequests: any[] = []
const mockServer = http.createServer(async (req, res) => {
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(chunk as Buffer)
}
const body = Buffer.concat(chunks).toString('utf-8')
let parsed: any = null
try {
parsed = JSON.parse(body)
} catch {
// Not JSON — binary blob upload
}
// Handle the upload-trace token handshake (sends { filename })
if (parsed && parsed.filename) {
handshakeRequests.push(parsed)
res.writeHead(200, { 'content-type': 'application/json' })
res.end(
JSON.stringify({
clientToken: 'vercel_blob_client_TESTSTOREID_dGVzdC5wYXlsb2Fk',
pathname: `profiles/${parsed.filename}`,
sessionId: 'test-session-id',
sessionToken: 'test-session-token',
})
)
return
}
// Handle @vercel/blob put() — the actual blob upload
res.writeHead(200, { 'content-type': 'application/json' })
res.end(
JSON.stringify({
url: 'https://test.blob.vercel-storage.com/profiles/test',
downloadUrl:
'https://test.blob.vercel-storage.com/profiles/test?download=1',
pathname: 'profiles/test',
contentType: 'application/octet-stream',
contentDisposition: 'attachment; filename="test"',
})
)
})
await new Promise<void>((resolve) => {
mockServer.listen(0, '127.0.0.1', resolve)
})
const address = mockServer.address() as { port: number }
const mockUrl = `http://127.0.0.1:${address.port}`
try {
const result = await runNextCommand(['internal', 'upload-trace'], {
cwd: next.testDir,
stderr: true,
stdout: true,
env: {
__NEXT_UPLOAD_TRACE_URL_OVERRIDE: mockUrl,
VERCEL_BLOB_API_URL: mockUrl,
},
})
if (result.code !== 0) {
console.log('upload-trace stdout:', result.stdout)
console.log('upload-trace stderr:', result.stderr)
}
expect(result.code).toBe(0)
expect(handshakeRequests.length).toBe(expectedUploadCount)
for (const req of handshakeRequests) {
expect(req.filename).toBeTruthy()
}
expect(result.stdout + result.stderr).toContain(
'All files uploaded successfully'
)
} finally {
mockServer.close()
}
})
it('should fail gracefully when no profiles directory exists', async () => {
const emptyDir = path.join(next.testDir, 'empty-project')
fs.mkdirSync(emptyDir, { recursive: true })
const result = await runNextCommand(
['internal', 'upload-trace', emptyDir],
{
cwd: next.testDir,
stderr: true,
stdout: true,
env: {
__NEXT_UPLOAD_TRACE_URL_OVERRIDE: 'http://127.0.0.1:1',
},
}
)
expect(result.code).toBe(1)
expect(result.stdout + result.stderr).toContain(
'Profiles directory not found'
)
})
})