Repository: vercel/next.js
Pull request: Include `AggregateError.errors` in terminal output

- Fixes https://github.com/vercel/next.js/issues/84464

## Summary

This fixes a bug where Next.js overrides Node's `util.inspect` (to rewrite the stack trace), but that override accidentally omits the `errors` property of an `AggregateError`.

`console.error(myAggregateError)` now includes `[errors]`, matching Node's behavior.


### Changes

1. Special-cased `AggregateError` in the `util.inspect` patch to maintain the `errors` property.
2. Fixed a miscalculation on `depth` in the `util.inspect` patch which caused nested errors in `Error.cause` and `AggregateError.errors` to truncate in weird ways and at the wrong depth.
3. Added unit tests which demonstrate these are now aligned to Node's `util.inspect` behavior.

## Background

When logging an `AggregateError`, it should print an `[errors]` object.

```ts
const error1 = new Error("Error 1");
const error2 = new TypeError("Error 2");
const myAggregateError = new AggregateError([error1, error2], "Multiple errors:");

console.error(myAggregateError);
```

```
AggregateError: Multiple errors:
    at x (.next/server/app/api/log/route.js:1:1569) {
  [errors]: [
    Error: Error 1
        at x (.next/server/app/api/log/route.js:1:1569)
    TypeError: Error 2
        at x (.next/server/app/api/log/route.js:1:1588)
  ]
}
```

However, the `[errors]` object is totally omitted in Next.js production backends today:

```
AggregateError: Multiple errors:
    at x (.next/server/app/api/log/route.js:1:1569)
```

This PR ensures `console.error(myAggregateError)` now includes `[errors]`, matching Node's behavior.

## Are there other omissions like this?

No:

1. `Error.cause` is already special-cased in the patch function.
2. Only `AggregateError` needs this special treatment (and e.g., `TypeError` does not), because `AggregateError` is the only built-in error object that has [its own instance property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError/errors). That property is non-enumerable, so it is not handled by this logic:
    https://github.com/vercel/next.js/blob/f51e3acf961c9bbcab68aab6078c9267d230db21/packages/next/src/server/patch-error-inspect.ts#L465-L471

## Tests

Added unit tests to test for consistency between `util.inspect` and the patched `util.inspect`. The test suite runs twice: once against the original `util.inspect`, and then again for the patched version of `util.inspect`, demonstrating they are consistent (stack trace aside).

<details>

<summary>Unit test results before the bugfix (note that "Original util.inspect" tests pass, meaning the tests are aligned to default Node behavior, and the patch was breaking that behavior)</summary>

```console
$ pnpm jest patch-error-inspect

 FAIL   Turbopack  packages/next/src/server/patch-error-inspect.test.ts
  patch-error-inspect
    Original `util.inspect`
      Regular Error
        ✓ should preserve message formatting for regular Error objects (3 ms)
        ✓ should preserve cause property for regular Error objects (1 ms)
      TypeError
        ✓ should preserve name for TypeError
      Deep Error
        ✓ should collapse deeply nested errors (1 ms)
      AggregateError
        ✓ should preserve errors property when formatting AggregateError with cause (1 ms)
        ✓ should preserve errors property when formatting empty AggregateError
    Patched `util.inspect`
      Regular Error
        ✓ should preserve message formatting for regular Error objects (2 ms)
        ✓ should preserve cause property for regular Error objects (1 ms)
      TypeError
        ✓ should preserve name for TypeError
      Deep Error
        ✕ should collapse deeply nested errors (2 ms)
      AggregateError
        ✕ should preserve errors property when formatting AggregateError with cause (1 ms)
        ✕ should preserve errors property when formatting empty AggregateError

  ● patch-error-inspect › Patched `util.inspect` › Deep Error › should collapse deeply nested errors

    expect(received).toContain(expected) // indexOf

    Expected substring: "[cause]: [Error]"
    Received string:    "Error: Depth 0 error
        at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:101:32)
        at new Promise (<anonymous>) {
      [cause]: Error: Depth 1 error
          at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:98:32)
          at new Promise (<anonymous>) {
        [cause]: Error: Depth 2 error
            at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:95:32)
            at new Promise (<anonymous>) {
          [cause]: Error: Depth 3 error
              at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:92:32)
              at new Promise (<anonymous>) {
            [cause]: Error: Depth 4 error
                at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:91:32)
                at new Promise (<anonymous>)
          }
        }
      }
    }"

    

      at Object.<anonymous> (../packages/next/src/server/patch-error-inspect.test.ts:111:35)

  ● patch-error-inspect › Patched `util.inspect` › AggregateError › should preserve errors property when formatting AggregateError with cause

    expect(received).toContain(expected) // indexOf

    Expected substring: "[errors]:"
    Received string:    "AggregateError: Multiple errors:
        at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:121:40)
        at new Promise (<anonymous>) {
      [cause]: Error: Root error
          at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:120:35)
          at new Promise (<anonymous>)
    }"

    

      at Object.<anonymous> (../packages/next/src/server/patch-error-inspect.test.ts:129:35)

  ● patch-error-inspect › Patched `util.inspect` › AggregateError › should preserve errors property when formatting empty AggregateError

    expect(received).toContain(expected) // indexOf

    Expected substring: "[errors]:"
    Received string:    "AggregateError: No errors
        at Object.<anonymous> (packages/next/src/server/patch-error-inspect.test.ts:136:40)
        at new Promise (<anonymous>)"

    

      at Object.<anonymous> (../packages/next/src/server/patch-error-inspect.test.ts:139:35)

Test Suites: 1 failed, 1 total
Tests:       3 failed, 9 passed, 12 total
Snapshots:   0 total
Time:        0.131 s, estimated 1 s
Ran all test suites matching /patch-error-inspect/i.
```

</details>

Closes https://linear.app/vercel/issue/NAR-752/

Changed files:
filename: packages/next/src/server/patch-error-inspect.ts
patch: @@ -451,13 +451,16 @@ function sourceMapError(
   error: Error,
   inspectOptions: util.InspectOptions
 ): Error {
+  // Setting an undefined `cause` would print `[cause]: undefined`
+  const options = error.cause !== undefined ? { cause: error.cause } : undefined
+
   // Create a new Error object with the source mapping applied and then use native
   // Node.js formatting on the result.
   const newError =
-    error.cause !== undefined
-      ? // Setting an undefined `cause` would print `[cause]: undefined`
-        new Error(error.message, { cause: error.cause })
-      : new Error(error.message)
+    error instanceof AggregateError
+      ? // Preserve AggregateError's `errors` instance property
+        new AggregateError(error.errors, error.message, options)
+      : new Error(error.message, options)
 
   // TODO: Ensure `class MyError extends Error {}` prints `MyError` as the name
   newError.stack = parseAndSourceMap(error, inspectOptions)

filename: test/e2e/app-dir/server-source-maps/fixtures/default/app/rsc-error-log-aggregate/page.js
patch: @@ -0,0 +1,17 @@
+/* global AggregateError */
+function logError() {
+  const error1 = new Error('Error 1')
+  const error2 = new TypeError('Error 2')
+  const rootError = new Error('Root error')
+  const aggregateError = new AggregateError(
+    [error1, error2],
+    'rsc-error-log-aggregate',
+    { cause: rootError }
+  )
+  console.error(aggregateError)
+}
+
+export default function Page() {
+  logError()
+  return null
+}

filename: test/e2e/app-dir/server-source-maps/server-source-maps.test.ts
patch: @@ -199,6 +199,76 @@ describe('app-dir - server source maps', () => {
     }
   })
 
+  it('logged errors include `[errors]` for AggregateError', async () => {
+    if (isNextDev) {
+      const outputIndex = next.cliOutput.length
+      await next.render('/rsc-error-log-aggregate')
+
+      await retry(() => {
+        expect(next.cliOutput.slice(outputIndex)).toContain(
+          'AggregateError: rsc-error-log-aggregate'
+        )
+      })
+      expect(normalizeCliOutput(next.cliOutput.slice(outputIndex))).toContain(
+        'AggregateError: rsc-error-log-aggregate' +
+          '\n    at logError (app/rsc-error-log-aggregate/page.js:6:26)' +
+          '\n    at Page (app/rsc-error-log-aggregate/page.js:15:3)' +
+          "\n  4 |   const error2 = new TypeError('Error 2')" +
+          "\n  5 |   const rootError = new Error('Root error')" +
+          '\n> 6 |   const aggregateError = new AggregateError(' +
+          '\n    |                          ^' +
+          '\n  7 |     [error1, error2],' +
+          "\n  8 |     'rsc-error-log-aggregate'," +
+          '\n  9 |     { cause: rootError } {' +
+          '\n  [cause]: Error: Root error' +
+          '\n      at logError (app/rsc-error-log-aggregate/page.js:5:21)' +
+          '\n      at Page (app/rsc-error-log-aggregate/page.js:15:3)' +
+          "\n    3 |   const error1 = new Error('Error 1')" +
+          "\n    4 |   const error2 = new TypeError('Error 2')" +
+          "\n  > 5 |   const rootError = new Error('Root error')" +
+          '\n      |                     ^' +
+          '\n    6 |   const aggregateError = new AggregateError(' +
+          '\n    7 |     [error1, error2],' +
+          "\n    8 |     'rsc-error-log-aggregate',," +
+          '\n  [errors]: [' +
+          '\n    Error: Error 1' +
+          '\n        at logError (app/rsc-error-log-aggregate/page.js:3:18)' +
+          '\n        at Page (app/rsc-error-log-aggregate/page.js:15:3)' +
+          '\n      1 | /* global AggregateError */' +
+          '\n      2 | function logError() {' +
+          "\n    > 3 |   const error1 = new Error('Error 1')" +
+          '\n        |                  ^' +
+          "\n      4 |   const error2 = new TypeError('Error 2')" +
+          "\n      5 |   const rootError = new Error('Root error')" +
+          '\n      6 |   const aggregateError = new AggregateError(,' +
+          '\n    TypeError: Error 2' +
+          '\n        at logError (app/rsc-error-log-aggregate/page.js:4:18)' +
+          '\n        at Page (app/rsc-error-log-aggregate/page.js:15:3)' +
+          '\n      2 | function logError() {' +
+          "\n      3 |   const error1 = new Error('Error 1')" +
+          "\n    > 4 |   const error2 = new TypeError('Error 2')" +
+          '\n        |                  ^' +
+          "\n      5 |   const rootError = new Error('Root error')" +
+          '\n      6 |   const aggregateError = new AggregateError(' +
+          '\n      7 |     [error1, error2],' +
+          '\n  ]' +
+          '\n}'
+      )
+    } else {
+      if (isTurbopack) {
+        // TODO(veil): Sourcemap names
+        // TODO(veil): relative paths in production
+        expect(normalizeCliOutput(next.cliOutput)).toContain(
+          '(app/rsc-error-log-aggregate/page.js:6:26)'
+        )
+        expect(normalizeCliOutput(next.cliOutput)).toContain('[errors]:')
+        expect(normalizeCliOutput(next.cliOutput)).toContain('[cause]:')
+      } else {
+        // TODO(veil): line/column numbers are flaky in Webpack
+      }
+    }
+  })
+
   it('stack frames are ignore-listed in ssr', async () => {
     if (isNextDev) {
       const outputIndex = next.cliOutput.length