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..c6ed760c 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,44 @@ 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. + * 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 = ''; + 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); + 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; + // 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, signal) => finish(new Error(`server exited early (code ${code}, signal ${signal})`))); + }); +} + describe('end-to-end scaffold + install + typecheck + test', () => { let base: string | undefined; @@ -40,8 +80,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 +110,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'); + }); +});