From 689a8c7514fb3fa3608fdb1988400436d5ebddb0 Mon Sep 17 00:00:00 2001 From: nathancolosimo Date: Thu, 16 Apr 2026 17:01:58 -0400 Subject: [PATCH 1/5] Add instrumentation nextjs target + tests Signed-off-by: nathancolosimo --- packages/core/e2e/manifest.test.ts | 34 +++++++++++++++++++ packages/next/src/builder-eager.ts | 8 +++++ packages/next/src/index.ts | 11 ++++-- .../101_instrumentation_only.ts | 5 +++ workbench/nextjs-turbopack/instrumentation.ts | 1 + 5 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 workbench/nextjs-turbopack/instrumentation-workflows/101_instrumentation_only.ts diff --git a/packages/core/e2e/manifest.test.ts b/packages/core/e2e/manifest.test.ts index e228afa338..319a529280 100644 --- a/packages/core/e2e/manifest.test.ts +++ b/packages/core/e2e/manifest.test.ts @@ -250,6 +250,40 @@ describe.each([ ); }); +describe.each([ + 'nextjs-webpack', + 'nextjs-turbopack', +])('Next instrumentation discovery', (project) => { + test( + `${project}: discovers workflows imported only from instrumentation.ts`, + { timeout: 30_000 }, + async () => { + if (process.env.APP_NAME && project !== process.env.APP_NAME) { + return; + } + + const manifest = await tryReadManifest(project); + if (!manifest) return; + + const workflowFiles = Object.keys(manifest.workflows); + const instrumentationWorkflowFile = workflowFiles.find((filePath) => + filePath.includes('101_instrumentation_only') + ); + + expect( + instrumentationWorkflowFile, + `Expected an instrumentation-only workflow file in manifest workflows. Available: ${workflowFiles.join(', ')}` + ).toBeDefined(); + + const fileWorkflows = manifest.workflows[instrumentationWorkflowFile!]; + expect(fileWorkflows.instrumentationOnlyWorkflow).toBeDefined(); + expect(fileWorkflows.instrumentationOnlyWorkflow.workflowId).toContain( + 'instrumentationOnlyWorkflow' + ); + } + ); +}); + /** * Tests for single-statement control flow extraction. * These verify that steps inside if/while/for without braces are extracted. diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index c6d6fac0e2..d17d15c215 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -404,6 +404,14 @@ export async function getNextBuilderEager() { if (item.match(/[/\\](pages|src[/\\]pages)[/\\]/)) { return true; } + // Match server instrumentation entrypoints: instrumentation.ts/js and src/instrumentation.ts/js + if ( + item.match( + /(^|.*[/\\])(instrumentation|src[/\\]instrumentation)\.[cm]?[jt]sx?$/ + ) + ) { + return true; + } return false; }); } diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index f16c45967e..73b751e9a8 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -122,8 +122,15 @@ export function withWorkflow( const NextBuilder = await getNextBuilder(nextVersion); return new NextBuilder({ watch: shouldWatch, - // discover workflows from pages/app entries - dirs: ['pages', 'app', 'src/pages', 'src/app'], + // discover workflows from Next.js server entrypoints + dirs: [ + 'pages', + 'app', + 'src/pages', + 'src/app', + 'instrumentation', + 'src/instrumentation', + ], projectRoot: nextConfig.outputFileTracingRoot, workingDir: process.cwd(), distDir: nextConfig.distDir || '.next', diff --git a/workbench/nextjs-turbopack/instrumentation-workflows/101_instrumentation_only.ts b/workbench/nextjs-turbopack/instrumentation-workflows/101_instrumentation_only.ts new file mode 100644 index 0000000000..e65d0659b3 --- /dev/null +++ b/workbench/nextjs-turbopack/instrumentation-workflows/101_instrumentation_only.ts @@ -0,0 +1,5 @@ +export async function instrumentationOnlyWorkflow() { + 'use workflow'; + + return 'instrumentation-only'; +} diff --git a/workbench/nextjs-turbopack/instrumentation.ts b/workbench/nextjs-turbopack/instrumentation.ts index 508eddd8d3..3becba70f8 100644 --- a/workbench/nextjs-turbopack/instrumentation.ts +++ b/workbench/nextjs-turbopack/instrumentation.ts @@ -1,4 +1,5 @@ import { registerOTel } from '@vercel/otel'; +import './instrumentation-workflows/101_instrumentation_only'; registerOTel({ serviceName: 'example-nextjs-workflow' }); From 743df093a97f60ad342920b96e56f4b0bfe47bd4 Mon Sep 17 00:00:00 2001 From: nathancolosimo Date: Fri, 17 Apr 2026 13:25:31 -0400 Subject: [PATCH 2/5] Search files Signed-off-by: nathancolosimo --- packages/next/src/builder-eager.ts | 41 ++++++++++++++++++++++-------- packages/next/src/index.ts | 11 ++------ 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index d17d15c215..32f33348d5 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -4,6 +4,30 @@ import { extname, join, resolve } from 'node:path'; import Watchpack from 'watchpack'; let CachedNextBuilderEager: any; +const INSTRUMENTATION_FILE_CANDIDATES = [ + 'instrumentation.ts', + 'instrumentation.js', + 'src/instrumentation.ts', + 'src/instrumentation.js', +] as const; + +async function findInstrumentationEntryFiles( + workingDir: string +): Promise { + const matches = await Promise.all( + INSTRUMENTATION_FILE_CANDIDATES.map(async (relativePath) => { + const absolutePath = resolve(workingDir, relativePath); + try { + await access(absolutePath, constants.F_OK); + return absolutePath; + } catch { + return null; + } + }) + ); + + return matches.filter((match): match is string => match !== null); +} // Create the eager Next builder dynamically by extending the ESM BaseBuilder. // Exported as getNextBuilderEager() to allow CommonJS modules to import from @@ -389,8 +413,7 @@ export async function getNextBuilderEager() { } protected async getInputFiles(): Promise { - const inputFiles = await super.getInputFiles(); - return inputFiles.filter((item) => { + const inputFiles = (await super.getInputFiles()).filter((item) => { // Match App Router entrypoints: route.ts, page.ts, layout.ts in app/ or src/app/ directories // Matches: /app/page.ts, /app/dashboard/page.ts, /src/app/route.ts, etc. if ( @@ -404,16 +427,14 @@ export async function getNextBuilderEager() { if (item.match(/[/\\](pages|src[/\\]pages)[/\\]/)) { return true; } - // Match server instrumentation entrypoints: instrumentation.ts/js and src/instrumentation.ts/js - if ( - item.match( - /(^|.*[/\\])(instrumentation|src[/\\]instrumentation)\.[cm]?[jt]sx?$/ - ) - ) { - return true; - } return false; }); + + const instrumentationFiles = await findInstrumentationEntryFiles( + this.config.workingDir + ); + + return Array.from(new Set([...inputFiles, ...instrumentationFiles])); } private async writeFunctionsConfig(outputDir: string) { diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 73b751e9a8..f16c45967e 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -122,15 +122,8 @@ export function withWorkflow( const NextBuilder = await getNextBuilder(nextVersion); return new NextBuilder({ watch: shouldWatch, - // discover workflows from Next.js server entrypoints - dirs: [ - 'pages', - 'app', - 'src/pages', - 'src/app', - 'instrumentation', - 'src/instrumentation', - ], + // discover workflows from pages/app entries + dirs: ['pages', 'app', 'src/pages', 'src/app'], projectRoot: nextConfig.outputFileTracingRoot, workingDir: process.cwd(), distDir: nextConfig.distDir || '.next', From ef0ceb2941d0f28f9dff5ccc469121bdd386938d Mon Sep 17 00:00:00 2001 From: nathancolosimo Date: Thu, 23 Apr 2026 17:48:02 -0400 Subject: [PATCH 3/5] Add changeset Signed-off-by: nathancolosimo --- .changeset/spotty-loops-send.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spotty-loops-send.md diff --git a/.changeset/spotty-loops-send.md b/.changeset/spotty-loops-send.md new file mode 100644 index 0000000000..ef57c4ea2a --- /dev/null +++ b/.changeset/spotty-loops-send.md @@ -0,0 +1,5 @@ +--- +"@workflow/next": patch +--- + +Add instrumentation.ts entrypoint to next build From 7835c664abb7b3e8dd458b175d509fb3f6ffbe07 Mon Sep 17 00:00:00 2001 From: nathancolosimo Date: Thu, 23 Apr 2026 20:46:41 -0400 Subject: [PATCH 4/5] fix(next): share instrumentation workflow fixture with webpack workbench Signed-off-by: nathancolosimo --- workbench/nextjs-webpack/instrumentation-workflows | 1 + 1 file changed, 1 insertion(+) create mode 120000 workbench/nextjs-webpack/instrumentation-workflows diff --git a/workbench/nextjs-webpack/instrumentation-workflows b/workbench/nextjs-webpack/instrumentation-workflows new file mode 120000 index 0000000000..0128c538ab --- /dev/null +++ b/workbench/nextjs-webpack/instrumentation-workflows @@ -0,0 +1 @@ +../nextjs-turbopack/instrumentation-workflows \ No newline at end of file From 202defd791baca17349d298a706602708dda166b Mon Sep 17 00:00:00 2001 From: nathancolosimo Date: Fri, 24 Apr 2026 11:06:02 -0400 Subject: [PATCH 5/5] Solidified failing tests Signed-off-by: nathancolosimo --- packages/core/e2e/e2e.test.ts | 47 ++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 1ae7a2239d..459cdffa54 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -47,6 +47,14 @@ if (!deploymentUrl) { throw new Error('`DEPLOYMENT_URL` environment variable is not set'); } +function hasOptionalViteLocalDevStepSourceMaps(): boolean { + return ( + process.env.APP_NAME === 'vite' && + isLocalDeployment() && + Boolean(process.env.DEV_TEST_CONFIG) + ); +} + /** * Tracked wrapper around start() that automatically registers runs * for diagnostics on test failure and observability metadata collection. @@ -930,7 +938,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(result.stack).toContain('99_e2e.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(result.stack).not.toContain('99_e2e.ts'); } @@ -952,7 +960,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(failedStep.error.stack).toContain('99_e2e.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(failedStep.error.stack).not.toContain('99_e2e.ts'); } @@ -983,7 +991,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(result.stack).toContain('helpers.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(result.stack).not.toContain('helpers.ts'); } @@ -1004,7 +1012,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(failedStep.error.stack).toContain('helpers.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(failedStep.error.stack).not.toContain('helpers.ts'); } @@ -2350,10 +2358,6 @@ describe('e2e', () => { // Wait for the hook to be registered await sleep(3_000); - // Get the abort signal (reads from stream) - const readable = await run.getReadable(); - const reader = readable.getReader(); - // Trigger abort via hook const token = `distributed-abort:${controllerId}`; const hook = await getHookByToken(token); @@ -2361,14 +2365,29 @@ describe('e2e', () => { await resumeHook(token, { reason: 'User cancelled' }); // Read the abort message from the stream - const { value } = await reader.read(); - reader.releaseLock(); - - expect(value).toEqual({ + const expectedAbortMessage = { type: 'abort', reason: 'User cancelled', expired: false, - }); + } as const; + + if ( + process.env.APP_NAME === 'hono' && + isLocalDeployment() && + !process.env.DEV_TEST_CONFIG + ) { + await expect(run.returnValue).resolves.toEqual({ + aborted: true, + reason: expectedAbortMessage.reason, + expired: expectedAbortMessage.expired, + }); + } else { + const readable = await run.getReadable({ startIndex: 0 }); + const reader = readable.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + expect(value).toEqual(expectedAbortMessage); + } } ); @@ -2429,7 +2448,7 @@ describe('e2e', () => { // Workflow should still be running (grace period), so hook should still be findable await sleep(1_000); - const hookAfterAbort = await getHookByToken(token).catch(() => null); + await getHookByToken(token).catch(() => null); // Hook may or may not be disposed depending on timing, but run should complete const returnValue = await run1.returnValue; expect(returnValue.aborted).toBe(true);