Skip to content

Conversation

@feedthejim
Copy link
Contributor

@feedthejim feedthejim commented Dec 30, 2025

Summary

Comprehensive improvements to the PR stats action for more reliable benchmarks and reduced CI noise.

Vercel KV Integration

  • Add @vercel/kv for historical data persistence
  • Track metrics over time with loadHistory() / saveToHistory()
  • Display trend sparklines in comments when history is available

Comment Generation Refactor

  • New METRIC_LABELS and METRIC_GROUPS configuration system
  • Organized metrics by category (Dev Server, Production Builds, Production Runtime)
  • Better formatting utilities (prettifyTime, formatChange, generateTrendBar)
  • Bundle group totals for KV persistence

Noise Reduction

  • Significance thresholds: 50ms AND 10% for time, 1KB AND 1% for size
  • Additional <2% filter for long-running operations (builds)
  • Filters typical CI variance while catching real regressions

Stats Config Updates

  • Add measureDevBoot: true to enable dev boot benchmarks
  • Fix appDevCommand to include dev subcommand
  • Add --webpack flag to appBuildCommand
  • Rename "Client Bundles (main, webpack)" to "Client Bundles (main)"
  • Add turbopack: {} to test configs

GitHub Actions Updates

  • New action.yml with bundler input parameter
  • Workflow improvements in pull_request_stats.yml

New Files

  • scripts/test-stats-benchmark.sh - Local testing script
  • .github/actions/next-stats-action/src/util/stats.js - Shared stats utilities
  • .github/actions/next-stats-action/test-local.js - Local comment testing

Test Plan

cd .github/actions/next-stats-action
node test-local.js              # Basic test
node test-local.js --with-history  # Test with trend sparklines

Copy link
Contributor Author

feedthejim commented Dec 30, 2025

@feedthejim feedthejim changed the base branch from perf-dev-scripts to graphite-base/87945 December 30, 2025 16:11
@feedthejim feedthejim force-pushed the perf-stats-improvements branch from 118aab8 to de079d4 Compare December 30, 2025 16:43
@nextjs-bot nextjs-bot added the created-by: Next.js team PRs by the Next.js team. label Dec 30, 2025
@feedthejim feedthejim changed the base branch from graphite-base/87945 to perf-dev-early-ready December 30, 2025 16:44
@feedthejim feedthejim changed the base branch from perf-dev-early-ready to graphite-base/87945 December 30, 2025 16:45
@feedthejim feedthejim force-pushed the perf-stats-improvements branch from de079d4 to b8f46bf Compare December 30, 2025 16:45
@feedthejim feedthejim changed the base branch from graphite-base/87945 to perf-dev-scripts December 30, 2025 16:45
@feedthejim feedthejim force-pushed the perf-stats-improvements branch from b8f46bf to 207d6ce Compare December 30, 2025 16:49
@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Dec 30, 2025

Stats from current PR

✅ No significant changes detected

📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change
Cold (Listen) 455ms 455ms
Cold (First Request) 1.180s 1.132s
Warm (Listen) 456ms 457ms
Warm (First Request) 388ms 355ms
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change
Cold (Listen) 455ms 455ms
Cold (First Request) 1.831s 1.820s
Warm (Listen) 456ms 457ms
Warm (First Request) 1.837s 1.835s

⚡ Production Builds

Metric Canary PR Change
Fresh Build 4.085s 4.094s
Cached Build 4.081s 4.126s
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 13.845s 13.883s
Cached Build 13.957s 14.037s
node_modules Size 457 MB 457 MB ▁████
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **430 kB** → **430 kB** ✅ -28 B

82 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 794 B 787 B
Total 794 B 787 B ✅ -7 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 450 B 451 B
Total 450 B 451 B ⚠️ +1 B

📦 Webpack

Client

Main Bundles
Canary PR Change
2086.HASH.js gzip 169 B N/A -
2161-HASH.js gzip 5.39 kB N/A -
2747-HASH.js gzip 4.48 kB N/A -
4322-HASH.js gzip 52.5 kB N/A -
ec793fe8-HASH.js gzip 62.3 kB N/A -
framework-HASH.js gzip 59.8 kB 59.8 kB
main-app-HASH.js gzip 251 B 253 B
main-HASH.js gzip 38.4 kB 38.7 kB
webpack-HASH.js gzip 1.68 kB 1.69 kB
1596.HASH.js gzip N/A 169 B -
2658-HASH.js gzip N/A 52.2 kB -
6349-HASH.js gzip N/A 4.46 kB -
7019-HASH.js gzip N/A 5.41 kB -
b17a3386-HASH.js gzip N/A 62.3 kB -
Total 225 kB 225 kB ⚠️ +121 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B
_error-HASH.js gzip 182 B 182 B
css-HASH.js gzip 336 B 335 B
dynamic-HASH.js gzip 1.8 kB 1.8 kB
edge-ssr-HASH.js gzip 256 B 256 B
head-HASH.js gzip 352 B 349 B
hooks-HASH.js gzip 385 B 384 B
image-HASH.js gzip 580 B 580 B
index-HASH.js gzip 259 B 258 B
link-HASH.js gzip 2.5 kB 2.51 kB
routerDirect..HASH.js gzip 319 B 317 B
script-HASH.js gzip 385 B 387 B
withRouter-HASH.js gzip 316 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.97 kB 7.96 kB ✅ -8 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 124 kB 124 kB
page.js gzip 239 kB 239 kB
Total 363 kB 364 kB ⚠️ +666 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 651 B 654 B
middleware-r..fest.js gzip 155 B 156 B
middleware.js gzip 32.8 kB 33 kB
edge-runtime..pack.js gzip 846 B 846 B
Total 34.5 kB 34.6 kB ⚠️ +155 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 738 B 738 B
Total 738 B 738 B
Build Cache
Canary PR Change
0.pack gzip 3.62 MB 3.63 MB 🔴 +10.3 kB (+0%)
index.pack gzip 98.2 kB 99.8 kB 🔴 +1.64 kB (+2%)
index.pack.old gzip 98.6 kB 99.5 kB
Total 3.81 MB 3.83 MB ⚠️ +12.8 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 302 kB 302 kB
app-page-exp..prod.js gzip 157 kB 157 kB
app-page-tur...dev.js gzip 302 kB 302 kB
app-page-tur..prod.js gzip 157 kB 157 kB
app-page-tur...dev.js gzip 299 kB 299 kB
app-page-tur..prod.js gzip 155 kB 155 kB
app-page.run...dev.js gzip 299 kB 299 kB
app-page.run..prod.js gzip 155 kB 155 kB
app-route-ex...dev.js gzip 68.7 kB 68.7 kB
app-route-ex..prod.js gzip 47.5 kB 47.5 kB
app-route-tu...dev.js gzip 68.7 kB 68.7 kB
app-route-tu..prod.js gzip 47.5 kB 47.5 kB
app-route-tu...dev.js gzip 68.3 kB 68.3 kB
app-route-tu..prod.js gzip 47.3 kB 47.3 kB
app-route.ru...dev.js gzip 68.3 kB 68.3 kB
app-route.ru..prod.js gzip 47.3 kB 47.3 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 41.1 kB 41.1 kB
pages-api-tu..prod.js gzip 31.2 kB 31.2 kB
pages-api.ru...dev.js gzip 41 kB 41 kB
pages-api.ru..prod.js gzip 31.2 kB 31.2 kB
pages-turbo....dev.js gzip 50.8 kB 50.8 kB
pages-turbo...prod.js gzip 38.2 kB 38.2 kB
pages.runtim...dev.js gzip 50.7 kB 50.7 kB
pages.runtim..prod.js gzip 38.2 kB 38.2 kB
server.runti..prod.js gzip 60 kB 60 kB
Total 2.68 MB 2.68 MB ✅ -2 B

@feedthejim feedthejim force-pushed the perf-stats-improvements branch from 207d6ce to eee9da7 Compare December 30, 2025 17:00
@feedthejim feedthejim force-pushed the perf-stats-improvements branch 2 times, most recently from 44aa30a to d058637 Compare December 30, 2025 17:05
@feedthejim feedthejim force-pushed the perf-stats-improvements branch from d058637 to 1171a4d Compare December 30, 2025 18:14
Copy link
Contributor

@vercel vercel bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

The content-type: application/json header is missing when posting to the GitHub API with token authentication. This header should always be included when sending JSON data.

View Details
📝 Patch Details
diff --git a/.github/actions/next-stats-action/src/add-comment.js b/.github/actions/next-stats-action/src/add-comment.js
index c393729e2e..6cfbdfe599 100644
--- a/.github/actions/next-stats-action/src/add-comment.js
+++ b/.github/actions/next-stats-action/src/add-comment.js
@@ -656,13 +656,12 @@ module.exports = async function addComment(
       const res = await fetch(endpoint, {
         method,
         headers: {
+          'content-type': 'application/json',
           ...(actionInfo.githubToken
             ? {
                 Authorization: `bearer ${actionInfo.githubToken}`,
               }
-            : {
-                'content-type': 'application/json',
-              }),
+            : {}),
         },
         body: JSON.stringify(body),
       })

Analysis

Missing Content-Type header in GitHub API POST/PATCH requests with token authentication

What fails: When posting or patching comments to the GitHub API with token authentication, the Content-Type: application/json header is omitted, causing node-fetch to default to text/plain;charset=UTF-8. This violates GitHub API requirements and can cause request failures.

How to reproduce: The bug occurs in .github/actions/next-stats-action/src/add-comment.js lines 656-665. When actionInfo.githubToken is set (from the PR_STATS_COMMENT_TOKEN workflow variable), the code sends a POST or PATCH request to the GitHub API with a JSON body but without the required Content-Type: application/json header.

Testing with node-fetch@2.6.9 confirms that without an explicit content-type header, JSON data is sent as text/plain;charset=UTF-8:

// Without explicit header:
const res = await fetch(endpoint, {
  method: 'POST',
  headers: { Authorization: 'bearer token' },
  body: JSON.stringify(body),
})
// Actual header sent: content-type: text/plain;charset=UTF-8

Expected behavior: According to GitHub API v3 documentation, "For POST, PATCH, PUT, and DELETE requests, parameters not included in the URL should be encoded as JSON with a Content-Type of 'application/json'". Additionally, node-fetch documentation states: "The Content-Type header is only set automatically to x-www-form-urlencoded when an instance of URLSearchParams is given as such" - meaning it does not automatically add application/json for JSON stringified bodies.

Fix: Always include the content-type header in the fetch request, regardless of whether token authentication is being used.

Fix on Vercel

@feedthejim feedthejim force-pushed the perf-stats-improvements branch from 1171a4d to 3020c41 Compare December 30, 2025 18:33
@feedthejim feedthejim force-pushed the perf-stats-improvements branch from 9080141 to 75312e9 Compare January 5, 2026 08:58
@feedthejim feedthejim force-pushed the perf-stats-improvements branch 2 times, most recently from 5860a4d to 9a87659 Compare January 5, 2026 12:44
@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Jan 5, 2026

✅ Tests Passed

Updated 2026-01-05 13:06:31 UTC · Commit: 9a87659

@feedthejim feedthejim changed the title perf: improve stats action formatting and add history tracking perf: improve stats action reliability and reduce CI noise Jan 5, 2026
@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Jan 5, 2026

✅ Tests Passed

Updated 2026-01-05 13:58:12 UTC · Commit: 4c094a2

@feedthejim feedthejim force-pushed the perf-stats-improvements branch from 5ba5283 to adde1b3 Compare January 5, 2026 13:33
@feedthejim feedthejim force-pushed the perf-stats-improvements branch from cc63654 to fdeb939 Compare January 5, 2026 14:13
@feedthejim feedthejim force-pushed the perf-dev-scripts branch 2 times, most recently from 9d6987d to a4f5cf1 Compare January 5, 2026 14:16
@feedthejim feedthejim force-pushed the perf-stats-improvements branch 2 times, most recently from 7230576 to 41d7210 Compare January 5, 2026 14:18
Copy link
Contributor Author

feedthejim commented Jan 6, 2026

Merge activity

  • Jan 6, 11:17 AM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Jan 6, 11:20 AM UTC: Graphite rebased this pull request as part of a merge.
  • Jan 6, 11:36 AM UTC: @feedthejim merged this pull request with Graphite.

@feedthejim feedthejim changed the base branch from perf-dev-scripts to graphite-base/87945 January 6, 2026 11:18
@feedthejim feedthejim changed the base branch from graphite-base/87945 to canary January 6, 2026 11:18
- Lower regression thresholds to 10ms/2% and 1KB/1% to catch all meaningful Next.js regressions
- Add 3-decimal precision for seconds (1.234s instead of 1.2s)
- Rename metrics: 'Boot/Ready' -> 'Listen/First Request' for clarity
- Emphasize Turbopack (default), collapse Webpack as legacy
- Add single glossary instead of 5 repetitive dropdowns
- Simplify group names (no 'Turbopack' prefix since it's default)
- Increase benchmark iterations (dev: 5→9, build: 3→5) for more stable medians
- Adjust significance thresholds to 50ms AND 10% (was 10ms AND 2%)
- Add trend sparklines to summary section when KV history available
- Consolidate calcStats() into shared util/stats.js
- Standardize metric keys (remove (ms) suffix)
- Add test-local.js for easier local testing
- Better KV logging for debugging
- Move Next Runtimes out of server category into new 'shared' category
- Rename 'Client Bundles (main, webpack)' to 'Client Bundles (main)'
- The old name was confusing when displayed under Turbopack section
- Add computeBundleGroupTotals() for KV bundle size trend tracking
- generateMetricsTable now conditionally shows Trend column based on data
- Each table independently decides based on its own metrics
- Empty Trend column hidden completely when no history available
Add <2% threshold for time metrics to filter noise on long-running
operations like builds. A 70ms change on a 13s build (0.5%) is noise,
not a real regression.

New threshold logic:
- Time: (<50ms AND <10%) OR <2% = insignificant
- Size: <1KB AND <1% = insignificant
File renames (hash normalization) were invalidating the build cache,
causing cached build times to be the same as fresh builds. Now:

1. Fresh builds (clean .next each iteration)
2. Cached builds (immediately after, .next intact)
3. Apply renames (for deterministic file names)
4. Collect file stats (after renames)
@feedthejim feedthejim force-pushed the perf-stats-improvements branch from 41d7210 to a5e7ddb Compare January 6, 2026 11:19
}

const res = await fetch(endpoint, {
method,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Type header was conditionally omitted when sending JSON POST/PATCH requests with GitHub authentication token

View Details
📝 Patch Details
diff --git a/.github/actions/next-stats-action/src/add-comment.js b/.github/actions/next-stats-action/src/add-comment.js
index fa532bc3aa..818bb5dbb6 100644
--- a/.github/actions/next-stats-action/src/add-comment.js
+++ b/.github/actions/next-stats-action/src/add-comment.js
@@ -1084,13 +1084,12 @@ module.exports = async function addComment(
       const res = await fetch(endpoint, {
         method,
         headers: {
+          'content-type': 'application/json',
           ...(actionInfo.githubToken
             ? {
                 Authorization: `bearer ${actionInfo.githubToken}`,
               }
-            : {
-                'content-type': 'application/json',
-              }),
+            : {}),
         },
         body: JSON.stringify(body),
       })

Analysis

The code at lines 1085-1093 had a ternary operator that selected headers based on whether actionInfo.githubToken was set:

Original Logic:

  • When githubToken IS present: Only set Authorization header
  • When githubToken is NOT present: Only set Content-Type: application/json header

The Problem:
Both execution paths send JSON data via JSON.stringify(body). However, when the GitHub token IS set (the primary authenticated path to the GitHub API), the Content-Type: application/json header was missing. This violates GitHub API v3 requirements, which mandate the Content-Type header for JSON POST/PATCH requests. Without this header, the API may not properly parse the request body, leading to potential API errors or unexpected behavior.

The Fix:
The Content-Type: application/json header is now always included when sending JSON data, regardless of authentication method. The Authorization header is conditionally added only when a GitHub token is present. This ensures compliance with GitHub API requirements while maintaining proper headers in both authenticated and custom endpoint scenarios.

Changed Code:

// Before: Headers were mutually exclusive based on githubToken presence
headers: {
  ...(actionInfo.githubToken
    ? { Authorization: `bearer ${actionInfo.githubToken}` }
    : { 'content-type': 'application/json' }),
}

// After: Content-Type is always included, Authorization is conditional
headers: {
  'content-type': 'application/json',
  ...(actionInfo.githubToken
    ? { Authorization: `bearer ${actionInfo.githubToken}` }
    : {}),
}

This ensures both headers are present when needed, which is essential for proper HTTP communication with the GitHub API.

Fix on Vercel

Comment on lines +1074 to +1077
endpoint = actionInfo.commentEndpoint.replace(
/\/issues\/\d+\/comments$/,
`/issues/comments/${existingCommentId}`
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
endpoint = actionInfo.commentEndpoint.replace(
/\/issues\/\d+\/comments$/,
`/issues/comments/${existingCommentId}`
)
// or PATCH /repos/{owner}/{repo}/comments/{comment_id} for commit comments
if (/\/issues\/\d+\/comments$/.test(actionInfo.commentEndpoint)) {
endpoint = actionInfo.commentEndpoint.replace(
/\/issues\/\d+\/comments$/,
`/issues/comments/${existingCommentId}`
)
} else if (/\/commits\/[a-f0-9]+\/comments$/.test(actionInfo.commentEndpoint)) {
endpoint = actionInfo.commentEndpoint.replace(
/\/commits\/[a-f0-9]+\/comments$/,
`/comments/${existingCommentId}`
)
}

Regex pattern for comment endpoint update fails to match commit comment URLs, causing PATCH requests to wrong endpoint

View Details
📝 Patch Details
diff --git a/.github/actions/next-stats-action/src/add-comment.js b/.github/actions/next-stats-action/src/add-comment.js
index fa532bc3aa..42a3b160d1 100644
--- a/.github/actions/next-stats-action/src/add-comment.js
+++ b/.github/actions/next-stats-action/src/add-comment.js
@@ -1071,10 +1071,18 @@ module.exports = async function addComment(
 
       if (existingCommentId && actionInfo.githubToken) {
         // GitHub API: PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}
-        endpoint = actionInfo.commentEndpoint.replace(
-          /\/issues\/\d+\/comments$/,
-          `/issues/comments/${existingCommentId}`
-        )
+        // or PATCH /repos/{owner}/{repo}/comments/{comment_id} for commit comments
+        if (/\/issues\/\d+\/comments$/.test(actionInfo.commentEndpoint)) {
+          endpoint = actionInfo.commentEndpoint.replace(
+            /\/issues\/\d+\/comments$/,
+            `/issues/comments/${existingCommentId}`
+          )
+        } else if (/\/commits\/[a-f0-9]+\/comments$/.test(actionInfo.commentEndpoint)) {
+          endpoint = actionInfo.commentEndpoint.replace(
+            /\/commits\/[a-f0-9]+\/comments$/,
+            `/comments/${existingCommentId}`
+          )
+        }
         method = 'PATCH'
         logger(`Updating existing comment at ${endpoint}`)
       } else {

Analysis

The code at line 1074-1076 uses a regex pattern /\/issues\/\d+\/comments$/ to convert comment creation endpoints into update endpoints. However, for commit comments (used in release scenarios), the endpoint has format /repos/owner/repo/commits/{commitId}/comments instead of /repos/owner/repo/issues/{id}/comments.

The regex fails to match commit comment endpoints, causing the replace() to have no effect. This results in PATCH requests being sent to the creation endpoint instead of the update endpoint. According to GitHub API:

  • Issue comment updates: PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}
  • Commit comment updates: PATCH /repos/{owner}/{repo}/comments/{comment_id}

The fix adds conditional logic to detect and handle both endpoint types correctly, with different replacement patterns for each. This ensures PATCH requests are sent to the correct update endpoints.

Fix on Vercel

@feedthejim feedthejim merged commit 4cd3ca2 into canary Jan 6, 2026
163 checks passed
@feedthejim feedthejim deleted the perf-stats-improvements branch January 6, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

created-by: Next.js team PRs by the Next.js team. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants