From c42cdc50368d63e5a4b3224006ff98892f0f4197 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 20 Oct 2026 14:14:03 +0300 Subject: [PATCH 1/2] V42: Keep lousho build working without the optional OTel packages The deploy runtime statically imported the OTel exporter module, so a node-server build of a project without @opentelemetry/api (every scaffold) failed with 'Could not resolve'. The exporter is now imported lazily, and the node-server build turns an unresolvable @opentelemetry/api into a runtime require that only runs when OTEL_EXPORTER_OTLP_ENDPOINT is set. Co-Authored-By: Claude Opus 5.5 --- docs/observability.md | 2 +- llms-full.txt | 2 +- .../src/template.e2e.test.ts | 46 +++++++++-- src/deploy/adapters/node-server.ts | 4 +- src/deploy/bundle.ts | 31 ++++++++ src/deploy/otelTracing.test.ts | 4 +- src/deploy/otelTracing.ts | 8 +- src/deploy/runtimePeer.test.ts | 78 +++++++++++++++++++ 8 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 src/deploy/runtimePeer.test.ts diff --git a/docs/observability.md b/docs/observability.md index 156b098e..03f5cb56 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -395,7 +395,7 @@ The OpenTelemetry SDK is not bundled. Install these packages next to before you build the docker image: ```bash -npm install @opentelemetry/sdk-trace-node@^2 @opentelemetry/resources@^2 @opentelemetry/exporter-trace-otlp-http +npm install @opentelemetry/api @opentelemetry/sdk-trace-node@^2 @opentelemetry/resources@^2 @opentelemetry/exporter-trace-otlp-http OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 OTEL_SERVICE_NAME=support-agent node dist/server.js ``` diff --git a/llms-full.txt b/llms-full.txt index 75b29ff2..289f1fe3 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -14007,7 +14007,7 @@ The OpenTelemetry SDK is not bundled. Install these packages next to before you build the docker image: ```bash -npm install @opentelemetry/sdk-trace-node@^2 @opentelemetry/resources@^2 @opentelemetry/exporter-trace-otlp-http +npm install @opentelemetry/api @opentelemetry/sdk-trace-node@^2 @opentelemetry/resources@^2 @opentelemetry/exporter-trace-otlp-http OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 OTEL_SERVICE_NAME=support-agent node dist/server.js ``` diff --git a/packages/create-lousho-agent/src/template.e2e.test.ts b/packages/create-lousho-agent/src/template.e2e.test.ts index 21c13cb5..5b121d5e 100644 --- a/packages/create-lousho-agent/src/template.e2e.test.ts +++ b/packages/create-lousho-agent/src/template.e2e.test.ts @@ -3,13 +3,15 @@ * a project in a FRESH TEMP DIRECTORY (never inside this checkout) with * `--sdk-path` pointing at this checkout, so the generator `npm pack`s the * local SDK build instead of fetching a published version. It lets `init` - * run the real `npm install`, then typechecks the generated project and runs - * its own `npm test` offline. Slow (a real install), hence the long timeout. + * run the real `npm install`, then typechecks the generated project, runs + * its own `npm test` offline, and builds it with `npm run build` (`lousho + * build`), starting the built server to check /health. Slow (a real install), + * hence the long timeout. * * Needs the SDK built first: `npm run build` at the repository root. */ import { describe, it, expect, afterEach } from 'vitest'; -import { execFileSync, execSync } from 'node:child_process'; +import { execFileSync, execSync, spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -30,6 +32,32 @@ function npm(args: string[], cwd: string): string { return execSync(`npm ${args.join(' ')}`, { cwd, encoding: 'utf8', env: offlineEnv() }); } +/** Starts the built server (`node dist/server.js`) on a free port and resolves with its /health status. */ +function healthOfBuiltServer(buildDir: string, env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['dist/server.js', '--port=0'], { cwd: buildDir, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + const timer = setTimeout(() => finish(new Error(`server did not start: ${output}`)), 30_000); + // Settles once the server has exited, so the temp dir is no longer in use when it is removed (Windows). + const finish = (error: Error | undefined, status?: number) => { + clearTimeout(timer); + child.removeAllListeners('exit'); + const settle = () => (error ? reject(error) : resolve(status!)); + if (child.exitCode !== null || child.signalCode !== null) return settle(); + child.once('exit', settle); + child.kill(); + }; + const onData = (chunk: Buffer) => { + output += chunk; + const match = /listening on http:\/\/[^:]+:(\d+)/.exec(output); + if (match) fetch(`http://127.0.0.1:${match[1]}/health`).then((res) => finish(undefined, res.status), finish); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => finish(new Error(`server exited early (code ${code}): ${output}`))); + }); +} + describe('end-to-end scaffold + install + typecheck + test', () => { let base: string | undefined; @@ -40,8 +68,8 @@ describe('end-to-end scaffold + install + typecheck + test', () => { // OpenRouter runs on `@ai-sdk/openai` 4 through its Chat Completions model, like OpenAI. it.each(['openai', 'openrouter'])( - '%s: generates a project that installs from the locally packed SDK, typechecks, and passes its own tests', - (provider) => { + '%s: generates a project that installs from the locally packed SDK, typechecks, passes its own tests and builds a server that starts', + async (provider) => { base = fs.mkdtempSync(path.join(os.tmpdir(), `create-lousho-agent-e2e-${provider}-`)); const dir = path.join(base, 'test-agent'); @@ -70,6 +98,14 @@ describe('end-to-end scaffold + install + typecheck + test', () => { JSON.parse(fs.readFileSync(path.join(dir, 'node_modules', name, 'package.json'), 'utf8')).version as string; expect(versionOf('ai')).toMatch(/^7\./); expect(versionOf('@ai-sdk/openai')).toMatch(/^4\./); + + // `lousho build` bundles the project without the optional OTel packages (not installed here), + // and the built server starts. A placeholder key: starting the server makes no provider call. + npm(['run', 'build'], dir); + const buildDir = path.join(dir, '.lousho', 'build', 'node-server'); + expect(fs.existsSync(path.join(buildDir, 'dist', 'server.js'))).toBe(true); + const keyVar = provider === 'openrouter' ? 'OPENROUTER_API_KEY' : 'OPENAI_API_KEY'; + expect(await healthOfBuiltServer(buildDir, { ...offlineEnv(), [keyVar]: 'placeholder-key' })).toBe(200); }, 300_000 ); diff --git a/src/deploy/adapters/node-server.ts b/src/deploy/adapters/node-server.ts index be7803b9..4f3c9c1f 100644 --- a/src/deploy/adapters/node-server.ts +++ b/src/deploy/adapters/node-server.ts @@ -37,7 +37,7 @@ import { specSchedules } from '../../schedules/specSchedules'; import { resolveSpecTool } from '../../spec/specToAgent'; import { listProviders, type AiMajor } from '../../providers/providerSpec'; import { findConfigFile, isCodeConfigFile } from '../../agentDir/readConfig'; -import { RUNTIME_SPECIFIER, buildBundle, bundleExternals, findSdkRoot, loadTsup, sdkRuntimePlugin, writeFile } from '../bundle'; +import { RUNTIME_SPECIFIER, buildBundle, bundleExternals, findSdkRoot, loadTsup, runtimePeerPlugin, sdkRuntimePlugin, writeFile } from '../bundle'; import { agentDirEntries, copyAgentDirAssets, isAgentDir, scaffoldedAgentDir, writeAgentDirPointer } from './node-server-dir'; const SPEC_EXTENSIONS = new Set(['.yaml', '.yml', '.json']); @@ -456,7 +456,7 @@ export const NodeServerAdapter: DeploymentAdapter = { // except the SDK's optional peers (dockerode, provider packages, MCP, ...): they are imported // lazily and a missing one raises the SDK's coded missing-peer error where it is needed. ...bundleExternals(), - esbuildPlugins: [sdkRuntimePlugin()], + esbuildPlugins: [sdkRuntimePlugin(), runtimePeerPlugin()], clean: true, sourcemap: false, dts: false, diff --git a/src/deploy/bundle.ts b/src/deploy/bundle.ts index 6417695a..6bca1135 100644 --- a/src/deploy/bundle.ts +++ b/src/deploy/bundle.ts @@ -317,6 +317,37 @@ export function workerMissingPeerPlugin(): Plugin { }; } +const RUNTIME_PEER_NAMESPACE = 'lousho-runtime-peer'; + +/** + * esbuild plugin (node-server / docker builds) for `@opentelemetry/api`, an + * optional peer the SDK's OTel exporter imports (the built server loads that + * exporter lazily, only when OTEL_EXPORTER_OTLP_ENDPOINT is set). Installed + * where the build can resolve it from the SDK, it is bundled as before; when + * it is not, the build does not fail: the import becomes a `require()` that + * runs only when the exporter module is loaded, so the server starts without + * it and reports the packages to install when traces are asked for. + */ +export function runtimePeerPlugin(): Plugin { + const filter = /^@opentelemetry\/api(?:\/.*)?$/; + return { + name: 'lousho-runtime-peer', + setup(build) { + build.onResolve({ filter }, async (args) => { + if (args.namespace === RUNTIME_PEER_NAMESPACE) return { path: args.path, external: true }; + if (isBuiltinCheck(args.pluginData)) return undefined; + const resolved = await resolveNormally(build, args); + if (resolved.errors.length === 0) return resolvedAs(resolved); + return { path: args.path, namespace: RUNTIME_PEER_NAMESPACE }; + }); + build.onLoad({ filter: /.*/, namespace: RUNTIME_PEER_NAMESPACE }, (args) => ({ + contents: `module.exports = require(${JSON.stringify(args.path)});\n`, + loader: 'js', + })); + }, + }; +} + /** * The SDK's optional peers: the keys of `peerDependenciesMeta` in its own * package.json whose `optional` is true. The SDK imports them lazily and diff --git a/src/deploy/otelTracing.test.ts b/src/deploy/otelTracing.test.ts index 66345fa0..0d5ea528 100644 --- a/src/deploy/otelTracing.test.ts +++ b/src/deploy/otelTracing.test.ts @@ -97,8 +97,8 @@ describe('otelTracingFromEnv', () => { otelEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: 'http://c:4318' }); expect(await otelTracingFromEnv({ serviceName: 'a', load, log })).toBeUndefined(); const message = log.error.mock.calls[0][0] as string; - expect(message).toContain('@opentelemetry/sdk-trace-node, @opentelemetry/resources, @opentelemetry/exporter-trace-otlp-http'); - expect(message).toContain('npm install @opentelemetry/sdk-trace-node@^2 @opentelemetry/resources@^2 @opentelemetry/exporter-trace-otlp-http'); + expect(message).toContain('@opentelemetry/api, @opentelemetry/sdk-trace-node, @opentelemetry/resources, @opentelemetry/exporter-trace-otlp-http'); + expect(message).toContain('npm install @opentelemetry/api @opentelemetry/sdk-trace-node@^2 @opentelemetry/resources@^2 @opentelemetry/exporter-trace-otlp-http'); }); it('logs any other start-up error and returns undefined', async () => { diff --git a/src/deploy/otelTracing.ts b/src/deploy/otelTracing.ts index f1765709..20cdc8b2 100644 --- a/src/deploy/otelTracing.ts +++ b/src/deploy/otelTracing.ts @@ -10,13 +10,16 @@ * not bundle them: the server imports them from its node_modules at start-up, only * when the variable is set. When they are missing it logs the packages to install * and serves without exporting traces. With the variable unset nothing here runs. + * The exporter module (and with it `@opentelemetry/api`, an optional peer) is + * imported lazily too, so a build without the OTel packages installed still builds + * and runs (see runtimePeerPlugin() in ./bundle.ts). */ -import { createOtelTraceExporter } from '../execution/otel'; +import type { createOtelTraceExporter } from '../execution/otel'; import type { Span, TraceExporter } from '../execution/tracing'; import { createAgent, createAgentConfigOf, type CreateAgentConfig, type SimpleAgent } from '../createAgent'; /** The packages the server imports to export traces, as `npm install` takes them. */ -const OTEL_TRACE_PACKAGES = ['@opentelemetry/sdk-trace-node@^2', '@opentelemetry/resources@^2', '@opentelemetry/exporter-trace-otlp-http'] as const; +const OTEL_TRACE_PACKAGES = ['@opentelemetry/api', '@opentelemetry/sdk-trace-node@^2', '@opentelemetry/resources@^2', '@opentelemetry/exporter-trace-otlp-http'] as const; /** A running trace export: the exporter to give the agent, and `shutdown()` to flush it before exit. */ export interface OtelTracing { @@ -100,6 +103,7 @@ export async function otelTracingFromEnv(options: OtelTracingOptions): Promise(load, '@opentelemetry/sdk-trace-node'); const resources = await exportsOf(load, '@opentelemetry/resources'); const { OTLPTraceExporter } = await exportsOf(load, '@opentelemetry/exporter-trace-otlp-http'); diff --git a/src/deploy/runtimePeer.test.ts b/src/deploy/runtimePeer.test.ts new file mode 100644 index 00000000..436f4f2b --- /dev/null +++ b/src/deploy/runtimePeer.test.ts @@ -0,0 +1,78 @@ +/** + * A project scaffolded by create-lousho-agent does not install + * `@opentelemetry/api` (an optional peer of the SDK; `ai` keeps its own copy + * nested under node_modules/ai), so the node-server build must neither fail + * to resolve it nor need it to start: the OTel exporter is loaded only when + * OTEL_EXPORTER_OTLP_ENDPOINT is set. This repo has it installed, so a + * plugin hides it from the SDK's own source, as in such a project. + */ +import { describe, expect, it, vi } from 'vitest'; +import { build, type Plugin } from 'esbuild'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { bundleExternals, runtimePeerPlugin } from './bundle'; +import type { otelTracingFromEnv } from './otelTracing'; + +/** Fails to resolve `@opentelemetry/api` for importers outside node_modules (the SDK source), like a scaffold whose node_modules lacks it. */ +const hideOtelApi: Plugin = { + name: 'hide-otel-api', + setup(b) { + b.onResolve({ filter: /^@opentelemetry\/api(?:\/.*)?$/ }, (args) => + /[\\/]node_modules[\\/]/.test(args.importer) ? undefined : { errors: [{ text: `Could not resolve "${args.path}"` }] } + ); + }, +}; + +describe('runtimePeerPlugin (node-server build without @opentelemetry/api)', () => { + it('builds the OTel wiring, loads without the package and reports it as missing only when traces are asked for', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lousho-runtime-peer-')); + try { + const outfile = path.join(dir, 'server.cjs'); + await build({ + stdin: { contents: `export { otelTracingFromEnv } from ${JSON.stringify(path.join(__dirname, 'otelTracing.ts'))};\n`, resolveDir: __dirname, loader: 'ts' }, + bundle: true, + outfile, + format: 'cjs', + platform: 'node', + target: 'node18', + logLevel: 'silent', + external: bundleExternals().external, + // Registered first, as in the node-server build: it sees the import before the hiding plugin fails it. + plugins: [runtimePeerPlugin(), hideOtelApi], + }); + + // dist/server.js runs from a directory where @opentelemetry/api is not installed. + const mod = createRequire(outfile)(outfile) as { otelTracingFromEnv: typeof otelTracingFromEnv }; + const log = { log: vi.fn(), error: vi.fn() }; + vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', ''); + vi.stubEnv('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', ''); + expect(await mod.otelTracingFromEnv({ serviceName: 's', log })).toBeUndefined(); + expect(log.error).not.toHaveBeenCalled(); + + vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://127.0.0.1:4318'); + expect(await mod.otelTracingFromEnv({ serviceName: 's', log, load: () => Promise.reject(new Error('the OTel SDK must not be loaded first')) })).toBeUndefined(); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error.mock.calls[0]![0]).toContain('npm install @opentelemetry/api '); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + + it('bundles @opentelemetry/api as before when the build can resolve it', async () => { + const result = await build({ + stdin: { contents: "export { trace } from '@opentelemetry/api';\n", resolveDir: __dirname, loader: 'ts' }, + bundle: true, + write: false, + format: 'cjs', + platform: 'node', + logLevel: 'silent', + plugins: [runtimePeerPlugin()], + }); + const code = result.outputFiles[0]!.text; + expect(code).not.toContain('require("@opentelemetry/api")'); + expect(code).toContain('TraceAPI'); + }); +}); From 0cb5db5a40a2df9b565d8ffb77488aa4c8d565d2 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 20 Oct 2026 14:37:21 +0300 Subject: [PATCH 2/2] V42: Probe the built server once in the e2e test On Linux the killed server logs its SIGTERM line before it exits; the helper probed /health again on that output and the late failure replaced the 200. Probe once, settle once, read the port from the whole line, and put the server's output in any failure. Co-Authored-By: Claude Opus 5.5 --- .../src/template.e2e.test.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/create-lousho-agent/src/template.e2e.test.ts b/packages/create-lousho-agent/src/template.e2e.test.ts index 5b121d5e..c6ed760c 100644 --- a/packages/create-lousho-agent/src/template.e2e.test.ts +++ b/packages/create-lousho-agent/src/template.e2e.test.ts @@ -32,29 +32,41 @@ function npm(args: string[], cwd: string): string { return execSync(`npm ${args.join(' ')}`, { cwd, encoding: 'utf8', env: offlineEnv() }); } -/** Starts the built server (`node dist/server.js`) on a free port and resolves with its /health status. */ +/** + * Starts the built server (`node dist/server.js`) on a free port and resolves with its /health status. + * Probes once and settles once, after the server has exited (so its temp dir can be removed on + * Windows); a failure carries the server's output. + */ function healthOfBuiltServer(buildDir: string, env: NodeJS.ProcessEnv): Promise { return new Promise((resolve, reject) => { const child = spawn(process.execPath, ['dist/server.js', '--port=0'], { cwd: buildDir, env, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; - const timer = setTimeout(() => finish(new Error(`server did not start: ${output}`)), 30_000); - // Settles once the server has exited, so the temp dir is no longer in use when it is removed (Windows). + let probing = false; + let finished = false; + const timer = setTimeout(() => finish(new Error('server did not start')), 30_000); const finish = (error: Error | undefined, status?: number) => { + if (finished) return; + finished = true; clearTimeout(timer); - child.removeAllListeners('exit'); - const settle = () => (error ? reject(error) : resolve(status!)); + const settle = () => (error ? reject(new Error(`${error.message}\n--- server output ---\n${output}`, { cause: error })) : resolve(status!)); if (child.exitCode !== null || child.signalCode !== null) return settle(); child.once('exit', settle); child.kill(); }; const onData = (chunk: Buffer) => { output += chunk; - const match = /listening on http:\/\/[^:]+:(\d+)/.exec(output); - if (match) fetch(`http://127.0.0.1:${match[1]}/health`).then((res) => finish(undefined, res.status), finish); + // The whole line, so a port split across chunks is not read short. + const match = /listening on http:\/\/[^:\s]+:(\d+)\r?\n/.exec(output); + if (!match || probing) return; + probing = true; + fetch(`http://127.0.0.1:${match[1]}/health`).then( + (res) => finish(undefined, res.status), + (error: Error) => finish(error) + ); }; child.stdout.on('data', onData); child.stderr.on('data', onData); - child.on('exit', (code) => finish(new Error(`server exited early (code ${code}): ${output}`))); + child.on('exit', (code, signal) => finish(new Error(`server exited early (code ${code}, signal ${signal})`))); }); }