Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/stable-dev-instrumentation-entrypoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

`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.
79 changes: 25 additions & 54 deletions packages/eve/src/internal/application/compiled-artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";

Expand All @@ -7,6 +6,7 @@ import type { CompileAgentResult } from "#compiler/compile-agent.js";
import { createCompiledModuleMapSource } from "#compiler/module-map.js";
import { getWorldImport } from "@workflow/utils";
import { stringifyEsmImportSpecifier } from "#internal/application/import-specifier.js";
import { resolveInstrumentationModule } from "#internal/application/instrumentation-module.js";
import {
resolvePackageCompiledFilePath,
resolvePackageSourceFilePath,
Expand All @@ -25,16 +25,8 @@ export interface GeneratedCompiledArtifactsFiles {
bootstrapPath: string;
/** Nitro plugin that installs the selected vendored Workflow world. */
workflowWorldPluginPath: string;
/**
* Optional Nitro plugin that imports the authored instrumentation module
* from the application when present.
*/
instrumentationPluginPath?: string;
/**
* Absolute path to the authored instrumentation module when present.
* Nitro uses this to preserve the module's side effects during bundling.
*/
instrumentationSourcePath?: string;
/** Nitro plugin that imports authored instrumentation when present. */
instrumentationPluginPath: string;
}

/**
Expand Down Expand Up @@ -71,45 +63,20 @@ export async function writeCompiledArtifactsFiles(input: {
),
);

if (instrumentationPath !== undefined) {
await writeFile(
instrumentationPluginPath,
createInstrumentationPluginSource({
agentName: input.compileResult.manifest.config.name,
instrumentationPath,
registerConfigPath: resolvePackageSourceFilePath("src/harness/instrumentation-config.ts"),
}),
);
}
await writeFile(
instrumentationPluginPath,
createInstrumentationPluginSource({
agentName: input.compileResult.manifest.config.name,
instrumentationPath,
registerConfigPath: resolvePackageSourceFilePath("src/harness/instrumentation-config.ts"),
}),
);

const generatedArtifacts: GeneratedCompiledArtifactsFiles = {
return {
bootstrapPath,
instrumentationPluginPath,
workflowWorldPluginPath,
};

if (instrumentationPath !== undefined) {
generatedArtifacts.instrumentationPluginPath = instrumentationPluginPath;
generatedArtifacts.instrumentationSourcePath = instrumentationPath;
}

return generatedArtifacts;
}

const INSTRUMENTATION_EXTENSIONS = [".ts", ".mts", ".js", ".mjs"];

/**
* Resolves the optional `agent/instrumentation` module from the agent root
* directory. Returns the absolute path if found, `undefined` otherwise.
*/
function resolveInstrumentationModule(agentRoot: string): string | undefined {
for (const ext of INSTRUMENTATION_EXTENSIONS) {
const candidate = join(agentRoot, `instrumentation${ext}`);
if (existsSync(candidate)) {
return candidate;
}
}

return undefined;
}

function stripCompiledModuleMapExports(source: string): string {
Expand Down Expand Up @@ -212,18 +179,22 @@ export function createWorkflowWorldPluginSource(

function createInstrumentationPluginSource(input: {
agentName: string;
instrumentationPath: string;
instrumentationPath: string | undefined;
registerConfigPath: string;
}): string {
return [
"// Generated by eve. Do not edit by hand.",
`import * as instrumentationModule from ${stringifyEsmImportSpecifier(input.instrumentationPath)};`,
`import { registerInstrumentationConfig } from ${stringifyEsmImportSpecifier(input.registerConfigPath)};`,
"",
"if (instrumentationModule.default != null) {",
` registerInstrumentationConfig(instrumentationModule.default, { agentName: ${JSON.stringify(input.agentName)} });`,
"}",
"",
...(input.instrumentationPath === undefined
? []
: [
`import * as instrumentationModule from ${stringifyEsmImportSpecifier(input.instrumentationPath)};`,
`import { registerInstrumentationConfig } from ${stringifyEsmImportSpecifier(input.registerConfigPath)};`,
"",
"if (instrumentationModule.default != null) {",
` registerInstrumentationConfig(instrumentationModule.default, { agentName: ${JSON.stringify(input.agentName)} });`,
"}",
"",
]),
"// Default export satisfies the Nitro plugin contract so this file",
"// can be used directly as a Nitro plugin without a separate wrapper.",
"export default function installInstrumentationPlugin() {}",
Expand Down
21 changes: 21 additions & 0 deletions packages/eve/src/internal/application/instrumentation-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";

const INSTRUMENTATION_EXTENSIONS = [".ts", ".mts", ".js", ".mjs"] as const;

export function resolveInstrumentationModulePaths(agentRoot: string): string[] {
return INSTRUMENTATION_EXTENSIONS.map((extension) =>
join(agentRoot, `instrumentation${extension}`),
);
}

export function resolveInstrumentationModule(agentRoot: string): string | undefined {
return resolveInstrumentationModulePaths(agentRoot).find((path) => existsSync(path));
}

export function isInstrumentationModulePath(agentRoot: string, path: string): boolean {
const resolvedPath = resolve(path);
return resolveInstrumentationModulePaths(agentRoot).some(
(candidate) => resolve(candidate) === resolvedPath,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ function createPreparedHost(appRoot: string): PreparedApplicationHost {
} as unknown as PreparedApplicationHost["compileResult"],
compiledArtifacts: {
bootstrapPath: join(appRoot, ".eve", "compile", "compiled-artifacts-bootstrap.mjs"),
instrumentationPluginPath: join(
appRoot,
".eve",
"compile",
"compiled-artifacts-instrumentation.mjs",
),
workflowWorldPluginPath: join(
appRoot,
".eve",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function createPreparedHost(): PreparedApplicationHost {
} as unknown as PreparedApplicationHost["compileResult"],
compiledArtifacts: {
bootstrapPath: `${appRoot}/.eve/bootstrap.mjs`,
instrumentationPluginPath: `${appRoot}/.eve/instrumentation.mjs`,
workflowWorldPluginPath: `${appRoot}/.eve/workflow-world.mjs`,
} as PreparedApplicationHost["compiledArtifacts"],
scheduleRegistrations: [],
Expand Down Expand Up @@ -168,6 +169,46 @@ describe("createApplicationNitro", () => {
expect(plugins.indexOf(preparedHost.compiledArtifacts.bootstrapPath)).toBeLessThan(
plugins.indexOf(preparedHost.compiledArtifacts.workflowWorldPluginPath),
);
expect(plugins).toContain(preparedHost.compiledArtifacts.instrumentationPluginPath);
});

it("preserves side effects for every authored instrumentation module extension", async () => {
const nitroStub = createNitroStub();
createNitroMock.mockResolvedValueOnce(nitroStub.nitro);

const { createApplicationNitro } =
await import("#internal/nitro/host/create-application-nitro.js");
const preparedHost = createPreparedHost();
await createApplicationNitro(preparedHost, true);

const rollupBeforeHooks = nitroStub.hookHandlers.get("rollup:before") ?? [];
const config = { plugins: [] as unknown[] };
for (const hook of rollupBeforeHooks) {
await hook(nitroStub.nitro, config);
}
const sideEffectsPlugin = (
config.plugins as Array<{
name?: string;
resolveId?: (source: string) => unknown;
}>
).find((plugin) => plugin.name === "eve:instrumentation-module-side-effects");

if (sideEffectsPlugin?.resolveId === undefined) {
throw new Error("Expected instrumentation side-effects plugin to be registered.");
}

for (const extension of [".ts", ".mts", ".js", ".mjs"]) {
expect(
sideEffectsPlugin.resolveId(
join(preparedHost.compileResult.project.agentRoot, `instrumentation${extension}`),
),
).toMatchObject({ moduleSideEffects: "no-treeshake" });
}
expect(
sideEffectsPlugin.resolveId(
join(preparedHost.compileResult.project.agentRoot, "instructions.md"),
),
).toBeNull();
});

it("preserves workflow bundle side effects and skips workflow transform for cached bundles", async () => {
Expand Down
23 changes: 8 additions & 15 deletions packages/eve/src/internal/nitro/host/create-application-nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
writeEveVersionedCacheMetadata,
} from "#internal/application/cache-metadata.js";
import { resolveNitroBuildDirectory } from "#internal/application/paths.js";
import { resolveInstrumentationModulePaths } from "#internal/application/instrumentation-module.js";
import {
createNitroArtifactsConfig,
type NitroArtifactsConfigInput,
Expand Down Expand Up @@ -564,11 +565,10 @@ function addDynamicToolTransformPlugin(nitro: Nitro): void {
* Rollup/Rolldown pass preserves its eager evaluation from the generated
* instrumentation plugin.
*/
function addInstrumentationModuleSideEffectsPlugin(
nitro: Nitro,
instrumentationModulePath: string,
): void {
const normalizedInstrumentationModulePath = normalizePath(instrumentationModulePath);
function addInstrumentationModuleSideEffectsPlugin(nitro: Nitro, agentRoot: string): void {
const normalizedInstrumentationModulePaths = new Set(
resolveInstrumentationModulePaths(agentRoot).map((path) => normalizePath(path)),
);

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

Expand Down Expand Up @@ -712,6 +712,7 @@ export async function createApplicationNitro(
const nitroPlugins: string[] = [];
nitroPlugins.push(preparedHost.compiledArtifacts.bootstrapPath);
nitroPlugins.push(preparedHost.compiledArtifacts.workflowWorldPluginPath);
nitroPlugins.push(preparedHost.compiledArtifacts.instrumentationPluginPath);
if (!dev) {
// Stops all tracked sandboxes when the production server shuts
// down. Dev servers are excluded: the dev CLI parent already stops
Expand All @@ -725,9 +726,6 @@ export async function createApplicationNitro(
resolvePackageSourceFilePath("src/internal/nitro/host/workflow-sandbox-runtime-plugin.ts"),
);
}
if (preparedHost.compiledArtifacts.instrumentationPluginPath !== undefined) {
nitroPlugins.push(preparedHost.compiledArtifacts.instrumentationPluginPath);
}
await prepareEveVersionedCacheDirectory(nitroBuildDir);
const nitro = await createNitro(
{
Expand Down Expand Up @@ -798,12 +796,7 @@ export async function createApplicationNitro(
// execute functions for all tool files, not just workflow step targets.
addDynamicToolTransformPlugin(nitro);

if (preparedHost.compiledArtifacts.instrumentationSourcePath !== undefined) {
addInstrumentationModuleSideEffectsPlugin(
nitro,
preparedHost.compiledArtifacts.instrumentationSourcePath,
);
}
addInstrumentationModuleSideEffectsPlugin(nitro, preparedHost.compileResult.project.agentRoot);

// Prevent Nitro from re-bundling the pre-built workflow bundle in dev
// mode. `steps.mjs` is now a source entry that Nitro must still bundle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,30 @@ describe("startAuthoredSourceWatcher", () => {
}
});

it("reloads Nitro when authored instrumentation changes", async () => {
const previousHost = createPreparedHost();
const nextHost = createPreparedHost();
const nitroStub = createNitroStub();

prepareApplicationHostMock.mockResolvedValueOnce(nextHost);

const watcher = await startAuthoredSourceWatcher({
nitro: nitroStub.nitro,
preparedHost: previousHost,
});

try {
await triggerChangeEvent(
join(previousHost.compileResult.project.agentRoot, "instrumentation.ts"),
);

expect(nitroStub.callHook).toHaveBeenCalledTimes(1);
expect(nitroStub.callHook).toHaveBeenCalledWith("rollup:reload");
} finally {
await watcher.close();
}
});

it("never registers authored schedules in dev when registrations change", async () => {
// `eve dev` intentionally does not register Nitro scheduled tasks for
// authored schedules — production cron firing in dev would invoke every
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Nitro } from "nitro/types";
import { clearCompiledRuntimeAgentBundleCache } from "#runtime/sessions/compiled-agent-cache.js";
import { toErrorMessage } from "#shared/errors.js";
import { resolveTsConfigDependencyPaths } from "#internal/application/tsconfig-dependencies.js";
import { isInstrumentationModulePath } from "#internal/application/instrumentation-module.js";
import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js";
import { resolveDevelopmentSourceSnapshotWatchPaths } from "#internal/nitro/dev-runtime-source-snapshot.js";
import { pruneDevelopmentRuntimeArtifactsSnapshotsInBackground } from "#internal/nitro/dev-runtime-artifacts.js";
Expand Down Expand Up @@ -133,6 +134,9 @@ export async function startAuthoredSourceWatcher(input: {
previousHost.appRoot,
changedPaths,
);
const hasInstrumentationChange = changedPaths.some((path) =>
isInstrumentationModulePath(previousHost.compileResult.project.agentRoot, path),
);
if (changeEvents.length > 0) {
console.log(formatChangeDetectedLogLine(previousHost.appRoot, changeEvents));
}
Expand Down Expand Up @@ -173,7 +177,8 @@ export async function startAuthoredSourceWatcher(input: {
// `POST /eve/v1/dev/schedules/:scheduleId` route, which reads
// compiled registrations from disk on every request without
// needing Nitro wiring.
const hasStructuralChange = hasChannelRouteChanged || hasEnvironmentChange;
const hasStructuralChange =
hasChannelRouteChanged || hasEnvironmentChange || hasInstrumentationChange;

if (hasStructuralChange) {
console.log(STRUCTURAL_RELOAD_LOG_LINE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ describe("prepareApplicationHost", () => {
expect(firstHost.compiledArtifacts.workflowWorldPluginPath).toBe(
join(stableHostDirectory, "compiled-artifacts-workflow-world.mjs"),
);
expect(firstHost.compiledArtifacts.instrumentationPluginPath).toBe(
join(stableHostDirectory, "compiled-artifacts-instrumentation.mjs"),
);
expect(firstHost.compiledArtifacts.bootstrapPath).not.toContain("/.eve/dev-runtime/snapshots/");
expect(await readFile(stableBootstrapPath, "utf8")).toContain(
normalizeEsmImportSpecifier(agentModulePath),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";

Expand Down Expand Up @@ -108,6 +108,24 @@ describe("writeCompiledArtifactsFiles", () => {

expect((globalThis as Record<string, unknown>).__eveInstrumentationLoaded).toBe("yes");
expect(instrumentationPluginModule.default()).toBeUndefined();

await rm(join(agentRoot, "instrumentation.ts"));
await writeCompiledArtifactsFiles({
compileResult,
outDir,
});

const instrumentationPluginWithoutAuthoredModule = await readFile(
instrumentationPluginPath,
"utf8",
);
expect(instrumentationPluginWithoutAuthoredModule).not.toContain("instrumentation.ts");
expect(instrumentationPluginWithoutAuthoredModule).not.toContain(
"registerInstrumentationConfig",
);
await expect(
import(`${pathToFileURL(instrumentationPluginPath).href}?case=instrumentation-removed`),
).resolves.toMatchObject({ default: expect.any(Function) });
});

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