Skip to content

Inject default Vercel tracing for Next.js builds without an instrumentation file - #148

Open
kakadiadarpan wants to merge 26 commits into
nextjs:mainfrom
kakadiadarpan:darpan/inject-instrumentation
Open

kakadiadarpan wants to merge 26 commits into
nextjs:mainfrom
kakadiadarpan:darpan/inject-instrumentation

Conversation

@kakadiadarpan

@kakadiadarpan kakadiadarpan commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Problem

New Next.js projects on Vercel get no traces unless they add instrumentation.ts and install @vercel/otel. Default tracing has to work without touching the project's package.json or lockfile, and without downloading anything per build.

Solution

In modifyConfig, during next build, the adapter writes a wrapper into the project and points Next's new instrumentationInject at it.

<projectDir>/.vercel/otel/
├── instrumentation.mjs       register(): on Node.js only, import @vercel/otel → registerOTel({ serviceName: VERCEL_PROJECT_NAME }); a failure only warns
└── node_modules/             copied from $VERCEL_OTEL_PACKAGES_DIR
    ├── @vercel/otel
    ├── @opentelemetry/api-logs
    └── @opentelemetry/api    pinned to 1.9.x by the image
config.instrumentationInject ??= []
config.instrumentationInject.push('./.vercel/otel/instrumentation.mjs')

Each build worker copies the tree into its own temp folder and renames it into place, so parallel webpack workers never read a half-copied tree. A stamp of the image path and package versions lets later workers reuse it.

The files have to sit inside the project. Turbopack can't read outside the project root, and it rejects symlinks that point out of it.

It injects only when every check passes. A miss skips silently (the reason is logged with VERCEL_BUILDER_DEBUG=1), and errors never fail the build.

modifyConfig (index.ts)
  phase is next build, projectDir set
  VERCEL_DEFAULT_TRACING_ENABLED === '1'       set by the API for eligible projects
  VERCEL_OTEL_PACKAGES_DIR set                 set by the build image
injectDefaultTracing (tracing.ts)
  no instrumentation.* in the project, src/, or a parent folder up to outputFileTracingRoot
  instrumentationInject has no other entries   another setter may register its own provider
                                               Next.js would chain both, so skipping keeps the user's file as the only one
  the @opentelemetry/api that the project root resolves, and the one Next.js resolves from its own folder,
  are each absent or 1.x below 1.10, with no prerelease
  the image's @opentelemetry/api is 1.9.x
  no copied package has runtime dependencies outside the copied set
  all copied packages resolve from the wrapper

Spans use VERCEL_PROJECT_NAME as the service name. A customer's OTEL_SERVICE_NAME still wins.

There's no Next.js version check: older versions ignore instrumentationInject.

@vercel/otel instruments fetch, so onBuildComplete sets shouldDisableAutomaticFetchInstrumentation on Node.js functions and Node middleware when the build compiled the inject (the config has the entry and .next/server/instrumentation.js exists). Otherwise every fetch would be traced twice. A Next.js version that ignores instrumentationInject compiles no instrumentation entry, so it keeps the runtime's fetch spans.

@vercel/otel registers through its own pinned @opentelemetry/api 1.9, except on webpack when Next.js finds no API at all: webpack then aliases every import to Next's compiled 1.6.0, which has the API surface @vercel/otel uses. Every 1.x copy of the API shares one global slot and accepts a provider registered by the same or a newer minor version. So the app's copy, Next's vendored 1.6.0, or a copy only Next.js can see (pnpm hoists a dependency's API into node_modules/.pnpm/node_modules) all read the same provider. Webpack always finds the API next to @vercel/otel, so the build no longer fails when Next.js finds a copy the project root can't. The inject skips when either copy is 1.10 or newer, because those readers reject a 1.9 provider. It also skips for 2.x, which uses a different slot, and for prereleases, which only accept an exact match. Nested API copies in the image, such as the 1.10.0 that yarn 1 puts under api-logs, are left out of the copy. The SDK 2.x bundled in @vercel/otel caps the API below 1.10.

Related PR(s)

modifyConfig writes .vercel/otel and appends injectInstrumentation only
on a production build that can use it. The inject is skipped when the
project already has an instrumentation file, Next.js does not read the
field, @opentelemetry/api is missing or outside >=1.9.0 <2.0.0, or the
image is missing a package. A failure is logged and does not fail the
build.

VERCEL_OTEL_PACKAGES_DIR is a placeholder until the build image names
the directory that contains @vercel/otel and @opentelemetry/api-logs.
The tests cover the copied tree, createRequire resolution, a project
instrumentation file, a missing image package, an incompatible
OpenTelemetry API, and preserved injectInstrumentation entries.
…I below 1.10

The build image sets VERCEL_OTEL_PACKAGES_DIR on every build, so it only
locates the packages now. The API sets VERCEL_DEFAULT_TRACING_ENABLED=1
for eligible projects. OpenTelemetry JS SDK 2.x caps the API below 1.10.
@vercel/otel 2.1.3 only uses API features that 1.6.0 has, so Next's
vendored copy is enough. Lower the floor for app copies to 1.6.0.
@vercel

vercel Bot commented Oct 27, 2026

Copy link
Copy Markdown

@kakadiadarpan is attempting to deploy a commit to the vtest314-next-adapter-e2e-tests Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3ff6296

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@next-community/adapter-vercel Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Pass VERCEL_PROJECT_NAME as serviceName so spans don't all report "app".
OTEL_SERVICE_NAME still wins, and a missing value falls back to "app".
@kakadiadarpan
kakadiadarpan marked this pull request as ready for review October 28, 2026 08:13
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
It required a next/dist internal. The adapter doesn't gate other config
on the Next.js version: older versions ignore injectInstrumentation.
Use build-utils' debug() so customer build logs don't show a skip line,
for example on every build of a project with its own instrumentation file.
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Match the other blocks in modifyConfig. injectDefaultTracing now only does
the filesystem checks and the copy.
Next.js always sets pageExtensions, defaulting to tsx, ts, jsx and js.
The accepted range is >=1.6.0 <1.10.0, so compare the major and minor
numbers instead of parsing full semver.
Next.js now chains injected instrumentation before the project's own file,
and Turbopack finds that file in parent folders up to the tracing root.
Check the same folders so a monorepo root instrumentation file isn't
chained with default tracing.
webpack parses .js as CommonJS when the app's package.json sets
"type": "commonjs", and .vercel/otel/ has no package.json of its own, so
the wrapper's import failed to parse and the build failed. .mjs is always
ESM.
@vercel

vercel Bot commented Oct 29, 2026

Copy link
Copy Markdown

Your Vercel team vtest314-next-adapter-e2e-tests is not permitted to deploy from this git repository. Contact an administrator to add github organization nextjs as a Protected Git Scope in vtest314-next-adapter-e2e-tests on Vercel. Once added, commit again to see your changes.

Learn more: https://vercel.com/docs/security/protected-git-scopes

Next.js compiles instrumentation for both runtimes, so the static import put
@vercel/otel's edge build in every Edge function (about +60 KB gzipped each).
Default tracing targets Node.js, and NEXT_RUNTIME is inlined per runtime, so
the dynamic import behind it drops out of Edge bundles.
Copy @opentelemetry/api from the build image next to @vercel/otel, so
webpack always resolves it from .vercel/otel, and skip only when the
project root or Next.js resolves an API that a 1.9 provider can't reach:
1.10 or newer, 2.x, or a prerelease.
@kakadiadarpan

Copy link
Copy Markdown
Contributor Author

Fixed the pnpm hidden-API case in 800c462 and a8a24e6:

  • The adapter now copies a pinned @opentelemetry/api 1.9 next to @vercel/otel, so webpack always resolves it. A 1.9 provider is visible to every 1.x reader up to 1.9.
  • It skips when the API that the project root or Next.js resolves (createRequire(next/package.json)) is 1.10+, 2.x, or a prerelease. It also skips when the image's API isn't 1.9.x. vercel/api#103251 adds that pin.
  • Nested API copies in the image are left out of the copy.

Checked with linux/amd64, pnpm 10.5.2, both bundlers, and .vercel/ deleted before next start:

  • Your ai@5 repro now builds on webpack, and Turbopack sends Next.js spans plus fetch spans.
  • inngest@4 skips.
  • A custom span from the app's own API nests under Next's request span.
  • An app on API 1.4.1 now gets the inject.

vercel/next.js#100658 renamed the config option to match
instrumentationClientInject.

@lucleray lucleray left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

looks good, thanks! left a few notes inline, i think the duplicate fetch spans one is worth fixing before GA 👍

Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread packages/adapter/src/tracing.ts Resolved
Comment thread .changeset/default-vercel-tracing.md Resolved
mischnic added a commit to vercel/next.js that referenced this pull request Nov 3, 2026
### What?

Adds an `instrumentationInject?: string[]` config option. Next.js
compiles one module that runs every entry's `register` and
`onRequestError`, then the project's own `instrumentation.{ts,js}` if it
has one. This works like `instrumentationClientInject` does for the
client.

```ts
// set by a deployment adapter in modifyConfig, or a config wrapper like withSentry
config.instrumentationInject ??= []
config.instrumentationInject.push('./path/to/instrumentation.js')
```

```text
instrumentation slot
  → each instrumentationInject entry, in array order
  → then the project's instrumentation file, if there is one
```

### Why?

Hosting platforms want to give every app default instrumentation, such
as OpenTelemetry tracing. Today that means asking users to add an
`instrumentation.ts` and a dependency to their project. This hook lets
an adapter or a config wrapper supply the module at build time instead.
The user's files, `package.json` and lockfile stay untouched, and
Next.js installs nothing.

### How?

- Anyone can set the field: a deployment adapter in `modifyConfig`, or a
config wrapper such as `withSentry`. Whoever sets it decides whether to
inject when the project already has its own instrumentation file.
- Both bundlers generate the same CommonJS module: it requires every
entry from the project root, then the project's instrumentation file,
and passes them to `combineInstrumentation`
(`next/dist/lib/combine-instrumentation`).
- webpack compiles a stub entry through
`next-instrumentation-inject-loader`. Turbopack uses a virtual
`instrumentation.inject.cjs` at the project root, so a `type: module`
package.json can't change how it parses.
- `register` hooks run in array order. `onRequestError` hooks run
concurrently, so a slow or hanging handler can't stop the project's own
from running. A hook that throws, or a module with top-level await that
fails to load, doesn't stop the others. The error is rethrown after all
have run, as an `AggregateError` when more than one fails, so Next.js
surfaces it as it does for a single file. A module without top-level
await that throws while loading stops startup, as a single broken file
does today.
- Modules that use top-level `await` come back from `require()` as
promises. `combineInstrumentation` awaits each one, and marks them
handled up front so a later rejection isn't reported as unhandled while
an earlier hook runs.
- Entries can be package names, project-relative paths, or absolute
paths. The bundler resolves them from the project root, as it would an
import. Package entries keep their name, so `serverExternalPackages` and
the built-in externals list apply, and an external entry is still
included in the server trace. Absolute paths are rewritten to
project-relative ones for Turbopack, which resolves a leading `/` from
the project root.
- `instrumentationInject` defaults to `[]`, like
`instrumentationClientInject`, so `modifyConfig` can push to it.
- To find the project file, webpack tries each `pageExtensions`
extension, in config order, at the project root and then in `src/`.
Turbopack uses its usual `instrumentation` lookup, which also checks
parent folders up to `outputFileTracingRoot` and tries longer extensions
first. With more than one candidate file, the two can pick different
ones.
- An entry that doesn't resolve fails the build, so whoever sets the
field should check it first.

### Tests

`test/e2e/app-dir/inject-instrumentation` covers five cases with webpack
and Turbopack:

1. With two injected entries and no project file, both `register()`
calls run, in order.
2. With a project instrumentation file, both injected `register()` calls
run, then the project's.
3. With `pageExtensions: ['page.ts', …]`, the project's
`instrumentation.page.ts` is chained.
4. With no inject and no file, there's no instrumentation.
5. Package entries: one listed in `serverExternalPackages` stays out of
the Node server bundles and appears in `instrumentation.js.nft.json`,
and a bundled one is compiled in. The bundle check reads build output,
so it's gated to `!deploy`, and the trace check to `start`.

The fixtures cover a relative and an absolute entry, an injected entry
and a project file that both use top-level `await`, and an injected
module that imports a sibling file.
`lib/combine-instrumentation.test.ts` covers `register` order, error
isolation, `onRequestError` arguments, concurrent `onRequestError`
handlers (a hanging one doesn't block the rest), promise modules, and
that a later rejection isn't left unhandled.

### Related
nextjs/adapter-vercel#148

<!-- NEXT_JS_LLM -->

<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

We encourage you to use AI to assist you in researching, creating, and
reviewing changes. However, you must review and deeply understand the
contributions you are making. For this reason, **pull request
descriptions from external contributors must be written by a human**.

### Improving Documentation

- Run `pnpm prettier-fix` to fix formatting issues before opening the
PR.
- Read the Docs Contribution Guide to ensure your contribution follows
the docs guidelines:
https://nextjs.org/docs/community/contribution-guide

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See:
https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature
request has been accepted for implementation before opening a PR. (A
discussion must be opened, see
https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added
(https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md

### Signed commits

- This repository requires verified commit signatures on protected
branches.
- If this pull request is blocked for unsigned commits, re-sign the
commits and force-push the branch.
- A `Signed-off-by` line in the commit message is not enough.

## For Maintainers

- Minimal description (aim for explaining to someone not on the team to
understand the PR)
- When linking to a Slack thread, you might want to share details of the
conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Closes NEXT-
Fixes #

-->

---------

Co-authored-by: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>
When Next.js can't resolve the API from its own folder, webpack aliases every
import to Next's compiled 1.6.0, so the copied 1.9.0 isn't used there.
A config wrapper that injects its own instrumentation may register its own
provider. Chaining default tracing after it would register a second one,
the same reason the inject skips when the project has its own file.
Only @vercel/otel, @opentelemetry/api-logs and @opentelemetry/api are copied.
A future image with a package that needs another runtime dependency would
pass the resolve check, then fail next build on the missing import.
Next.js keeps the register() promise and every request awaits it, and
combineInstrumentation rethrows. If the import or registerOTel ever threw,
every request on the instance would fail, for a customer who never opted in.
The wrapper now catches and warns.
webpack runs modifyConfig in each build worker, so several processes copied
the ~8 MB tree into the same folder at once. With 8 parallel runs, 7 skipped
the inject on EEXIST and the tree was left a file short. Each run now copies
into its own temp folder and renames it into place, and a stamp of the image
path and package versions lets later runs reuse the tree.
The runtime traces outbound fetches unless a function sets
shouldDisableAutomaticFetchInstrumentation, which the CLI only does when
package.json lists OpenTelemetry packages. The injected @vercel/otel also
instruments fetch, so each fetch was traced twice. onBuildComplete now sets
the flag on Node.js functions and Node middleware when the build compiled
the inject. Next.js versions that ignore instrumentationInject compile no
instrumentation entry, so they keep the runtime's fetch spans.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants