From fee7719d1b6b2c287eefc3d3043fae00c44b8f12 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 10 Sep 2026 15:21:53 -0400 Subject: [PATCH 1/2] feat: add project aware observability support for harness --- src/handlers/project/log/harness.test.tsx | 132 ++++++++++++++ src/handlers/project/log/harness.tsx | 55 ++++++ src/handlers/project/log/index.ts | 4 +- src/handlers/project/traces/harness.test.tsx | 176 +++++++++++++++++++ src/handlers/project/traces/harness.tsx | 71 ++++++++ src/handlers/project/traces/index.ts | 4 +- 6 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 src/handlers/project/log/harness.test.tsx create mode 100644 src/handlers/project/log/harness.tsx create mode 100644 src/handlers/project/traces/harness.test.tsx create mode 100644 src/handlers/project/traces/harness.tsx diff --git a/src/handlers/project/log/harness.test.tsx b/src/handlers/project/log/harness.test.tsx new file mode 100644 index 000000000..65011217d --- /dev/null +++ b/src/handlers/project/log/harness.test.tsx @@ -0,0 +1,132 @@ +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 type { 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 HARNESS = { name: "support", path: "app/support" } as const; + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function inProject(harnesses: readonly unknown[], targets = [DEFAULT_TARGET]) { + const root = await mkdtemp(join(tmpdir(), "agentcore-project-harness-log-")); + temporaryDirectories.push(root); + await mkdir(join(root, "agentcore"), { recursive: true }); + const spec = ProjectSpecSchema.parse({ + name: "orders", + version: 1, + harnesses, + }); + await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); + await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify(targets)); + process.chdir(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.harnesses.map(({ name }) => ({ + resourceType: "harness" as const, + name, + id: `${name}-AbCdEf1234`, + target: input.target, + })); + }, + async resolveProjectResources() { + throw new Error("project Harness logs resolve deployed 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", + "log", + "harness", + ...args, + "--region", + "us-east-1", + ]), + }; +} + +describe("project log harness", () => { + test("resolves the Harness and its managed Runtime before tailing logs", async () => { + await inProject([HARNESS]); + const resolved = backend(); + const subject = command(resolved.value); + subject.core.harness.setResolvedRuntime({ + runtimeId: "harness_support-XyZ123", + runtimeName: "harness_support", + }); + subject.core.observability.logEvents = [ + { timestamp: new Date("2026-09-10T12:00:00Z"), message: "ready" }, + ]; + + await subject.run(); + + expect(resolved.calls).toEqual([{ target: DEFAULT_TARGET }]); + const harnessCall = subject.core.harness.calls[0]!; + expect(harnessCall.method).toBe("resolveRuntime"); + expect(harnessCall.args[0]).toBe("support-AbCdEf1234"); + expect(harnessCall.args[1]).toEqual({ + region: DEFAULT_TARGET.region, + endpointUrl: undefined, + }); + + const call = subject.core.observability.calls[0]!; + expect(call.method).toBe("tailLogs"); + expect(call.args[0] as LogSource).toEqual({ + logGroupName: "/aws/bedrock-agentcore/runtimes/harness_support-XyZ123-DEFAULT", + }); + expect(call.args[2]).toEqual({ region: DEFAULT_TARGET.region, endpointUrl: undefined }); + expect(subject.io.stderr()).toContain( + "Streaming logs for Harness 'support' on target 'default'... (Ctrl+C to stop)", + ); + expect(subject.io.stdout()).toBe("2026-09-10T12:00:00.000Z ready"); + }); +}); diff --git a/src/handlers/project/log/harness.tsx b/src/handlers/project/log/harness.tsx new file mode 100644 index 000000000..e55ca7c84 --- /dev/null +++ b/src/handlers/project/log/harness.tsx @@ -0,0 +1,55 @@ +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 } from "../../../router"; +import { createLogsHandler } from "../../observability/logs"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { selectProjectResource } from "../selection"; + +const projectHarnessFlags = [ + flag("name", "the logical project Harness name", z.string().optional()), + flag("target", "project deployment target", z.string().min(1).default(DEFAULT_TARGET_NAME)), + flag("qualifier", "the Harness endpoint qualifier", z.string().min(1).optional()), +] as const; + +export const createProjectHarnessLogHandler = (core: Core, io: AppIO) => + createLogsHandler(io, { + name: "harness", + description: "stream or search logs for a Harness in the current project", + flags: projectHarnessFlags, + read: async (ctx, flags, request, signal) => { + const project = ctx.require(ProjectKey); + const name = selectProjectResource(project, "harness", flags.name, "inspect logs for"); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "harness", + name, + }); + const options = { + ...coreOptsFromCtx(ctx), + region: deployed.target.region, + }; + const runtime = await core.harness.resolveRuntime(deployed.id, options, signal); + const source = { + logGroupName: runtimeLogGroup( + runtime.runtimeId, + flags.qualifier ?? DEFAULT_ENDPOINT_QUALIFIER, + ), + }; + + if (request.mode === "search") { + return { + events: core.observability.searchLogs(source, request.query, options, signal), + }; + } + + return { + events: core.observability.tailLogs(source, request.query, options, signal), + announcement: + `Streaming logs for Harness '${name}' on target '${deployed.target.name}'... ` + + "(Ctrl+C to stop)", + }; + }, + }); diff --git a/src/handlers/project/log/index.ts b/src/handlers/project/log/index.ts index 0317a6fc3..082beb1a1 100644 --- a/src/handlers/project/log/index.ts +++ b/src/handlers/project/log/index.ts @@ -2,10 +2,12 @@ import type { AppIO } from "../../../io"; import { withProject } from "../../../middleware"; import { Router } from "../../../router"; import type { Core } from "../../types"; +import { createProjectHarnessLogHandler } from "./harness"; import { createProjectRuntimeLogHandler } from "./runtime"; export function createProjectLogHandler(core: Core, io: AppIO): Router { return new Router("log", "inspect logs for resources in the current project") .use(withProject({ projectManager: core.projectManager })) - .handler(createProjectRuntimeLogHandler(core, io)); + .handler(createProjectRuntimeLogHandler(core, io)) + .handler(createProjectHarnessLogHandler(core, io)); } diff --git a/src/handlers/project/traces/harness.test.tsx b/src/handlers/project/traces/harness.test.tsx new file mode 100644 index 000000000..4ca3772d6 --- /dev/null +++ b/src/handlers/project/traces/harness.test.tsx @@ -0,0 +1,176 @@ +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 } 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 HARNESSES = [ + { name: "support", path: "app/support" }, + { name: "sales", path: "app/sales" }, +] as const; + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function inProject( + harnesses: readonly unknown[], + targets = [DEFAULT_TARGET, PRODUCTION_TARGET], +) { + const root = await mkdtemp(join(tmpdir(), "agentcore-project-harness-traces-")); + temporaryDirectories.push(root); + await mkdir(join(root, "agentcore"), { recursive: true }); + const spec = ProjectSpecSchema.parse({ + name: "orders", + version: 1, + harnesses, + }); + await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); + await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify(targets)); + process.chdir(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.harnesses.map(({ name }) => ({ + resourceType: "harness" as const, + name, + id: `${name}-AbCdEf1234`, + target: input.target, + })); + }, + async resolveProjectResources() { + throw new Error("project Harness traces resolve deployed 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", + "harness", + ...args, + "--region", + "us-east-1", + ]), + }; +} + +describe("project traces harness", () => { + test("selects a named Harness, target, and endpoint qualifier", async () => { + await inProject(HARNESSES); + const resolved = backend(); + const subject = command(resolved.value); + subject.core.harness.setResolvedRuntime({ + runtimeId: "harness_sales-XyZ456", + runtimeName: "harness_sales", + }); + + await subject.run([ + "list", + "--name", + "sales", + "--target", + "production", + "--qualifier", + "BLUE", + "--since", + "1h", + "--limit", + "5", + ]); + + expect(resolved.calls).toEqual([{ target: PRODUCTION_TARGET }]); + const harnessCall = subject.core.harness.calls[0]!; + expect(harnessCall.args[0]).toBe("sales-AbCdEf1234"); + expect(harnessCall.args[1]).toEqual({ + region: PRODUCTION_TARGET.region, + endpointUrl: undefined, + }); + + const call = subject.core.observability.calls[0]!; + expect(call.args[0]).toEqual({ + logGroupName: "/aws/bedrock-agentcore/runtimes/harness_sales-XyZ456-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 Harness Runtime", async () => { + await inProject([HARNESSES[0]]); + const resolved = backend(); + const subject = command(resolved.value); + subject.core.harness.setResolvedRuntime({ + runtimeId: "harness_support-XyZ123", + runtimeName: "harness_support", + }); + 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/harness_support-XyZ123-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/harness.tsx b/src/handlers/project/traces/harness.tsx new file mode 100644 index 000000000..e16914b1c --- /dev/null +++ b/src/handlers/project/traces/harness.tsx @@ -0,0 +1,71 @@ +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 projectHarnessFlags = [ + flag("name", "the logical project Harness name", z.string().optional()), + flag("target", "project deployment target", z.string().min(1).default(DEFAULT_TARGET_NAME)), + flag("qualifier", "the Harness endpoint qualifier", z.string().min(1).optional()), +] as const; + +type ProjectHarnessFlagValues = ResourceFlagValues; + +async function resolveProjectHarness( + core: Core, + ctx: Context, + flags: ProjectHarnessFlagValues, + signal: AbortSignal, +) { + const project = ctx.require(ProjectKey); + const name = selectProjectResource(project, "harness", flags.name, "inspect traces for"); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "harness", + name, + }); + const options = { + ...coreOptsFromCtx(ctx), + region: deployed.target.region, + }; + const runtime = await core.harness.resolveRuntime(deployed.id, options, signal); + + return { + source: { + logGroupName: runtimeLogGroup( + runtime.runtimeId, + flags.qualifier ?? DEFAULT_ENDPOINT_QUALIFIER, + ), + }, + options, + }; +} + +export function createProjectHarnessTracesHandler(core: Core, io: AppIO): Router { + const list = createListTracesHandler(io, { + description: "list a Harness's recent traces", + flags: projectHarnessFlags, + read: async (ctx, flags, query, signal) => { + const { source, options } = await resolveProjectHarness(core, ctx, flags, signal); + return core.observability.listTraces(source, query, options, signal); + }, + }); + const get = createGetTraceHandler(io, { + description: "download a Harness trace's log records to a JSON file", + flags: projectHarnessFlags, + read: async (ctx, flags, query, signal) => { + const { source, options } = await resolveProjectHarness(core, ctx, flags, signal); + return core.observability.getTrace(source, query, options, signal); + }, + resolveOutputPath: (_ctx, _flags, request) => resolveTraceOutputPath(request), + }); + + return new Router("harness", "inspect a Harness's traces").handler(list).handler(get); +} diff --git a/src/handlers/project/traces/index.ts b/src/handlers/project/traces/index.ts index 5cbe4e788..12c8a9d78 100644 --- a/src/handlers/project/traces/index.ts +++ b/src/handlers/project/traces/index.ts @@ -2,10 +2,12 @@ import type { AppIO } from "../../../io"; import { withProject } from "../../../middleware"; import { Router } from "../../../router"; import type { Core } from "../../types"; +import { createProjectHarnessTracesHandler } from "./harness"; 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)); + .handler(createProjectRuntimeTracesHandler(core, io)) + .handler(createProjectHarnessTracesHandler(core, io)); } From 186b208a24a58aa5aee824a0d817620d61677c0d Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 10 Sep 2026 15:22:15 -0400 Subject: [PATCH 2/2] chore: update readMe with harness project observability --- README.md | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 993180b04..685017e34 100644 --- a/README.md +++ b/README.md @@ -190,36 +190,47 @@ agentcore project invoke harness \ Use `--target` to select a deployment target. When a project declares exactly one resource of the requested type, `--name` may be omitted. -### Inspect project Runtime logs +### Inspect project logs -Project logging resolves a logical Runtime name through the selected deployment -target, so the physical Runtime ID and deployment region do not need to be +Project logging resolves a logical resource name through the selected +deployment target, so physical IDs and deployment regions do not need to be supplied: ```bash agentcore project log runtime agentcore project log runtime --name checkout --target production agentcore project log runtime --name checkout --since 1h --level error +agentcore project log harness +agentcore project log harness --name support --target production +agentcore project log harness --name support --since 1h --level error ``` -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. +When the project declares exactly one resource of the requested type, `--name` +may be omitted. For Harnesses, the CLI also resolves the managed Harness to its +underlying Runtime before reading CloudWatch. Use the imperative +`agentcore runtime logs` or `agentcore harness logs` commands when addressing a +physical resource directly or working outside a project. -### Inspect project Runtime traces +### Inspect project traces -Project tracing uses the same logical Runtime and deployment target resolution, -then lists or downloads traces from the resolved Runtime's deployment region: +Project tracing uses the same logical resource 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 +agentcore project traces harness list +agentcore project traces harness list --name support --target production --since 30m +agentcore project traces harness get --name support --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. +When the project declares exactly one resource of the requested type, `--name` +may be omitted. For Harnesses, the CLI resolves the underlying Runtime before +querying its traces. Use the imperative `agentcore runtime traces` or +`agentcore harness traces` commands when addressing a physical resource +directly or working outside a project. ### Examples @@ -298,9 +309,12 @@ 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 +# Resolve project resources by logical name and deployment target +agentcore project log harness --name support --target production --since 1h agentcore project traces runtime list --name checkout --target production --since 30m agentcore project traces runtime get --name checkout --output trace.json +agentcore project traces harness list --name support --target production --since 30m +agentcore project traces harness get --name support --output trace.json # Inspect AgentCore Memories without project configuration or deployment agentcore memory get --id