mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
## Summary - Classifies Instant Insights records as internal and hides them by default. - Adds a persisted “Internal activity” toggle. - Nests internal records below their matching foreground request. - Preserves orphaned internal records exactly once. - Signals hidden internal errors without littering the main request list. - Raw endpoint and CLI data remain complete; filtering is UI-only. ## Verification ```bash pnpm build-all pnpm --filter=next types pnpm --filter=next build pnpm test-unit --runTestsByPath packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.test.ts pnpm test-dev-turbo test/development/app-dir/request-insights/request-insights.test.ts pnpm test-dev-webpack test/development/app-dir/request-insights/request-insights.test.ts ``` <!-- NEXT_JS_LLM -->
533 lines
16 KiB
TypeScript
533 lines
16 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
import { createServer } from 'http'
|
|
import type { AddressInfo } from 'net'
|
|
import {
|
|
retry,
|
|
toggleDevToolsIndicatorPopover,
|
|
waitForNoRedbox,
|
|
} from 'next-test-utils'
|
|
|
|
type RequestInsight = {
|
|
requestId: string
|
|
kind?: 'request' | 'instant-insights'
|
|
htmlRequestId: string
|
|
route: string
|
|
startTime: number
|
|
status: 'ok'
|
|
spans: Array<{
|
|
name?: string
|
|
spanId?: string
|
|
parentSpanId?: string
|
|
attributes?: Record<string, string | number | boolean>
|
|
}>
|
|
fetches: Array<{
|
|
durationMs: number
|
|
statusCode: number
|
|
cacheStatus: string
|
|
method: string
|
|
url: string
|
|
}>
|
|
}
|
|
|
|
describe('request insights', () => {
|
|
const { next } = nextTestSetup({
|
|
files: __dirname,
|
|
})
|
|
|
|
function createRequest(index: number, fetchCount = 0): RequestInsight {
|
|
return {
|
|
requestId: `request-${index}`,
|
|
htmlRequestId: `page-${index}`,
|
|
route: `/route-${index}`,
|
|
startTime: index,
|
|
status: 'ok',
|
|
spans: [],
|
|
fetches: Array.from({ length: fetchCount }, (_, fetchIndex) => ({
|
|
durationMs: fetchIndex + 1,
|
|
statusCode: 200,
|
|
cacheStatus: 'miss',
|
|
method: 'GET',
|
|
url: `https://example.com/fetch-${fetchIndex}`,
|
|
})),
|
|
}
|
|
}
|
|
|
|
async function openRequestInsightsPanel(
|
|
browser: Awaited<ReturnType<typeof next.browser>>
|
|
) {
|
|
await toggleDevToolsIndicatorPopover(browser)
|
|
await browser.elementByCss('[data-request-insights]').click()
|
|
await browser.waitForElementByCss('.request-insights-list-toolbar')
|
|
// The panel selector menu stays mounted for its exit animation and its
|
|
// click-outside handler would close the freshly opened panel. Wait for
|
|
// it to fully unmount before interacting with the panel.
|
|
await retry(async () => {
|
|
const selectorMenuGone = await browser.eval(() => {
|
|
const root = document.querySelector('nextjs-portal')?.shadowRoot
|
|
return !root?.querySelector('#nextjs-dev-tools-menu')
|
|
})
|
|
expect(selectorMenuGone).toBe(true)
|
|
})
|
|
}
|
|
|
|
async function patchRequestInsightsConfig(patch: {
|
|
showInternal?: boolean
|
|
verbose?: boolean
|
|
}) {
|
|
const response = await next.fetch('/__nextjs_devtools_config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ requestInsights: patch }),
|
|
})
|
|
expect(response.status).toBe(204)
|
|
}
|
|
|
|
let shouldResetRequestInsightsConfig = false
|
|
afterEach(async () => {
|
|
if (!shouldResetRequestInsightsConfig) {
|
|
return
|
|
}
|
|
|
|
await patchRequestInsightsConfig({
|
|
showInternal: false,
|
|
verbose: false,
|
|
})
|
|
shouldResetRequestInsightsConfig = false
|
|
})
|
|
|
|
async function runWithResponse(body: unknown, args: string[] = []) {
|
|
const requestedPaths: string[] = []
|
|
const server = createServer((req, res) => {
|
|
requestedPaths.push(req.url ?? '')
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify(body))
|
|
})
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
|
|
try {
|
|
const address = server.address() as AddressInfo
|
|
const result = await next.runCommand([
|
|
'experimental-request-insights',
|
|
'--url',
|
|
`http://127.0.0.1:${address.port}`,
|
|
...args,
|
|
])
|
|
return { result, requestedPaths }
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()))
|
|
})
|
|
}
|
|
}
|
|
|
|
it('discovers the running dev server from the project lockfile', async () => {
|
|
const result = await next.runCommand(['experimental-request-insights'])
|
|
|
|
if (result.code !== 0) {
|
|
throw new Error(result.cliOutput)
|
|
}
|
|
expect(result.cliOutput).toMatch(
|
|
/No request insights captured yet|retained requests \(newest first\)/
|
|
)
|
|
})
|
|
|
|
it('keeps outer server and app render spans on the same request', async () => {
|
|
await next.render('/')
|
|
|
|
await retry(async () => {
|
|
const snapshot = (await next
|
|
.fetch('/_next/development/request-insights')
|
|
.then((response) => response.json())) as {
|
|
requests: RequestInsight[]
|
|
}
|
|
const pageRequests = snapshot.requests.filter(
|
|
(request) => request.route === '/'
|
|
)
|
|
const requestsWithRelevantSpans = pageRequests.filter((request) =>
|
|
request.spans.some((span) => {
|
|
const spanType = span.attributes?.['next.span_type']
|
|
return (
|
|
spanType === 'BaseServer.handleRequest' ||
|
|
spanType === 'AppRender.getBodyResult'
|
|
)
|
|
})
|
|
)
|
|
|
|
expect(requestsWithRelevantSpans).toHaveLength(1)
|
|
expect(
|
|
requestsWithRelevantSpans[0].spans.map(
|
|
(span) => span.attributes?.['next.span_type']
|
|
)
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
'BaseServer.handleRequest',
|
|
'AppRender.getBodyResult',
|
|
])
|
|
)
|
|
})
|
|
})
|
|
|
|
it('does not attribute Request Insights bookkeeping to the app', async () => {
|
|
const outputIndex = next.cliOutput.length
|
|
const browser = await next.browser('/safe-clock')
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('p').text()).toBe('safe clock')
|
|
})
|
|
await waitForNoRedbox(browser)
|
|
expect(next.cliOutput.slice(outputIndex)).not.toContain(
|
|
'Route "/safe-clock": Next.js encountered the unstable value `Date.now()` while prerendering.'
|
|
)
|
|
})
|
|
|
|
it('still reports genuine app wall-clock access with app source attribution', async () => {
|
|
const outputIndex = next.cliOutput.length
|
|
await next.browser('/app-date-now')
|
|
|
|
await retry(() => {
|
|
const output = next.cliOutput.slice(outputIndex)
|
|
expect(output).toContain(
|
|
'Route "/app-date-now": Next.js encountered the unstable value `Date.now()` while prerendering.'
|
|
)
|
|
expect(output).toMatch(/at Page \(app\/app-date-now\/page\.tsx:\d+:\d+\)/)
|
|
})
|
|
})
|
|
|
|
it('records Instant Insights separately from its originating request', async () => {
|
|
await next.render('/instant-insights')
|
|
|
|
await retry(async () => {
|
|
const snapshot = (await next
|
|
.fetch('/_next/development/request-insights')
|
|
.then((response) => response.json())) as {
|
|
requests: RequestInsight[]
|
|
}
|
|
const routeInsights = snapshot.requests.filter(
|
|
(request) => request.route === '/instant-insights'
|
|
)
|
|
const instantInsights = routeInsights.find(
|
|
(request) => request.kind === 'instant-insights'
|
|
)
|
|
const request = routeInsights.find(
|
|
(item) =>
|
|
(item.kind === undefined || item.kind === 'request') &&
|
|
item.requestId === instantInsights?.requestId
|
|
)
|
|
|
|
expect(instantInsights).toEqual(
|
|
expect.objectContaining({
|
|
kind: 'instant-insights',
|
|
durationMs: expect.any(Number),
|
|
status: 'ok',
|
|
})
|
|
)
|
|
expect(instantInsights?.htmlRequestId).toBe(request?.htmlRequestId)
|
|
const rootSpans = instantInsights?.spans.filter(
|
|
(span) =>
|
|
span.attributes?.['next.span_type'] === 'AppRender.instantInsights' &&
|
|
span.name === 'Instant Insights'
|
|
)
|
|
expect(rootSpans?.length).toBeGreaterThan(0)
|
|
for (const rootSpan of rootSpans ?? []) {
|
|
expect(rootSpan.parentSpanId).toBeUndefined()
|
|
}
|
|
|
|
const pipelineSpanTypes = [
|
|
'AppRender.instantInsights.prepareValidation',
|
|
'AppRender.instantInsights.runValidation',
|
|
]
|
|
const pipelineSpans = instantInsights?.spans.filter((span) =>
|
|
pipelineSpanTypes.includes(
|
|
span.attributes?.['next.span_type'] as string
|
|
)
|
|
)
|
|
expect(
|
|
pipelineSpans?.map((span) => span.attributes?.['next.span_type'])
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
'AppRender.instantInsights.prepareValidation',
|
|
'AppRender.instantInsights.runValidation',
|
|
])
|
|
)
|
|
for (const span of pipelineSpans ?? []) {
|
|
expect(
|
|
rootSpans?.some((rootSpan) => rootSpan.spanId === span.parentSpanId)
|
|
).toBe(true)
|
|
}
|
|
expect(
|
|
request?.spans.some(
|
|
(span) =>
|
|
span.attributes?.['next.span_type'] ===
|
|
'AppRender.instantInsights' ||
|
|
pipelineSpanTypes.includes(
|
|
span.attributes?.['next.span_type'] as string
|
|
)
|
|
)
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('persists verbose trace settings', async () => {
|
|
const browser = await next.browser('/instant-insights')
|
|
shouldResetRequestInsightsConfig = true
|
|
|
|
function getSettingsMenuState(): Promise<{
|
|
open: boolean
|
|
checked: string | null
|
|
}> {
|
|
return browser.eval(() => {
|
|
const root = document.querySelector('nextjs-portal')?.shadowRoot
|
|
const item = Array.from(
|
|
root?.querySelectorAll('.request-insights-settings-item') ?? []
|
|
).find((candidate) => candidate.textContent?.includes('Verbose traces'))
|
|
return {
|
|
open: !!root?.querySelector('.request-insights-settings-menu'),
|
|
checked:
|
|
item
|
|
?.querySelector('.request-insights-settings-checkbox')
|
|
?.getAttribute('data-checked') ?? null,
|
|
}
|
|
})
|
|
}
|
|
|
|
function getSpanRowCount(): Promise<number> {
|
|
return browser.eval(() => {
|
|
const root = document.querySelector('nextjs-portal')?.shadowRoot
|
|
return root?.querySelectorAll('.request-insights-span-row').length ?? 0
|
|
})
|
|
}
|
|
|
|
await openRequestInsightsPanel(browser)
|
|
await retry(async () => {
|
|
const selectedRegularRequest = await browser.eval(() => {
|
|
const root = document.querySelector('nextjs-portal')?.shadowRoot
|
|
const row = Array.from(
|
|
root?.querySelectorAll<HTMLButtonElement>('.request-insights-row') ??
|
|
[]
|
|
).find(
|
|
(candidate) => !candidate.textContent?.includes('Instant Insights')
|
|
)
|
|
row?.click()
|
|
return row !== undefined
|
|
})
|
|
expect(selectedRegularRequest).toBe(true)
|
|
})
|
|
await browser.elementByCss('.request-insights-settings-trigger').click()
|
|
|
|
await retry(async () => {
|
|
expect(await getSettingsMenuState()).toEqual({
|
|
open: true,
|
|
checked: null,
|
|
})
|
|
})
|
|
|
|
let defaultSpanRowCount = 0
|
|
await retry(async () => {
|
|
defaultSpanRowCount = await getSpanRowCount()
|
|
expect(defaultSpanRowCount).toBeGreaterThan(0)
|
|
})
|
|
|
|
await browser
|
|
.elementByCss(
|
|
'.request-insights-settings-item:has-text("Verbose traces")'
|
|
)
|
|
.click()
|
|
|
|
await retry(async () => {
|
|
expect(await getSettingsMenuState()).toEqual({
|
|
open: true,
|
|
checked: 'true',
|
|
})
|
|
expect(await getSpanRowCount()).toBeGreaterThan(defaultSpanRowCount)
|
|
})
|
|
|
|
await retry(async () => {
|
|
const config = JSON.parse(
|
|
await next.readFile('build/dev/cache/next-devtools-config.json')
|
|
)
|
|
expect(config.requestInsights?.verbose).toBe(true)
|
|
})
|
|
|
|
await browser.elementByCss('.request-insights-details').click()
|
|
await retry(async () => {
|
|
expect((await getSettingsMenuState()).open).toBe(false)
|
|
})
|
|
|
|
await browser.refresh()
|
|
await openRequestInsightsPanel(browser)
|
|
await browser.elementByCss('.request-insights-settings-trigger').click()
|
|
|
|
await retry(async () => {
|
|
expect(await getSettingsMenuState()).toEqual({
|
|
open: true,
|
|
checked: 'true',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('hides internal activity behind the settings menu', async () => {
|
|
const browser = await next.browser('/instant-insights')
|
|
shouldResetRequestInsightsConfig = true
|
|
|
|
function getSettingsMenuItems(): Promise<
|
|
Array<{ label: string | undefined; checked: string | null }>
|
|
> {
|
|
return browser.eval(() => {
|
|
const root = document.querySelector('nextjs-portal')?.shadowRoot
|
|
return Array.from(
|
|
root?.querySelectorAll('.request-insights-settings-item') ?? []
|
|
).map((item) => ({
|
|
label: item.textContent?.trim(),
|
|
checked:
|
|
item
|
|
.querySelector('.request-insights-settings-checkbox')
|
|
?.getAttribute('data-checked') ?? null,
|
|
}))
|
|
})
|
|
}
|
|
|
|
await openRequestInsightsPanel(browser)
|
|
|
|
await retry(async () => {
|
|
const state = await browser.eval(() => {
|
|
const root = document.querySelector('nextjs-portal')?.shadowRoot
|
|
const rows = Array.from(
|
|
root?.querySelectorAll('.request-insights-row') ?? []
|
|
)
|
|
return {
|
|
rowCount: rows.length,
|
|
hasInstantInsightsRow: rows.some((row) =>
|
|
row.textContent?.includes('Instant Insights')
|
|
),
|
|
}
|
|
})
|
|
|
|
expect(state.rowCount).toBeGreaterThan(0)
|
|
expect(state.hasInstantInsightsRow).toBe(false)
|
|
})
|
|
|
|
await browser.elementByCss('.request-insights-settings-trigger').click()
|
|
await retry(async () => {
|
|
expect(await getSettingsMenuItems()).toEqual([
|
|
{ label: 'Internal activity', checked: null },
|
|
{ label: 'Verbose traces', checked: null },
|
|
])
|
|
})
|
|
|
|
await browser
|
|
.elementByCss(
|
|
'.request-insights-settings-item:has-text("Internal activity")'
|
|
)
|
|
.click()
|
|
await browser
|
|
.elementByCss(
|
|
'.request-insights-settings-item:has-text("Verbose traces")'
|
|
)
|
|
.click()
|
|
|
|
await retry(async () => {
|
|
const nestedRows = await browser.eval(() => {
|
|
const root = document.querySelector('nextjs-portal')?.shadowRoot
|
|
return Array.from(
|
|
root?.querySelectorAll('.request-insights-row[data-nested="true"]') ??
|
|
[]
|
|
).map((row) => ({
|
|
internal: row.getAttribute('data-internal'),
|
|
hasArrow: !!row.querySelector('.request-insights-nested-arrow'),
|
|
label: row.textContent ?? '',
|
|
}))
|
|
})
|
|
|
|
expect(await getSettingsMenuItems()).toEqual([
|
|
{ label: 'Internal activity', checked: 'true' },
|
|
{ label: 'Verbose traces', checked: 'true' },
|
|
])
|
|
expect(nestedRows.length).toBeGreaterThan(0)
|
|
for (const row of nestedRows) {
|
|
expect(row.internal).toBe('true')
|
|
expect(row.hasArrow).toBe(true)
|
|
expect(row.label).toContain('Instant Insights')
|
|
}
|
|
})
|
|
|
|
await retry(async () => {
|
|
const config = JSON.parse(
|
|
await next.readFile('build/dev/cache/next-devtools-config.json')
|
|
)
|
|
expect(config.requestInsights).toEqual({
|
|
showInternal: true,
|
|
verbose: true,
|
|
})
|
|
})
|
|
|
|
await browser.elementByCss('.request-insights-details').click()
|
|
await browser.refresh()
|
|
await openRequestInsightsPanel(browser)
|
|
await browser.elementByCss('.request-insights-settings-trigger').click()
|
|
|
|
await retry(async () => {
|
|
expect(await getSettingsMenuItems()).toEqual([
|
|
{ label: 'Internal activity', checked: 'true' },
|
|
{ label: 'Verbose traces', checked: 'true' },
|
|
])
|
|
})
|
|
})
|
|
|
|
it('uses the development endpoint and reports truncated output', async () => {
|
|
const { result, requestedPaths } = await runWithResponse(
|
|
{
|
|
requests: [
|
|
createRequest(1),
|
|
createRequest(2),
|
|
{ ...createRequest(3, 7), kind: 'instant-insights' },
|
|
],
|
|
},
|
|
['--limit', '1']
|
|
)
|
|
|
|
expect(result.code).toBe(0)
|
|
expect(requestedPaths).toEqual(['/_next/development/request-insights'])
|
|
expect(result.stdout).toContain(
|
|
'Showing 1 of 3 retained requests (newest first).'
|
|
)
|
|
expect(result.stdout).toContain('/route-3')
|
|
expect(result.stdout).toContain('Instant Insights · /route-3')
|
|
expect(result.stdout).toContain('kind instant-insights')
|
|
expect(result.stdout).not.toContain('/route-2')
|
|
expect(result.stdout).toContain('showing first 5 of 7 fetches')
|
|
expect(result.stdout).toContain('https://example.com/fetch-4')
|
|
expect(result.stdout).not.toContain('https://example.com/fetch-5')
|
|
})
|
|
|
|
it.each(['localhost:3000', 'https://[', 'ftp://localhost:3000'])(
|
|
'rejects invalid dev server URL %s',
|
|
async (url) => {
|
|
const result = await next.runCommand([
|
|
'experimental-request-insights',
|
|
'--url',
|
|
url,
|
|
])
|
|
|
|
expect(result.code).toBe(1)
|
|
expect(result.stderr).toContain(
|
|
`Invalid dev server URL "${url}". Pass a valid HTTP or HTTPS URL.`
|
|
)
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
{ body: { requests: null }, args: ['--json'] },
|
|
{ body: { requests: [{ fetches: null }] }, args: [] },
|
|
])('rejects malformed responses', async ({ body, args }) => {
|
|
const { result } = await runWithResponse(body, args)
|
|
|
|
expect(result.code).toBe(1)
|
|
expect(result.stderr).toContain(
|
|
'expected requests and fetches to be arrays'
|
|
)
|
|
})
|
|
})
|