Skip to content

Commit 363bfb2

Browse files
authored
feat: add project aware trace support for runtime (#2273)
* feat: support project aware logging in runtime * feat: add project aware trace support for runtime * chore: update readMe with project traces * chore: rename runtime log helper for clarity * chore: apply shared credential provider to traces commands
1 parent 04d3460 commit 363bfb2

7 files changed

Lines changed: 321 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ agentcore # interactive TUI
139139
│ │ ├── runtime # use the existing Runtime invoke experience
140140
│ │ └── harness # use the existing Harness invoke experience
141141
│ ├── status # inspect deployed project resources (TUI when run bare)
142-
│ └── build # synthesize the project's CloudFormation templates
143-
│ └── log
144-
│ └── runtime # resolve a project Runtime and inspect its logs
142+
│ ├── build # synthesize the project's CloudFormation templates
143+
│ ├── log
144+
│ │ └── runtime # resolve a project Runtime and inspect its logs
145+
│ └── traces
146+
│ └── runtime
145147
└── config # read/write global config values
146148
```
147149

@@ -204,6 +206,21 @@ When the project declares exactly one Runtime, `--name` may be omitted. Use the
204206
imperative `agentcore runtime logs --id <runtimeId>` command when addressing a
205207
Runtime directly or working outside a project.
206208

209+
### Inspect project Runtime traces
210+
211+
Project tracing uses the same logical Runtime and deployment target resolution,
212+
then lists or downloads traces from the resolved Runtime's deployment region:
213+
214+
```bash
215+
agentcore project traces runtime list
216+
agentcore project traces runtime list --name checkout --target production --since 30m
217+
agentcore project traces runtime get <traceId> --name checkout --output trace.json
218+
```
219+
220+
When the project declares exactly one Runtime, `--name` may be omitted. Use the
221+
imperative `agentcore runtime traces` commands when addressing a Runtime by
222+
physical ID or working outside a project.
223+
207224
### Examples
208225

209226
```bash
@@ -281,6 +298,10 @@ agentcore runtime logs --id <runtimeId> --since 2026-08-30T12:00:00Z --until now
281298
agentcore runtime traces list --id <runtimeId> --since 30m
282299
agentcore runtime traces get <traceId> --id <runtimeId> --output trace.json
283300

301+
# Resolve a project Runtime by logical name and deployment target
302+
agentcore project traces runtime list --name checkout --target production --since 30m
303+
agentcore project traces runtime get <traceId> --name checkout --output trace.json
304+
284305
# Inspect AgentCore Memories without project configuration or deployment
285306
agentcore memory get --id <memoryId>
286307
agentcore memory get --id <memoryId> --view without_decryption

‎src/core/observability/index.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
export { CloudWatchClient } from "./cloudWatchClient";
22
export { ObservabilityClient } from "./client";
3-
export const DEFAULT_ENDPOINT_QUALIFIER = "DEFAULT";
4-
5-
export function runtimeLogGroup(runtimeId: string, endpoint: string): string {
6-
return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${endpoint}`;
7-
}
3+
export { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "./runtimeLogGroup";
84
export {
95
INSIGHTS_MAX_ROWS,
106
runInsightsQuery,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const DEFAULT_ENDPOINT_QUALIFIER = "DEFAULT";
2+
3+
export function runtimeLogGroup(runtimeId: string, endpoint: string): string {
4+
return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${endpoint}`;
5+
}

‎src/handlers/project/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { createAddProjectResourceHandler } from "./add";
1919
import { createExportProjectResourceHandler } from "./export";
2020
import { createProjectInvokeHandler } from "./invoke";
2121
import { createProjectLogHandler } from "./log";
22+
import { createProjectTracesHandler } from "./traces";
2223

2324
type ProjectHandlerConfig = {
2425
core: Core;
@@ -90,6 +91,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
9091
);
9192
project.handler(createProjectInvokeHandler(core, io));
9293
project.handler(createProjectLogHandler(core, io));
94+
project.handler(createProjectTracesHandler(core, io));
9395
// A bare `agentcore project status` in an interactive session opens the TUI
9496
// linked-resources screen; any user-supplied flag, --json, or a non-TTY
9597
// invocation keeps the headless JSON report. withProject runs before the TUI
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { AppIO } from "../../../io";
2+
import { withProject } from "../../../middleware";
3+
import { Router } from "../../../router";
4+
import type { Core } from "../../types";
5+
import { createProjectRuntimeTracesHandler } from "./runtime";
6+
7+
export function createProjectTracesHandler(core: Core, io: AppIO): Router {
8+
return new Router("traces", "inspect traces for resources in the current project")
9+
.use(withProject({ projectManager: core.projectManager }))
10+
.handler(createProjectRuntimeTracesHandler(core, io));
11+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import type { GetTraceQuery, ListTracesQuery, LogSource } from "../../../core/observability/index";
6+
import type { ProjectBackend, ResolveDeployedResourcesBackendInput } from "../../../core/project";
7+
import { ProjectSpecSchema } from "../../../projectSchemas/project";
8+
import {
9+
createSilentLogger,
10+
TestCoreClient,
11+
TestGlobalConfigAccessor,
12+
testIO,
13+
} from "../../../testing";
14+
import { createRootHandler } from "../../index";
15+
16+
const originalCwd = process.cwd();
17+
const temporaryDirectories: string[] = [];
18+
const DEFAULT_TARGET = {
19+
name: "default",
20+
account: "111122223333",
21+
region: "eu-west-1",
22+
} as const;
23+
const PRODUCTION_TARGET = {
24+
name: "production",
25+
account: "111122223333",
26+
region: "ap-southeast-2",
27+
} as const;
28+
const TARGET_CREDENTIAL_PROVIDER = async () => ({
29+
accessKeyId: "target-access-key",
30+
secretAccessKey: "target-secret-key",
31+
});
32+
const RUNTIMES = [
33+
{
34+
name: "checkout",
35+
build: "CodeZip",
36+
entrypoint: "main.py",
37+
codeLocation: "app/checkout",
38+
runtimeVersion: "PYTHON_3_14",
39+
},
40+
{
41+
name: "inventory",
42+
build: "CodeZip",
43+
entrypoint: "main.py",
44+
codeLocation: "app/inventory",
45+
runtimeVersion: "PYTHON_3_14",
46+
},
47+
] as const;
48+
49+
afterEach(async () => {
50+
process.chdir(originalCwd);
51+
await Promise.all(
52+
temporaryDirectories
53+
.splice(0)
54+
.map((directory) => rm(directory, { recursive: true, force: true })),
55+
);
56+
});
57+
58+
async function inProject(
59+
runtimes: readonly unknown[],
60+
targets = [DEFAULT_TARGET, PRODUCTION_TARGET],
61+
) {
62+
const root = await mkdtemp(join(tmpdir(), "agentcore-project-traces-"));
63+
temporaryDirectories.push(root);
64+
await mkdir(join(root, "agentcore"), { recursive: true });
65+
const spec = ProjectSpecSchema.parse({
66+
name: "orders",
67+
version: 1,
68+
runtimes,
69+
});
70+
await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec));
71+
await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify(targets));
72+
process.chdir(root);
73+
return root;
74+
}
75+
76+
function backend() {
77+
const calls: ResolveDeployedResourcesBackendInput[] = [];
78+
const value: ProjectBackend = {
79+
async *build() {},
80+
async *deploy() {
81+
yield* [];
82+
return { outputs: {} };
83+
},
84+
async resolveDeployedResources(project, input) {
85+
calls.push(input);
86+
return project.spec.runtimes.map(({ name }) => ({
87+
resourceType: "runtime" as const,
88+
name,
89+
id: `${name}-AbCdEf1234`,
90+
target: input.target,
91+
credentialProvider: TARGET_CREDENTIAL_PROVIDER,
92+
}));
93+
},
94+
async resolveProjectResources() {
95+
throw new Error("project traces resolve deployed resources, not project resources");
96+
},
97+
};
98+
return { calls, value };
99+
}
100+
101+
function command(projectBackend: ProjectBackend) {
102+
const core = new TestCoreClient({ backends: { CDK: projectBackend } });
103+
const io = testIO();
104+
const root = createRootHandler(core, {
105+
io: io.io,
106+
logger: createSilentLogger(),
107+
globalConfigAccessor: new TestGlobalConfigAccessor(),
108+
});
109+
return {
110+
core,
111+
io,
112+
run: (args: string[]) =>
113+
root.route([
114+
"bun",
115+
"agentcore",
116+
"project",
117+
"traces",
118+
"runtime",
119+
...args,
120+
"--region",
121+
"us-east-1",
122+
]),
123+
};
124+
}
125+
126+
describe("project traces runtime", () => {
127+
test("resolves the only logical Runtime and lists traces in the target region", async () => {
128+
await inProject([RUNTIMES[0]]);
129+
const resolved = backend();
130+
const subject = command(resolved.value);
131+
subject.core.observability.traceSummaries = [
132+
{
133+
traceId: "abc123",
134+
timestamp: "1709391000000",
135+
sessionId: "session-1",
136+
},
137+
];
138+
139+
await subject.run(["list", "--since", "1h"]);
140+
141+
expect(resolved.calls).toEqual([{ target: DEFAULT_TARGET }]);
142+
const call = subject.core.observability.calls[0]!;
143+
expect(call.method).toBe("listTraces");
144+
expect(call.args[0] as LogSource).toEqual({
145+
logGroupName: "/aws/bedrock-agentcore/runtimes/checkout-AbCdEf1234-DEFAULT",
146+
});
147+
expect(call.args[1] as ListTracesQuery).toMatchObject({ limit: 20 });
148+
expect(call.args[2]).toEqual({
149+
region: DEFAULT_TARGET.region,
150+
endpointUrl: undefined,
151+
credentials: TARGET_CREDENTIAL_PROVIDER,
152+
});
153+
expect(subject.io.stdout()).toContain("abc123");
154+
});
155+
156+
test("selects a named Runtime, deployment target, and endpoint qualifier", async () => {
157+
await inProject(RUNTIMES);
158+
const resolved = backend();
159+
const subject = command(resolved.value);
160+
161+
await subject.run([
162+
"list",
163+
"--name",
164+
"inventory",
165+
"--target",
166+
"production",
167+
"--qualifier",
168+
"BLUE",
169+
"--since",
170+
"1h",
171+
"--limit",
172+
"5",
173+
]);
174+
175+
expect(resolved.calls).toEqual([{ target: PRODUCTION_TARGET }]);
176+
const call = subject.core.observability.calls[0]!;
177+
expect(call.args[0]).toEqual({
178+
logGroupName: "/aws/bedrock-agentcore/runtimes/inventory-AbCdEf1234-BLUE",
179+
});
180+
expect(call.args[1] as ListTracesQuery).toMatchObject({ limit: 5 });
181+
expect(call.args[2]).toEqual({
182+
region: PRODUCTION_TARGET.region,
183+
endpointUrl: undefined,
184+
credentials: TARGET_CREDENTIAL_PROVIDER,
185+
});
186+
});
187+
188+
test("downloads a trace from the resolved Runtime", async () => {
189+
await inProject([RUNTIMES[0]]);
190+
const resolved = backend();
191+
const subject = command(resolved.value);
192+
subject.core.observability.traceRecords = [
193+
{ "@timestamp": "2026-09-10 12:00:00.000", "@message": { body: "hello" } },
194+
];
195+
196+
await subject.run(["get", "abc123def456", "--output", "traces/trace.json"]);
197+
198+
const call = subject.core.observability.calls[0]!;
199+
expect(call.method).toBe("getTrace");
200+
expect(call.args[0]).toEqual({
201+
logGroupName: "/aws/bedrock-agentcore/runtimes/checkout-AbCdEf1234-DEFAULT",
202+
});
203+
expect(call.args[1] as GetTraceQuery).toMatchObject({ traceId: "abc123def456" });
204+
expect(call.args[2]).toEqual({
205+
region: DEFAULT_TARGET.region,
206+
endpointUrl: undefined,
207+
credentials: TARGET_CREDENTIAL_PROVIDER,
208+
});
209+
210+
const output = join(process.cwd(), "traces", "trace.json");
211+
expect(subject.io.stdout()).toBe(output);
212+
expect(JSON.parse(await readFile(output, "utf8"))).toEqual(
213+
subject.core.observability.traceRecords,
214+
);
215+
});
216+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import z from "zod";
2+
import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../core/observability/index";
3+
import type { AppIO } from "../../../io";
4+
import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets";
5+
import { flag, ProjectKey, Router, type Context } from "../../../router";
6+
import { createGetTraceHandler, createListTracesHandler } from "../../observability/traces";
7+
import { resolveTraceOutputPath } from "../../observability/traceOutputPath";
8+
import type { ResourceFlagValues } from "../../observability/types";
9+
import type { Core } from "../../types";
10+
import { coreOptsFromCtx } from "../../utils";
11+
import { selectProjectResource } from "../selection";
12+
13+
const projectRuntimeFlags = [
14+
flag("name", "the logical project Runtime name", z.string().optional()),
15+
flag("target", "project deployment target", z.string().min(1).default(DEFAULT_TARGET_NAME)),
16+
flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()),
17+
] as const;
18+
19+
type ProjectRuntimeFlagValues = ResourceFlagValues<typeof projectRuntimeFlags>;
20+
21+
async function resolveProjectRuntime(core: Core, ctx: Context, flags: ProjectRuntimeFlagValues) {
22+
const project = ctx.require(ProjectKey);
23+
const name = selectProjectResource(project, "runtime", flags.name, "inspect traces for");
24+
const deployed = await core.projectManager.resolveDeployedResource(project, {
25+
target: flags.target,
26+
resourceType: "runtime",
27+
name,
28+
});
29+
30+
return {
31+
source: {
32+
logGroupName: runtimeLogGroup(deployed.id, flags.qualifier ?? DEFAULT_ENDPOINT_QUALIFIER),
33+
},
34+
options: {
35+
...coreOptsFromCtx(ctx),
36+
region: deployed.target.region,
37+
credentials: deployed.credentialProvider,
38+
},
39+
};
40+
}
41+
42+
export function createProjectRuntimeTracesHandler(core: Core, io: AppIO): Router {
43+
const list = createListTracesHandler(io, {
44+
description: "list a Runtime's recent traces",
45+
flags: projectRuntimeFlags,
46+
read: async (ctx, flags, query, signal) => {
47+
const { source, options } = await resolveProjectRuntime(core, ctx, flags);
48+
return core.observability.listTraces(source, query, options, signal);
49+
},
50+
});
51+
const get = createGetTraceHandler(io, {
52+
description: "download a trace's log records to a JSON file",
53+
flags: projectRuntimeFlags,
54+
read: async (ctx, flags, query, signal) => {
55+
const { source, options } = await resolveProjectRuntime(core, ctx, flags);
56+
return core.observability.getTrace(source, query, options, signal);
57+
},
58+
resolveOutputPath: (_ctx, _flags, request) => resolveTraceOutputPath(request),
59+
});
60+
61+
return new Router("runtime", "inspect a Runtime's traces").handler(list).handler(get);
62+
}

0 commit comments

Comments
 (0)