Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
58 changes: 53 additions & 5 deletions packages/create-lousho-agent/src/template.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<number> {
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;

Expand All @@ -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');

Expand Down Expand Up @@ -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
);
Expand Down
4 changes: 2 additions & 2 deletions src/deploy/adapters/node-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/deploy/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/deploy/otelTracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 6 additions & 2 deletions src/deploy/otelTracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -100,6 +103,7 @@ export async function otelTracingFromEnv(options: OtelTracingOptions): Promise<O
if (!endpoint) return undefined;
const load = options.load ?? importPackage;
try {
const { createOtelTraceExporter } = await import('../execution/otel');
const { NodeTracerProvider, BatchSpanProcessor } = await exportsOf<SdkTraceNode>(load, '@opentelemetry/sdk-trace-node');
const resources = await exportsOf<Resources>(load, '@opentelemetry/resources');
const { OTLPTraceExporter } = await exportsOf<OtlpHttp>(load, '@opentelemetry/exporter-trace-otlp-http');
Expand Down
78 changes: 78 additions & 0 deletions src/deploy/runtimePeer.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading