Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e7665fe
Add injectInstrumentation for adapter-supplied instrumentation
kakadiadarpan Oct 27, 2026
0a3b1d5
Add combine-instrumentation to the next-server trace snapshot
kakadiadarpan Oct 28, 2026
b666846
Chain the project instrumentation file after injected entries
kakadiadarpan Oct 29, 2026
37d57b5
Update the injectInstrumentation doc comment for chaining
kakadiadarpan Oct 29, 2026
3926bda
Inline probe() in the inject-instrumentation test
kakadiadarpan Oct 29, 2026
e49ef33
Match compound page extensions for the chained instrumentation file
kakadiadarpan Oct 29, 2026
f229f0b
Await chained instrumentation modules that use top-level await
kakadiadarpan Oct 29, 2026
93edd31
Make absolute injectInstrumentation entries relative for Turbopack
kakadiadarpan Oct 29, 2026
04e76de
Describe chaining in two remaining injectInstrumentation comments
kakadiadarpan Oct 29, 2026
418dd8a
Mark chained instrumentation promises as handled up front
kakadiadarpan Oct 29, 2026
ff3b8e2
Update injectInstrumentation comments for chaining and absolute paths
kakadiadarpan Oct 29, 2026
0ac079b
Default injectInstrumentation to an empty array
kakadiadarpan Oct 29, 2026
300aed5
Say injectInstrumentation paths need their file extension
kakadiadarpan Oct 29, 2026
68ba406
Add the instrumentation inject options to the webpack cache key
kakadiadarpan Oct 30, 2026
0b573c3
Give each config load its own instrumentation inject arrays
kakadiadarpan Oct 30, 2026
a7a5c49
Say which instrumentation errors don't stop the other modules
kakadiadarpan Oct 30, 2026
6da6b5e
Apply suggestion from @mischnic
mischnic Oct 30, 2026
c656d4a
Use interopDefault when combining instrumentation modules
mischnic Oct 30, 2026
96a5e74
Define the empty module alias outside the client branch
mischnic Oct 30, 2026
817f920
Inline the instrumentation page constant at its consumers
mischnic Oct 30, 2026
a5c3e47
Exclude injected instrumentation from root mapping inputs
mischnic Oct 30, 2026
3bd6696
Replace instrumentation injection resolution with a config check
mischnic Oct 30, 2026
8ff3d74
Preserve webpack externals for injected instrumentation packages
mischnic Oct 30, 2026
f3c376a
Inspect emitted bundles to verify instrumentation externals
mischnic Oct 30, 2026
bc84160
Assert injected external instrumentation is included in NFT traces
mischnic Oct 30, 2026
43b5e1f
better fix
mischnic Oct 30, 2026
6dc245e
Inspect only Node.js instrumentation bundles across bundlers
mischnic Oct 30, 2026
23dd071
Rename injectInstrumentation to instrumentationInject
mischnic Oct 30, 2026
0970483
Run chained onRequestError handlers concurrently
kakadiadarpan Nov 2, 2026
f2c7b9e
Describe how webpack's instrumentation file lookup differs from Turbo…
kakadiadarpan Nov 2, 2026
3eca83f
fix test
mischnic Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 62 additions & 8 deletions crates/next-api/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ use turbo_tasks::{
};
use turbo_tasks_env::{EnvMap, ProcessEnv};
use turbo_tasks_fs::{
DiskFileSystem, DiskFileSystemMap, DiskWatcherConfig, FileContent, FileSystem, FileSystemPath,
VirtualFileSystem, invalidation,
DiskFileSystem, DiskFileSystemMap, DiskWatcherConfig, File, FileContent, FileSystem,
FileSystemPath, VirtualFileSystem, invalidation,
};
use turbo_unix_path::join_path;
use turbopack::{
Expand All @@ -60,6 +60,7 @@ use turbopack::{
};
use turbopack_core::{
PROJECT_FILESYSTEM_NAME,
asset::AssetContent,
changed::content_changed,
chunk::{
ChunkingContext, EvaluatableAssets, UnusedReferences,
Expand Down Expand Up @@ -89,9 +90,11 @@ use turbopack_core::{
reference::all_assets_from_entries,
reference_type::{CommonJsReferenceSubType, ReferenceType},
resolve::{FindContextFileResult, find_context_file},
source::{OptionSource, Source},
version::{
NotFoundVersion, OptionVersionedContent, Update, Version, VersionState, VersionedContent,
},
virtual_source::VirtualSource,
};
use turbopack_ecmascript::async_chunk::proxy::{
activation_key_from_chunk_path, lazy_compilation_state,
Expand Down Expand Up @@ -2207,8 +2210,7 @@ impl Project {
None
};

let instrumentation = self.find_instrumentation();
let instrumentation = if let FindContextFileResult::Found(..) = *instrumentation.await? {
let instrumentation = if self.instrumentation_source().await?.is_some() {
Some(Instrumentation {
node_js: self.instrumentation_endpoint(false).to_resolved().await?,
edge: self.instrumentation_endpoint(true).to_resolved().await?,
Expand Down Expand Up @@ -2547,16 +2549,68 @@ impl Project {
))
}

/// The source compiled into the instrumentation slot. Without `instrumentationInject`, that is
/// the project's instrumentation file, including one in a parent directory. Otherwise a
/// virtual module at the project root requires every `instrumentationInject` entry, so the
/// bundler resolves them there, then the project's file, and runs all of their hooks.
/// `entrypoints` and `instrumentation_endpoint` both read this.
#[turbo_tasks::function]
async fn instrumentation_source(self: Vc<Self>) -> Result<Vc<OptionSource>> {
let project_file = match &*self.find_instrumentation().await? {
FindContextFileResult::Found(fs_path, _) => Some(fs_path.clone()),
_ => None,
};
let inject = self.next_config().instrumentation_inject().await?;
if inject.is_empty() {
let Some(fs_path) = project_file else {
return Ok(Vc::cell(None));
};
let source = Vc::upcast::<Box<dyn Source>>(FileSource::new(fs_path));
return Ok(Vc::cell(Some(source.to_resolved().await?)));
}
let project_path = self.project_path().owned().await?;
let mut specifiers: Vec<RcStr> = inject.iter().cloned().collect();
if let Some(fs_path) = &project_file {
let request = project_path
.get_relative_request_to(fs_path)
.context("project instrumentation file is outside the project filesystem")?;
specifiers.push(request);
}
// Keep in sync with
// packages/next/src/build/webpack/loaders/next-instrumentation-inject-loader.ts.
let requires = specifiers
.iter()
.map(|spec| {
Ok(format!(
"require({})",
serde_json::to_string(spec.as_str())?
))
})
.collect::<Result<Vec<_>>>()?
.join(", ");
let source_text = format!(
"const {{ combineInstrumentation }} = \
require('next/dist/lib/combine-instrumentation');\nmodule.exports = \
combineInstrumentation([{requires}]);\n"
);
// CommonJS so a `type: module` package.json can't change how it parses. Not a convention
// file name, so discovery never treats it as a project file.
let path = project_path.join("instrumentation.inject.cjs")?;
let source = Vc::upcast::<Box<dyn Source>>(VirtualSource::new(
path,
AssetContent::file(FileContent::Content(File::from(source_text)).cell()),
));
Ok(Vc::cell(Some(source.to_resolved().await?)))
}

#[turbo_tasks::function]
async fn instrumentation_endpoint(
self: Vc<Self>,
is_edge: bool,
) -> Result<Vc<Box<dyn Endpoint>>> {
let instrumentation = self.find_instrumentation();
let FindContextFileResult::Found(fs_path, _) = &*instrumentation.await? else {
let Some(source) = *self.instrumentation_source().await? else {
return Ok(Vc::upcast(EmptyEndpoint::new(self)));
};
let source = Vc::upcast(FileSource::new(fs_path.clone()));
let app_dir = find_app_dir(self.project_path().owned().await?)
.owned()
.await?;
Expand All @@ -2573,7 +2627,7 @@ impl Project {
Ok(Vc::upcast(InstrumentationEndpoint::new(
self,
instrumentation_asset_context,
source,
*source,
is_edge,
app_dir.clone(),
ecmascript_client_reference_transition_name,
Expand Down
8 changes: 8 additions & 0 deletions crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ pub struct NextConfig {
images: ImageConfig,
page_extensions: Vec<RcStr>,
instrumentation_client_inject: Option<Vec<RcStr>>,
/// Server instrumentation module specifiers. They run in order, before the project's own
/// instrumentation file. Next.js does not install the modules.
instrumentation_inject: Option<Vec<RcStr>>,
react_compiler: Option<ReactCompilerOptionsOrBoolean>,
react_production_profiling: Option<bool>,
react_strict_mode: Option<bool>,
Expand Down Expand Up @@ -1700,6 +1703,11 @@ impl NextConfig {
Vc::cell(self.react_production_profiling.unwrap_or_default())
}

#[turbo_tasks::function]
pub fn instrumentation_inject(&self) -> Vc<Vec<RcStr>> {
Vc::cell(self.instrumentation_inject.clone().unwrap_or_default())
}

#[turbo_tasks::function]
pub fn server_external_packages(&self) -> Vc<Vec<RcStr>> {
Vc::cell(
Expand Down
25 changes: 21 additions & 4 deletions packages/next/src/build/create-compiler-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,6 @@ export function createWebpackAliases({
'private-next-empty-module',
],

// disable typechecker, webpack5 allows aliases to be set to false to create a no-op module
'private-next-empty-module': false as any,

// In the browser bundle, swap every module that has a `.browser` sibling
// (see browser-variant-modules.ts, generated from the filesystem) for that
// sibling. The default module holds the full server logic; bundling it would
Expand Down Expand Up @@ -220,7 +217,27 @@ export function createWebpackAliases({
}
: {}),
}
: {}),
: {
// The project's own `instrumentation.{pageExt}` file, chained after
// the `instrumentationInject` entries by
// `next-instrumentation-inject-loader`. Checks the project root,
// then `src/`, trying each page extension in config order. Exact
// names, so compound extensions like `page.ts` match. Turbopack also
// checks parent folders and tries longer extensions first, so with
// more than one candidate file the two bundlers can pick different
// ones.
'private-next-instrumentation-user': [
...['', 'src'].flatMap((subDir) =>
pageExtensions.map((ext) =>
path.join(dir, subDir, `instrumentation.${ext}`)
)
),
'private-next-empty-module',
],
}),

// disable typechecker, webpack5 allows aliases to be set to false to create a no-op module
'private-next-empty-module': false as any,

[DOT_NEXT_ALIAS]: distDir,
...(isClient || isEdgeServer ? getOptimizedModuleAliases() : {}),
Expand Down
23 changes: 21 additions & 2 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ import {
pageToRoute,
} from './utils'
import type { DynamicManifestRoute, PageInfo, PageInfos } from './utils'
import { INSTRUMENTATION_INJECT_STUB_PATH } from './next-dir-paths'
import type {
FallbackRouteParam,
PrerenderRouteMatcher,
Expand Down Expand Up @@ -1535,7 +1536,10 @@ export default async function build(
)
}

const hasInstrumentationHook = Boolean(instrumentationHookFilePath)
const hasInstrumentationInject =
(config.instrumentationInject?.length ?? 0) > 0
const hasInstrumentationHook =
Boolean(instrumentationHookFilePath) || hasInstrumentationInject
const hasMiddlewareFile = Boolean(middlewareFilePath)
const hasProxyFile = Boolean(proxyFilePath)

Expand Down Expand Up @@ -1577,13 +1581,28 @@ export default async function build(
createPagesMapping({
isDev: false,
pageExtensions: config.pageExtensions,
pagePaths: rootPaths,
// The stub loads the project's instrumentation file, so don't
// create a separate entry for it when instrumentation is injected.
pagePaths: hasInstrumentationInject
? rootPaths.filter(
(rootPath) =>
!instrumentationHookDetectionRegExp.test(
path.basename(rootPath)
)
)
: rootPaths,
pagesType: PAGE_TYPES.ROOT,
pagesDir,
appDir,
appDirOnly,
})
)
if (hasInstrumentationInject) {
// The stub chains the project's instrumentation file after the
// injected entries, so it replaces that file as the entry.
NextBuildContext.mappedRootPaths[`/${INSTRUMENTATION_HOOK_FILENAME}`] =
INSTRUMENTATION_INJECT_STUB_PATH
}

const {
pageRoutes,
Expand Down
11 changes: 11 additions & 0 deletions packages/next/src/build/next-dir-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@ export const NEXT_PROJECT_ROOT_DIST_CLIENT = path.join(
NEXT_PROJECT_ROOT_DIST,
'client'
)

/**
* The webpack entry for the instrumentation slot when `instrumentationInject`
* is set. `next-instrumentation-inject-loader` replaces its contents with a
* module that runs every entry's hooks, then the project's own
* `instrumentation` file.
*/
export const INSTRUMENTATION_INJECT_STUB_PATH = path.join(
NEXT_PROJECT_ROOT_DIST,
'build/webpack/loaders/instrumentation-inject-stub.js'
)
11 changes: 11 additions & 0 deletions packages/next/src/build/swc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,17 @@ function bindingToApi(
)
}

// Turbopack resolves a leading `/` from the project root, so make absolute
// instrumentationInject entries relative. Package names stay as they are.
if (nextConfigSerializable.instrumentationInject?.length) {
nextConfigSerializable.instrumentationInject =
nextConfigSerializable.instrumentationInject.map((entry: string) =>
path.isAbsolute(entry)
? './' + normalizePathOnWindows(path.relative(projectPath, entry))
: entry
)
}

if (nextConfigSerializable.turbopack != null) {
// clone to allow in-place mutations
const turbopack = { ...nextConfigSerializable.turbopack }
Expand Down
28 changes: 28 additions & 0 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
getReactCompilerLoader,
} from './get-babel-loader-config'
import {
INSTRUMENTATION_INJECT_STUB_PATH,
NEXT_PROJECT_ROOT,
NEXT_PROJECT_ROOT_DIST_CLIENT,
} from './next-dir-paths'
Expand Down Expand Up @@ -1373,6 +1374,7 @@ export default async function getBaseWebpackConfig(
'next-swc-loader',
'next-client-pages-loader',
'next-instrumentation-client-loader',
'next-instrumentation-inject-loader',
'next-image-loader',
'next-metadata-image-loader',
'next-style-loader',
Expand Down Expand Up @@ -1954,6 +1956,18 @@ export default async function getBaseWebpackConfig(
},
},
},
// The server instrumentation entry when `instrumentationInject` is set.
// The loader emits one module that runs every entry's hooks, then the
// project's own instrumentation file.
{
test: /[\\/]next[\\/]dist[\\/](esm[\\/])?build[\\/]webpack[\\/]loaders[\\/]instrumentation-inject-stub\.js$/,
use: {
loader: 'next-instrumentation-inject-loader',
options: {
modules: config.instrumentationInject ?? [],
},
},
},
{
// This loader rule should be before other rules, as it can output code
// that still contains `"use client"` or `"use server"` statements that
Expand Down Expand Up @@ -1993,6 +2007,18 @@ export default async function getBaseWebpackConfig(
],
},
plugins: [
!isClient &&
config.instrumentationInject?.length &&
new bundler.NormalModuleReplacementPlugin(
new RegExp(
`^${escapeStringRegexp(INSTRUMENTATION_INJECT_STUB_PATH)}$`
),
({ createData }) => {
if (createData?.resource === INSTRUMENTATION_INJECT_STUB_PATH) {
createData.context = dir
}
}
),
// In prod Webpack will already have a runtime for all reachable chunks.
// During dev, it will update the runtime as chunks come in which may be too late for Flight.
dev && new CompleteRuntimePlugin(),
Expand Down Expand Up @@ -2366,6 +2392,8 @@ export default async function getBaseWebpackConfig(
optimizePackageImports: config?.experimental?.optimizePackageImports,
crossOrigin: config.crossOrigin,
pageExtensions: pageExtensions,
instrumentationInject: config.instrumentationInject,
instrumentationClientInject: config.instrumentationClientInject,
trailingSlash: config.trailingSlash,
devIndicatorsPosition:
config.devIndicators === false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Placeholder module. When `instrumentationInject` is set, this file is the
// instrumentation entry. Its contents are replaced by
// `next-instrumentation-inject-loader` via a `module.rules` entry in
// webpack-config.ts.
module.exports = {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { webpack } from 'next/dist/compiled/webpack/webpack'

export type InstrumentationInjectLoaderOptions = {
modules: string[]
}

// Keep in sync with `instrumentation_source` in crates/next-api/src/project.rs.
const NextInstrumentationInjectLoader: webpack.LoaderDefinitionFunction<InstrumentationInjectLoaderOptions> =
function () {
const { modules } = this.getOptions()

// Keep package requests intact so webpack's externals handler can apply
// serverExternalPackages and the built-in opt-out list. The webpack config
// sets this module's context to the project root before module creation.
// The project's own `instrumentation` file runs last, or an empty module
// when there is none (see `create-compiler-aliases.ts`).
const allModules = [...modules, 'private-next-instrumentation-user']

return (
`const { combineInstrumentation } = require('next/dist/lib/combine-instrumentation');\n` +
`module.exports = combineInstrumentation([${allModules
.map((spec) => `require(${JSON.stringify(spec)})`)
.join(', ')}]);\n`
)
}

export default NextInstrumentationInjectLoader
Loading
Loading