Skip to content

Commit 10f26fc

Browse files
committed
fix(eve): stabilize dev instrumentation entrypoint
Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
1 parent a435b06 commit 10f26fc

11 files changed

Lines changed: 172 additions & 77 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eve": patch
3+
---
4+
5+
`eve dev` now keeps its Nitro instrumentation entrypoint stable when authored instrumentation is added or removed. Instrumentation changes trigger a structural reload without leaving retained plugin paths that import deleted files.

packages/eve/src/internal/application/compiled-artifacts.ts

Lines changed: 25 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { existsSync } from "node:fs";
21
import { mkdir, writeFile } from "node:fs/promises";
32
import { join } from "node:path";
43

@@ -7,6 +6,7 @@ import type { CompileAgentResult } from "#compiler/compile-agent.js";
76
import { createCompiledModuleMapSource } from "#compiler/module-map.js";
87
import { getWorldImport } from "@workflow/utils";
98
import { stringifyEsmImportSpecifier } from "#internal/application/import-specifier.js";
9+
import { resolveInstrumentationModule } from "#internal/application/instrumentation-module.js";
1010
import {
1111
resolvePackageCompiledFilePath,
1212
resolvePackageSourceFilePath,
@@ -25,16 +25,8 @@ export interface GeneratedCompiledArtifactsFiles {
2525
bootstrapPath: string;
2626
/** Nitro plugin that installs the selected vendored Workflow world. */
2727
workflowWorldPluginPath: string;
28-
/**
29-
* Optional Nitro plugin that imports the authored instrumentation module
30-
* from the application when present.
31-
*/
32-
instrumentationPluginPath?: string;
33-
/**
34-
* Absolute path to the authored instrumentation module when present.
35-
* Nitro uses this to preserve the module's side effects during bundling.
36-
*/
37-
instrumentationSourcePath?: string;
28+
/** Nitro plugin that imports authored instrumentation when present. */
29+
instrumentationPluginPath: string;
3830
}
3931

4032
/**
@@ -71,45 +63,20 @@ export async function writeCompiledArtifactsFiles(input: {
7163
),
7264
);
7365

74-
if (instrumentationPath !== undefined) {
75-
await writeFile(
76-
instrumentationPluginPath,
77-
createInstrumentationPluginSource({
78-
agentName: input.compileResult.manifest.config.name,
79-
instrumentationPath,
80-
registerConfigPath: resolvePackageSourceFilePath("src/harness/instrumentation-config.ts"),
81-
}),
82-
);
83-
}
66+
await writeFile(
67+
instrumentationPluginPath,
68+
createInstrumentationPluginSource({
69+
agentName: input.compileResult.manifest.config.name,
70+
instrumentationPath,
71+
registerConfigPath: resolvePackageSourceFilePath("src/harness/instrumentation-config.ts"),
72+
}),
73+
);
8474

85-
const generatedArtifacts: GeneratedCompiledArtifactsFiles = {
75+
return {
8676
bootstrapPath,
77+
instrumentationPluginPath,
8778
workflowWorldPluginPath,
8879
};
89-
90-
if (instrumentationPath !== undefined) {
91-
generatedArtifacts.instrumentationPluginPath = instrumentationPluginPath;
92-
generatedArtifacts.instrumentationSourcePath = instrumentationPath;
93-
}
94-
95-
return generatedArtifacts;
96-
}
97-
98-
const INSTRUMENTATION_EXTENSIONS = [".ts", ".mts", ".js", ".mjs"];
99-
100-
/**
101-
* Resolves the optional `agent/instrumentation` module from the agent root
102-
* directory. Returns the absolute path if found, `undefined` otherwise.
103-
*/
104-
function resolveInstrumentationModule(agentRoot: string): string | undefined {
105-
for (const ext of INSTRUMENTATION_EXTENSIONS) {
106-
const candidate = join(agentRoot, `instrumentation${ext}`);
107-
if (existsSync(candidate)) {
108-
return candidate;
109-
}
110-
}
111-
112-
return undefined;
11380
}
11481

11582
function stripCompiledModuleMapExports(source: string): string {
@@ -212,18 +179,22 @@ export function createWorkflowWorldPluginSource(
212179

213180
function createInstrumentationPluginSource(input: {
214181
agentName: string;
215-
instrumentationPath: string;
182+
instrumentationPath: string | undefined;
216183
registerConfigPath: string;
217184
}): string {
218185
return [
219186
"// Generated by eve. Do not edit by hand.",
220-
`import * as instrumentationModule from ${stringifyEsmImportSpecifier(input.instrumentationPath)};`,
221-
`import { registerInstrumentationConfig } from ${stringifyEsmImportSpecifier(input.registerConfigPath)};`,
222-
"",
223-
"if (instrumentationModule.default != null) {",
224-
` registerInstrumentationConfig(instrumentationModule.default, { agentName: ${JSON.stringify(input.agentName)} });`,
225-
"}",
226-
"",
187+
...(input.instrumentationPath === undefined
188+
? []
189+
: [
190+
`import * as instrumentationModule from ${stringifyEsmImportSpecifier(input.instrumentationPath)};`,
191+
`import { registerInstrumentationConfig } from ${stringifyEsmImportSpecifier(input.registerConfigPath)};`,
192+
"",
193+
"if (instrumentationModule.default != null) {",
194+
` registerInstrumentationConfig(instrumentationModule.default, { agentName: ${JSON.stringify(input.agentName)} });`,
195+
"}",
196+
"",
197+
]),
227198
"// Default export satisfies the Nitro plugin contract so this file",
228199
"// can be used directly as a Nitro plugin without a separate wrapper.",
229200
"export default function installInstrumentationPlugin() {}",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { existsSync } from "node:fs";
2+
import { join, resolve } from "node:path";
3+
4+
const INSTRUMENTATION_EXTENSIONS = [".ts", ".mts", ".js", ".mjs"] as const;
5+
6+
export function resolveInstrumentationModulePaths(agentRoot: string): string[] {
7+
return INSTRUMENTATION_EXTENSIONS.map((extension) =>
8+
join(agentRoot, `instrumentation${extension}`),
9+
);
10+
}
11+
12+
export function resolveInstrumentationModule(agentRoot: string): string | undefined {
13+
return resolveInstrumentationModulePaths(agentRoot).find((path) => existsSync(path));
14+
}
15+
16+
export function isInstrumentationModulePath(agentRoot: string, path: string): boolean {
17+
const resolvedPath = resolve(path);
18+
return resolveInstrumentationModulePaths(agentRoot).some(
19+
(candidate) => resolve(candidate) === resolvedPath,
20+
);
21+
}

packages/eve/src/internal/nitro/host/build-application.scenario.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ function createPreparedHost(appRoot: string): PreparedApplicationHost {
116116
} as unknown as PreparedApplicationHost["compileResult"],
117117
compiledArtifacts: {
118118
bootstrapPath: join(appRoot, ".eve", "compile", "compiled-artifacts-bootstrap.mjs"),
119+
instrumentationPluginPath: join(
120+
appRoot,
121+
".eve",
122+
"compile",
123+
"compiled-artifacts-instrumentation.mjs",
124+
),
119125
workflowWorldPluginPath: join(
120126
appRoot,
121127
".eve",

packages/eve/src/internal/nitro/host/create-application-nitro.scenario.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function createPreparedHost(): PreparedApplicationHost {
136136
} as unknown as PreparedApplicationHost["compileResult"],
137137
compiledArtifacts: {
138138
bootstrapPath: `${appRoot}/.eve/bootstrap.mjs`,
139+
instrumentationPluginPath: `${appRoot}/.eve/instrumentation.mjs`,
139140
workflowWorldPluginPath: `${appRoot}/.eve/workflow-world.mjs`,
140141
} as PreparedApplicationHost["compiledArtifacts"],
141142
scheduleRegistrations: [],
@@ -168,6 +169,46 @@ describe("createApplicationNitro", () => {
168169
expect(plugins.indexOf(preparedHost.compiledArtifacts.bootstrapPath)).toBeLessThan(
169170
plugins.indexOf(preparedHost.compiledArtifacts.workflowWorldPluginPath),
170171
);
172+
expect(plugins).toContain(preparedHost.compiledArtifacts.instrumentationPluginPath);
173+
});
174+
175+
it("preserves side effects for every authored instrumentation module extension", async () => {
176+
const nitroStub = createNitroStub();
177+
createNitroMock.mockResolvedValueOnce(nitroStub.nitro);
178+
179+
const { createApplicationNitro } =
180+
await import("#internal/nitro/host/create-application-nitro.js");
181+
const preparedHost = createPreparedHost();
182+
await createApplicationNitro(preparedHost, true);
183+
184+
const rollupBeforeHooks = nitroStub.hookHandlers.get("rollup:before") ?? [];
185+
const config = { plugins: [] as unknown[] };
186+
for (const hook of rollupBeforeHooks) {
187+
await hook(nitroStub.nitro, config);
188+
}
189+
const sideEffectsPlugin = (
190+
config.plugins as Array<{
191+
name?: string;
192+
resolveId?: (source: string) => unknown;
193+
}>
194+
).find((plugin) => plugin.name === "eve:instrumentation-module-side-effects");
195+
196+
if (sideEffectsPlugin?.resolveId === undefined) {
197+
throw new Error("Expected instrumentation side-effects plugin to be registered.");
198+
}
199+
200+
for (const extension of [".ts", ".mts", ".js", ".mjs"]) {
201+
expect(
202+
sideEffectsPlugin.resolveId(
203+
join(preparedHost.compileResult.project.agentRoot, `instrumentation${extension}`),
204+
),
205+
).toMatchObject({ moduleSideEffects: "no-treeshake" });
206+
}
207+
expect(
208+
sideEffectsPlugin.resolveId(
209+
join(preparedHost.compileResult.project.agentRoot, "instructions.md"),
210+
),
211+
).toBeNull();
171212
});
172213

173214
it("preserves workflow bundle side effects and skips workflow transform for cached bundles", async () => {

packages/eve/src/internal/nitro/host/create-application-nitro.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
writeEveVersionedCacheMetadata,
1717
} from "#internal/application/cache-metadata.js";
1818
import { resolveNitroBuildDirectory } from "#internal/application/paths.js";
19+
import { resolveInstrumentationModulePaths } from "#internal/application/instrumentation-module.js";
1920
import {
2021
createNitroArtifactsConfig,
2122
type NitroArtifactsConfigInput,
@@ -564,11 +565,10 @@ function addDynamicToolTransformPlugin(nitro: Nitro): void {
564565
* Rollup/Rolldown pass preserves its eager evaluation from the generated
565566
* instrumentation plugin.
566567
*/
567-
function addInstrumentationModuleSideEffectsPlugin(
568-
nitro: Nitro,
569-
instrumentationModulePath: string,
570-
): void {
571-
const normalizedInstrumentationModulePath = normalizePath(instrumentationModulePath);
568+
function addInstrumentationModuleSideEffectsPlugin(nitro: Nitro, agentRoot: string): void {
569+
const normalizedInstrumentationModulePaths = new Set(
570+
resolveInstrumentationModulePaths(agentRoot).map((path) => normalizePath(path)),
571+
);
572572

573573
nitro.hooks.hook("rollup:before", (_nitro, config) => {
574574
if (!Array.isArray(config.plugins)) {
@@ -578,7 +578,7 @@ function addInstrumentationModuleSideEffectsPlugin(
578578
config.plugins.unshift({
579579
name: "eve:instrumentation-module-side-effects",
580580
resolveId(source: string) {
581-
if (normalizePath(source) !== normalizedInstrumentationModulePath) {
581+
if (!normalizedInstrumentationModulePaths.has(normalizePath(source))) {
582582
return null;
583583
}
584584

@@ -712,6 +712,7 @@ export async function createApplicationNitro(
712712
const nitroPlugins: string[] = [];
713713
nitroPlugins.push(preparedHost.compiledArtifacts.bootstrapPath);
714714
nitroPlugins.push(preparedHost.compiledArtifacts.workflowWorldPluginPath);
715+
nitroPlugins.push(preparedHost.compiledArtifacts.instrumentationPluginPath);
715716
if (!dev) {
716717
// Stops all tracked sandboxes when the production server shuts
717718
// down. Dev servers are excluded: the dev CLI parent already stops
@@ -725,9 +726,6 @@ export async function createApplicationNitro(
725726
resolvePackageSourceFilePath("src/internal/nitro/host/workflow-sandbox-runtime-plugin.ts"),
726727
);
727728
}
728-
if (preparedHost.compiledArtifacts.instrumentationPluginPath !== undefined) {
729-
nitroPlugins.push(preparedHost.compiledArtifacts.instrumentationPluginPath);
730-
}
731729
await prepareEveVersionedCacheDirectory(nitroBuildDir);
732730
const nitro = await createNitro(
733731
{
@@ -798,12 +796,7 @@ export async function createApplicationNitro(
798796
// execute functions for all tool files, not just workflow step targets.
799797
addDynamicToolTransformPlugin(nitro);
800798

801-
if (preparedHost.compiledArtifacts.instrumentationSourcePath !== undefined) {
802-
addInstrumentationModuleSideEffectsPlugin(
803-
nitro,
804-
preparedHost.compiledArtifacts.instrumentationSourcePath,
805-
);
806-
}
799+
addInstrumentationModuleSideEffectsPlugin(nitro, preparedHost.compileResult.project.agentRoot);
807800

808801
// Prevent Nitro from re-bundling the pre-built workflow bundle in dev
809802
// mode. `steps.mjs` is now a source entry that Nitro must still bundle

packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,30 @@ describe("startAuthoredSourceWatcher", () => {
447447
}
448448
});
449449

450+
it("reloads Nitro when authored instrumentation changes", async () => {
451+
const previousHost = createPreparedHost();
452+
const nextHost = createPreparedHost();
453+
const nitroStub = createNitroStub();
454+
455+
prepareApplicationHostMock.mockResolvedValueOnce(nextHost);
456+
457+
const watcher = await startAuthoredSourceWatcher({
458+
nitro: nitroStub.nitro,
459+
preparedHost: previousHost,
460+
});
461+
462+
try {
463+
await triggerChangeEvent(
464+
join(previousHost.compileResult.project.agentRoot, "instrumentation.ts"),
465+
);
466+
467+
expect(nitroStub.callHook).toHaveBeenCalledTimes(1);
468+
expect(nitroStub.callHook).toHaveBeenCalledWith("rollup:reload");
469+
} finally {
470+
await watcher.close();
471+
}
472+
});
473+
450474
it("never registers authored schedules in dev when registrations change", async () => {
451475
// `eve dev` intentionally does not register Nitro scheduled tasks for
452476
// authored schedules — production cron firing in dev would invoke every

packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Nitro } from "nitro/types";
55
import { clearCompiledRuntimeAgentBundleCache } from "#runtime/sessions/compiled-agent-cache.js";
66
import { toErrorMessage } from "#shared/errors.js";
77
import { resolveTsConfigDependencyPaths } from "#internal/application/tsconfig-dependencies.js";
8+
import { isInstrumentationModulePath } from "#internal/application/instrumentation-module.js";
89
import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js";
910
import { resolveDevelopmentSourceSnapshotWatchPaths } from "#internal/nitro/dev-runtime-source-snapshot.js";
1011
import { pruneDevelopmentRuntimeArtifactsSnapshotsInBackground } from "#internal/nitro/dev-runtime-artifacts.js";
@@ -133,6 +134,9 @@ export async function startAuthoredSourceWatcher(input: {
133134
previousHost.appRoot,
134135
changedPaths,
135136
);
137+
const hasInstrumentationChange = changedPaths.some((path) =>
138+
isInstrumentationModulePath(previousHost.compileResult.project.agentRoot, path),
139+
);
136140
if (changeEvents.length > 0) {
137141
console.log(formatChangeDetectedLogLine(previousHost.appRoot, changeEvents));
138142
}
@@ -173,7 +177,8 @@ export async function startAuthoredSourceWatcher(input: {
173177
// `POST /eve/v1/dev/schedules/:scheduleId` route, which reads
174178
// compiled registrations from disk on every request without
175179
// needing Nitro wiring.
176-
const hasStructuralChange = hasChannelRouteChanged || hasEnvironmentChange;
180+
const hasStructuralChange =
181+
hasChannelRouteChanged || hasEnvironmentChange || hasInstrumentationChange;
177182

178183
if (hasStructuralChange) {
179184
console.log(STRUCTURAL_RELOAD_LOG_LINE);

packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ describe("prepareApplicationHost", () => {
5151
expect(firstHost.compiledArtifacts.workflowWorldPluginPath).toBe(
5252
join(stableHostDirectory, "compiled-artifacts-workflow-world.mjs"),
5353
);
54+
expect(firstHost.compiledArtifacts.instrumentationPluginPath).toBe(
55+
join(stableHostDirectory, "compiled-artifacts-instrumentation.mjs"),
56+
);
5457
expect(firstHost.compiledArtifacts.bootstrapPath).not.toContain("/.eve/dev-runtime/snapshots/");
5558
expect(await readFile(stableBootstrapPath, "utf8")).toContain(
5659
normalizeEsmImportSpecifier(agentModulePath),

packages/eve/test/scenarios/compiled-artifacts-bootstrap.scenario.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, readFile, writeFile } from "node:fs/promises";
1+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
22
import { join } from "node:path";
33
import { pathToFileURL } from "node:url";
44

@@ -108,6 +108,24 @@ describe("writeCompiledArtifactsFiles", () => {
108108

109109
expect((globalThis as Record<string, unknown>).__eveInstrumentationLoaded).toBe("yes");
110110
expect(instrumentationPluginModule.default()).toBeUndefined();
111+
112+
await rm(join(agentRoot, "instrumentation.ts"));
113+
await writeCompiledArtifactsFiles({
114+
compileResult,
115+
outDir,
116+
});
117+
118+
const instrumentationPluginWithoutAuthoredModule = await readFile(
119+
instrumentationPluginPath,
120+
"utf8",
121+
);
122+
expect(instrumentationPluginWithoutAuthoredModule).not.toContain("instrumentation.ts");
123+
expect(instrumentationPluginWithoutAuthoredModule).not.toContain(
124+
"registerInstrumentationConfig",
125+
);
126+
await expect(
127+
import(`${pathToFileURL(instrumentationPluginPath).href}?case=instrumentation-removed`),
128+
).resolves.toMatchObject({ default: expect.any(Function) });
111129
});
112130

113131
it("surfaces instrumentation import failures when the Nitro plugin module loads", async () => {

0 commit comments

Comments
 (0)