From b02cc5d2425777717a5878771b9759410f34aeb7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 10 Aug 2026 16:40:38 -0400 Subject: [PATCH 1/5] feat(project): wire dev handler --- package.json | 3 + .../templates/shared/env.local.template | 6 +- src/core/dev/container.test.ts | 71 +++++- src/core/dev/container.ts | 68 +++++- src/core/dev/port.test.ts | 49 ++++ src/core/dev/port.ts | 47 ++++ src/core/types.tsx | 2 +- src/errors/errors.tsx | 5 +- src/errors/index.tsx | 3 +- src/handlers/eval/ondemand/ondemand.test.tsx | 4 +- src/handlers/project/dev/environment.test.ts | 71 ++++++ src/handlers/project/dev/environment.ts | 65 ++++++ src/handlers/project/dev/index.test.ts | 211 ++++++++++++++++++ src/handlers/project/dev/index.ts | 122 +++++++++- src/handlers/project/index.ts | 19 +- src/handlers/project/project.test.ts | 7 +- src/handlers/runtime/invoke/index.tsx | 6 +- src/handlers/runtime/invoke/response.ts | 4 +- src/io/index.ts | 1 + src/io/port.test.ts | 29 +++ src/io/port.ts | 27 +++ src/middleware/withJsonRenderer.tsx | 1 + src/runnable/index.test.ts | 8 +- src/testing/renderScreen.tsx | 2 +- src/tui/index.tsx | 1 + 25 files changed, 797 insertions(+), 35 deletions(-) create mode 100644 src/core/dev/port.test.ts create mode 100644 src/core/dev/port.ts create mode 100644 src/handlers/project/dev/environment.test.ts create mode 100644 src/handlers/project/dev/environment.ts create mode 100644 src/handlers/project/dev/index.test.ts create mode 100644 src/io/port.test.ts create mode 100644 src/io/port.ts diff --git a/package.json b/package.json index 279d871a6..8786ec2a3 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "agentcore": "./dist/index.js" }, "main": "./dist/index.js", + "engines": { + "node": ">=20.12.0" + }, "files": [ "dist" ], diff --git a/src/assets/templates/shared/env.local.template b/src/assets/templates/shared/env.local.template index 30a18b616..cc9e703e7 100644 --- a/src/assets/templates/shared/env.local.template +++ b/src/assets/templates/shared/env.local.template @@ -1,7 +1,7 @@ # Environment variables for local development. -# `agentcore dev` loads this file into your agent's process. Values here -# override anything the CLI injects. This file is gitignored — keep secrets -# out of version control, but they are safe here. +# `agentcore project dev` loads this file into your agent's process. Values here +# override injected values except PORT, FASTMCP_PORT, and LOCAL_DEV, which the +# CLI owns. This file is gitignored — keep secrets out of version control. # # Example: # MY_API_KEY=... diff --git a/src/core/dev/container.test.ts b/src/core/dev/container.test.ts index c8ece4891..0a4ce0ff4 100644 --- a/src/core/dev/container.test.ts +++ b/src/core/dev/container.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { InputValidationError, InvalidEnvironmentError } from "../../errors"; @@ -61,6 +61,8 @@ function harness( config: { available?: (tool: string, probeArgs?: string[]) => Promise; stream?: StreamBehavior; + awsDirectory?: string; + processEnv?: NodeJS.ProcessEnv; } = {}, ) { const calls: ProcessCall[] = []; @@ -77,6 +79,11 @@ function harness( (async (tool) => { return tool === "docker"; }), + awsDirectory: config.awsDirectory ?? join(tmpdir(), "agentcore-container-no-aws"), + processEnv: config.processEnv ?? { + AWS_ACCESS_KEY_ID: "test-access-key", + AWS_SECRET_ACCESS_KEY: "test-secret-key", + }, }), }; } @@ -189,6 +196,10 @@ describe("ContainerDevRunner", () => { "-p", `127.0.0.1:3000:${containerPort}`, "-e", + "AWS_ACCESS_KEY_ID=test-access-key", + "-e", + "AWS_SECRET_ACCESS_KEY=test-secret-key", + "-e", "API_KEY=super-secret", "-e", `PORT=${containerPort}`, @@ -202,6 +213,37 @@ describe("ContainerDevRunner", () => { expect(run.options.redactedCommand?.join(" ")).not.toContain("super-secret"); }); + test("uses a shared AWS config and rejects missing credentials", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const awsDirectory = join(root, ".aws"); + await mkdir(awsDirectory); + await writeFile(join(awsDirectory, "config"), "[profile sandbox]\nregion=us-east-1\n"); + const { calls, runner } = harness({ + awsDirectory, + processEnv: { AWS_PROFILE: "sandbox", AWS_REGION: "us-east-1" }, + }); + + await collect(runner.run(input(root, projectRuntime))); + + const run = commandCall(calls, "run"); + expect(run.command).toContain(`${awsDirectory}:/aws-config:ro`); + expect(run.command).toContain("AWS_PROFILE=sandbox"); + expect(run.command).toContain("AWS_CONFIG_FILE=/aws-config/config"); + expect(run.options.redactedCommand?.join(" ")).not.toContain("sandbox"); + + const missing = harness({ + awsDirectory: join(root, "missing-aws"), + processEnv: {}, + }); + const missingCredentials = collect(missing.runner.run(input(root, projectRuntime))); + await expect(missingCredentials).rejects.toBeInstanceOf(InvalidEnvironmentError); + await expect(missingCredentials).rejects.toThrow( + "Unable to resolve AWS credentials for the container", + ); + expect(missing.calls).toHaveLength(0); + }); + test("preserves an existing build context .dockerignore", async () => { const projectRuntime = runtime({ buildContextPath: "." }); const root = await projectRoot(projectRuntime); @@ -409,6 +451,33 @@ describe("ContainerDevRunner", () => { expect(calls.map(({ command }) => command[1])).toEqual(["rm"]); }); + test("rejects build contexts outside the project root, including symlinks", async () => { + const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); + const outside = await mkdtemp(join(tmpdir(), "agentcore-container-outside-")); + tempDirectories.push(root, outside); + await symlink(outside, join(root, "linked"), process.platform === "win32" ? "junction" : "dir"); + const probes: string[] = []; + + for (const buildContextPath of ["..", "linked"]) { + const { calls, runner } = harness({ + available: async (tool) => { + probes.push(tool); + return true; + }, + }); + + const escapedContext = collect(runner.run(input(root, runtime({ buildContextPath })))); + await expect(escapedContext).rejects.toBeInstanceOf(InputValidationError); + await expect(escapedContext).rejects.toThrow( + "container build context must be within the project root", + ); + expect(calls).toHaveLength(0); + } + + expect(probes).toHaveLength(0); + await expect(readFile(join(outside, ".dockerignore"), "utf8")).rejects.toThrow(); + }); + test("rejects a build context that is not a directory", async () => { const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); tempDirectories.push(root); diff --git a/src/core/dev/container.ts b/src/core/dev/container.ts index 720d6e8dd..84aaee28b 100644 --- a/src/core/dev/container.ts +++ b/src/core/dev/container.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; -import { existsSync, statSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { InputValidationError, InvalidEnvironmentError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; import { @@ -10,8 +11,17 @@ import { type ProcessStreamer, type StreamProcessOptions, } from "../../io"; +import { DEV_PORTS } from "./port"; const CONTAINER_TOOLS = ["docker", "podman", "finch"] as const; +const AWS_ENV_KEYS = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", +] as const; const CLEANUP_TIMEOUT_MS = 2_000; const DOCKERFILE_NAME = "Dockerfile"; const CONTAINER_RUNTIME_INSTALL_HINT = @@ -44,15 +54,21 @@ type ToolAvailable = typeof toolAvailable; type ContainerDevRunnerConfig = { streamProcess?: ProcessStreamer; toolAvailable?: ToolAvailable; + awsDirectory?: string; + processEnv?: NodeJS.ProcessEnv; }; export class ContainerDevRunner implements DevRunner { private readonly streamProcess: ProcessStreamer; private readonly toolAvailable: ToolAvailable; + private readonly awsDirectory: string; + private readonly processEnv: NodeJS.ProcessEnv; constructor(config: ContainerDevRunnerConfig = {}) { this.streamProcess = config.streamProcess ?? streamProcess; this.toolAvailable = config.toolAvailable ?? toolAvailable; + this.awsDirectory = config.awsDirectory ?? join(homedir(), ".aws"); + this.processEnv = config.processEnv ?? process.env; } public async *run(input: DevServerInput): AsyncGenerator { @@ -65,12 +81,36 @@ export class ContainerDevRunner implements DevRunner { throw new InputValidationError(`container build context directory not found: ${context}`); } + const canonicalContext = realpathSync(context); + const relativeContext = relative(realpathSync(input.projectRoot), canonicalContext); + if ( + relativeContext === ".." || + relativeContext.startsWith(`..${sep}`) || + isAbsolute(relativeContext) + ) { + throw new InputValidationError( + `container build context must be within the project root: ${canonicalContext}`, + ); + } + const dockerfile = input.runtime.dockerfile ?? DOCKERFILE_NAME; const dockerfilePath = join(context, dockerfile); if (!isFile(dockerfilePath)) { throw new InputValidationError(`container Dockerfile not found: ${dockerfilePath}`); } + const hasAwsCredentials = Boolean( + (input.env?.AWS_ACCESS_KEY_ID ?? this.processEnv.AWS_ACCESS_KEY_ID) && + (input.env?.AWS_SECRET_ACCESS_KEY ?? this.processEnv.AWS_SECRET_ACCESS_KEY), + ); + const hasAwsConfig = existsSync(this.awsDirectory); + if (!hasAwsCredentials && !hasAwsConfig) { + throw new InvalidEnvironmentError( + "Unable to resolve AWS credentials for the container. Configure AWS credentials " + + "or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, then retry.", + ); + } + const tool = await this.resolveContainerTool(input.signal); input.signal.throwIfAborted(); if (input.runtime.buildContextPath) { @@ -116,15 +156,23 @@ export class ContainerDevRunner implements DevRunner { yield { type: "status", message: `Building image with ${tool}` }; yield* this.streamProcess(buildCommand, buildOptions); - const containerPort = portForProtocol(input.runtime.protocol); - const forwardedEnv: Record = { - ...input.env, + const containerPort = DEV_PORTS[input.runtime.protocol ?? "HTTP"]; + const forwardedEnv: Record = {}; + for (const key of AWS_ENV_KEYS) { + if (this.processEnv[key]) forwardedEnv[key] = this.processEnv[key]; + } + Object.assign(forwardedEnv, input.env, { PORT: String(containerPort), LOCAL_DEV: "1", - }; + }); if (input.runtime.protocol === "MCP") { forwardedEnv.FASTMCP_PORT = String(containerPort); } + const awsMount = hasAwsConfig ? ["-v", `${this.awsDirectory}:/aws-config:ro`] : []; + if (awsMount.length) { + forwardedEnv.AWS_CONFIG_FILE = "/aws-config/config"; + forwardedEnv.AWS_SHARED_CREDENTIALS_FILE = "/aws-config/credentials"; + } const envFlags = Object.entries(forwardedEnv).flatMap(([key, value]) => [ "-e", `${key}=${value}`, @@ -141,6 +189,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...envFlags, imageTag, ]; @@ -158,6 +207,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...redactedEnvFlags, imageTag, ], @@ -204,12 +254,6 @@ export class ContainerDevRunner implements DevRunner { } } -function portForProtocol(protocol: DevServerInput["runtime"]["protocol"]): number { - if (protocol === "MCP") return 8000; - if (protocol === "A2A") return 9000; - return 8080; -} - function isDirectory(path: string): boolean { try { return statSync(path).isDirectory(); diff --git a/src/core/dev/port.test.ts b/src/core/dev/port.test.ts new file mode 100644 index 000000000..cdb84411e --- /dev/null +++ b/src/core/dev/port.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import type { PortChecker } from "../../io"; +import { PortInUseError, resolveDevPort } from "./port"; + +const signal = new AbortController().signal; + +describe("resolveDevPort", () => { + test.each([ + ["HTTP", 8080], + ["AGUI", 8080], + ["MCP", 8000], + ["A2A", 9000], + ] as const)("uses the %s default", async (protocol, port) => { + expect(await resolveDevPort(protocol, undefined, async () => true, signal)).toEqual({ + port, + requestedPort: port, + }); + }); + + test("walks up from occupied defaults", async () => { + const checked: number[] = []; + const check: PortChecker = async (port) => { + checked.push(port); + return port === 8002; + }; + + expect(await resolveDevPort("MCP", undefined, check, signal)).toEqual({ + port: 8002, + requestedPort: 8000, + }); + expect(checked).toEqual([8000, 8001, 8002]); + }); + + test("accepts a free explicit port and rejects an occupied one", async () => { + expect(await resolveDevPort("A2A", 4567, async () => true, signal)).toEqual({ + port: 4567, + requestedPort: 4567, + }); + const occupied = resolveDevPort("A2A", 4567, async () => false, signal); + await expect(occupied).rejects.toBeInstanceOf(PortInUseError); + await expect(occupied).rejects.toThrow("lsof -i :4567"); + }); + + test("bounds the default search", async () => { + await expect(resolveDevPort("HTTP", undefined, async () => false, signal)).rejects.toThrow( + "No free port found in range 8080-8179", + ); + }); +}); diff --git a/src/core/dev/port.ts b/src/core/dev/port.ts new file mode 100644 index 000000000..1b88425ec --- /dev/null +++ b/src/core/dev/port.ts @@ -0,0 +1,47 @@ +import { InputValidationError } from "../../errors"; +import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import type { PortChecker } from "../../io"; + +const MAX_PORT_ATTEMPTS = 100; +export const DEV_PORTS = { HTTP: 8080, AGUI: 8080, MCP: 8000, A2A: 9000 } as const; + +export type DevPort = { + port: number; + requestedPort: number; +}; + +export class PortInUseError extends InputValidationError { + constructor(port: number) { + super( + `Port ${port} is already in use. Find the process with ` + + `'lsof -i :${port}' (macOS/Linux) or 'netstat -ano | findstr :${port}' (Windows), ` + + "then stop it or choose a different --port.", + ); + } +} + +export async function resolveDevPort( + protocol: ProjectRuntime["protocol"], + explicitPort: number | undefined, + checkPort: PortChecker, + signal: AbortSignal, +): Promise { + const defaultPort = DEV_PORTS[protocol ?? "HTTP"]; + const requestedPort = explicitPort ?? defaultPort; + + if (await checkPort(requestedPort, signal)) { + return { port: requestedPort, requestedPort }; + } + + if (explicitPort !== undefined) { + throw new PortInUseError(requestedPort); + } + + for (let port = requestedPort + 1; port < requestedPort + MAX_PORT_ATTEMPTS; port++) { + if (await checkPort(port, signal)) return { port, requestedPort }; + } + + throw new InputValidationError( + `No free port found in range ${requestedPort}-${requestedPort + MAX_PORT_ATTEMPTS - 1}.`, + ); +} diff --git a/src/core/types.tsx b/src/core/types.tsx index 9b1a2d4a8..98a2f338d 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -37,7 +37,7 @@ export type CoreFetch = ( // full ClientConfig so callers can request any client customization (region, // endpoint, ...). export interface AwsClients { - control(config: ClientConfig): BedrockAgentCoreControlClient + control(config: ClientConfig): BedrockAgentCoreControlClient; data(config: ClientConfig): BedrockAgentCoreClient; iam(config: ClientConfig): IAMClient; // logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index e671c1996..efc9a9e11 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -69,6 +69,9 @@ export class InputValidationError extends AgentCoreCLIError { } } +/** Error raised when valid user input references a resource that does not exist. */ +export class ResourceNotFoundError extends InputValidationError {} + /** Error raised when a command or operation has not been implemented yet. */ export class NotImplementedError extends AgentCoreCLIError { constructor(message?: string, options?: Omit) { @@ -135,7 +138,7 @@ export class EmbeddedAssetNotFoundError extends AgentCoreCLIError { } } -export class RuntimeInvokeInterruptedError extends AgentCoreCLIError { +export class CommandInterruptedError extends AgentCoreCLIError { readonly reported: boolean; constructor(cause?: unknown, reported = false) { diff --git a/src/errors/index.tsx b/src/errors/index.tsx index 9b3249fa2..bd2f99684 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -1,5 +1,6 @@ export { AgentCoreCLIError, + CommandInterruptedError, DeserializationError, EmbeddedAssetNotFoundError, FileWriteError, @@ -12,8 +13,8 @@ export { NetworkingError, NotImplementedError, ProjectFileExistsError, + ResourceNotFoundError, ResultTruncationError, - RuntimeInvokeInterruptedError, RuntimeInvokeResponseError, SourceResolutionError, type AgentCoreCLIErrorOptions, diff --git a/src/handlers/eval/ondemand/ondemand.test.tsx b/src/handlers/eval/ondemand/ondemand.test.tsx index 6d7173444..77c0c688a 100644 --- a/src/handlers/eval/ondemand/ondemand.test.tsx +++ b/src/handlers/eval/ondemand/ondemand.test.tsx @@ -24,7 +24,9 @@ const TRACE: SessionTrace = { const RESULT: EvaluateResult = { sessionsRequested: 1, sessionsEvaluated: 1, - results: [{ evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number]], + results: [ + { evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number], + ], }; async function run(args: string[], configure?: (core: TestCoreClient) => void) { diff --git a/src/handlers/project/dev/environment.test.ts b/src/handlers/project/dev/environment.test.ts new file mode 100644 index 000000000..219cf7d37 --- /dev/null +++ b/src/handlers/project/dev/environment.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { createDevEnvironmentLoader } from "./environment"; + +const projectRoot = "/workspace/project"; + +const runtime = (envVars: { name: string; value: string }[] = []) => + ({ name: "orders", build: "Container", envVars }) as ProjectRuntime; + +const input = (envVars: { name: string; value: string }[] = []) => ({ + projectRoot, + runtime: runtime(envVars), + region: "us-east-1", +}); + +describe("createDevEnvironmentLoader", () => { + test("merges runtime, region, and .env.local while removing runner-owned keys", async () => { + const loader = createDevEnvironmentLoader({ + readFile: async () => ` +SHARED="local value" +AWS_REGION=local-region +PORT=9999 +FASTMCP_PORT=9998 +LOCAL_DEV=0 +MULTILINE="first +second" +`, + }); + + await expect( + loader( + input([ + { name: "SHARED", value: "runtime" }, + { name: "RUNTIME_ONLY", value: "yes" }, + { name: "PORT", value: "1234" }, + ]), + ), + ).resolves.toEqual({ + env: { + SHARED: "local value", + RUNTIME_ONLY: "yes", + AWS_REGION: "local-region", + MULTILINE: "first\nsecond", + }, + }); + }); + + test.each([ + ["ENOENT", undefined], + [ + "EACCES", + `Unable to read local environment file at ${join(projectRoot, "agentcore", ".env.local")}`, + ], + ] as const)("handles .env.local read error %s", async (code, expectedError) => { + const loader = createDevEnvironmentLoader({ + readFile: async () => { + throw Object.assign(new Error("read failed"), { code }); + }, + }); + + const pending = loader(input([{ name: "RUNTIME_ONLY", value: "yes" }])); + if (expectedError) { + await expect(pending).rejects.toThrow(expectedError); + } else { + await expect(pending).resolves.toEqual({ + env: { RUNTIME_ONLY: "yes", AWS_REGION: "us-east-1" }, + }); + } + }); +}); diff --git a/src/handlers/project/dev/environment.ts b/src/handlers/project/dev/environment.ts new file mode 100644 index 000000000..f852d5431 --- /dev/null +++ b/src/handlers/project/dev/environment.ts @@ -0,0 +1,65 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parseEnv } from "node:util"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { InputValidationError } from "../../../errors"; + +const RESERVED_ENV_KEYS = ["PORT", "FASTMCP_PORT", "LOCAL_DEV"] as const; + +export type DevEnvironmentInput = { + projectRoot: string; + runtime: ProjectRuntime; + region?: string; +}; + +export type DevEnvironment = { + env: Record; +}; + +export type DevEnvironmentLoader = (input: DevEnvironmentInput) => Promise; + +type DevEnvironmentLoaderConfig = { + readFile?: (path: string, encoding: BufferEncoding) => Promise; +}; + +async function localEnvironment( + projectRoot: string, + read: (path: string, encoding: BufferEncoding) => Promise, +): Promise> { + const path = join(projectRoot, "agentcore", ".env.local"); + let contents: string; + try { + contents = await read(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new InputValidationError(`Unable to read local environment file at ${path}`, { + cause: error, + }); + } + + try { + return parseEnv(contents) as Record; + } catch (error) { + throw new InputValidationError(`Invalid local environment file at ${path}`, { cause: error }); + } +} + +export function createDevEnvironmentLoader( + config: DevEnvironmentLoaderConfig = {}, +): DevEnvironmentLoader { + const read = config.readFile ?? readFile; + + return async (input) => { + const env = Object.fromEntries( + (input.runtime.envVars ?? []).map(({ name, value }) => [name, value]), + ); + if (input.region) env.AWS_REGION = input.region; + + Object.assign(env, await localEnvironment(input.projectRoot, read)); + for (const key of RESERVED_ENV_KEYS) delete env[key]; + + return { env }; + }; +} + +export const loadDevEnvironment = createDevEnvironmentLoader(); diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts new file mode 100644 index 000000000..7a4781ae2 --- /dev/null +++ b/src/handlers/project/dev/index.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { InputValidationError, ResourceNotFoundError } from "../../../errors"; +import type { PortChecker } from "../../../io"; +import { ProjectKey, ValueContext } from "../../../router"; +import { testIO } from "../../../testing"; +import { JsonRendererKey } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import type { Project } from "../types"; +import { createDevProjectHandler, type DevProjectHandlerConfig } from "."; +import type { DevEnvironmentInput } from "./environment"; +import type { DevEvent, DevRunner, DevServerInput } from "./types"; + +function runtime(name = "orders", build: ProjectRuntime["build"] = "CodeZip"): ProjectRuntime { + return { + name, + build, + protocol: "HTTP", + entrypoint: "main.py", + codeLocation: `app/${name}`, + } as ProjectRuntime; +} + +function project(...runtimes: ProjectRuntime[]): Project { + return { name: "test-project", rootPath: "/workspace/project", managedBy: "CDK", runtimes }; +} + +function captureRunner(events: DevEvent[] = []) { + const inputs: DevServerInput[] = []; + const runner: DevRunner = { + run: async function* (input) { + inputs.push(input); + yield* events; + }, + }; + return { runner, inputs }; +} + +type HarnessOptions = { + project?: Project; + codeZip?: ReturnType; + container?: ReturnType; + checkPort?: PortChecker; + json?: boolean; + loadEnvironment?: DevProjectHandlerConfig["loadDevEnvironment"]; +}; + +function harness(options: HarnessOptions = {}) { + const io = testIO(); + const codeZip = options.codeZip ?? captureRunner(); + const container = options.container ?? captureRunner(); + const environmentInputs: DevEnvironmentInput[] = []; + const handler = createDevProjectHandler({ + io: io.io, + runners: { CodeZip: codeZip.runner, Container: container.runner }, + loadDevEnvironment: + options.loadEnvironment ?? + (async (input) => { + environmentInputs.push(input); + return { env: { FROM_LOADER: "yes" } }; + }), + checkPort: options.checkPort ?? (async () => true), + }); + const ctx = ValueContext.EmptyContext() + .withValue(ProjectKey, options.project ?? project(runtime())) + .withValue(JsonKey, options.json ?? false) + .withValue(RegionKey, "us-west-2") + .withValue(JsonRendererKey, { + renderJson: (data) => io.io.stdout.write(`${JSON.stringify(data, null, 2)}\n`), + renderJsonLine: (data) => io.io.stdout.write(`${JSON.stringify(data)}\n`), + }); + + return { + codeZip, + container, + environmentInputs, + io, + run: (flags: { agent?: string; port?: number } = {}) => handler.handle(ctx, flags, {}), + }; +} + +describe("project dev selection and dispatch", () => { + test.each([ + [project(), {}, "This project has no runtimes", InputValidationError], + [ + project(runtime("orders"), runtime("support", "Container")), + {}, + "Use --agent to select one. Available runtimes: orders, support", + InputValidationError, + ], + [ + project(runtime("orders"), runtime("support", "Container")), + { agent: "missing" }, + "Runtime 'missing' was not found. Available runtimes: orders, support", + ResourceNotFoundError, + ], + ] as const)( + "rejects invalid runtime selection", + async (configuredProject, flags, message, ErrorType) => { + const pending = harness({ project: configuredProject }).run(flags); + await expect(pending).rejects.toBeInstanceOf(ErrorType); + await expect(pending).rejects.toThrow(message); + }, + ); + + test("loads the environment and dispatches the selected runtime", async () => { + const subject = harness({ + project: project(runtime("orders"), runtime("support", "Container")), + }); + await subject.run({ agent: "support", port: 4567 }); + + expect(subject.codeZip.inputs).toHaveLength(0); + expect(subject.environmentInputs).toEqual([ + { + projectRoot: "/workspace/project", + runtime: expect.objectContaining({ name: "support" }), + region: "us-west-2", + }, + ]); + expect(subject.container.inputs[0]).toMatchObject({ + projectRoot: "/workspace/project", + port: 4567, + env: { FROM_LOADER: "yes" }, + runtime: { name: "support", build: "Container" }, + }); + }); + + test("announces an automatically selected port", async () => { + const checked: number[] = []; + const subject = harness({ + checkPort: async (port) => { + checked.push(port); + return port === 8081; + }, + }); + await subject.run(); + + expect(checked).toEqual([8080, 8081]); + expect(subject.codeZip.inputs[0]?.port).toBe(8081); + expect(subject.io.stderr()).toBe("Port 8080 is in use; using 8081."); + }); +}); + +test("project dev renders human and NDJSON output", async () => { + const events: DevEvent[] = [ + { type: "status", message: "Starting" }, + { type: "stdout", line: "agent output" }, + { type: "stderr", line: "agent warning" }, + ]; + + for (const json of [false, true]) { + const subject = harness({ codeZip: captureRunner(events), json }); + await subject.run(); + expect(subject.io.stdout()).toBe( + json ? events.map((event) => JSON.stringify(event)).join("\n") : "agent output", + ); + expect(subject.io.stderr()).toBe(json ? "" : "Starting\nagent warning"); + } +}); + +function heldRunner() { + let start!: (input: DevServerInput) => void; + let release: (() => void) | undefined; + const started = new Promise((resolve) => (start = resolve)); + const runner: DevRunner = { + run: async function* (input) { + yield* []; + start(input); + await new Promise((resolve) => (release = resolve)); + input.signal.throwIfAborted(); + }, + }; + return { runner, inputs: [], started, release: () => release?.() }; +} + +describe("project dev interruption", () => { + test.each(["SIGINT", "SIGTERM"] as const)( + "%s aborts, reports exit 130, and removes its listener", + async (signal) => { + const codeZip = heldRunner(); + const before = process.listenerCount(signal); + const subject = harness({ codeZip }); + const pending = subject.run(); + const input = await codeZip.started; + + process.emit(signal, signal); + process.emit(signal, signal); + codeZip.release(); + + expect(input.signal.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ + name: "AbortError", + reported: true, + exitCode: 130, + }); + expect(subject.io.stderr()).toBe("Shutting down…"); + expect(process.listenerCount(signal)).toBe(before); + }, + ); + + test("preserves an ordinary runner failure", async () => { + const failure = new InputValidationError("runner failed"); + const codeZip = captureRunner(); + codeZip.runner.run = async function* () { + yield* []; + throw failure; + }; + + await expect(harness({ codeZip }).run()).rejects.toBe(failure); + }); +}); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 310634651..ff94815aa 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -1,11 +1,123 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import z from "zod"; +import { resolveDevPort } from "../../../core/dev/port"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { + CommandInterruptedError, + InputValidationError, + ResourceNotFoundError, +} from "../../../errors"; +import type { AppIO, PortChecker } from "../../../io"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { JsonRendererKey, type JsonRenderer } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import type { Project } from "../types"; +import type { DevEnvironmentLoader } from "./environment"; +import type { DevEvent, DevRunner } from "./types"; -export const createDevProjectHandler = () => +export type DevProjectHandlerConfig = { + io: AppIO; + runners: { CodeZip: DevRunner; Container: DevRunner }; + loadDevEnvironment: DevEnvironmentLoader; + checkPort: PortChecker; +}; + +function selectRuntime(project: Project, name?: string): ProjectRuntime { + if (project.runtimes.length === 0) { + throw new InputValidationError( + "This project has no runtimes. Add a runtime to agentcore/agentcore.json and retry.", + ); + } + const available = project.runtimes.map(({ name }) => name).join(", "); + + if (name) { + const runtime = project.runtimes.find((candidate) => candidate.name === name); + if (runtime) return runtime; + throw new ResourceNotFoundError( + `Runtime '${name}' was not found. Available runtimes: ${available}.`, + ); + } + + if (project.runtimes.length === 1) return project.runtimes[0]!; + throw new InputValidationError( + `Multiple runtimes found. Use --agent to select one. Available runtimes: ${available}.`, + ); +} + +function renderEvent(io: AppIO, event: DevEvent, json?: JsonRenderer): void { + if (json) { + json.renderJsonLine(event); + return; + } + + const output = event.type === "stdout" ? io.stdout : io.stderr; + output.write(`${event.type === "status" ? event.message : event.line}\n`); +} + +export const createDevProjectHandler = (config: DevProjectHandlerConfig) => createHandler({ name: "dev", description: "run the project locally for development", - handle: async () => { - throw new NotImplementedError("agentcore project dev is not implemented yet"); + flags: [ + flag("agent", "runtime to run", z.string().optional()), + flag( + "port", + "port for the development server", + z.coerce.number().int().min(1).max(65535).optional(), + ), + ], + handle: async (ctx, flags) => { + const controller = new AbortController(); + const json = ctx.require(JsonKey) ? ctx.require(JsonRendererKey) : undefined; + const interrupt = () => { + if (controller.signal.aborted) return; + config.io.stderr.write("Shutting down…\n"); + controller.abort(); + }; + + const signals = ["SIGINT", "SIGTERM"] as const; + for (const signal of signals) process.on(signal, interrupt); + try { + const project = ctx.require(ProjectKey); + const runtime = selectRuntime(project, flags.agent); + const devPort = await resolveDevPort( + runtime.protocol, + flags.port, + config.checkPort, + controller.signal, + ); + if (devPort.port !== devPort.requestedPort) { + renderEvent( + config.io, + { + type: "status", + message: `Port ${devPort.requestedPort} is in use; using ${devPort.port}.`, + }, + json, + ); + } + + const { env } = await config.loadDevEnvironment({ + projectRoot: project.rootPath, + runtime, + region: ctx.require(RegionKey), + }); + controller.signal.throwIfAborted(); + + const runner = config.runners[runtime.build]; + for await (const event of runner.run({ + runtime, + projectRoot: project.rootPath, + port: devPort.port, + env, + signal: controller.signal, + })) { + renderEvent(config.io, event, json); + } + } catch (error) { + if (!controller.signal.aborted) throw error; + throw new CommandInterruptedError(error, true); + } finally { + for (const signal of signals) process.removeListener(signal, interrupt); + } }, }); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 91d0350ad..474ac5511 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,9 +1,12 @@ import { Router } from "../../router"; +import { checkPort, type AppIO } from "../../io"; +import { CodeZipDevRunner } from "../../core/dev/codezip"; +import { ContainerDevRunner } from "../../core/dev/container"; import { withProject } from "../../middleware"; -import type { AppIO } from "../../io"; import { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; import { createDevProjectHandler } from "./dev"; +import { loadDevEnvironment } from "./dev/environment"; import { createDeployProjectHandler } from "./deploy"; import { createStatusProjectHandler } from "./status"; import { createBuildProjectHandler } from "./build"; @@ -23,7 +26,19 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { ); project.handler(createAddProjectResourceHandler(config)); project.handler(createRemoveProjectHandler()); - project.handler(createDevProjectHandler()); + project.handler( + withProject({ projectManager: config.projectManager })( + createDevProjectHandler({ + io: config.io, + runners: { + CodeZip: new CodeZipDevRunner(), + Container: new ContainerDevRunner(), + }, + loadDevEnvironment, + checkPort, + }), + ), + ); project.handler(createDeployProjectHandler()); project.handler(createStatusProjectHandler()); // withProject wraps only the commands that require an existing project, so diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d9dce3bba..8b501fd9c 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -23,12 +23,17 @@ async function run(args: string[]) { return { io, core }; } -describe.each(["remove", "dev", "deploy", "status"])("project %s", (command) => { +describe.each(["remove", "deploy", "status"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); }); +test("project dev requires an AgentCore project", async () => { + await inTempDirectory(); + await expect(run(["dev"])).rejects.toThrow(/No AgentCore project found/); +}); + const originalCwd = process.cwd(); const tempDirectories: string[] = []; diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 0110273a3..c7f1d6a58 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -2,7 +2,7 @@ import z from "zod"; import { InputValidationError, InvalidEnvironmentError, - RuntimeInvokeInterruptedError, + CommandInterruptedError, } from "../../../errors"; import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO } from "../../../io"; @@ -159,8 +159,8 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => }); } catch (error) { if (controller.signal.aborted && (error as Error)?.name === "AbortError") { - if (error instanceof RuntimeInvokeInterruptedError) throw error; - throw new RuntimeInvokeInterruptedError(error); + if (error instanceof CommandInterruptedError) throw error; + throw new CommandInterruptedError(error); } throw error; } finally { diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 190138c4f..2893d1d75 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,4 +1,4 @@ -import { RuntimeInvokeInterruptedError, RuntimeInvokeResponseError } from "../../../errors"; +import { CommandInterruptedError, RuntimeInvokeResponseError } from "../../../errors"; import { classifyStreamingResponse, writeStreamingResponse, @@ -24,7 +24,7 @@ export async function writeRuntimeInvokeFile( function failure(error: unknown): never { const interrupted = (error as Error)?.name === "AbortError"; - if (interrupted) throw new RuntimeInvokeInterruptedError(error, true); + if (interrupted) throw new CommandInterruptedError(error, true); throw new RuntimeInvokeResponseError(RESPONSE_STREAM_FAILED, error); } diff --git a/src/io/index.ts b/src/io/index.ts index e93169768..fb0da0b0e 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -36,3 +36,4 @@ export { } from "./streamingResponse"; export type { AppIO, ReadWriteJson } from "./types"; export { warn } from "./warn"; +export { checkPort, type PortChecker } from "./port"; diff --git a/src/io/port.test.ts b/src/io/port.test.ts new file mode 100644 index 000000000..f3ccf0d68 --- /dev/null +++ b/src/io/port.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { createServer, type Server } from "node:net"; +import { checkPort } from "./port"; + +function listen(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +test("checkPort rejects an occupied loopback port and accepts it after release", async () => { + const server = await listen(); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected a TCP address"); + const signal = new AbortController().signal; + + expect(await checkPort(address.port, signal)).toBe(false); + await new Promise((resolve) => server.close(() => resolve())); + expect(await checkPort(address.port, signal)).toBe(true); +}); + +test("checkPort respects an aborted signal", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(checkPort(49152, controller.signal)).rejects.toHaveProperty("name", "AbortError"); +}); diff --git a/src/io/port.ts b/src/io/port.ts new file mode 100644 index 000000000..897de2da5 --- /dev/null +++ b/src/io/port.ts @@ -0,0 +1,27 @@ +import { createServer } from "node:net"; + +export type PortChecker = (port: number, signal: AbortSignal) => Promise; + +function canBind(port: number, host: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + + return new Promise((resolve, reject) => { + const server = createServer().unref(); + server.once("error", () => resolve(false)); + server.once("close", () => { + if (signal.aborted) reject(signal.reason); + }); + server.listen({ port, host, exclusive: true, signal }, () => { + server.close(() => resolve(true)); + }); + }); +} + +/** Checks that a port can be bound by both loopback-only and all-interface servers. */ +export const checkPort: PortChecker = async (port, signal) => { + if (!(await canBind(port, "127.0.0.1", signal))) return false; + signal.throwIfAborted(); + const available = await canBind(port, "0.0.0.0", signal); + signal.throwIfAborted(); + return available; +}; diff --git a/src/middleware/withJsonRenderer.tsx b/src/middleware/withJsonRenderer.tsx index 8be45ef4c..6e6ef2463 100644 --- a/src/middleware/withJsonRenderer.tsx +++ b/src/middleware/withJsonRenderer.tsx @@ -11,6 +11,7 @@ import type { AppIO } from "../io"; export function withJsonRenderer(io: AppIO): Middleware { const renderer = { renderJson: (data: unknown) => renderJson(data, (line) => io.stdout.write(line + "\n")), + renderJsonLine: (data: unknown) => io.stdout.write(JSON.stringify(data) + "\n"), }; return (h) => ({ diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index acf43b090..a695ca09e 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { CommanderError } from "commander"; -import { AgentCoreCLIError, InputValidationError } from "../errors"; +import { AgentCoreCLIError, CommandInterruptedError, InputValidationError } from "../errors"; import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; async function captureErrors(run: () => Promise) { @@ -89,6 +89,12 @@ test.each([ ExitCode.INTERRUPTED, ["AbortError: The operation was aborted"], ], + [ + "reported command interruption", + new CommandInterruptedError(undefined, true), + ExitCode.INTERRUPTED, + [], + ], [ "Commander parse failure", new CommanderError(1, "commander.invalidArgument", "invalid option"), diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index f638bb6cb..1cc7ae47a 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -43,7 +43,7 @@ function baseContext(core: TestCoreClient, endpointUrl?: string): Context { .withValue(EndpointKey, endpointUrl) .withValue(JsonKey, false) .withValue(DebugKey, false) - .withValue(JsonRendererKey, { renderJson: () => {} }); + .withValue(JsonRendererKey, { renderJson: () => {}, renderJsonLine: () => {} }); } // testQueryClient returns a QueryClient with retries and caching disabled so diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 8555a53f0..2ae966f6b 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -29,6 +29,7 @@ export function renderJson(data: unknown, writer: (line: string) => void = conso // of any direct dependency on a global output stream. export interface JsonRenderer { renderJson(data: unknown): void; + renderJsonLine(data: unknown): void; } // JsonRendererKey exposes the prewired JsonRenderer on the context. Installed by From 1dcf32e90a7435a264842dcbe25e3167777cc3d6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:40:25 -0400 Subject: [PATCH 2/5] feat(dev): collect local OTEL traces in project dev Adds an in-process OTLP/HTTP collector (protobuf + JSON ingest, JSONL persistence per trace) started by the dev handler unless --no-traces or instrumentation.enableOtel is false. Spawned agents receive OTEL env pointing at the collector; container runtimes get a host.docker.internal endpoint, and Python CodeZip agents get sitecustomize-based auto-instrumentation so uvicorn --reload workers stay traced. --- bun.lock | 57 +++- package.json | 1 + .../templates/shared/env.local.template | 6 +- src/core/dev/codezip.test.ts | 81 ++++- src/core/dev/codezip.ts | 53 +++- src/core/dev/otel/collector.test.ts | 167 +++++++++++ src/core/dev/otel/collector.ts | 144 +++++++++ src/core/dev/otel/store.test.ts | 120 ++++++++ src/core/dev/otel/store.ts | 122 ++++++++ src/core/dev/otel/transforms.test.ts | 217 ++++++++++++++ src/core/dev/otel/transforms.ts | 276 ++++++++++++++++++ src/core/dev/otel/types.ts | 65 +++++ src/handlers/project/dev/index.test.ts | 90 +++++- src/handlers/project/dev/index.ts | 38 ++- src/handlers/project/dev/types.ts | 13 + src/handlers/project/index.ts | 2 + src/io/httpServer.test.ts | 63 ++++ src/io/httpServer.ts | 119 ++++++++ src/io/index.ts | 8 + src/router/flags.tsx | 8 +- src/router/router.test.ts | 21 ++ 21 files changed, 1650 insertions(+), 21 deletions(-) create mode 100644 src/core/dev/otel/collector.test.ts create mode 100644 src/core/dev/otel/collector.ts create mode 100644 src/core/dev/otel/store.test.ts create mode 100644 src/core/dev/otel/store.ts create mode 100644 src/core/dev/otel/transforms.test.ts create mode 100644 src/core/dev/otel/transforms.ts create mode 100644 src/core/dev/otel/types.ts create mode 100644 src/io/httpServer.test.ts create mode 100644 src/io/httpServer.ts diff --git a/bun.lock b/bun.lock index 779b6bc4c..f1a58f213 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@aws-sdk/client-iam": "^3.1080.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", + "@opentelemetry/otlp-transformer": "0.213.0", "@opentelemetry/resources": "^2.10.0", "@opentelemetry/sdk-metrics": "^2.10.0", "@smithy/core": "3.29.3", @@ -90,7 +91,7 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw=="], "@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], @@ -98,16 +99,18 @@ "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.221.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/otlp-transformer": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA=="], - "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.213.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.213.0", "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/sdk-logs": "0.213.0", "@opentelemetry/sdk-metrics": "2.6.0", "@opentelemetry/sdk-trace-base": "2.6.0", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw=="], "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], - "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.213.0", "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g=="], "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="], "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], @@ -148,6 +151,24 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@smithy/core": ["@smithy/core@3.29.8", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.13", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q=="], @@ -266,6 +287,8 @@ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -292,6 +315,8 @@ "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], @@ -406,6 +431,24 @@ "@aws-sdk/client-iam/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw=="], + + "@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + "listr2/cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -470,6 +513,14 @@ "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.8", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], + "listr2/cli-truncate/slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], diff --git a/package.json b/package.json index 8786ec2a3..c1223ecbc 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@aws-sdk/client-iam": "^3.1080.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", + "@opentelemetry/otlp-transformer": "0.213.0", "@opentelemetry/resources": "^2.10.0", "@opentelemetry/sdk-metrics": "^2.10.0", "@smithy/core": "3.29.3", diff --git a/src/assets/templates/shared/env.local.template b/src/assets/templates/shared/env.local.template index cc9e703e7..fb931a12b 100644 --- a/src/assets/templates/shared/env.local.template +++ b/src/assets/templates/shared/env.local.template @@ -1,7 +1,11 @@ # Environment variables for local development. # `agentcore project dev` loads this file into your agent's process. Values here # override injected values except PORT, FASTMCP_PORT, and LOCAL_DEV, which the -# CLI owns. This file is gitignored — keep secrets out of version control. +# CLI owns. While trace collection is on (the default), the CLI also owns the +# OTEL_* and AGENT_OBSERVABILITY_ENABLED variables so traces reach its local +# collector — pass --no-traces (or set instrumentation.enableOtel to false in +# agentcore.json) to disable collection and set your own. +# This file is gitignored — keep secrets out of version control. # # Example: # MY_API_KEY=... diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index 1fdd1c5c7..4286f9ca3 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types"; -import type { ProcessEvent, ProcessStreamer, StreamProcessOptions } from "../../io"; +import type { ProcessEvent, ProcessRunner, ProcessStreamer, StreamProcessOptions } from "../../io"; import { CodeZipDevRunner } from "./codezip"; type ProcessCall = { @@ -43,15 +43,22 @@ async function projectRoot(withNodeModules = false): Promise { return root; } -function harness(output: ProcessEvent[] = []) { +function harness(output: ProcessEvent[] = [], probe: { dir?: string; fail?: boolean } = {}) { const calls: ProcessCall[] = []; + const probeCalls: string[][] = []; const fakeStreamProcess: ProcessStreamer = async function* (command, options) { calls.push({ command, options }); yield* output; }; + const fakeRunProcess: ProcessRunner = async (command, options) => { + probeCalls.push(command); + if (probe.fail) throw new Error("probe failed"); + options.onOutput?.(`${probe.dir ?? ""}\n`); + }; return { calls, - runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess }), + probeCalls, + runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess, runProcess: fakeRunProcess }), }; } @@ -153,3 +160,69 @@ describe("CodeZipDevRunner", () => { ]); }); }); + +describe("CodeZipDevRunner OTEL instrumentation", () => { + async function sitecustomizeDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), "otel-site-")); + tempDirectories.push(directory); + await writeFile(join(directory, "sitecustomize.py"), ""); + return directory; + } + + function otelInput(root: string, extraEnv: Record = {}): DevServerInput { + const base = input(root, runtime()); + return { + ...base, + env: { ...base.env, OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318", ...extraEnv }, + }; + } + + test("prepends the sitecustomize directory to PYTHONPATH when instrumentation is installed", async () => { + const root = await projectRoot(); + const directory = await sitecustomizeDir(); + const { calls, probeCalls, runner } = harness([], { dir: directory }); + + await collect(runner.run(otelInput(root))); + + expect(probeCalls[0]?.slice(0, 4)).toEqual(["uv", "run", "python", "-c"]); + expect(calls[0]?.options.env?.PYTHONPATH).toBe(directory); + }); + + test("preserves an existing PYTHONPATH", async () => { + const root = await projectRoot(); + const directory = await sitecustomizeDir(); + const { calls, runner } = harness([], { dir: directory }); + + await collect(runner.run(otelInput(root, { PYTHONPATH: "/existing" }))); + + expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}:/existing`); + }); + + test("does not probe without an OTEL endpoint or for Node entrypoints", async () => { + const root = await projectRoot(true); + const { probeCalls, runner } = harness(); + + await collect(runner.run(input(root, runtime()))); + await collect(runner.run({ ...otelInput(root), runtime: runtime({ entrypoint: "index.js" }) })); + + expect(probeCalls).toEqual([]); + }); + + test.each([ + ["probe failure", { fail: true }], + ["missing sitecustomize.py", { dir: "/nonexistent" }], + ] as const)("warns and starts untraced on %s", async (_case, probe) => { + const root = await projectRoot(); + const { calls, probeCalls, runner } = harness([], probe); + + const events = await collect(runner.run(otelInput(root))); + + expect(probeCalls).toHaveLength(1); + expect(calls).toHaveLength(1); + expect(calls[0]?.options.env?.PYTHONPATH).toBeUndefined(); + expect(events).toContainEqual({ + type: "status", + message: expect.stringContaining("traces will not be collected"), + }); + }); +}); diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index 0b2314cf0..bf4fe759e 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -1,18 +1,27 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { InputValidationError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; -import { streamProcess, type ProcessStreamer, type StreamProcessOptions } from "../../io"; +import { + runProcess, + streamProcess, + type ProcessRunner, + type ProcessStreamer, + type StreamProcessOptions, +} from "../../io"; type CodeZipDevRunnerConfig = { streamProcess?: ProcessStreamer; + runProcess?: ProcessRunner; }; export class CodeZipDevRunner implements DevRunner { private readonly streamProcess: ProcessStreamer; + private readonly runProcess: ProcessRunner; constructor(config: CodeZipDevRunnerConfig = {}) { this.streamProcess = config.streamProcess ?? streamProcess; + this.runProcess = config.runProcess ?? runProcess; } public async *run(input: DevServerInput): AsyncGenerator { @@ -33,8 +42,48 @@ export class CodeZipDevRunner implements DevRunner { yield { type: "status", message: "Starting development server" }; const serverProcess = commandForRuntime(entrypoint!, directory, input); + if (entrypoint!.endsWith(".py") && input.env?.OTEL_EXPORTER_OTLP_ENDPOINT) { + const sitecustomizeDir = await this.findOtelSitecustomizeDir(directory); + if (sitecustomizeDir) { + const existing = serverProcess.options.env?.PYTHONPATH; + serverProcess.options.env = { + ...serverProcess.options.env, + PYTHONPATH: existing ? `${sitecustomizeDir}${delimiter}${existing}` : sitecustomizeDir, + }; + } else { + yield { + type: "status", + message: + "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add opentelemetry-distro to enable them.", + }; + } + } yield* this.streamProcess(serverProcess.command, serverProcess.options); } + + /** + * Locate the auto-instrumentation sitecustomize.py directory inside the agent's + * uv environment. Prepending it to PYTHONPATH instruments every Python process — + * an `opentelemetry-instrument` wrapper would only instrument uvicorn's reloader + * parent, leaving the re-spawned worker processes untraced. + */ + private async findOtelSitecustomizeDir(directory: string): Promise { + const output: string[] = []; + const probe = + "import opentelemetry.instrumentation.auto_instrumentation as m; import os; print(os.path.dirname(m.__file__))"; + try { + await this.runProcess(["uv", "run", "python", "-c", probe], { + cwd: directory, + onOutput: (chunk) => output.push(chunk), + }); + } catch { + return undefined; + } + const sitecustomizeDir = output.join("").trim().split("\n").at(-1)?.trim(); + if (!sitecustomizeDir || !existsSync(join(sitecustomizeDir, "sitecustomize.py"))) + return undefined; + return sitecustomizeDir; + } } function commandForRuntime( diff --git a/src/core/dev/otel/collector.test.ts b/src/core/dev/otel/collector.test.ts new file mode 100644 index 000000000..fb0f49b6d --- /dev/null +++ b/src/core/dev/otel/collector.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ExportLogsServiceRequest, + ExportTraceServiceRequest, + type OtelCollector, + startOtelCollector, +} from "./collector"; + +const TRACE_ID_HEX = "0123456789abcdef0123456789abcdef"; + +function protobufTracePayload(): Uint8Array { + const message = ExportTraceServiceRequest.fromObject({ + resourceSpans: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-agent" } }] }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId: Buffer.from(TRACE_ID_HEX, "hex"), + spanId: Buffer.from("0123456789abcdef", "hex"), + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }); + return ExportTraceServiceRequest.encode(message).finish(); +} + +function protobufLogsPayload(): Uint8Array { + const message = ExportLogsServiceRequest.fromObject({ + resourceLogs: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-agent" } }] }, + scopeLogs: [ + { + scope: { name: "test" }, + logRecords: [ + { + traceId: Buffer.from(TRACE_ID_HEX, "hex"), + timeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + body: { stringValue: "a log line" }, + }, + ], + }, + ], + }, + ], + }); + return ExportLogsServiceRequest.encode(message).finish(); +} + +let directory: string; +let collector: OtelCollector; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "otel-collector-")); + collector = await startOtelCollector({ tracesDirectory: directory }); +}); + +afterEach(async () => { + await collector.close(); + await rm(directory, { recursive: true, force: true }); +}); + +function post( + path: string, + body: string | Uint8Array, + contentType = "application/x-protobuf", +): Promise { + return fetch(`http://127.0.0.1:${collector.port}${path}`, { + method: "POST", + headers: { "Content-Type": contentType }, + body, + }); +} + +describe("startOtelCollector", () => { + test("ingests protobuf trace exports and serves them back through the store", async () => { + const response = await post("/v1/traces", protobufTracePayload()); + expect(response.status).toBe(200); + + const traces = await collector.store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.traceId).toBe(TRACE_ID_HEX); + }); + + test("ingests protobuf log exports into the same trace", async () => { + await post("/v1/traces", protobufTracePayload()); + const response = await post("/v1/logs", protobufLogsPayload()); + expect(response.status).toBe(200); + + const detail = await collector.store.get(TRACE_ID_HEX); + expect(detail?.resourceLogs).toBeDefined(); + }); + + test("ingests JSON trace exports", async () => { + const body = JSON.stringify({ + resourceSpans: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "json-agent" } }] }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId: TRACE_ID_HEX, + spanId: "0123456789abcdef", + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }); + const response = await post("/v1/traces", body, "application/json"); + expect(response.status).toBe(200); + expect((await collector.store.list()).map((trace) => trace.traceId)).toEqual([TRACE_ID_HEX]); + }); + + test("rejects malformed payloads with 400", async () => { + expect((await post("/v1/traces", "not json", "application/json")).status).toBe(400); + expect((await post("/v1/traces", Buffer.from([0xff, 0xff, 0xff]))).status).toBe(400); + expect(await collector.store.list()).toEqual([]); + }); + + test("health check responds ok and unknown routes 404", async () => { + const health = await fetch(`http://127.0.0.1:${collector.port}/`); + expect(await health.json()).toEqual({ status: "ok" }); + expect( + (await fetch(`http://127.0.0.1:${collector.port}/v1/metrics`, { method: "POST" })).status, + ).toBe(404); + }); + + test("envVars point the SDK at the collector", () => { + expect(collector.envVars.OTEL_EXPORTER_OTLP_ENDPOINT).toBe( + `http://127.0.0.1:${collector.port}`, + ); + expect(collector.envVars.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/protobuf"); + expect(collector.envVars.OTEL_METRICS_EXPORTER).toBe("none"); + }); + + test("abort signal closes the receiver", async () => { + const controller = new AbortController(); + const aborted = await startOtelCollector({ + tracesDirectory: directory, + signal: controller.signal, + }); + controller.abort(); + await Bun.sleep(20); + expect(fetch(`http://127.0.0.1:${aborted.port}/`)).rejects.toThrow(); + }); +}); diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts new file mode 100644 index 000000000..0e1a2d422 --- /dev/null +++ b/src/core/dev/otel/collector.ts @@ -0,0 +1,144 @@ +// Decodes OTLP/HTTP protobuf payloads (the only protocol Python and Node OTEL +// SDKs export over HTTP) with the generated types from @opentelemetry/otlp-transformer. +// The version is pinned: newer releases dropped the generated request decoders. +import root from "@opentelemetry/otlp-transformer/build/src/generated/root"; +import { + type HttpRequest, + type HttpResponse, + type HttpServerStarter, + startHttpServer, +} from "../../../io"; +import { TraceStore } from "./store"; +import type { OtlpPayload } from "./types"; + +/** The slice of a generated protobufjs message type the collector (and its tests) use. */ +export interface OtlpMessageType { + decode(data: Uint8Array): unknown; + fromObject(object: object): unknown; + encode(message: unknown): { finish(): Uint8Array }; +} + +// The generated root's declaration file types it as an opaque protobufjs Root, +// so the real static-message shape is asserted once, here. +const { trace, logs } = ( + root as unknown as { + opentelemetry: { + proto: { + collector: { + trace: { v1: { ExportTraceServiceRequest: OtlpMessageType } }; + logs: { v1: { ExportLogsServiceRequest: OtlpMessageType } }; + }; + }; + }; + } +).opentelemetry.proto.collector; + +export const ExportTraceServiceRequest = trace.v1.ExportTraceServiceRequest; +export const ExportLogsServiceRequest = logs.v1.ExportLogsServiceRequest; +type OtlpDecoder = Pick; + +export interface OtelCollector { + /** The loopback port the OTLP/HTTP receiver listens on. */ + port: number; + /** Reads the traces this collector persists. */ + store: TraceStore; + /** Environment variables that point an agent's OTEL SDK at this collector. */ + envVars: Record; + /** Stops the receiver. Also invoked by the start signal, if one was given. */ + close(): Promise; +} + +export interface StartOtelCollectorOptions { + /** Directory to persist OTLP JSON Lines files into. */ + tracesDirectory: string; + /** Closes the collector when aborted. */ + signal?: AbortSignal; + startServer?: HttpServerStarter; +} + +/** + * Starts an in-process OTLP/HTTP receiver for dev mode on an OS-assigned + * loopback port. Accepts `POST /v1/traces` and `POST /v1/logs` in protobuf or + * JSON encoding and appends the raw payloads to a TraceStore. + */ +export async function startOtelCollector( + options: StartOtelCollectorOptions, +): Promise { + const store = new TraceStore(options.tracesDirectory); + const startServer = options.startServer ?? startHttpServer; + const server = await startServer((request) => route(request, store), { signal: options.signal }); + + return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close }; +} + +async function route(request: HttpRequest, store: TraceStore): Promise { + if (request.method === "POST" && request.url === "/v1/traces") { + return ingest(request, store, ExportTraceServiceRequest); + } + if (request.method === "POST" && request.url === "/v1/logs") { + return ingest(request, store, ExportLogsServiceRequest); + } + if (request.method === "GET" && request.url === "/") { + return json(200, { status: "ok" }); + } + return { status: 404 }; +} + +async function ingest( + request: HttpRequest, + store: TraceStore, + decoder: OtlpDecoder, +): Promise { + let payload: OtlpPayload; + try { + payload = decodePayload(request.body, String(request.headers["content-type"] ?? ""), decoder); + } catch { + return json(400, { error: "Invalid OTLP payload" }); + } + await store.append(payload); + return json(200, {}); +} + +/** + * Decode an OTLP payload. The JSON round-trip on the protobuf path converts the + * message to plain objects (protobufjs renders Long as string and bytes as base64). + */ +function decodePayload(body: Buffer, contentType: string, decoder: OtlpDecoder): OtlpPayload { + if (contentType.includes("application/json")) { + return JSON.parse(body.toString()) as OtlpPayload; + } + return JSON.parse(JSON.stringify(decoder.decode(new Uint8Array(body)))) as OtlpPayload; +} + +/** Environment for a spawned agent so its OTEL SDK exports to the collector at `port`. */ +export function otelEnvVars(port: number): Record { + return { + OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${port}`, + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_METRICS_EXPORTER: "none", + AGENT_OBSERVABILITY_ENABLED: "true", + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "true", + OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true", + }; +} + +/** + * Rewrite a loopback OTLP endpoint so a containerized agent can reach the + * collector on the host. host.docker.internal resolves on Docker Desktop, + * Finch, and Podman; bare-metal Linux Docker would additionally need + * `--add-host=host.docker.internal:host-gateway` (matches the reference CLI). + */ +export function rewriteOtelEndpointForContainer( + env: Record, +): Record { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!endpoint) return env; + return { + ...env, + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint.replace(/127\.0\.0\.1|localhost/, "host.docker.internal"), + }; +} + +function json(status: number, body: unknown): HttpResponse { + return { status, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; +} diff --git a/src/core/dev/otel/store.test.ts b/src/core/dev/otel/store.test.ts new file mode 100644 index 000000000..59c5bd694 --- /dev/null +++ b/src/core/dev/otel/store.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TraceStore } from "./store"; +import type { OtlpPayload } from "./types"; + +const TRACE_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TRACE_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function payload( + traceId: string, + options: { serviceName?: string; startNano?: string; name?: string } = {}, +): OtlpPayload { + return { + resourceSpans: [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: options.serviceName ?? "agent-1" } }, + ], + }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId, + spanId: "0123456789abcdef", + name: options.name ?? "invoke_agent strands", + kind: 1, + startTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }; +} + +let directory: string; +let store: TraceStore; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "trace-store-")); + store = new TraceStore(directory); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +describe("TraceStore", () => { + test("append then list returns the trace with metadata", async () => { + await store.append(payload(TRACE_A)); + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.traceId).toBe(TRACE_A); + expect(traces[0]!.spanCount).toBe("1"); + expect(traces[0]!.resourceSpans).toBeDefined(); + }); + + test("appends to the same trace accumulate spans", async () => { + await store.append(payload(TRACE_A)); + await store.append(payload(TRACE_A, { name: "tool_use" })); + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.spanCount).toBe("2"); + }); + + test("payloads without a trace id are dropped", async () => { + await store.append({ resourceSpans: [] }); + expect(await store.list()).toEqual([]); + }); + + test("list filters by service name", async () => { + await store.append(payload(TRACE_A, { serviceName: "agent-1" })); + await store.append(payload(TRACE_B, { serviceName: "agent-2" })); + const traces = await store.list({ serviceName: "agent-2" }); + expect(traces.map((trace) => trace.traceId)).toEqual([TRACE_B]); + }); + + test("list filters by time window and sorts newest first", async () => { + const oldNano = `${BigInt(Date.now() - 24 * 60 * 60 * 1000) * 1_000_000n}`; + await store.append(payload(TRACE_A, { startNano: oldNano })); + await store.append(payload(TRACE_B)); + + expect((await store.list()).map((trace) => trace.traceId)).toEqual([TRACE_B]); + + const all = await store.list({ startTime: 0 }); + expect(all.map((trace) => trace.traceId)).toEqual([TRACE_B, TRACE_A]); + }); + + test("get returns the trace detail or undefined for unknown ids", async () => { + await store.append(payload(TRACE_A)); + const detail = await store.get(TRACE_A); + expect(detail?.resourceSpans).toBeDefined(); + expect(await store.get(TRACE_B)).toBeUndefined(); + }); + + test("skips malformed lines and files without failing", async () => { + await store.append(payload(TRACE_A)); + await writeFile(join(directory, `agent-1-${TRACE_A}.otlp.jsonl`), "{not json}\n", { + flag: "a", + }); + await writeFile(join(directory, "garbage.otlp.jsonl"), "also not json\n"); + + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.spanCount).toBe("1"); + }); + + test("list on a directory that does not exist returns empty", async () => { + const empty = new TraceStore(join(directory, "missing")); + expect(await empty.list()).toEqual([]); + expect(await empty.get(TRACE_A)).toBeUndefined(); + }); +}); diff --git a/src/core/dev/otel/store.ts b/src/core/dev/otel/store.ts new file mode 100644 index 000000000..2df3ba643 --- /dev/null +++ b/src/core/dev/otel/store.ts @@ -0,0 +1,122 @@ +import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { buildTraceDetail, extractFirstTraceInfo, extractTraceMeta } from "./transforms"; +import type { OtlpPayload, OtlpResourceLog, OtlpResourceSpan } from "./types"; + +const OTLP_EXT = ".otlp.jsonl"; +const DEFAULT_LIST_WINDOW_MS = 12 * 60 * 60 * 1000; + +export interface TraceSummary { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount: string; + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; +} + +export interface TraceDetail { + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; +} + +export interface ListTracesOptions { + serviceName?: string; + startTime?: number; + endTime?: number; +} + +/** + * Append-only local trace storage: one JSON Lines file per trace under the store + * directory, each line a raw OTLP export payload. No in-memory state — reads go + * to disk on demand, which is fine because the inspector only fetches traces on + * user actions. Malformed files and lines are skipped, never fatal. + */ +export class TraceStore { + constructor(private readonly directory: string) {} + + /** Append one OTLP export payload to its trace's file. Payloads without a trace id are dropped. */ + public async append(payload: OtlpPayload): Promise { + const { traceId, serviceName } = extractFirstTraceInfo(payload); + if (!traceId) return; + + await mkdir(this.directory, { recursive: true }); + const fileName = `${sanitize(serviceName ?? "dev")}-${sanitize(traceId)}${OTLP_EXT}`; + await appendFile(join(this.directory, fileName), JSON.stringify(payload) + "\n"); + } + + /** List traces newest-first, filtered by service name and time range (default: last 12 hours). */ + public async list(options: ListTracesOptions = {}): Promise { + const now = Date.now(); + const start = options.startTime ?? now - DEFAULT_LIST_WINDOW_MS; + const end = options.endTime ?? now; + + const summaries: TraceSummary[] = []; + for (const file of await this.traceFiles()) { + const trace = await this.readTraceFile(file); + if (!trace) continue; + + const meta = extractTraceMeta(trace.resourceSpans, trace.resourceLogs); + if (!meta.traceId) continue; + if (meta.lastSeen < start || meta.firstSeen > end) continue; + if (options.serviceName && meta.serviceName !== options.serviceName) continue; + + summaries.push({ + traceId: meta.traceId, + timestamp: new Date(meta.lastSeen).toISOString(), + sessionId: meta.sessionId, + spanCount: String(meta.spanCount), + ...buildTraceDetail(trace.resourceSpans, trace.resourceLogs), + }); + } + + return summaries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + } + + /** All spans and logs for one trace, or undefined when the trace is unknown. */ + public async get(traceId: string): Promise { + const match = (await this.traceFiles()).find((file) => file.includes(sanitize(traceId))); + if (!match) return undefined; + + const trace = await this.readTraceFile(match); + if (!trace) return undefined; + return buildTraceDetail(trace.resourceSpans, trace.resourceLogs); + } + + private async traceFiles(): Promise { + try { + return (await readdir(this.directory)).filter((file) => file.endsWith(OTLP_EXT)); + } catch { + return []; + } + } + + private async readTraceFile( + fileName: string, + ): Promise<{ resourceSpans: OtlpResourceSpan[]; resourceLogs: OtlpResourceLog[] } | undefined> { + let content: string; + try { + content = await readFile(join(this.directory, fileName), "utf8"); + } catch { + return undefined; + } + + const resourceSpans: OtlpResourceSpan[] = []; + const resourceLogs: OtlpResourceLog[] = []; + for (const line of content.split("\n")) { + if (!line.trim()) continue; + try { + const payload = JSON.parse(line) as OtlpPayload; + if (payload.resourceSpans) resourceSpans.push(...payload.resourceSpans); + if (payload.resourceLogs) resourceLogs.push(...payload.resourceLogs); + } catch { + // Skip malformed lines — a partially written line must not break reads. + } + } + return { resourceSpans, resourceLogs }; + } +} + +function sanitize(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} diff --git a/src/core/dev/otel/transforms.test.ts b/src/core/dev/otel/transforms.test.ts new file mode 100644 index 000000000..7b68ce4c8 --- /dev/null +++ b/src/core/dev/otel/transforms.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test"; +import { + buildTraceDetail, + extractAnyValue, + extractFirstTraceInfo, + extractTraceMeta, + flattenAttributes, + hexFromB64OrString, + nanoToMs, +} from "./transforms"; +import type { OtlpResourceLog, OtlpResourceSpan } from "./types"; + +const TRACE_ID_HEX = "0123456789abcdef0123456789abcdef"; +const TRACE_ID_B64 = Buffer.from(TRACE_ID_HEX, "hex").toString("base64"); +const SPAN_ID_HEX = "0123456789abcdef"; + +function resourceSpan(overrides: { serviceName?: string; spans: object[] }): OtlpResourceSpan { + return { + resource: overrides.serviceName + ? { attributes: [{ key: "service.name", value: { stringValue: overrides.serviceName } }] } + : undefined, + scopeSpans: [{ scope: { name: "test-scope" }, spans: overrides.spans }], + }; +} + +const agentSpan = { + traceId: TRACE_ID_B64, + spanId: SPAN_ID_HEX, + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: "1700000000000000000", + endTimeUnixNano: "1700000001500000000", + attributes: [ + { key: "gen_ai.prompt", value: { stringValue: "hello" } }, + { key: "session.id", value: { stringValue: "session-1" } }, + ], +}; + +describe("extractTraceMeta", () => { + test("collects trace id, time bounds, session, service, and span count", () => { + const meta = extractTraceMeta( + [resourceSpan({ serviceName: "my-agent", spans: [agentSpan] })], + [], + ); + expect(meta).toEqual({ + traceId: TRACE_ID_HEX, + firstSeen: 1700000000000, + lastSeen: 1700000001500, + sessionId: "session-1", + serviceName: "my-agent", + spanCount: 1, + }); + }); + + test("counts log records and falls back to observed time", () => { + const logs: OtlpResourceLog[] = [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "log-agent" } }] }, + scopeLogs: [ + { + scope: {}, + logRecords: [{ traceId: TRACE_ID_HEX, observedTimeUnixNano: "1700000002000000000" }], + }, + ], + }, + ]; + const meta = extractTraceMeta([], logs); + expect(meta.traceId).toBe(TRACE_ID_HEX); + expect(meta.serviceName).toBe("log-agent"); + expect(meta.spanCount).toBe(1); + expect(meta.firstSeen).toBe(1700000002000); + expect(meta.lastSeen).toBe(1700000002000); + }); + + test("defaults time bounds to now when no timestamps exist", () => { + const before = Date.now(); + const meta = extractTraceMeta([], []); + expect(meta.firstSeen).toBeGreaterThanOrEqual(before); + expect(meta.lastSeen).toBeGreaterThanOrEqual(before); + expect(meta.traceId).toBeUndefined(); + }); +}); + +describe("extractFirstTraceInfo", () => { + test("finds the first span's trace id and service name", () => { + const info = extractFirstTraceInfo({ + resourceSpans: [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], + }); + expect(info).toEqual({ traceId: TRACE_ID_HEX, serviceName: "svc" }); + }); + + test("falls back to log records and returns empty when nothing matches", () => { + expect(extractFirstTraceInfo({})).toEqual({}); + const info = extractFirstTraceInfo({ + resourceLogs: [{ scopeLogs: [{ logRecords: [{ traceId: TRACE_ID_HEX }] }] }], + }); + expect(info.traceId).toBe(TRACE_ID_HEX); + }); +}); + +describe("buildTraceDetail", () => { + test("hexes ids, flattens attributes, and unwraps log bodies", () => { + const detail = buildTraceDetail( + [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], + [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "svc" } }] }, + scopeLogs: [ + { + scope: {}, + logRecords: [ + { traceId: TRACE_ID_B64, spanId: SPAN_ID_HEX, body: { stringValue: "log line" } }, + ], + }, + ], + }, + ], + ); + + const spans = detail.resourceSpans as { + resource: { attributes: Record }; + scopeSpans: { spans: { traceId: string; attributes: Record }[] }[]; + }[]; + expect(spans[0]!.resource.attributes).toEqual({ "service.name": "svc" }); + expect(spans[0]!.scopeSpans[0]!.spans[0]!.traceId).toBe(TRACE_ID_HEX); + expect(spans[0]!.scopeSpans[0]!.spans[0]!.attributes).toEqual({ + "gen_ai.prompt": "hello", + "session.id": "session-1", + }); + + const logs = detail.resourceLogs as { + scopeLogs: { logRecords: { traceId: string; body: unknown }[] }[]; + }[]; + expect(logs[0]!.scopeLogs[0]!.logRecords[0]!.traceId).toBe(TRACE_ID_HEX); + expect(logs[0]!.scopeLogs[0]!.logRecords[0]!.body).toBe("log line"); + }); + + test("filters transport noise but keeps meaningful spans", () => { + const noiseSpans = [ + { name: "GET / http send", attributes: [] }, + { + name: "http.request", + attributes: [{ key: "asgi.event.type", value: { stringValue: "http.request" } }], + }, + { name: "POST", kind: 3, attributes: [] }, + { + name: "POST /invocations", + kind: 2, + attributes: [{ key: "http.method", value: { stringValue: "POST" } }], + }, + ]; + const detail = buildTraceDetail([resourceSpan({ spans: [...noiseSpans, agentSpan] })], []); + const spans = detail.resourceSpans as { scopeSpans: { spans: { name: string }[] }[] }[]; + expect(spans[0]!.scopeSpans[0]!.spans.map((span) => span.name)).toEqual([ + "invoke_agent strands", + ]); + }); + + test("string span kinds from JSON ingest are normalized before filtering", () => { + const detail = buildTraceDetail( + [resourceSpan({ spans: [{ name: "POST", kind: "SPAN_KIND_CLIENT", attributes: [] }] })], + [], + ); + expect(detail.resourceSpans).toBeUndefined(); + }); + + test("returns undefined sections when everything is filtered or empty", () => { + expect(buildTraceDetail([], [])).toEqual({ resourceSpans: undefined, resourceLogs: undefined }); + }); +}); + +describe("helpers", () => { + test("nanoToMs converts and handles absence", () => { + expect(nanoToMs("1700000000123456789")).toBe(1700000000123); + expect(nanoToMs(undefined)).toBe(0); + }); + + test("hexFromB64OrString accepts hex, base64, and empty", () => { + expect(hexFromB64OrString(TRACE_ID_HEX.toUpperCase())).toBe(TRACE_ID_HEX); + expect(hexFromB64OrString(TRACE_ID_B64)).toBe(TRACE_ID_HEX); + expect(hexFromB64OrString(undefined)).toBe(""); + }); + + test("flattenAttributes handles typed values, arrays, and flat passthrough", () => { + expect( + flattenAttributes([ + { key: "s", value: { stringValue: "x" } }, + { key: "i", value: { intValue: "42" } }, + { key: "d", value: { doubleValue: 1.5 } }, + { key: "b", value: { boolValue: true } }, + { key: "a", value: { arrayValue: { values: [{ stringValue: "y" }, { intValue: "7" }] } } }, + { key: "skipped" }, + ]), + ).toEqual({ s: "x", i: 42, d: 1.5, b: true, a: ["y", "7"] }); + expect(flattenAttributes({ already: "flat" })).toEqual({ already: "flat" }); + expect(flattenAttributes([])).toBeUndefined(); + expect(flattenAttributes(undefined)).toBeUndefined(); + }); + + test("extractAnyValue unwraps nested kvlist and array values", () => { + expect( + extractAnyValue({ + kvlistValue: { + values: [ + { + key: "nested", + value: { arrayValue: { values: [{ intValue: "1" }, { boolValue: false }] } }, + }, + { key: "plain", value: { stringValue: "v" } }, + ], + }, + }), + ).toEqual({ nested: [1, false], plain: "v" }); + expect(extractAnyValue("passthrough")).toBe("passthrough"); + expect(extractAnyValue(null)).toBeNull(); + }); +}); diff --git a/src/core/dev/otel/transforms.ts b/src/core/dev/otel/transforms.ts new file mode 100644 index 000000000..b2c814b40 --- /dev/null +++ b/src/core/dev/otel/transforms.ts @@ -0,0 +1,276 @@ +import type { + OtlpAttributes, + OtlpAttributeValue, + OtlpPayload, + OtlpResource, + OtlpResourceLog, + OtlpResourceSpan, +} from "./types"; + +export interface TraceMeta { + traceId?: string; + firstSeen: number; + lastSeen: number; + sessionId?: string; + serviceName?: string; + spanCount: number; +} + +/** Extract listing metadata (trace id, time bounds, session, service, count) from raw OTLP arrays. */ +export function extractTraceMeta( + resourceSpans: OtlpResourceSpan[], + resourceLogs: OtlpResourceLog[], +): TraceMeta { + const meta: TraceMeta = { firstSeen: Infinity, lastSeen: 0, spanCount: 0 }; + + for (const resourceSpan of resourceSpans) { + meta.serviceName ??= getResourceAttribute(resourceSpan.resource, "service.name"); + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + meta.spanCount++; + meta.traceId ??= hexFromB64OrString(span.traceId) || undefined; + widenTimeBounds(meta, nanoToMs(span.startTimeUnixNano)); + widenTimeBounds(meta, nanoToMs(span.endTimeUnixNano)); + meta.sessionId ??= + getAttributeValue(span.attributes, "session.id") ?? + getAttributeValue(span.attributes, "attributes.session.id"); + } + } + } + + for (const resourceLog of resourceLogs) { + meta.serviceName ??= getResourceAttribute(resourceLog.resource, "service.name"); + for (const scopeLog of resourceLog.scopeLogs ?? []) { + for (const record of scopeLog.logRecords ?? []) { + meta.spanCount++; + meta.traceId ??= hexFromB64OrString(record.traceId) || undefined; + widenTimeBounds( + meta, + nanoToMs(record.timeUnixNano) || nanoToMs(record.observedTimeUnixNano), + ); + } + } + } + + const now = Date.now(); + if (meta.firstSeen === Infinity) meta.firstSeen = now; + if (meta.lastSeen === 0) meta.lastSeen = now; + return meta; +} + +/** Extract the traceId and serviceName of the first span or log record in a payload. */ +export function extractFirstTraceInfo(payload: OtlpPayload): { + traceId?: string; + serviceName?: string; +} { + for (const resourceSpan of payload.resourceSpans ?? []) { + const serviceName = getResourceAttribute(resourceSpan.resource, "service.name"); + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + if (span.traceId) return { traceId: hexFromB64OrString(span.traceId), serviceName }; + } + } + } + for (const resourceLog of payload.resourceLogs ?? []) { + const serviceName = getResourceAttribute(resourceLog.resource, "service.name"); + for (const scopeLog of resourceLog.scopeLogs ?? []) { + for (const record of scopeLog.logRecords ?? []) { + if (record.traceId) return { traceId: hexFromB64OrString(record.traceId), serviceName }; + } + } + } + return {}; +} + +/** + * Build frontend-ready trace detail from raw OTLP arrays: ids to hex, attributes + * flattened to plain records, transport-noise spans dropped, log bodies unwrapped. + */ +export function buildTraceDetail( + resourceSpans: OtlpResourceSpan[], + resourceLogs: OtlpResourceLog[], +): { resourceSpans?: unknown[]; resourceLogs?: unknown[] } { + const spans = resourceSpans + .map((resourceSpan) => ({ + resource: resourceSpan.resource + ? { attributes: flattenAttributes(resourceSpan.resource.attributes) } + : undefined, + scopeSpans: resourceSpan.scopeSpans + ?.map((scopeSpan) => ({ + scope: scopeSpan.scope, + spans: scopeSpan.spans + ?.map((span) => ({ + ...span, + traceId: hexFromB64OrString(span.traceId), + spanId: hexFromB64OrString(span.spanId), + parentSpanId: hexFromB64OrString(span.parentSpanId), + attributes: flattenAttributes(span.attributes), + })) + .filter((span) => isMeaningfulSpan(span)), + })) + .filter((scopeSpan) => scopeSpan.spans && scopeSpan.spans.length > 0), + })) + .filter((resourceSpan) => resourceSpan.scopeSpans && resourceSpan.scopeSpans.length > 0); + + const logs = resourceLogs + .map((resourceLog) => ({ + resource: resourceLog.resource + ? { attributes: flattenAttributes(resourceLog.resource.attributes) } + : undefined, + scopeLogs: resourceLog.scopeLogs?.map((scopeLog) => ({ + scope: scopeLog.scope, + logRecords: scopeLog.logRecords?.map((record) => ({ + ...record, + traceId: hexFromB64OrString(record.traceId), + spanId: hexFromB64OrString(record.spanId), + body: record.body === undefined ? undefined : extractAnyValue(record.body), + attributes: flattenAttributes(record.attributes), + })), + })), + })) + .filter((resourceLog) => resourceLog.scopeLogs && resourceLog.scopeLogs.length > 0); + + return { + resourceSpans: spans.length > 0 ? spans : undefined, + resourceLogs: logs.length > 0 ? logs : undefined, + }; +} + +/** + * Whether a span carries application-level signal. Filters ASGI transport events, + * bare HTTP client/server noise, and other framework spans that add nothing in the UI. + */ +function isMeaningfulSpan(span: { + name?: string; + kind?: number | string; + attributes?: Record; +}): boolean { + const name = span.name ?? ""; + const attributes = span.attributes ?? {}; + const kind = normalizeSpanKind(span.kind); + + if (name.endsWith(" http send") || name.endsWith(" http receive")) return false; + if (attributes["asgi.event.type"]) return false; + if (Object.keys(attributes).some((key) => key.startsWith("gen_ai."))) return true; + if (attributes["rpc.system"] || attributes["rpc.method"]) return true; + + const scopeHints = ["strands", "bedrock", "langchain", "crewai", "autogen", "google_adk"]; + if (scopeHints.some((hint) => name.toLowerCase().includes(hint))) return true; + if (name === "tool_use" || name === "tool_call" || attributes["tool.name"]) return true; + + if (kind === SPAN_KIND.CLIENT && (name === "POST" || name === "GET" || name.startsWith("HTTP "))) + return false; + if (kind === SPAN_KIND.SERVER && name.startsWith("POST /") && attributes["http.method"]) + return false; + + return true; +} + +const SPAN_KIND = { INTERNAL: 1, SERVER: 2, CLIENT: 3, PRODUCER: 4, CONSUMER: 5 } as const; + +/** Normalize a span kind from its protobuf enum name or number to the numeric value. */ +function normalizeSpanKind(kind: number | string | undefined): number { + if (typeof kind === "number") return kind; + if (typeof kind === "string") { + const name = kind.replace(/^SPAN_KIND_/, "") as keyof typeof SPAN_KIND; + return SPAN_KIND[name] ?? 0; + } + return 0; +} + +/** Convert a nanosecond timestamp string to milliseconds (0 when absent). */ +export function nanoToMs(nano: string | undefined): number { + if (!nano) return 0; + return Math.floor(Number(nano) / 1_000_000); +} + +/** + * Normalize a trace/span id that may be base64 (protobuf JSON conversion) or + * already hex (JSON ingest) into lowercase hex. + */ +export function hexFromB64OrString(value: string | undefined): string { + if (!value) return ""; + if (/^[0-9a-f]+$/i.test(value) && (value.length === 32 || value.length === 16)) + return value.toLowerCase(); + try { + return Buffer.from(value, "base64").toString("hex"); + } catch { + return value; + } +} + +/** Flatten OTLP attributes into a plain record; passes already-flat records through. */ +export function flattenAttributes( + attributes: OtlpAttributes | undefined, +): Record | undefined { + if (!attributes) return undefined; + if (!Array.isArray(attributes)) return attributes; + if (attributes.length === 0) return undefined; + + const flat: Record = {}; + for (const attribute of attributes) { + if (!attribute.value) continue; + const value = attribute.value; + if (value.stringValue !== undefined) flat[attribute.key] = value.stringValue; + else if (value.intValue !== undefined) flat[attribute.key] = Number(value.intValue); + else if (value.doubleValue !== undefined) flat[attribute.key] = value.doubleValue; + else if (value.boolValue !== undefined) flat[attribute.key] = value.boolValue; + else if (value.arrayValue?.values) { + flat[attribute.key] = value.arrayValue.values.map( + (item: OtlpAttributeValue) => + item.stringValue ?? item.intValue ?? item.doubleValue ?? item.boolValue ?? null, + ); + } + } + return flat; +} + +/** Unwrap an OTLP AnyValue (string/int/double/bool/array/kvlist) into a plain value. */ +export function extractAnyValue(value: unknown): unknown { + if (!value || typeof value !== "object") return value; + const anyValue = value as Record; + if (anyValue.stringValue !== undefined) return anyValue.stringValue; + if (anyValue.intValue !== undefined) return Number(anyValue.intValue); + if (anyValue.doubleValue !== undefined) return anyValue.doubleValue; + if (anyValue.boolValue !== undefined) return anyValue.boolValue; + if (anyValue.arrayValue && typeof anyValue.arrayValue === "object") { + const { values } = anyValue.arrayValue as { values?: unknown[] }; + return (values ?? []).map(extractAnyValue); + } + if (anyValue.kvlistValue && typeof anyValue.kvlistValue === "object") { + const { values } = anyValue.kvlistValue as { values?: { key: string; value?: unknown }[] }; + const record: Record = {}; + for (const entry of values ?? []) { + record[entry.key] = entry.value === undefined ? undefined : extractAnyValue(entry.value); + } + return record; + } + return value; +} + +function getResourceAttribute(resource: OtlpResource | undefined, key: string): string | undefined { + return getAttributeValue(resource?.attributes, key); +} + +function getAttributeValue( + attributes: OtlpAttributes | undefined, + key: string, +): string | undefined { + if (!attributes) return undefined; + if (Array.isArray(attributes)) { + const attribute = attributes.find((entry) => entry.key === key); + if (!attribute?.value) return undefined; + return ( + attribute.value.stringValue ?? + (attribute.value.intValue != null ? String(attribute.value.intValue) : undefined) + ); + } + const value = attributes[key]; + return typeof value === "string" ? value : undefined; +} + +function widenTimeBounds(meta: TraceMeta, timeMs: number): void { + if (!timeMs) return; + if (timeMs < meta.firstSeen) meta.firstSeen = timeMs; + if (timeMs > meta.lastSeen) meta.lastSeen = timeMs; +} diff --git a/src/core/dev/otel/types.ts b/src/core/dev/otel/types.ts new file mode 100644 index 000000000..a458de9d4 --- /dev/null +++ b/src/core/dev/otel/types.ts @@ -0,0 +1,65 @@ +/** + * Wire shapes for OTLP/HTTP payloads after protobuf JSON conversion or JSON ingest. + * Attributes appear either as OTLP key/value arrays (from the SDK exporters) or as + * already-flat records (after our own flattening) — helpers accept both. + */ + +export interface OtlpAttributeValue { + stringValue?: string; + intValue?: string; + doubleValue?: number; + boolValue?: boolean; + arrayValue?: { values?: OtlpAttributeValue[] }; + kvlistValue?: { values?: OtlpAttribute[] }; +} + +export interface OtlpAttribute { + key: string; + value?: OtlpAttributeValue; +} + +export type OtlpAttributes = OtlpAttribute[] | Record; + +export interface OtlpResource { + attributes?: OtlpAttributes; +} + +export interface OtlpSpan { + traceId?: string; + spanId?: string; + parentSpanId?: string; + name?: string; + kind?: number | string; + startTimeUnixNano?: string; + endTimeUnixNano?: string; + attributes?: OtlpAttributes; + status?: { code?: number; message?: string }; + events?: unknown[]; +} + +export interface OtlpResourceSpan { + resource?: OtlpResource; + scopeSpans?: { scope?: { name?: string; version?: string }; spans?: OtlpSpan[] }[]; +} + +export interface OtlpLogRecord { + timeUnixNano?: string; + observedTimeUnixNano?: string; + severityNumber?: number; + severityText?: string; + body?: unknown; + attributes?: OtlpAttributes; + traceId?: string; + spanId?: string; +} + +export interface OtlpResourceLog { + resource?: OtlpResource; + scopeLogs?: { scope?: { name?: string; version?: string }; logRecords?: OtlpLogRecord[] }[]; +} + +/** One OTLP export payload: what a single POST /v1/traces or /v1/logs carries. */ +export interface OtlpPayload { + resourceSpans?: OtlpResourceSpan[]; + resourceLogs?: OtlpResourceLog[]; +} diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 7a4781ae2..6d66a6cef 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -9,7 +9,7 @@ import { JsonKey, RegionKey } from "../../keys"; import type { Project } from "../types"; import { createDevProjectHandler, type DevProjectHandlerConfig } from "."; import type { DevEnvironmentInput } from "./environment"; -import type { DevEvent, DevRunner, DevServerInput } from "./types"; +import type { DevEvent, DevRunner, DevServerInput, DevTraceCollector } from "./types"; function runtime(name = "orders", build: ProjectRuntime["build"] = "CodeZip"): ProjectRuntime { return { @@ -36,6 +36,26 @@ function captureRunner(events: DevEvent[] = []) { return { runner, inputs }; } +function fakeCollector() { + const starts: { tracesDirectory: string; signal?: AbortSignal }[] = []; + const state = { closed: 0 }; + const collector: DevTraceCollector = { + port: 43180, + envVars: { + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + }, + close: async () => { + state.closed++; + }, + }; + const start: DevProjectHandlerConfig["startTraceCollector"] = async (options) => { + starts.push(options); + return collector; + }; + return { start, starts, state }; +} + type HarnessOptions = { project?: Project; codeZip?: ReturnType; @@ -49,6 +69,7 @@ function harness(options: HarnessOptions = {}) { const io = testIO(); const codeZip = options.codeZip ?? captureRunner(); const container = options.container ?? captureRunner(); + const collector = fakeCollector(); const environmentInputs: DevEnvironmentInput[] = []; const handler = createDevProjectHandler({ io: io.io, @@ -60,6 +81,7 @@ function harness(options: HarnessOptions = {}) { return { env: { FROM_LOADER: "yes" } }; }), checkPort: options.checkPort ?? (async () => true), + startTraceCollector: collector.start, }); const ctx = ValueContext.EmptyContext() .withValue(ProjectKey, options.project ?? project(runtime())) @@ -73,9 +95,11 @@ function harness(options: HarnessOptions = {}) { return { codeZip, container, + collector, environmentInputs, io, - run: (flags: { agent?: string; port?: number } = {}) => handler.handle(ctx, flags, {}), + run: (flags: { agent?: string; port?: number; traces?: boolean } = {}) => + handler.handle(ctx, { traces: true, ...flags }, {}), }; } @@ -120,7 +144,11 @@ describe("project dev selection and dispatch", () => { expect(subject.container.inputs[0]).toMatchObject({ projectRoot: "/workspace/project", port: 4567, - env: { FROM_LOADER: "yes" }, + env: { + FROM_LOADER: "yes", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.docker.internal:43180", + OTEL_SERVICE_NAME: "support", + }, runtime: { name: "support", build: "Container" }, }); }); @@ -137,7 +165,56 @@ describe("project dev selection and dispatch", () => { expect(checked).toEqual([8080, 8081]); expect(subject.codeZip.inputs[0]?.port).toBe(8081); - expect(subject.io.stderr()).toBe("Port 8080 is in use; using 8081."); + expect(subject.io.stderr()).toContain("Port 8080 is in use; using 8081."); + }); +}); + +describe("project dev trace collection", () => { + test("starts the collector, announces it, and points a CodeZip agent at loopback", async () => { + const subject = harness(); + await subject.run(); + + expect(subject.collector.starts).toEqual([ + { + tracesDirectory: "/workspace/project/agentcore/.cli/traces/otlp", + signal: expect.any(AbortSignal), + }, + ]); + expect(subject.io.stderr()).toContain("OTEL collector listening on port 43180"); + expect(subject.codeZip.inputs[0]?.env).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_SERVICE_NAME: "orders", + }); + expect(subject.collector.state.closed).toBe(1); + }); + + test("--no-traces skips the collector entirely", async () => { + const subject = harness(); + await subject.run({ traces: false }); + + expect(subject.collector.starts).toHaveLength(0); + expect(subject.codeZip.inputs[0]?.env).toEqual({ FROM_LOADER: "yes" }); + }); + + test("a runtime with instrumentation disabled skips the collector", async () => { + const disabled = { ...runtime(), instrumentation: { enableOtel: false } } as ProjectRuntime; + const subject = harness({ project: project(disabled) }); + await subject.run(); + + expect(subject.collector.starts).toHaveLength(0); + expect(subject.codeZip.inputs[0]?.env).toEqual({ FROM_LOADER: "yes" }); + }); + + test("the collector is closed when the runner fails", async () => { + const codeZip = captureRunner(); + codeZip.runner.run = async function* () { + yield* []; + throw new InputValidationError("runner failed"); + }; + const subject = harness({ codeZip }); + + await expect(subject.run()).rejects.toThrow("runner failed"); + expect(subject.collector.state.closed).toBe(1); }); }); @@ -150,7 +227,7 @@ test("project dev renders human and NDJSON output", async () => { for (const json of [false, true]) { const subject = harness({ codeZip: captureRunner(events), json }); - await subject.run(); + await subject.run({ traces: false }); expect(subject.io.stdout()).toBe( json ? events.map((event) => JSON.stringify(event)).join("\n") : "agent output", ); @@ -193,7 +270,8 @@ describe("project dev interruption", () => { reported: true, exitCode: 130, }); - expect(subject.io.stderr()).toBe("Shutting down…"); + expect(subject.io.stderr()).toContain("Shutting down…"); + expect(subject.collector.state.closed).toBe(1); expect(process.listenerCount(signal)).toBe(before); }, ); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index ff94815aa..9b6dfaf9d 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -1,4 +1,6 @@ +import { join } from "node:path"; import z from "zod"; +import { rewriteOtelEndpointForContainer } from "../../../core/dev/otel/collector"; import { resolveDevPort } from "../../../core/dev/port"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { @@ -12,15 +14,25 @@ import { JsonRendererKey, type JsonRenderer } from "../../../tui"; import { JsonKey, RegionKey } from "../../keys"; import type { Project } from "../types"; import type { DevEnvironmentLoader } from "./environment"; -import type { DevEvent, DevRunner } from "./types"; +import type { DevEvent, DevRunner, DevTraceCollector, DevTraceCollectorStarter } from "./types"; export type DevProjectHandlerConfig = { io: AppIO; runners: { CodeZip: DevRunner; Container: DevRunner }; loadDevEnvironment: DevEnvironmentLoader; checkPort: PortChecker; + startTraceCollector: DevTraceCollectorStarter; }; +/** Env for a spawned agent so its OTEL SDK reports to the collector as this runtime. */ +function otelEnvForRuntime( + collector: DevTraceCollector, + runtime: ProjectRuntime, +): Record { + const env = { ...collector.envVars, OTEL_SERVICE_NAME: runtime.name }; + return runtime.build === "Container" ? rewriteOtelEndpointForContainer(env) : env; +} + function selectRuntime(project: Project, name?: string): ProjectRuntime { if (project.runtimes.length === 0) { throw new InputValidationError( @@ -64,6 +76,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => "port for the development server", z.coerce.number().int().min(1).max(65535).optional(), ), + flag("traces", "disable local OTEL trace collection", z.boolean().default(true)), ], handle: async (ctx, flags) => { const controller = new AbortController(); @@ -76,6 +89,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => const signals = ["SIGINT", "SIGTERM"] as const; for (const signal of signals) process.on(signal, interrupt); + let collector: DevTraceCollector | undefined; try { const project = ctx.require(ProjectKey); const runtime = selectRuntime(project, flags.agent); @@ -103,12 +117,31 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => }); controller.signal.throwIfAborted(); + let otelEnv: Record = {}; + if (flags.traces && (runtime.instrumentation?.enableOtel ?? true)) { + const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); + collector = await config.startTraceCollector({ + tracesDirectory, + signal: controller.signal, + }); + otelEnv = otelEnvForRuntime(collector, runtime); + renderEvent( + config.io, + { + type: "status", + message: `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, + }, + json, + ); + } + controller.signal.throwIfAborted(); + const runner = config.runners[runtime.build]; for await (const event of runner.run({ runtime, projectRoot: project.rootPath, port: devPort.port, - env, + env: { ...env, ...otelEnv }, signal: controller.signal, })) { renderEvent(config.io, event, json); @@ -118,6 +151,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => throw new CommandInterruptedError(error, true); } finally { for (const signal of signals) process.removeListener(signal, interrupt); + await collector?.close(); } }, }); diff --git a/src/handlers/project/dev/types.ts b/src/handlers/project/dev/types.ts index 933c0c452..289c1b5c9 100644 --- a/src/handlers/project/dev/types.ts +++ b/src/handlers/project/dev/types.ts @@ -16,3 +16,16 @@ export type DevServerInput = { export interface DevRunner { run(input: DevServerInput): AsyncGenerator; } + +/** A local OTLP receiver that spawned agents export traces to. */ +export interface DevTraceCollector { + port: number; + /** Environment variables that point an agent's OTEL SDK at the receiver. */ + envVars: Record; + close(): Promise; +} + +export type DevTraceCollectorStarter = (options: { + tracesDirectory: string; + signal?: AbortSignal; +}) => Promise; diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 474ac5511..f5ad74a26 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -2,6 +2,7 @@ import { Router } from "../../router"; import { checkPort, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; +import { startOtelCollector } from "../../core/dev/otel/collector"; import { withProject } from "../../middleware"; import { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; @@ -36,6 +37,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { }, loadDevEnvironment, checkPort, + startTraceCollector: startOtelCollector, }), ), ); diff --git a/src/io/httpServer.test.ts b/src/io/httpServer.test.ts new file mode 100644 index 000000000..770afb5c2 --- /dev/null +++ b/src/io/httpServer.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { type HttpServerHandle, startHttpServer } from "./httpServer"; + +let handle: HttpServerHandle | undefined; + +afterEach(async () => { + await handle?.close(); + handle = undefined; +}); + +describe("startHttpServer", () => { + test("serves requests on an OS-assigned loopback port", async () => { + handle = await startHttpServer((request) => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + method: request.method, + url: request.url, + body: request.body.toString(), + }), + })); + + expect(handle.port).toBeGreaterThan(0); + const response = await fetch(`http://127.0.0.1:${handle.port}/v1/traces`, { + method: "POST", + body: "ping", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ method: "POST", url: "/v1/traces", body: "ping" }); + }); + + test("handler errors become 500s without crashing the server", async () => { + handle = await startHttpServer(() => { + throw new Error("boom"); + }); + + const response = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(response.status).toBe(500); + + const again = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(again.status).toBe(500); + }); + + test("aborting the signal closes the server", async () => { + const controller = new AbortController(); + const server = await startHttpServer(() => ({ status: 200 }), { signal: controller.signal }); + + controller.abort(); + await Bun.sleep(20); + expect(fetch(`http://127.0.0.1:${server.port}/`)).rejects.toThrow(); + }); + + test("close is idempotent", async () => { + const server = await startHttpServer(() => ({ status: 200 })); + await server.close(); + await server.close(); + }); + + test("listen failure rejects instead of hanging", async () => { + handle = await startHttpServer(() => ({ status: 200 })); + expect(startHttpServer(() => ({ status: 200 }), { port: handle.port })).rejects.toThrow(); + }); +}); diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts new file mode 100644 index 000000000..d95ee831a --- /dev/null +++ b/src/io/httpServer.ts @@ -0,0 +1,119 @@ +// Uses node:http rather than Bun.serve because the npm bundle targets Node, +// where Bun APIs are absent (same constraint as exec.ts). +import { + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, + createServer, +} from "node:http"; + +/** Cap request bodies so a runaway local client cannot exhaust memory. */ +const MAX_BODY_BYTES = 50 * 1024 * 1024; + +export interface HttpRequest { + method: string; + url: string; + headers: IncomingHttpHeaders; + body: Buffer; +} + +export interface HttpResponse { + status: number; + headers?: Record; + body?: string | Buffer; +} + +export type HttpRequestHandler = (request: HttpRequest) => HttpResponse | Promise; + +export interface HttpServerHandle { + /** The port the server is listening on. */ + port: number; + /** Stops accepting connections and closes active ones. Idempotent. */ + close(): Promise; +} + +export type HttpServerStarter = typeof startHttpServer; + +/** + * Starts a loopback-only HTTP server for local dev tooling. Binds 127.0.0.1 on + * the given port (0 lets the OS assign one). Handler errors become plain 500s; + * oversized bodies become 413s. Aborting the signal closes the server. + */ +export async function startHttpServer( + handler: HttpRequestHandler, + options: { port?: number; signal?: AbortSignal } = {}, +): Promise { + const server = createServer((request, response) => { + void respond(handler, request, response); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port ?? 0, "127.0.0.1", resolve); + }); + + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + + const close = () => closeServer(server); + options.signal?.addEventListener("abort", () => void close(), { once: true }); + + return { port, close }; +} + +async function respond( + handler: HttpRequestHandler, + request: IncomingMessage, + response: ServerResponse, +): Promise { + let body: Buffer; + try { + body = await readBody(request); + } catch (error) { + const status = error instanceof BodyTooLargeError ? 413 : 400; + response.writeHead(status).end(); + return; + } + + try { + const result = await handler({ + method: request.method ?? "GET", + url: request.url ?? "/", + headers: request.headers, + body, + }); + response.writeHead(result.status, result.headers); + response.end(result.body); + } catch { + response.writeHead(500, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "internal error" })); + } +} + +class BodyTooLargeError extends Error {} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + request.destroy(); + reject(new BodyTooLargeError()); + return; + } + chunks.push(chunk); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} diff --git a/src/io/index.ts b/src/io/index.ts index fb0da0b0e..2fddca22d 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -37,3 +37,11 @@ export { export type { AppIO, ReadWriteJson } from "./types"; export { warn } from "./warn"; export { checkPort, type PortChecker } from "./port"; +export { + startHttpServer, + type HttpRequest, + type HttpRequestHandler, + type HttpResponse, + type HttpServerHandle, + type HttpServerStarter, +} from "./httpServer"; diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 2b690d094..5b26a8978 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -5,15 +5,17 @@ import type { Flag, GlobalFlag } from "./handler"; import { coerce, formatZodError, inspect } from "./schema"; // toOption builds a Commander Option from a flag's schema. Booleans become value-less -// toggles; everything else takes a value (`` / variadic ``). A -// required, non-boolean flag is made mandatory; defaults are forwarded. +// toggles — declared as `--no-` when they default to true, so the flag turns +// the behavior off (Commander's negation stores the value under the positive name). +// Everything else takes a value (`` / variadic ``). A required, +// non-boolean flag is made mandatory; defaults are forwarded. export function toOption(flag: Flag): Option { const info = inspect(flag.schema); const long = `--${flag.name}`; let token: string; if (info.boolean) { - token = long; + token = info.hasDefault && info.defaultValue === true ? `--no-${flag.name}` : long; } else if (info.variadic) { token = `${long} <${flag.name}...>`; } else { diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 5b17f96d9..dba80053b 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -284,6 +284,27 @@ test("boolean flags default to false when omitted", async () => { expect(seen).toEqual({ verbose: false }); }); +test("a boolean flag defaulting to true is declared as its --no- negation", async () => { + const seen: { traces: boolean }[] = []; + + const run = createHandler({ + name: "run", + description: "", + flags: [flag("traces", "collect traces", z.boolean().default(true))], + handle: async (_ctx, flags) => { + seen.push(flags); + }, + }); + + const root = new Router("app"); + root.handler(run); + + await root.route(["node", "app", "run"]); + await root.route(["node", "app", "run", "--no-traces"]); + + expect(seen).toEqual([{ traces: true }, { traces: false }]); +}); + test("applies a schema default for an omitted flag", async () => { let seen: { count: number } | undefined; From 6ce076be23efc678bc26e6d0081af6011ece1e76 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:42:46 -0400 Subject: [PATCH 3/5] refactor(dev): drop unused collector server injection seam --- src/core/dev/otel/collector.ts | 13 ++++--------- src/io/httpServer.ts | 2 -- src/io/index.ts | 1 - 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts index 0e1a2d422..e3b0191f2 100644 --- a/src/core/dev/otel/collector.ts +++ b/src/core/dev/otel/collector.ts @@ -2,12 +2,7 @@ // SDKs export over HTTP) with the generated types from @opentelemetry/otlp-transformer. // The version is pinned: newer releases dropped the generated request decoders. import root from "@opentelemetry/otlp-transformer/build/src/generated/root"; -import { - type HttpRequest, - type HttpResponse, - type HttpServerStarter, - startHttpServer, -} from "../../../io"; +import { type HttpRequest, type HttpResponse, startHttpServer } from "../../../io"; import { TraceStore } from "./store"; import type { OtlpPayload } from "./types"; @@ -53,7 +48,6 @@ export interface StartOtelCollectorOptions { tracesDirectory: string; /** Closes the collector when aborted. */ signal?: AbortSignal; - startServer?: HttpServerStarter; } /** @@ -65,8 +59,9 @@ export async function startOtelCollector( options: StartOtelCollectorOptions, ): Promise { const store = new TraceStore(options.tracesDirectory); - const startServer = options.startServer ?? startHttpServer; - const server = await startServer((request) => route(request, store), { signal: options.signal }); + const server = await startHttpServer((request) => route(request, store), { + signal: options.signal, + }); return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close }; } diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts index d95ee831a..dfdf32333 100644 --- a/src/io/httpServer.ts +++ b/src/io/httpServer.ts @@ -33,8 +33,6 @@ export interface HttpServerHandle { close(): Promise; } -export type HttpServerStarter = typeof startHttpServer; - /** * Starts a loopback-only HTTP server for local dev tooling. Binds 127.0.0.1 on * the given port (0 lets the OS assign one). Handler errors become plain 500s; diff --git a/src/io/index.ts b/src/io/index.ts index 2fddca22d..23eb4a03d 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -43,5 +43,4 @@ export { type HttpRequestHandler, type HttpResponse, type HttpServerHandle, - type HttpServerStarter, } from "./httpServer"; From e2e696c4cc4984f7139e3cccbc5787367c7693d3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:46:40 -0400 Subject: [PATCH 4/5] fix(dev): name aws-opentelemetry-distro in the missing-instrumentation hint --- src/core/dev/codezip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index bf4fe759e..7d6e83ba9 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -54,7 +54,7 @@ export class CodeZipDevRunner implements DevRunner { yield { type: "status", message: - "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add opentelemetry-distro to enable them.", + "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add aws-opentelemetry-distro to the agent's dependencies to enable them.", }; } } From 56c20f72a3dae1ef35982d03ab6ad112d13dbb7b Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 14 Aug 2026 16:58:23 -0400 Subject: [PATCH 5/5] fix(dev): address trace-identity, env-precedence, and Windows review findings - Partition each OTLP export batch by trace id before persistence: a batch routinely carries spans from several traces, and writing it whole to the first trace's file corrupted trace identity (get() missed every other trace in the batch). Files are now keyed .otlp.jsonl, making get() a direct path lookup. - Set signal-specific OTEL_EXPORTER_OTLP_TRACES_ENDPOINT/_LOGS_ENDPOINT (and protocols) alongside the generic variables: signal-specific values take SDK precedence, so a stray value from the shell or .env.local could silently redirect traces away from the local collector. The container rewrite now covers every OTLP endpoint variable. - Build the PYTHONPATH test expectation with node:path.delimiter so it passes on Windows. --- src/core/dev/codezip.test.ts | 4 +- src/core/dev/otel/collector.test.ts | 17 +++++--- src/core/dev/otel/collector.ts | 31 ++++++++++---- src/core/dev/otel/store.test.ts | 17 +++++++- src/core/dev/otel/store.ts | 35 +++++++++------ src/core/dev/otel/transforms.test.ts | 48 ++++++++++++++++----- src/core/dev/otel/transforms.ts | 59 ++++++++++++++++++++------ src/handlers/project/dev/index.test.ts | 2 + 8 files changed, 158 insertions(+), 55 deletions(-) diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index 4286f9ca3..a255a7584 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types"; import type { ProcessEvent, ProcessRunner, ProcessStreamer, StreamProcessOptions } from "../../io"; @@ -195,7 +195,7 @@ describe("CodeZipDevRunner OTEL instrumentation", () => { await collect(runner.run(otelInput(root, { PYTHONPATH: "/existing" }))); - expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}:/existing`); + expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}${delimiter}/existing`); }); test("does not probe without an OTEL endpoint or for Node entrypoints", async () => { diff --git a/src/core/dev/otel/collector.test.ts b/src/core/dev/otel/collector.test.ts index fb0f49b6d..eb4fc16e7 100644 --- a/src/core/dev/otel/collector.test.ts +++ b/src/core/dev/otel/collector.test.ts @@ -146,12 +146,17 @@ describe("startOtelCollector", () => { ).toBe(404); }); - test("envVars point the SDK at the collector", () => { - expect(collector.envVars.OTEL_EXPORTER_OTLP_ENDPOINT).toBe( - `http://127.0.0.1:${collector.port}`, - ); - expect(collector.envVars.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/protobuf"); - expect(collector.envVars.OTEL_METRICS_EXPORTER).toBe("none"); + test("envVars point the SDK at the collector, including signal-specific overrides", () => { + const endpoint = `http://127.0.0.1:${collector.port}`; + expect(collector.envVars).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `${endpoint}/v1/traces`, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${endpoint}/v1/logs`, + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/protobuf", + OTEL_METRICS_EXPORTER: "none", + }); }); test("abort signal closes the receiver", async () => { diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts index e3b0191f2..c19a3375a 100644 --- a/src/core/dev/otel/collector.ts +++ b/src/core/dev/otel/collector.ts @@ -105,11 +105,23 @@ function decodePayload(body: Buffer, contentType: string, decoder: OtlpDecoder): return JSON.parse(JSON.stringify(decoder.decode(new Uint8Array(body)))) as OtlpPayload; } -/** Environment for a spawned agent so its OTEL SDK exports to the collector at `port`. */ +/** + * Environment for a spawned agent so its OTEL SDK exports to the collector at + * `port`. Signal-specific variables are set alongside the generic ones because + * they take precedence in the SDK — a stray OTEL_EXPORTER_OTLP_TRACES_ENDPOINT + * from the shell or .env.local must not silently redirect traces elsewhere. + * Per the OTEL spec, signal-specific endpoints are full URLs (the signal path + * is only appended to the generic endpoint). + */ export function otelEnvVars(port: number): Record { + const endpoint = `http://127.0.0.1:${port}`; return { - OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${port}`, + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `${endpoint}/v1/traces`, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${endpoint}/v1/logs`, OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/protobuf", OTEL_METRICS_EXPORTER: "none", AGENT_OBSERVABILITY_ENABLED: "true", OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "true", @@ -118,7 +130,7 @@ export function otelEnvVars(port: number): Record { } /** - * Rewrite a loopback OTLP endpoint so a containerized agent can reach the + * Rewrite loopback OTLP endpoints so a containerized agent can reach the * collector on the host. host.docker.internal resolves on Docker Desktop, * Finch, and Podman; bare-metal Linux Docker would additionally need * `--add-host=host.docker.internal:host-gateway` (matches the reference CLI). @@ -126,12 +138,13 @@ export function otelEnvVars(port: number): Record { export function rewriteOtelEndpointForContainer( env: Record, ): Record { - const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; - if (!endpoint) return env; - return { - ...env, - OTEL_EXPORTER_OTLP_ENDPOINT: endpoint.replace(/127\.0\.0\.1|localhost/, "host.docker.internal"), - }; + const rewritten = { ...env }; + for (const [key, value] of Object.entries(rewritten)) { + if (key.startsWith("OTEL_EXPORTER_OTLP") && key.endsWith("_ENDPOINT")) { + rewritten[key] = value.replace(/127\.0\.0\.1|localhost/, "host.docker.internal"); + } + } + return rewritten; } function json(status: number, body: unknown): HttpResponse { diff --git a/src/core/dev/otel/store.test.ts b/src/core/dev/otel/store.test.ts index 59c5bd694..f4bb450c7 100644 --- a/src/core/dev/otel/store.test.ts +++ b/src/core/dev/otel/store.test.ts @@ -75,6 +75,21 @@ describe("TraceStore", () => { expect(await store.list()).toEqual([]); }); + test("a batch carrying several traces lands in each trace's own file", async () => { + const batch = payload(TRACE_A); + batch.resourceSpans![0]!.scopeSpans![0]!.spans!.push({ + ...batch.resourceSpans![0]!.scopeSpans![0]!.spans![0]!, + traceId: TRACE_B, + name: "tool_use", + }); + await store.append(batch); + + const traces = await store.list(); + expect(traces.map((trace) => trace.traceId).sort()).toEqual([TRACE_A, TRACE_B]); + expect(traces.every((trace) => trace.spanCount === "1")).toBe(true); + expect(await store.get(TRACE_B)).toBeDefined(); + }); + test("list filters by service name", async () => { await store.append(payload(TRACE_A, { serviceName: "agent-1" })); await store.append(payload(TRACE_B, { serviceName: "agent-2" })); @@ -102,7 +117,7 @@ describe("TraceStore", () => { test("skips malformed lines and files without failing", async () => { await store.append(payload(TRACE_A)); - await writeFile(join(directory, `agent-1-${TRACE_A}.otlp.jsonl`), "{not json}\n", { + await writeFile(join(directory, `${TRACE_A}.otlp.jsonl`), "{not json}\n", { flag: "a", }); await writeFile(join(directory, "garbage.otlp.jsonl"), "also not json\n"); diff --git a/src/core/dev/otel/store.ts b/src/core/dev/otel/store.ts index 2df3ba643..57e7e4593 100644 --- a/src/core/dev/otel/store.ts +++ b/src/core/dev/otel/store.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; -import { buildTraceDetail, extractFirstTraceInfo, extractTraceMeta } from "./transforms"; +import { buildTraceDetail, extractTraceMeta, partitionByTraceId } from "./transforms"; import type { OtlpPayload, OtlpResourceLog, OtlpResourceSpan } from "./types"; const OTLP_EXT = ".otlp.jsonl"; @@ -27,22 +27,32 @@ export interface ListTracesOptions { } /** - * Append-only local trace storage: one JSON Lines file per trace under the store - * directory, each line a raw OTLP export payload. No in-memory state — reads go - * to disk on demand, which is fine because the inspector only fetches traces on - * user actions. Malformed files and lines are skipped, never fatal. + * Append-only local trace storage: one JSON Lines file per trace (named by its + * trace id), each line a per-trace slice of an OTLP export payload. No in-memory + * state — reads go to disk on demand, which is fine because the inspector only + * fetches traces on user actions. Malformed files and lines are skipped, never fatal. */ export class TraceStore { constructor(private readonly directory: string) {} - /** Append one OTLP export payload to its trace's file. Payloads without a trace id are dropped. */ + /** + * Persist one OTLP export payload, partitioned by trace id so a batch that + * carries several traces lands in each trace's own file. Spans and log + * records without a trace id are dropped. + */ public async append(payload: OtlpPayload): Promise { - const { traceId, serviceName } = extractFirstTraceInfo(payload); - if (!traceId) return; + const partitions = partitionByTraceId(payload); + if (partitions.size === 0) return; await mkdir(this.directory, { recursive: true }); - const fileName = `${sanitize(serviceName ?? "dev")}-${sanitize(traceId)}${OTLP_EXT}`; - await appendFile(join(this.directory, fileName), JSON.stringify(payload) + "\n"); + await Promise.all( + [...partitions].map(([traceId, partition]) => + appendFile( + join(this.directory, `${sanitize(traceId)}${OTLP_EXT}`), + JSON.stringify(partition) + "\n", + ), + ), + ); } /** List traces newest-first, filtered by service name and time range (default: last 12 hours). */ @@ -75,10 +85,7 @@ export class TraceStore { /** All spans and logs for one trace, or undefined when the trace is unknown. */ public async get(traceId: string): Promise { - const match = (await this.traceFiles()).find((file) => file.includes(sanitize(traceId))); - if (!match) return undefined; - - const trace = await this.readTraceFile(match); + const trace = await this.readTraceFile(`${sanitize(traceId)}${OTLP_EXT}`); if (!trace) return undefined; return buildTraceDetail(trace.resourceSpans, trace.resourceLogs); } diff --git a/src/core/dev/otel/transforms.test.ts b/src/core/dev/otel/transforms.test.ts index 7b68ce4c8..0b3bc2eef 100644 --- a/src/core/dev/otel/transforms.test.ts +++ b/src/core/dev/otel/transforms.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test } from "bun:test"; import { buildTraceDetail, extractAnyValue, - extractFirstTraceInfo, extractTraceMeta, flattenAttributes, hexFromB64OrString, nanoToMs, + partitionByTraceId, } from "./transforms"; import type { OtlpResourceLog, OtlpResourceSpan } from "./types"; @@ -81,20 +81,46 @@ describe("extractTraceMeta", () => { }); }); -describe("extractFirstTraceInfo", () => { - test("finds the first span's trace id and service name", () => { - const info = extractFirstTraceInfo({ - resourceSpans: [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], +describe("partitionByTraceId", () => { + const OTHER_TRACE_HEX = "ffffffffffffffffffffffffffffffff"; + + test("splits a batch carrying several traces into per-trace payloads", () => { + const otherSpan = { ...agentSpan, traceId: OTHER_TRACE_HEX, name: "tool_use" }; + const partitions = partitionByTraceId({ + resourceSpans: [resourceSpan({ serviceName: "svc", spans: [agentSpan, otherSpan] })], + }); + + expect([...partitions.keys()].sort()).toEqual([TRACE_ID_HEX, OTHER_TRACE_HEX]); + const first = partitions.get(TRACE_ID_HEX)!.resourceSpans![0] as OtlpResourceSpan; + expect(first.scopeSpans![0]!.spans).toEqual([agentSpan]); + expect(first.resource).toBeDefined(); + const second = partitions.get(OTHER_TRACE_HEX)!.resourceSpans![0] as OtlpResourceSpan; + expect(second.scopeSpans![0]!.spans).toEqual([otherSpan]); + }); + + test("partitions log records by trace id and keys base64 ids as hex", () => { + const partitions = partitionByTraceId({ + resourceLogs: [ + { + scopeLogs: [ + { + scope: {}, + logRecords: [{ traceId: TRACE_ID_B64 }, { traceId: OTHER_TRACE_HEX }], + }, + ], + }, + ], }); - expect(info).toEqual({ traceId: TRACE_ID_HEX, serviceName: "svc" }); + + expect([...partitions.keys()].sort()).toEqual([TRACE_ID_HEX, OTHER_TRACE_HEX]); }); - test("falls back to log records and returns empty when nothing matches", () => { - expect(extractFirstTraceInfo({})).toEqual({}); - const info = extractFirstTraceInfo({ - resourceLogs: [{ scopeLogs: [{ logRecords: [{ traceId: TRACE_ID_HEX }] }] }], + test("drops spans without a trace id and returns empty for empty payloads", () => { + expect(partitionByTraceId({}).size).toBe(0); + const partitions = partitionByTraceId({ + resourceSpans: [resourceSpan({ spans: [{ name: "orphan" }] })], }); - expect(info.traceId).toBe(TRACE_ID_HEX); + expect(partitions.size).toBe(0); }); }); diff --git a/src/core/dev/otel/transforms.ts b/src/core/dev/otel/transforms.ts index b2c814b40..1c161d5e0 100644 --- a/src/core/dev/otel/transforms.ts +++ b/src/core/dev/otel/transforms.ts @@ -58,28 +58,63 @@ export function extractTraceMeta( return meta; } -/** Extract the traceId and serviceName of the first span or log record in a payload. */ -export function extractFirstTraceInfo(payload: OtlpPayload): { - traceId?: string; - serviceName?: string; -} { +/** + * Split one OTLP export payload into per-trace payloads, keyed by hex trace id. + * A single export batch routinely carries spans from several traces (SDKs batch + * by time, not by trace), so persistence must not attribute a whole batch to + * the first trace id it sees. Spans and log records without a trace id are dropped. + * Resource and scope structure is preserved within each partition. + */ +export function partitionByTraceId(payload: OtlpPayload): Map { + const partitions = new Map(); + const partition = (traceId: string): OtlpPayload => { + let entry = partitions.get(traceId); + if (!entry) { + entry = {}; + partitions.set(traceId, entry); + } + return entry; + }; + for (const resourceSpan of payload.resourceSpans ?? []) { - const serviceName = getResourceAttribute(resourceSpan.resource, "service.name"); for (const scopeSpan of resourceSpan.scopeSpans ?? []) { - for (const span of scopeSpan.spans ?? []) { - if (span.traceId) return { traceId: hexFromB64OrString(span.traceId), serviceName }; + const byTrace = groupBy(scopeSpan.spans ?? [], (span) => hexFromB64OrString(span.traceId)); + for (const [traceId, spans] of byTrace) { + (partition(traceId).resourceSpans ??= []).push({ + resource: resourceSpan.resource, + scopeSpans: [{ scope: scopeSpan.scope, spans }], + }); } } } + for (const resourceLog of payload.resourceLogs ?? []) { - const serviceName = getResourceAttribute(resourceLog.resource, "service.name"); for (const scopeLog of resourceLog.scopeLogs ?? []) { - for (const record of scopeLog.logRecords ?? []) { - if (record.traceId) return { traceId: hexFromB64OrString(record.traceId), serviceName }; + const byTrace = groupBy(scopeLog.logRecords ?? [], (record) => + hexFromB64OrString(record.traceId), + ); + for (const [traceId, logRecords] of byTrace) { + (partition(traceId).resourceLogs ??= []).push({ + resource: resourceLog.resource, + scopeLogs: [{ scope: scopeLog.scope, logRecords }], + }); } } } - return {}; + + return partitions; +} + +function groupBy(items: T[], key: (item: T) => string): Map { + const groups = new Map(); + for (const item of items) { + const groupKey = key(item); + if (!groupKey) continue; + const group = groups.get(groupKey); + if (group) group.push(item); + else groups.set(groupKey, [item]); + } + return groups; } /** diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 6d66a6cef..b7370be52 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -43,6 +43,7 @@ function fakeCollector() { port: 43180, envVars: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://127.0.0.1:43180/v1/traces", OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", }, close: async () => { @@ -147,6 +148,7 @@ describe("project dev selection and dispatch", () => { env: { FROM_LOADER: "yes", OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.docker.internal:43180", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://host.docker.internal:43180/v1/traces", OTEL_SERVICE_NAME: "support", }, runtime: { name: "support", build: "Container" },