From edc7316138d8123668d6ac372d52eaa0499afeff Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 10 Sep 2026 15:04:39 -0400 Subject: [PATCH 1/2] feat: add project aware trace support for runtime --- src/handlers/project/index.ts | 2 + src/handlers/project/traces/index.ts | 11 + src/handlers/project/traces/runtime.test.tsx | 199 +++++++++++++++++++ src/handlers/project/traces/runtime.tsx | 61 ++++++ 4 files changed, 273 insertions(+) create mode 100644 src/handlers/project/traces/index.ts create mode 100644 src/handlers/project/traces/runtime.test.tsx create mode 100644 src/handlers/project/traces/runtime.tsx diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index f899d52bf..b49f5d653 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -19,6 +19,7 @@ import { createAddProjectResourceHandler } from "./add"; import { createExportProjectResourceHandler } from "./export"; import { createProjectInvokeHandler } from "./invoke"; import { createProjectLogHandler } from "./log"; +import { createProjectTracesHandler } from "./traces"; type ProjectHandlerConfig = { core: Core; @@ -101,6 +102,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router ); project.handler(createProjectInvokeHandler(core, io)); project.handler(createProjectLogHandler(core, io)); + project.handler(createProjectTracesHandler(core, io)); // A bare `agentcore project status` in an interactive session opens the TUI // linked-resources screen; any user-supplied flag, --json, or a non-TTY // invocation keeps the headless JSON report (same dispatch shape as create). diff --git a/src/handlers/project/traces/index.ts b/src/handlers/project/traces/index.ts new file mode 100644 index 000000000..5cbe4e788 --- /dev/null +++ b/src/handlers/project/traces/index.ts @@ -0,0 +1,11 @@ +import type { AppIO } from "../../../io"; +import { withProject } from "../../../middleware"; +import { Router } from "../../../router"; +import type { Core } from "../../types"; +import { createProjectRuntimeTracesHandler } from "./runtime"; + +export function createProjectTracesHandler(core: Core, io: AppIO): Router { + return new Router("traces", "inspect traces for resources in the current project") + .use(withProject({ projectManager: core.projectManager })) + .handler(createProjectRuntimeTracesHandler(core, io)); +} diff --git a/src/handlers/project/traces/runtime.test.tsx b/src/handlers/project/traces/runtime.test.tsx new file mode 100644 index 000000000..db5cd5d7f --- /dev/null +++ b/src/handlers/project/traces/runtime.test.tsx @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { GetTraceQuery, ListTracesQuery, LogSource } from "../../../core/observability/index"; +import type { ProjectBackend, ResolveDeployedResourcesBackendInput } from "../../../core/project"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { createRootHandler } from "../../index"; + +const originalCwd = process.cwd(); +const temporaryDirectories: string[] = []; +const DEFAULT_TARGET = { + name: "default", + account: "111122223333", + region: "eu-west-1", +} as const; +const PRODUCTION_TARGET = { + name: "production", + account: "111122223333", + region: "ap-southeast-2", +} as const; +const RUNTIMES = [ + { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", + }, + { + name: "inventory", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/inventory", + runtimeVersion: "PYTHON_3_14", + }, +] as const; + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function inProject( + runtimes: readonly unknown[], + targets = [DEFAULT_TARGET, PRODUCTION_TARGET], +) { + const root = await mkdtemp(join(tmpdir(), "agentcore-project-traces-")); + temporaryDirectories.push(root); + await mkdir(join(root, "agentcore"), { recursive: true }); + const spec = ProjectSpecSchema.parse({ + name: "orders", + version: 1, + runtimes, + }); + await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); + await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify(targets)); + process.chdir(root); + return root; +} + +function backend() { + const calls: ResolveDeployedResourcesBackendInput[] = []; + const value: ProjectBackend = { + async *build() {}, + async *deploy() { + yield* []; + return { outputs: {} }; + }, + async resolveDeployedResources(project, input) { + calls.push(input); + return project.spec.runtimes.map(({ name }) => ({ + resourceType: "runtime" as const, + name, + id: `${name}-AbCdEf1234`, + target: input.target, + })); + }, + async resolveProjectResources() { + throw new Error("project traces resolve deployed resources, not project resources"); + }, + }; + return { calls, value }; +} + +function command(projectBackend: ProjectBackend) { + const core = new TestCoreClient({ backends: { CDK: projectBackend } }); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + return { + core, + io, + run: (args: string[]) => + root.route([ + "bun", + "agentcore", + "project", + "traces", + "runtime", + ...args, + "--region", + "us-east-1", + ]), + }; +} + +describe("project traces runtime", () => { + test("resolves the only logical Runtime and lists traces in the target region", async () => { + await inProject([RUNTIMES[0]]); + const resolved = backend(); + const subject = command(resolved.value); + subject.core.observability.traceSummaries = [ + { + traceId: "abc123", + timestamp: "1709391000000", + sessionId: "session-1", + }, + ]; + + await subject.run(["list", "--since", "1h"]); + + expect(resolved.calls).toEqual([{ target: DEFAULT_TARGET }]); + const call = subject.core.observability.calls[0]!; + expect(call.method).toBe("listTraces"); + expect(call.args[0] as LogSource).toEqual({ + logGroupName: "/aws/bedrock-agentcore/runtimes/checkout-AbCdEf1234-DEFAULT", + }); + expect(call.args[1] as ListTracesQuery).toMatchObject({ limit: 20 }); + expect(call.args[2]).toEqual({ region: DEFAULT_TARGET.region, endpointUrl: undefined }); + expect(subject.io.stdout()).toContain("abc123"); + }); + + test("selects a named Runtime, deployment target, and endpoint qualifier", async () => { + await inProject(RUNTIMES); + const resolved = backend(); + const subject = command(resolved.value); + + await subject.run([ + "list", + "--name", + "inventory", + "--target", + "production", + "--qualifier", + "BLUE", + "--since", + "1h", + "--limit", + "5", + ]); + + expect(resolved.calls).toEqual([{ target: PRODUCTION_TARGET }]); + const call = subject.core.observability.calls[0]!; + expect(call.args[0]).toEqual({ + logGroupName: "/aws/bedrock-agentcore/runtimes/inventory-AbCdEf1234-BLUE", + }); + expect(call.args[1] as ListTracesQuery).toMatchObject({ limit: 5 }); + expect(call.args[2]).toEqual({ region: PRODUCTION_TARGET.region, endpointUrl: undefined }); + }); + + test("downloads a trace from the resolved Runtime", async () => { + await inProject([RUNTIMES[0]]); + const resolved = backend(); + const subject = command(resolved.value); + subject.core.observability.traceRecords = [ + { "@timestamp": "2026-09-10 12:00:00.000", "@message": { body: "hello" } }, + ]; + + await subject.run(["get", "abc123def456", "--output", "traces/trace.json"]); + + const call = subject.core.observability.calls[0]!; + expect(call.method).toBe("getTrace"); + expect(call.args[0]).toEqual({ + logGroupName: "/aws/bedrock-agentcore/runtimes/checkout-AbCdEf1234-DEFAULT", + }); + expect(call.args[1] as GetTraceQuery).toMatchObject({ traceId: "abc123def456" }); + expect(call.args[2]).toEqual({ region: DEFAULT_TARGET.region, endpointUrl: undefined }); + + const output = join(process.cwd(), "traces", "trace.json"); + expect(subject.io.stdout()).toBe(output); + expect(JSON.parse(await readFile(output, "utf8"))).toEqual( + subject.core.observability.traceRecords, + ); + }); +}); diff --git a/src/handlers/project/traces/runtime.tsx b/src/handlers/project/traces/runtime.tsx new file mode 100644 index 000000000..db7957782 --- /dev/null +++ b/src/handlers/project/traces/runtime.tsx @@ -0,0 +1,61 @@ +import z from "zod"; +import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../core/observability/index"; +import type { AppIO } from "../../../io"; +import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { flag, ProjectKey, Router, type Context } from "../../../router"; +import { createGetTraceHandler, createListTracesHandler } from "../../observability/traces"; +import { resolveTraceOutputPath } from "../../observability/traceOutputPath"; +import type { ResourceFlagValues } from "../../observability/types"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { selectProjectResource } from "../selection"; + +const projectRuntimeFlags = [ + flag("name", "the logical project Runtime name", z.string().optional()), + flag("target", "project deployment target", z.string().min(1).default(DEFAULT_TARGET_NAME)), + flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()), +] as const; + +type ProjectRuntimeFlagValues = ResourceFlagValues; + +async function resolveProjectRuntime(core: Core, ctx: Context, flags: ProjectRuntimeFlagValues) { + const project = ctx.require(ProjectKey); + const name = selectProjectResource(project, "runtime", flags.name, "inspect traces for"); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "runtime", + name, + }); + + return { + source: { + logGroupName: runtimeLogGroup(deployed.id, flags.qualifier ?? DEFAULT_ENDPOINT_QUALIFIER), + }, + options: { + ...coreOptsFromCtx(ctx), + region: deployed.target.region, + }, + }; +} + +export function createProjectRuntimeTracesHandler(core: Core, io: AppIO): Router { + const list = createListTracesHandler(io, { + description: "list a Runtime's recent traces", + flags: projectRuntimeFlags, + read: async (ctx, flags, query, signal) => { + const { source, options } = await resolveProjectRuntime(core, ctx, flags); + return core.observability.listTraces(source, query, options, signal); + }, + }); + const get = createGetTraceHandler(io, { + description: "download a trace's log records to a JSON file", + flags: projectRuntimeFlags, + read: async (ctx, flags, query, signal) => { + const { source, options } = await resolveProjectRuntime(core, ctx, flags); + return core.observability.getTrace(source, query, options, signal); + }, + resolveOutputPath: (_ctx, _flags, request) => resolveTraceOutputPath(request), + }); + + return new Router("runtime", "inspect a Runtime's traces").handler(list).handler(get); +} From fb04f872c8d0a1318d146019ceaf502116fc09a7 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 10 Sep 2026 15:04:57 -0400 Subject: [PATCH 2/2] chore: update readMe with project traces --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ca359d9fa..993180b04 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,11 @@ agentcore # interactive TUI │ │ ├── runtime # use the existing Runtime invoke experience │ │ └── harness # use the existing Harness invoke experience │ ├── status # inspect deployed project resources (TUI when run bare) -│ └── build # synthesize the project's CloudFormation templates -│ └── log -│ └── runtime # resolve a project Runtime and inspect its logs +│ ├── build # synthesize the project's CloudFormation templates +│ ├── log +│ │ └── runtime # resolve a project Runtime and inspect its logs +│ └── traces +│ └── runtime └── config # read/write global config values ``` @@ -204,6 +206,21 @@ When the project declares exactly one Runtime, `--name` may be omitted. Use the imperative `agentcore runtime logs --id ` command when addressing a Runtime directly or working outside a project. +### Inspect project Runtime traces + +Project tracing uses the same logical Runtime and deployment target resolution, +then lists or downloads traces from the resolved Runtime's deployment region: + +```bash +agentcore project traces runtime list +agentcore project traces runtime list --name checkout --target production --since 30m +agentcore project traces runtime get --name checkout --output trace.json +``` + +When the project declares exactly one Runtime, `--name` may be omitted. Use the +imperative `agentcore runtime traces` commands when addressing a Runtime by +physical ID or working outside a project. + ### Examples ```bash @@ -281,6 +298,10 @@ agentcore runtime logs --id --since 2026-08-30T12:00:00Z --until now agentcore runtime traces list --id --since 30m agentcore runtime traces get --id --output trace.json +# Resolve a project Runtime by logical name and deployment target +agentcore project traces runtime list --name checkout --target production --since 30m +agentcore project traces runtime get --name checkout --output trace.json + # Inspect AgentCore Memories without project configuration or deployment agentcore memory get --id agentcore memory get --id --view without_decryption