From 43930d9af3b65ad6bc6e0f994c1d850faa439bd3 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 27 Jul 2026 18:43:07 -0400 Subject: [PATCH 1/2] fix(eve): run authored instrumentation before the server bundle OpenTelemetry auto-instrumentations patch a dependency as the module loader hands it over, so registration has to happen before that dependency is first imported. `agent/instrumentation.ts`'s `setup` callback ran from a Nitro plugin, and plugin bodies are inlined into the bundled entry while the entry's dependencies are hoisted static imports above them. ESM evaluates every import before any body code, so a driver such as `pg` was already loaded by the time `setup` ran and no spans were produced for it. Reordering the Nitro plugin list does not help: the ordering follows from import hoisting, not from the plugin list. Emit the instrumentation module as its own chunk and import it on the entry's first line, ahead of every hoisted import. `setup` is guarded so it runs once per process even when the module resolves to two instances. Signed-off-by: Matan Kushner --- .changeset/olive-donkeys-shave.md | 7 + docs/guides/instrumentation.md | 17 +++ .../eve/src/harness/instrumentation-config.ts | 23 +++- ...ntation-preload-plugin.integration.test.ts | 104 +++++++++++++++ .../instrumentation-preload-plugin.test.ts | 124 ++++++++++++++++++ .../bundler/instrumentation-preload-plugin.ts | 113 ++++++++++++++++ .../bundler/prepended-line-source-map.ts | 66 ++++++++++ .../nitro/host/create-application-nitro.ts | 7 + .../src/internal/node-esm-compat-banner.ts | 66 +--------- 9 files changed, 464 insertions(+), 63 deletions(-) create mode 100644 .changeset/olive-donkeys-shave.md create mode 100644 packages/eve/src/internal/bundler/instrumentation-preload-plugin.integration.test.ts create mode 100644 packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts create mode 100644 packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts create mode 100644 packages/eve/src/internal/bundler/prepended-line-source-map.ts diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 000000000..77021024b --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,7 @@ +--- +"eve": patch +--- + +Run authored instrumentation before the rest of the server bundle, so OpenTelemetry auto-instrumentations can patch dependencies such as `pg`. + +`agent/instrumentation.ts`'s `setup` callback previously ran from a Nitro plugin. Plugin bodies are inlined into the bundled entry, while the entry's dependencies — including externals — are hoisted static imports above them, and ESM evaluates every import before any body code. A driver was therefore already loaded by the time `registerOTel` installed its require hook, so no spans were produced for it. The instrumentation module is now emitted as its own chunk and imported on the entry's first line, ahead of every hoisted import. diff --git a/docs/guides/instrumentation.md b/docs/guides/instrumentation.md index f9d9df9c8..21fdcc885 100644 --- a/docs/guides/instrumentation.md +++ b/docs/guides/instrumentation.md @@ -46,6 +46,23 @@ Use the `setup` callback to register your OTel provider (for example `registerOT Any OTel-compatible backend works (Braintrust, Raindrop, Arize, Honeycomb, Datadog, Jaeger). Install the exporter package you need and configure it in the callback. +### Auto-instrumenting a dependency + +Auto-instrumentations such as `@opentelemetry/auto-instrumentations-node` patch a package as the module loader hands it over, so the package has to reach the loader at runtime. eve runs `setup` before the rest of the server bundle, which covers the timing half of that requirement; the remaining half is yours: a package that eve bundles never passes through the loader at all and cannot be patched. + +Keep any package you want auto-instrumented external so it ships to `server/node_modules` and is imported at runtime: + +```ts title="agent/agent.ts" +export default defineAgent({ + model: "anthropic/claude-sonnet-5", + build: { + externalDependencies: ["pg"], + }, +}); +``` + +Without this, an instrumentation registers cleanly and simply never reports spans for that package. + Three more fields control what the AI SDK records inside those spans (see the AI SDK's [telemetry reference](https://ai-sdk.dev/docs/ai-sdk-core/telemetry)): - `recordInputs` records full message history on each step span (defaults to `true`). Set it to `false` if inputs contain sensitive content or you want to reduce span payload size. diff --git a/packages/eve/src/harness/instrumentation-config.ts b/packages/eve/src/harness/instrumentation-config.ts index 509340fc0..d61421d39 100644 --- a/packages/eve/src/harness/instrumentation-config.ts +++ b/packages/eve/src/harness/instrumentation-config.ts @@ -21,8 +21,19 @@ import type { */ const INSTRUMENTATION_CONFIG_GLOBAL_KEY = Symbol.for("eve.harness-instrumentation-config"); +/** + * Tracks whether `setup` already ran in this process. Rooted on + * `globalThis` for the same reason as the config itself: the instrumentation + * preload chunk and the Nitro plugin can resolve to two distinct module + * instances, and `setup` must still run only once. + */ +const INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY = Symbol.for( + "eve.harness-instrumentation-setup-invoked", +); + interface InstrumentationConfigGlobal { [INSTRUMENTATION_CONFIG_GLOBAL_KEY]?: InstrumentationDefinition; + [INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY]?: boolean; } const globalContainer = globalThis as typeof globalThis & InstrumentationConfigGlobal; @@ -31,8 +42,10 @@ const globalContainer = globalThis as typeof globalThis & InstrumentationConfigG * Registers the authored instrumentation config and invokes its `setup` * callback with the resolved agent name. * - * Called once by the generated instrumentation Nitro plugin at server - * startup. Subsequent calls overwrite the previous value. + * Called at server startup by the generated instrumentation module, which + * the bundler emits both as the entry's preload chunk and as a Nitro + * plugin. Subsequent calls overwrite the stored config, but `setup` runs + * only on the first. * * @internal — not part of the public API. */ @@ -40,7 +53,11 @@ export function registerInstrumentationConfig( config: InstrumentationDefinition, context: InstrumentationSetupContext, ): void { - if (config.setup !== undefined) { + if ( + config.setup !== undefined && + globalContainer[INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY] !== true + ) { + globalContainer[INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY] = true; config.setup(context); } globalContainer[INSTRUMENTATION_CONFIG_GLOBAL_KEY] = config; diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.integration.test.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.integration.test.ts new file mode 100644 index 000000000..d37103f34 --- /dev/null +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.integration.test.ts @@ -0,0 +1,104 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { buildWithNitroRolldown } from "#internal/bundler/nitro-rolldown.js"; +import { + createInstrumentationPreloadPlugin, + INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, +} from "#internal/bundler/instrumentation-preload-plugin.js"; +import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; + +const execFileAsync = promisify(execFile); +const createScratchDirectory = useTemporaryDirectories(); + +const EXTERNAL_PACKAGE_NAME = "fake-db-driver"; + +/** + * Writes an app whose entry imports an external package, mirroring how a + * bundled Nitro server reaches a driver such as `pg`. The external records + * whether instrumentation already ran when it was loaded, which is the + * property the preload has to guarantee. + */ +function createScratchApp(directory: string): { entryPath: string; instrumentationPath: string } { + const packageDirectory = join(directory, "node_modules", EXTERNAL_PACKAGE_NAME); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync( + join(packageDirectory, "package.json"), + JSON.stringify({ name: EXTERNAL_PACKAGE_NAME, version: "1.0.0", type: "module" }), + ); + writeFileSync( + join(packageDirectory, "index.js"), + "globalThis.__loadOrder.push(`external:${globalThis.__instrumented === true}`);\nexport const connect = () => undefined;\n", + ); + + const entryPath = join(directory, "entry.mjs"); + writeFileSync( + entryPath, + `import { connect } from "${EXTERNAL_PACKAGE_NAME}";\nglobalThis.__loadOrder.push("entry-body");\nexport default connect;\n`, + ); + + const instrumentationPath = join(directory, "instrumentation.mjs"); + writeFileSync( + instrumentationPath, + 'globalThis.__instrumented = true;\nglobalThis.__loadOrder.push("instrumentation");\nexport default function installInstrumentationPlugin() {}\n', + ); + + return { entryPath, instrumentationPath }; +} + +async function bundleWithPreload(directory: string): Promise { + const { entryPath, instrumentationPath } = createScratchApp(directory); + const outDir = join(directory, "out"); + + await buildWithNitroRolldown({ + cwd: directory, + input: entryPath, + platform: "node", + external: [EXTERNAL_PACKAGE_NAME], + plugins: [createInstrumentationPreloadPlugin(instrumentationPath)], + output: { dir: outDir, entryFileNames: "index.mjs", format: "esm", sourcemap: false }, + }); + + return outDir; +} + +describe("instrumentation preload (bundled)", () => { + it("evaluates instrumentation before the entry's external imports", async () => { + const directory = await createScratchDirectory("eve-instrumentation-preload-"); + const outDir = await bundleWithPreload(directory); + + // Node resolves the external from the scratch app's node_modules, so the + // built entry runs the same way the hosted server would. + const { stdout } = await execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + `globalThis.__loadOrder = []; + await import(${JSON.stringify(join(outDir, "index.mjs"))}); + console.log(JSON.stringify(globalThis.__loadOrder));`, + ], + { cwd: directory }, + ); + + expect(JSON.parse(stdout.trim())).toEqual(["instrumentation", "external:true", "entry-body"]); + }); + + it("imports the preload chunk on the entry's first line", async () => { + const directory = await createScratchDirectory("eve-instrumentation-preload-entry-"); + const outDir = await bundleWithPreload(directory); + + const { stdout } = await execFileAsync(process.execPath, [ + "-e", + `process.stdout.write(require("node:fs").readFileSync(${JSON.stringify( + join(outDir, "index.mjs"), + )}, "utf8"))`, + ]); + + expect(stdout.split("\n")[0]).toBe(`import "./${INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME}";`); + }); +}); diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts new file mode 100644 index 000000000..78662fbc5 --- /dev/null +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { + createInstrumentationPreloadPlugin, + INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, +} from "#internal/bundler/instrumentation-preload-plugin.js"; + +const INSTRUMENTATION_MODULE_PATH = "/app/.eve/host/compiled-artifacts-instrumentation.mjs"; + +interface EmittedChunk { + readonly type: "chunk"; + readonly id: string; + readonly fileName: string; +} + +/** + * Minimal stand-in for the Rollup/Rolldown plugin context, recording the + * emitted chunk so assertions can read it back the way the bundler would. + */ +function createBundlerContext() { + const emitted: EmittedChunk[] = []; + + return { + emitted, + emitFile(file: EmittedChunk): string { + emitted.push(file); + return `ref-${emitted.length}`; + }, + getFileName(referenceId: string): string { + const index = Number(referenceId.replace("ref-", "")) - 1; + const file = emitted[index]; + if (file === undefined) { + throw new Error(`Unknown reference id ${referenceId}`); + } + return file.fileName; + }, + }; +} + +function renderEntry(input: { + readonly code?: string; + readonly fileName: string; + readonly isEntry?: boolean; +}) { + const plugin = createInstrumentationPreloadPlugin(INSTRUMENTATION_MODULE_PATH); + const context = createBundlerContext(); + plugin.buildStart.call(context); + + return { + context, + result: plugin.renderChunk.call(context, input.code ?? "console.log('entry');", { + fileName: input.fileName, + isEntry: input.isEntry ?? true, + }), + }; +} + +describe("createInstrumentationPreloadPlugin", () => { + it("emits the instrumentation module as its own chunk", () => { + const plugin = createInstrumentationPreloadPlugin(INSTRUMENTATION_MODULE_PATH); + const context = createBundlerContext(); + + plugin.buildStart.call(context); + + expect(context.emitted).toEqual([ + { + type: "chunk", + id: INSTRUMENTATION_MODULE_PATH, + fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, + }, + ]); + }); + + it("imports the preload on the entry's first line, ahead of hoisted imports", () => { + const { result } = renderEntry({ + code: 'import { Pool } from "pg";\nconsole.log(Pool);', + fileName: "index.mjs", + }); + + expect(result?.code.split("\n")[0]).toBe( + `import "./${INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME}";`, + ); + expect(result?.code).toContain('import { Pool } from "pg";'); + }); + + it("maps the shifted chunk back to its original line", () => { + const { result } = renderEntry({ fileName: "index.mjs" }); + + expect(result?.map.mappings.startsWith(";")).toBe(true); + expect(result?.map.sources).toEqual(["index.mjs"]); + }); + + it("resolves the preload relative to a nested entry chunk", () => { + const { result } = renderEntry({ fileName: "functions/__server.func/index.mjs" }); + + expect(result?.code.split("\n")[0]).toBe( + `import "../../${INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME}";`, + ); + }); + + it("does not prepend the import twice when rendered again", () => { + const plugin = createInstrumentationPreloadPlugin(INSTRUMENTATION_MODULE_PATH); + const context = createBundlerContext(); + plugin.buildStart.call(context); + const chunk = { fileName: "index.mjs", isEntry: true }; + + const first = plugin.renderChunk.call(context, "console.log('entry');", chunk); + const second = plugin.renderChunk.call(context, first?.code ?? "", chunk); + + expect(second).toBeNull(); + }); + + it("leaves shared chunks alone", () => { + const { result } = renderEntry({ fileName: "_libs/drizzle-orm.mjs", isEntry: false }); + + expect(result).toBeNull(); + }); + + it("does not make the preload chunk import itself", () => { + const { result } = renderEntry({ fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME }); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts new file mode 100644 index 000000000..6b34cfa58 --- /dev/null +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts @@ -0,0 +1,113 @@ +import { dirname, relative } from "node:path/posix"; + +import { + createPrependedLineSourceMap, + type PrependedLineSourceMap, +} from "#internal/bundler/prepended-line-source-map.js"; + +/** + * Name of the emitted chunk that registers authored instrumentation. + * Underscore-prefixed to match Nitro's convention for generated output that + * is not a route. + */ +export const INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME = "_eve-instrumentation.mjs"; + +/** Bundler hook context this plugin needs from Rollup and Rolldown. */ +interface InstrumentationPreloadPluginContext { + emitFile(file: { type: "chunk"; id: string; fileName: string }): string; + getFileName(referenceId: string): string; +} + +interface RenderedChunk { + readonly fileName?: string; + readonly isEntry?: boolean; +} + +/** The subset of the rolldown/rollup plugin shape this plugin implements. */ +export interface InstrumentationPreloadBundlerPlugin { + readonly name: string; + buildStart(this: InstrumentationPreloadPluginContext): void; + renderChunk( + this: InstrumentationPreloadPluginContext, + code: string, + chunk?: RenderedChunk, + ): { code: string; map: PrependedLineSourceMap } | null; +} + +/** + * Resolves the specifier an entry chunk uses to import the preload chunk. + * Both names are bundler-relative and always use forward slashes. + */ +function resolvePreloadSpecifier(chunkFileName: string, preloadFileName: string): string { + const chunkDirectory = dirname(chunkFileName); + const relativePath = + chunkDirectory === "." ? preloadFileName : relative(chunkDirectory, preloadFileName); + + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} + +/** + * Creates a bundler plugin that runs authored instrumentation before any + * other module in the bundle. + * + * Registering instrumentation from a Nitro plugin is too late to patch + * anything: plugin bodies are inlined into the bundled entry, while the + * entry's dependencies — including externals such as `pg` — are hoisted + * static imports above them. ESM evaluates every import before any body + * code, so a module loaded that way is already resolved by the time an + * OpenTelemetry instrumentation registers its require hook. Reordering the + * Nitro plugin list cannot fix that, because the ordering is a property of + * import hoisting rather than of the plugin list. + * + * So the instrumentation module is emitted as its own chunk and imported on + * the entry's first line, ahead of every hoisted import. Compatible with + * both Rollup and Rolldown. + */ +export function createInstrumentationPreloadPlugin( + instrumentationModulePath: string, +): InstrumentationPreloadBundlerPlugin { + let preloadReferenceId: string | undefined; + + return { + name: "eve-instrumentation-preload", + buildStart() { + preloadReferenceId = this.emitFile({ + type: "chunk", + id: instrumentationModulePath, + fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, + }); + }, + renderChunk(code, chunk) { + if (preloadReferenceId === undefined || chunk?.isEntry !== true) { + return null; + } + + // The preload is emitted as an entry chunk of its own; it must not + // import itself. + const preloadFileName = this.getFileName(preloadReferenceId); + if (chunk.fileName === undefined || chunk.fileName === preloadFileName) { + return null; + } + + const specifier = resolvePreloadSpecifier(chunk.fileName, preloadFileName); + const importLine = `import ${JSON.stringify(specifier)};`; + + // eve hands the same plugin instance to both the Rollup and the + // Rolldown Nitro config, so `renderChunk` can run more than once for + // one chunk. Prepending twice would be harmless at runtime but noisy + // in the output, and it would offset the source map twice. + if (code.startsWith(importLine)) { + return null; + } + + return { + code: `${importLine}\n${code}`, + map: createPrependedLineSourceMap({ + insertedLineCount: 1, + source: chunk.fileName, + sourceContent: code, + }), + }; + }, + }; +} diff --git a/packages/eve/src/internal/bundler/prepended-line-source-map.ts b/packages/eve/src/internal/bundler/prepended-line-source-map.ts new file mode 100644 index 000000000..be7d157b8 --- /dev/null +++ b/packages/eve/src/internal/bundler/prepended-line-source-map.ts @@ -0,0 +1,66 @@ +/** + * Source map shared by the bundler plugins that prepend lines to a chunk. + */ +export interface PrependedLineSourceMap { + readonly version: 3; + readonly sources: readonly string[]; + readonly sourcesContent: readonly string[]; + readonly names: readonly string[]; + readonly mappings: string; +} + +/** + * Builds the source map for a chunk that had `insertedLineCount` lines + * prepended to it, so positions in the emitted chunk still resolve to the + * original line. + */ +export function createPrependedLineSourceMap({ + insertedLineCount, + source, + sourceContent, +}: { + insertedLineCount: number; + source: string; + sourceContent: string; +}): PrependedLineSourceMap { + const originalLineCount = sourceContent.split("\n").length; + const lineMappings = Array.from({ length: originalLineCount }, (_, index) => + encodeVlqFields(index === 0 ? [0, 0, 0, 0] : [0, 0, 1, 0]), + ); + + return { + version: 3, + sources: [source], + sourcesContent: [sourceContent], + names: [], + mappings: `${";".repeat(insertedLineCount)}${lineMappings.join(";")}`, + }; +} + +const BASE64_VLQ_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const VLQ_BASE_SHIFT = 5; +const VLQ_BASE = 1 << VLQ_BASE_SHIFT; +const VLQ_BASE_MASK = VLQ_BASE - 1; +const VLQ_CONTINUATION_BIT = VLQ_BASE; + +function encodeVlqFields(fields: readonly number[]): string { + return fields.map((field) => encodeVlqInteger(field)).join(""); +} + +function encodeVlqInteger(value: number): string { + let vlq = value < 0 ? (-value << 1) + 1 : value << 1; + let encoded = ""; + + do { + let digit = vlq & VLQ_BASE_MASK; + vlq >>>= VLQ_BASE_SHIFT; + + if (vlq > 0) { + digit |= VLQ_CONTINUATION_BIT; + } + + encoded += BASE64_VLQ_CHARS[digit]; + } while (vlq > 0); + + return encoded; +} diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index 08cae87b3..4bdad5e0e 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -18,6 +18,7 @@ import { import { createProductionNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { createCompiledSandboxBackendPrunePlugin } from "#internal/nitro/host/compiled-sandbox-backend-prune-plugin.js"; import { createExtensionScopePlugin } from "#internal/bundler/extension-scope-plugin.js"; +import { createInstrumentationPreloadPlugin } from "#internal/bundler/instrumentation-preload-plugin.js"; import { configureDevelopmentNitroRoutes, configureProductionNitroRoutes, @@ -640,10 +641,16 @@ function createApplicationNitroBundlerConfiguration( packageNamespace: mount.packageNamespace, })), ); + const instrumentationPluginPath = preparedHost.compiledArtifacts.instrumentationPluginPath; + const instrumentationPreloadPlugin = + instrumentationPluginPath === undefined + ? null + : createInstrumentationPreloadPlugin(instrumentationPluginPath); const nitroBundlerPlugins = [ compiledSandboxBackendPrunePlugin, createOptionalEngineDependencyPlugin(unconfiguredOptionalEnginePackages), extensionScopePlugin, + instrumentationPreloadPlugin, ].filter((plugin) => plugin !== null); const nitroRolldownConfig = createNitroBundlerConfig(nitroBundlerPlugins); const nitroRollupConfig = createNitroBundlerConfig(nitroBundlerPlugins); diff --git a/packages/eve/src/internal/node-esm-compat-banner.ts b/packages/eve/src/internal/node-esm-compat-banner.ts index 5705e2f83..b3bcc0aec 100644 --- a/packages/eve/src/internal/node-esm-compat-banner.ts +++ b/packages/eve/src/internal/node-esm-compat-banner.ts @@ -1,3 +1,8 @@ +import { + createPrependedLineSourceMap, + type PrependedLineSourceMap, +} from "#internal/bundler/prepended-line-source-map.js"; + /** * Options for {@link buildNodeEsmCompatBanner} and * {@link createNodeEsmCompatBannerPlugin}. @@ -84,15 +89,7 @@ interface BannerPlugin { renderChunk( code: string, chunk?: { readonly fileName?: string }, - ): { code: string; map: SourceMap } | null; -} - -interface SourceMap { - readonly version: 3; - readonly sources: readonly string[]; - readonly sourcesContent: readonly string[]; - readonly names: readonly string[]; - readonly mappings: string; + ): { code: string; map: PrependedLineSourceMap } | null; } /** @@ -123,54 +120,3 @@ export function createNodeEsmCompatBannerPlugin( }, }; } - -function createPrependedLineSourceMap({ - insertedLineCount, - source, - sourceContent, -}: { - insertedLineCount: number; - source: string; - sourceContent: string; -}): SourceMap { - const originalLineCount = sourceContent.split("\n").length; - const lineMappings = Array.from({ length: originalLineCount }, (_, index) => - encodeVlqFields(index === 0 ? [0, 0, 0, 0] : [0, 0, 1, 0]), - ); - - return { - version: 3, - sources: [source], - sourcesContent: [sourceContent], - names: [], - mappings: `${";".repeat(insertedLineCount)}${lineMappings.join(";")}`, - }; -} - -const BASE64_VLQ_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -const VLQ_BASE_SHIFT = 5; -const VLQ_BASE = 1 << VLQ_BASE_SHIFT; -const VLQ_BASE_MASK = VLQ_BASE - 1; -const VLQ_CONTINUATION_BIT = VLQ_BASE; - -function encodeVlqFields(fields: readonly number[]): string { - return fields.map((field) => encodeVlqInteger(field)).join(""); -} - -function encodeVlqInteger(value: number): string { - let vlq = value < 0 ? (-value << 1) + 1 : value << 1; - let encoded = ""; - - do { - let digit = vlq & VLQ_BASE_MASK; - vlq >>>= VLQ_BASE_SHIFT; - - if (vlq > 0) { - digit |= VLQ_CONTINUATION_BIT; - } - - encoded += BASE64_VLQ_CHARS[digit]; - } while (vlq > 0); - - return encoded; -} From e31b387d28f77a6d24081041e90b9282a5add9b8 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 27 Jul 2026 19:11:30 -0400 Subject: [PATCH 2/2] refactor(eve): tighten the instrumentation preload after review - move the bundled load-order test to the scenario tier, which is where the tier docblocks put tests that spawn a subprocess, and drop its first-line assertion as a slower duplicate of the unit test - drop the emitted-chunk reference id: an explicit `fileName` is emitted verbatim, so the constant is the name and the `getFileName` round-trip only added state carried between two hooks - cover the setup-once guard, and clear its `globalThis` latch between tests so it cannot leak into the surrounding cases - restore the OpenTelemetry section's ordering by moving the new auto-instrumentation subsection below the fields it was splitting app-runtime-dependencies located the authored instrumentation module by matching either the global it assigns or the bare dependency name. The second clause also matches the traced dependency's own chunk, which does not run the authored module; it only passed before because the module was inlined into the entry. Match the assigned global alone. Signed-off-by: Matan Kushner --- docs/guides/instrumentation.md | 26 +++++++------- .../harness/instrumentation-config.test.ts | 27 ++++++++++++++- .../eve/src/harness/instrumentation-config.ts | 18 +++++----- ...mentation-preload-plugin.scenario.test.ts} | 21 ++---------- .../instrumentation-preload-plugin.test.ts | 34 ++++++------------- .../bundler/instrumentation-preload-plugin.ts | 31 +++++++++-------- .../app-runtime-dependencies.scenario.test.ts | 6 ++-- 7 files changed, 80 insertions(+), 83 deletions(-) rename packages/eve/src/internal/bundler/{instrumentation-preload-plugin.integration.test.ts => instrumentation-preload-plugin.scenario.test.ts} (82%) diff --git a/docs/guides/instrumentation.md b/docs/guides/instrumentation.md index 21fdcc885..e596576d9 100644 --- a/docs/guides/instrumentation.md +++ b/docs/guides/instrumentation.md @@ -46,11 +46,23 @@ Use the `setup` callback to register your OTel provider (for example `registerOT Any OTel-compatible backend works (Braintrust, Raindrop, Arize, Honeycomb, Datadog, Jaeger). Install the exporter package you need and configure it in the callback. +Three more fields control what the AI SDK records inside those spans (see the AI SDK's [telemetry reference](https://ai-sdk.dev/docs/ai-sdk-core/telemetry)): + +- `recordInputs` records full message history on each step span (defaults to `true`). Set it to `false` if inputs contain sensitive content or you want to reduce span payload size. +- `recordOutputs` records model outputs on spans (defaults to `true`). Set it to `false` to disable output recording. +- `functionId` overrides the function name on spans (defaults to the agent name). + +For sensitive, regulated, or production data, set `recordInputs` and `recordOutputs` to `false` unless you have reviewed the exporter and its data-retention path. + +You are responsible for ensuring any observability or eval provider is approved for the data exported to it. + +The third configurable surface, [runtime context events](#runtime-context), attaches per-model-call values to these spans. + ### Auto-instrumenting a dependency Auto-instrumentations such as `@opentelemetry/auto-instrumentations-node` patch a package as the module loader hands it over, so the package has to reach the loader at runtime. eve runs `setup` before the rest of the server bundle, which covers the timing half of that requirement; the remaining half is yours: a package that eve bundles never passes through the loader at all and cannot be patched. -Keep any package you want auto-instrumented external so it ships to `server/node_modules` and is imported at runtime: +Keep any package you want auto-instrumented external, so eve traces it into the hosted output and imports it at runtime: ```ts title="agent/agent.ts" export default defineAgent({ @@ -63,18 +75,6 @@ export default defineAgent({ Without this, an instrumentation registers cleanly and simply never reports spans for that package. -Three more fields control what the AI SDK records inside those spans (see the AI SDK's [telemetry reference](https://ai-sdk.dev/docs/ai-sdk-core/telemetry)): - -- `recordInputs` records full message history on each step span (defaults to `true`). Set it to `false` if inputs contain sensitive content or you want to reduce span payload size. -- `recordOutputs` records model outputs on spans (defaults to `true`). Set it to `false` to disable output recording. -- `functionId` overrides the function name on spans (defaults to the agent name). - -For sensitive, regulated, or production data, set `recordInputs` and `recordOutputs` to `false` unless you have reviewed the exporter and its data-retention path. - -You are responsible for ensuring any observability or eval provider is approved for the data exported to it. - -The third configurable surface, [runtime context events](#runtime-context), attaches per-model-call values to these spans. - ## Runtime context _Runtime context_ is an [AI SDK concept](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text): a user-defined object that flows through a generation lifecycle. eve exposes it through `events["step.started"]`, a callback that runs once eve has assembled the model input for an attempt and returns `{ runtimeContext }`. Because eve registers the AI SDK's OpenTelemetry integration with runtime context enabled, those returned values ride onto the model-call span and its children. The field is named `runtimeContext`, not `metadata`, because AI SDK v7 carries per-call attributes on runtime context rather than a dedicated metadata field. diff --git a/packages/eve/src/harness/instrumentation-config.test.ts b/packages/eve/src/harness/instrumentation-config.test.ts index af254587d..67766ee22 100644 --- a/packages/eve/src/harness/instrumentation-config.test.ts +++ b/packages/eve/src/harness/instrumentation-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; /** * Regression coverage for the instrumentation-config chunk-isolation failure. @@ -21,6 +21,14 @@ import { describe, expect, it, vi } from "vitest"; * module loaders holding their own copies of the file. */ describe("instrumentation-config chunk-isolation regression", () => { + // The setup-invoked latch lives on `globalThis`, so it outlives + // `vi.resetModules()` and would otherwise leak between tests. + beforeEach(() => { + delete (globalThis as Record)[ + Symbol.for("eve.harness-instrumentation-setup-invoked") + ]; + }); + it("a config registered in one module evaluation is visible from another", async () => { vi.resetModules(); const moduleA = await import("#harness/instrumentation-config.js"); @@ -70,4 +78,21 @@ describe("instrumentation-config chunk-isolation regression", () => { expect(setup).toHaveBeenCalledExactlyOnceWith({ agentName: "weather-agent" }); }); + + it("invokes the setup callback once across separate module evaluations", async () => { + // The bundler reaches this module from both the entry's instrumentation + // preload chunk and the Nitro plugin, so registration can run twice. + // Registering OTel providers twice would double-instrument the process. + const setup = vi.fn(); + + vi.resetModules(); + const moduleA = await import("#harness/instrumentation-config.js"); + moduleA.registerInstrumentationConfig({ setup }, { agentName: "weather-agent" }); + + vi.resetModules(); + const moduleB = await import("#harness/instrumentation-config.js"); + moduleB.registerInstrumentationConfig({ setup }, { agentName: "weather-agent" }); + + expect(setup).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/eve/src/harness/instrumentation-config.ts b/packages/eve/src/harness/instrumentation-config.ts index d61421d39..19c89b647 100644 --- a/packages/eve/src/harness/instrumentation-config.ts +++ b/packages/eve/src/harness/instrumentation-config.ts @@ -6,17 +6,17 @@ import type { /** * Process-global store for the authored instrumentation config. * - * Populated at server startup by the generated Nitro instrumentation plugin - * when the user's `agent/instrumentation.ts` has a default export produced - * by `defineInstrumentation()`. The harness reads from this at turn time - * to decide whether telemetry is enabled and which settings to pass to the + * Populated at server startup by the generated instrumentation module when + * the user's `agent/instrumentation.ts` has a default export produced by + * `defineInstrumentation()`. The harness reads from this at turn time to + * decide whether telemetry is enabled and which settings to pass to the * AI SDK. * - * Rooted on `globalThis` so the generated Nitro instrumentation plugin - * (which Nitro keeps external by `file://` URL) and the bundled harness - * chunk (which Nitro inlines via the package's `#harness/*` import alias) - * share one source of truth, even though they resolve to two distinct ESM - * module instances. See `context/key.ts` and + * Rooted on `globalThis` so every copy of that module shares one source of + * truth: the bundler emits it as the entry's instrumentation preload chunk + * and as a Nitro plugin (which Nitro keeps external by `file://` URL), and + * the bundled harness chunk (which Nitro inlines via the package's + * `#harness/*` import alias) is a third instance. See `context/key.ts` and * `runtime/sessions/runtime-session.ts` for the established pattern. */ const INSTRUMENTATION_CONFIG_GLOBAL_KEY = Symbol.for("eve.harness-instrumentation-config"); diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.integration.test.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.scenario.test.ts similarity index 82% rename from packages/eve/src/internal/bundler/instrumentation-preload-plugin.integration.test.ts rename to packages/eve/src/internal/bundler/instrumentation-preload-plugin.scenario.test.ts index d37103f34..5f413eaa1 100644 --- a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.integration.test.ts +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.scenario.test.ts @@ -1,15 +1,12 @@ -import { mkdirSync, writeFileSync } from "node:fs"; import { execFile } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { buildWithNitroRolldown } from "#internal/bundler/nitro-rolldown.js"; -import { - createInstrumentationPreloadPlugin, - INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, -} from "#internal/bundler/instrumentation-preload-plugin.js"; +import { createInstrumentationPreloadPlugin } from "#internal/bundler/instrumentation-preload-plugin.js"; import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; const execFileAsync = promisify(execFile); @@ -87,18 +84,4 @@ describe("instrumentation preload (bundled)", () => { expect(JSON.parse(stdout.trim())).toEqual(["instrumentation", "external:true", "entry-body"]); }); - - it("imports the preload chunk on the entry's first line", async () => { - const directory = await createScratchDirectory("eve-instrumentation-preload-entry-"); - const outDir = await bundleWithPreload(directory); - - const { stdout } = await execFileAsync(process.execPath, [ - "-e", - `process.stdout.write(require("node:fs").readFileSync(${JSON.stringify( - join(outDir, "index.mjs"), - )}, "utf8"))`, - ]); - - expect(stdout.split("\n")[0]).toBe(`import "./${INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME}";`); - }); }); diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts index 78662fbc5..9fd80483b 100644 --- a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts @@ -13,10 +13,7 @@ interface EmittedChunk { readonly fileName: string; } -/** - * Minimal stand-in for the Rollup/Rolldown plugin context, recording the - * emitted chunk so assertions can read it back the way the bundler would. - */ +/** Minimal stand-in for the Rollup/Rolldown plugin context. */ function createBundlerContext() { const emitted: EmittedChunk[] = []; @@ -26,14 +23,6 @@ function createBundlerContext() { emitted.push(file); return `ref-${emitted.length}`; }, - getFileName(referenceId: string): string { - const index = Number(referenceId.replace("ref-", "")) - 1; - const file = emitted[index]; - if (file === undefined) { - throw new Error(`Unknown reference id ${referenceId}`); - } - return file.fileName; - }, }; } @@ -46,13 +35,10 @@ function renderEntry(input: { const context = createBundlerContext(); plugin.buildStart.call(context); - return { - context, - result: plugin.renderChunk.call(context, input.code ?? "console.log('entry');", { - fileName: input.fileName, - isEntry: input.isEntry ?? true, - }), - }; + return plugin.renderChunk.call(context, input.code ?? "console.log('entry');", { + fileName: input.fileName, + isEntry: input.isEntry ?? true, + }); } describe("createInstrumentationPreloadPlugin", () => { @@ -72,7 +58,7 @@ describe("createInstrumentationPreloadPlugin", () => { }); it("imports the preload on the entry's first line, ahead of hoisted imports", () => { - const { result } = renderEntry({ + const result = renderEntry({ code: 'import { Pool } from "pg";\nconsole.log(Pool);', fileName: "index.mjs", }); @@ -84,14 +70,14 @@ describe("createInstrumentationPreloadPlugin", () => { }); it("maps the shifted chunk back to its original line", () => { - const { result } = renderEntry({ fileName: "index.mjs" }); + const result = renderEntry({ fileName: "index.mjs" }); expect(result?.map.mappings.startsWith(";")).toBe(true); expect(result?.map.sources).toEqual(["index.mjs"]); }); it("resolves the preload relative to a nested entry chunk", () => { - const { result } = renderEntry({ fileName: "functions/__server.func/index.mjs" }); + const result = renderEntry({ fileName: "functions/__server.func/index.mjs" }); expect(result?.code.split("\n")[0]).toBe( `import "../../${INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME}";`, @@ -111,13 +97,13 @@ describe("createInstrumentationPreloadPlugin", () => { }); it("leaves shared chunks alone", () => { - const { result } = renderEntry({ fileName: "_libs/drizzle-orm.mjs", isEntry: false }); + const result = renderEntry({ fileName: "_libs/drizzle-orm.mjs", isEntry: false }); expect(result).toBeNull(); }); it("does not make the preload chunk import itself", () => { - const { result } = renderEntry({ fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME }); + const result = renderEntry({ fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME }); expect(result).toBeNull(); }); diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts index 6b34cfa58..d66507f39 100644 --- a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts @@ -15,7 +15,6 @@ export const INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME = "_eve-instrumentation.mjs /** Bundler hook context this plugin needs from Rollup and Rolldown. */ interface InstrumentationPreloadPluginContext { emitFile(file: { type: "chunk"; id: string; fileName: string }): string; - getFileName(referenceId: string): string; } interface RenderedChunk { @@ -66,36 +65,38 @@ function resolvePreloadSpecifier(chunkFileName: string, preloadFileName: string) export function createInstrumentationPreloadPlugin( instrumentationModulePath: string, ): InstrumentationPreloadBundlerPlugin { - let preloadReferenceId: string | undefined; - return { name: "eve-instrumentation-preload", buildStart() { - preloadReferenceId = this.emitFile({ + // An explicit `fileName` is emitted verbatim, so the chunk's name is + // the constant rather than something to read back with `getFileName`. + this.emitFile({ type: "chunk", id: instrumentationModulePath, fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, }); }, renderChunk(code, chunk) { - if (preloadReferenceId === undefined || chunk?.isEntry !== true) { - return null; - } - // The preload is emitted as an entry chunk of its own; it must not // import itself. - const preloadFileName = this.getFileName(preloadReferenceId); - if (chunk.fileName === undefined || chunk.fileName === preloadFileName) { + if ( + chunk?.isEntry !== true || + chunk.fileName === undefined || + chunk.fileName === INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME + ) { return null; } - const specifier = resolvePreloadSpecifier(chunk.fileName, preloadFileName); + const specifier = resolvePreloadSpecifier( + chunk.fileName, + INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, + ); const importLine = `import ${JSON.stringify(specifier)};`; - // eve hands the same plugin instance to both the Rollup and the - // Rolldown Nitro config, so `renderChunk` can run more than once for - // one chunk. Prepending twice would be harmless at runtime but noisy - // in the output, and it would offset the source map twice. + // eve registers one plugin instance under both the Rolldown and the + // Rollup Nitro config, so every hook here runs twice per build. + // Without this guard the entry is rendered with the import already on + // it and gets a second copy, offsetting the source map twice. if (code.startsWith(importLine)) { return null; } diff --git a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts index 3b5958c25..375701bdc 100644 --- a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts +++ b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts @@ -895,8 +895,10 @@ describe("app runtime dependency tracing", () => { .map(async (entry) => { const source = await readFile(join(serverFunctionDirectory, entry), "utf8"); - return source.includes("__fixtureInstrumentationDep") || - source.includes("fixture-instrumentation-dep") + // Match the global the authored module assigns, not the bare + // package name: that also appears in the traced dependency's + // own chunk, which does not run the authored module. + return source.includes("__fixtureInstrumentationDep") ? join(serverFunctionDirectory, entry) : null; }),