diff --git a/README.md b/README.md index 79822cc32..0f5c5bb2c 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ agentcore # interactive TUI │ │ └── 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 └── config # read/write global config values ``` @@ -186,6 +188,22 @@ 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 + +Project logging resolves a logical Runtime name through the selected deployment +target, so the Runtime ID and deployment region 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 +``` + +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. + ### Examples ```bash @@ -251,7 +269,7 @@ agentcore runtime version list --id --max-results 20 agentcore runtime endpoint get --id --qualifier DEFAULT agentcore runtime endpoint list --id --max-results 20 -# Follow a Runtime's logs live (Ctrl+C to stop); inside a project --id is optional +# Follow a Runtime's logs live by resource ID (Ctrl+C to stop) agentcore runtime logs --id agentcore runtime logs --id --level error --query "database" diff --git a/src/core/eval.tsx b/src/core/eval.tsx index bf9080d51..170497193 100644 --- a/src/core/eval.tsx +++ b/src/core/eval.tsx @@ -115,8 +115,8 @@ import { runtimeLogGroup, sanitizeQueryValue, type InsightsRowLimit, -} from "./observability"; -import { CloudWatchClient } from "./observability/index"; + CloudWatchClient, +} from "./observability/index"; import type { BatchEvaluationDetail, CodeBasedUpdate, diff --git a/src/core/index.tsx b/src/core/index.tsx index d6c8658b0..368c564b7 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -8,11 +8,9 @@ import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; import { PolicyClient } from "./policy"; -import { ObservabilityClient } from "./observability"; -import { CloudWatchClient } from "./observability/index"; +import { CloudWatchClient, ObservabilityClient } from "./observability/index"; import { RuntimeClient } from "./runtime"; import type { OpenRuntimeShell } from "./runtime"; -import { FsReadWriteJson } from "../io"; import type { AwsClients, AwsCredentials, @@ -113,14 +111,7 @@ export class CoreClient implements AwsClients { cloudWatch, ); - // Observability resolves a project's deployed runtime from its stack - // outputs, so it reads aws-targets.json through the same JSON layer the - // project manager uses. - this.observability = new ObservabilityClient(cloudWatch, { - readJson: new FsReadWriteJson({ - logger: this.logger.child({ module: "observability" }), - }), - }); + this.observability = new ObservabilityClient(cloudWatch); this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), diff --git a/src/core/observability.test.ts b/src/core/observability.test.ts deleted file mode 100644 index 80dc92d60..000000000 --- a/src/core/observability.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - GetQueryResultsCommand, - StartQueryCommand, - type CloudWatchLogsClient, -} from "@aws-sdk/client-cloudwatch-logs"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - CloudWatchQueryError, - InputValidationError, - ProjectStateError, - ResourceNotFoundError, - ResultTruncationError, -} from "../errors"; -import type { ReadWriteJson } from "../io"; -import type { Project } from "../handlers/project/types"; -import type { AwsClients } from "./types"; -import { - ObservabilityClient, - runInsightsQuery, - runtimeLogGroup, - sanitizeQueryValue, - type DescribeStackOutputs, -} from "./observability"; -import { CloudWatchClient } from "./observability/index"; - -describe("runtimeLogGroup", () => { - test("derives the fixed per-runtime path keyed by runtime id and endpoint", () => { - expect(runtimeLogGroup("my_agent-AbC123XyZ9", "DEFAULT")).toBe( - "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", - ); - }); -}); - -describe("sanitizeQueryValue", () => { - test("strips single quotes so values cannot escape a quoted Insights literal", () => { - expect(sanitizeQueryValue("abc'| drop '123")).toBe("abc| drop 123"); - expect(sanitizeQueryValue("clean-id")).toBe("clean-id"); - }); -}); - -type Send = (command: unknown) => Promise; - -function fakeLogs(send: Send): CloudWatchLogsClient { - return { send } as unknown as CloudWatchLogsClient; -} - -function row(field: string, value: string) { - return [{ field, value }]; -} - -describe("runInsightsQuery", () => { - test("starts the query, waits for completion, and drains every result page", async () => { - // Poll phase sees Complete on the first read; the drain phase then re-reads - // page one and follows nextToken to page two. - const logs = fakeLogs(async (command) => { - if (command instanceof StartQueryCommand) { - expect(command.input).toEqual({ - logGroupNames: ["/aws/group-a", "/aws/group-b"], - queryString: "fields @message", - startTime: 100, - endTime: 200, - }); - return { queryId: "q-1" }; - } - expect(command).toBeInstanceOf(GetQueryResultsCommand); - const input = (command as GetQueryResultsCommand).input; - expect(input.queryId).toBe("q-1"); - if (input.nextToken === "page-2") { - return { status: "Complete", results: [row("@message", "second")] }; - } - return { - status: "Complete", - results: [row("@message", "first")], - nextToken: "page-2", - }; - }); - - const rows = await runInsightsQuery( - logs, - ["/aws/group-a", "/aws/group-b"], - "fields @message", - 100, - 200, - ); - expect(rows).toEqual([row("@message", "first"), row("@message", "second")]); - }); - - test("throws a typed error when the query reaches a terminal failure state", async () => { - const logs = fakeLogs(async (command) => { - if (command instanceof StartQueryCommand) return { queryId: "q-2" }; - return { status: "Failed" }; - }); - - await expect(runInsightsQuery(logs, ["/aws/g"], "q", 0, 1)).rejects.toThrow( - CloudWatchQueryError, - ); - await expect(runInsightsQuery(logs, ["/aws/g"], "q", 0, 1)).rejects.toThrow( - "CloudWatch Logs Insights query failed", - ); - }); - - test("fails loudly with the default truncation error when the row ceiling is hit", async () => { - const logs = fakeLogs(async (command) => { - if (command instanceof StartQueryCommand) return { queryId: "q-3" }; - return { status: "Complete", results: [row("@message", "a"), row("@message", "b")] }; - }); - - await expect( - runInsightsQuery(logs, ["/aws/g"], "q", 0, 1, { - maxRows: 2, - buildError: (maxRows) => new ResultTruncationError(`hit ceiling ${maxRows}`), - }), - ).rejects.toThrow("hit ceiling 2"); - }); - - test("lets the caller supply a domain-specific row-ceiling error", async () => { - const logs = fakeLogs(async (command) => { - if (command instanceof StartQueryCommand) return { queryId: "q-4" }; - return { status: "Complete", results: [row("@message", "a")] }; - }); - - await expect( - runInsightsQuery(logs, ["/aws/g"], "q", 0, 1, { - maxRows: 1, - buildError: () => new InputValidationError("narrow the scope"), - }), - ).rejects.toThrow(InputValidationError); - }); -}); - -const OPTIONS = { region: "us-east-1" }; - -function clientWith(logs: CloudWatchLogsClient, describeStackOutputs?: DescribeStackOutputs) { - const clients = { logs: () => logs } as unknown as AwsClients; - const readJson: ReadWriteJson = { - read: async (filePath, schema) => - schema.parse(JSON.parse(await Bun.file(filePath).text())) as never, - write: async () => { - throw new Error("not implemented"); - }, - } as ReadWriteJson; - return new ObservabilityClient(new CloudWatchClient(clients), { - readJson, - describeStackOutputs, - }); -} - -function fakeProject(rootPath: string, name = "My_Project"): Project { - return { name, rootPath, spec: {} } as unknown as Project; -} - -function projectWithTargets( - targets: { name: string; account: string; region: string }[] | undefined, -): Project { - const root = mkdtempSync(join(tmpdir(), "obs-test-")); - if (targets) { - mkdirSync(join(root, "agentcore"), { recursive: true }); - writeFileSync(join(root, "agentcore", "aws-targets.json"), JSON.stringify(targets)); - } - return fakeProject(root); -} - -const TARGETS = [{ name: "default", account: "111122223333", region: "us-east-2" }]; - -describe("ObservabilityClient.resolveDeployedRuntime", () => { - const noLogs = fakeLogs(async () => { - throw new Error("unexpected CloudWatch call"); - }); - - test("resolves the single deployed runtime from the target stack's outputs", async () => { - const described: { stackName?: string; region?: string } = {}; - const client = clientWith(noLogs, async (stackName, region) => { - described.stackName = stackName; - described.region = region; - return [ - { OutputKey: "StackNameOutput", OutputValue: "AgentCore-My-Project-default" }, - { - OutputKey: "ApplicationAgentHelloWorldRuntimeArnOutput0DF4BB9A", - OutputValue: "arn:aws:bedrock-agentcore:us-east-2:1:runtime/hello_world-AbC", - }, - { - OutputKey: "ApplicationAgentHelloWorldRuntimeIdOutput1CCED486", - OutputValue: "hello_world-AbC123XyZ9", - }, - ]; - }); - - const resolved = await client.resolveDeployedRuntime(projectWithTargets(TARGETS), "default"); - - // The stack name mirrors the vended CDK app: underscores sanitized to hyphens. - expect(described).toEqual({ stackName: "AgentCore-My-Project-default", region: "us-east-2" }); - expect(resolved).toEqual({ - runtimeId: "hello_world-AbC123XyZ9", - region: "us-east-2", - stackName: "AgentCore-My-Project-default", - targetName: "default", - }); - }); - - test("lists the candidates when several runtimes are deployed", async () => { - const client = clientWith(noLogs, async () => [ - { OutputKey: "ApplicationAgentOneRuntimeIdOutputAAAAAAAA", OutputValue: "one-AAAA" }, - { OutputKey: "ApplicationAgentTwoRuntimeIdOutputBBBBBBBB", OutputValue: "two-BBBB" }, - ]); - - await expect( - client.resolveDeployedRuntime(projectWithTargets(TARGETS), "default"), - ).rejects.toThrow("choose one with --id: one-AAAA, two-BBBB"); - }); - - test("fails with deploy guidance when the stack does not exist", async () => { - const client = clientWith(noLogs, async () => undefined); - - await expect( - client.resolveDeployedRuntime(projectWithTargets(TARGETS), "default"), - ).rejects.toThrow( - "Stack 'AgentCore-My-Project-default' is not deployed in us-east-2. " + - "Run 'agentcore project deploy' first, or pass --id .", - ); - }); - - test("fails when the stack exports no runtime ids", async () => { - const client = clientWith(noLogs, async () => [ - { OutputKey: "StackNameOutput", OutputValue: "AgentCore-My-Project-default" }, - ]); - - await expect( - client.resolveDeployedRuntime(projectWithTargets(TARGETS), "default"), - ).rejects.toThrow(ResourceNotFoundError); - }); - - test("fails when the named target is not configured", async () => { - const client = clientWith(noLogs, async () => []); - - await expect( - client.resolveDeployedRuntime(projectWithTargets(TARGETS), "production"), - ).rejects.toThrow("has no deployment target named 'production'"); - }); - - test("fails when the project has no aws-targets.json", async () => { - const client = clientWith(noLogs, async () => []); - - await expect( - client.resolveDeployedRuntime(projectWithTargets(undefined), "default"), - ).rejects.toThrow(ProjectStateError); - }); -}); - -// insightsLogs fakes the StartQuery/GetQueryResults protocol: every query -// completes immediately with `results`, and each StartQuery input is recorded. -function insightsLogs(results: { field: string; value: string }[][]) { - const queries: { - logGroupNames?: string[]; - queryString?: string; - startTime?: number; - endTime?: number; - }[] = []; - const logs = fakeLogs(async (command) => { - if (command instanceof StartQueryCommand) { - queries.push(command.input); - return { queryId: "q-traces" }; - } - expect(command).toBeInstanceOf(GetQueryResultsCommand); - return { status: "Complete", results }; - }); - return { logs, queries }; -} - -const TRACE_SOURCE = { - logGroupName: "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", -}; - -describe("ObservabilityClient.listTraces", () => { - const QUERY = { - startTimeMs: 1_700_000_000_123, - endTimeMs: 1_700_003_600_456, - limit: 5, - }; - - test("aggregates traces with a stats-by-traceId query over the runtime log group", async () => { - const { logs, queries } = insightsLogs([]); - - await clientWith(logs).listTraces(TRACE_SOURCE, QUERY, OPTIONS); - - expect(queries).toHaveLength(1); - expect(queries[0]!.logGroupNames).toEqual([TRACE_SOURCE.logGroupName]); - // Epoch ms narrows to whole seconds. - expect(queries[0]!.startTime).toBe(1_700_000_000); - expect(queries[0]!.endTime).toBe(1_700_003_600); - expect(queries[0]!.queryString).toBe( - 'filter ispresent(traceId) and traceId != ""\n' + - "| stats earliest(@timestamp) as firstSeen, latest(@timestamp) as lastSeen, " + - "count(*) as spanCount, earliest(attributes.session.id) as sessionId by traceId\n" + - "| sort lastSeen desc\n" + - "| limit 5", - ); - }); - - test("parses result rows into trace summaries, skipping rows without a trace id", async () => { - const { logs } = insightsLogs([ - [ - { field: "traceId", value: "abc123" }, - { field: "firstSeen", value: "1700000000000" }, - { field: "lastSeen", value: "1700000005000" }, - { field: "spanCount", value: "12" }, - { field: "sessionId", value: "session-1" }, - ], - [{ field: "lastSeen", value: "1700000001000" }], - [ - { field: "traceId", value: "def456" }, - { field: "firstSeen", value: "1700000002000" }, - ], - ]); - - const traces = await clientWith(logs).listTraces(TRACE_SOURCE, QUERY, OPTIONS); - - expect(traces).toEqual([ - { - traceId: "abc123", - timestamp: "1700000005000", - sessionId: "session-1", - spanCount: "12", - }, - // lastSeen falls back to firstSeen; sessionId/spanCount stay undefined. - { traceId: "def456", timestamp: "1700000002000", sessionId: undefined, spanCount: undefined }, - ]); - }); -}); - -describe("ObservabilityClient.getTrace", () => { - const QUERY = { - traceId: "68b2fabc0000000000abcdef", - startTimeMs: 1_700_000_000_000, - endTimeMs: 1_700_003_600_000, - }; - - test("rejects a malformed trace id before querying", async () => { - const logs = fakeLogs(async () => { - throw new Error("must not be called"); - }); - - await expect( - clientWith(logs).getTrace(TRACE_SOURCE, { ...QUERY, traceId: "not'a$trace" }, OPTIONS), - ).rejects.toThrow("Invalid trace ID format. Expected a hex string (e.g., abc123def456)."); - }); - - test("downloads the trace's records with @message parsed when it is JSON", async () => { - const { logs, queries } = insightsLogs([ - [ - { field: "@timestamp", value: "2026-08-30 12:00:00.000" }, - { field: "@message", value: '{"traceId":"68b2fabc","body":"hello"}' }, - { field: "@ptr", value: "pointer-1" }, - ], - [ - { field: "@timestamp", value: "2026-08-30 12:00:01.000" }, - { field: "@message", value: "not json" }, - ], - ]); - - const records = await clientWith(logs).getTrace(TRACE_SOURCE, QUERY, OPTIONS); - - expect(queries[0]!.queryString).toBe( - "fields @timestamp, @message\n" + - "| filter traceId = '68b2fabc0000000000abcdef'\n" + - "| sort @timestamp asc\n" + - "| limit 10000", - ); - expect(records).toEqual([ - { - "@timestamp": "2026-08-30 12:00:00.000", - "@message": { traceId: "68b2fabc", body: "hello" }, - "@ptr": "pointer-1", - }, - { "@timestamp": "2026-08-30 12:00:01.000", "@message": "not json" }, - ]); - }); - - test("fails when the trace has no records", async () => { - const { logs } = insightsLogs([]); - - await expect(clientWith(logs).getTrace(TRACE_SOURCE, QUERY, OPTIONS)).rejects.toThrow( - "No trace data found for trace ID: 68b2fabc0000000000abcdef", - ); - }); - - test("returns every record when the trace reaches the 10,000-record query limit", async () => { - const { logs } = insightsLogs( - Array.from({ length: 10_000 }, (_, index) => [ - { field: "@message", value: `record-${index}` }, - ]), - ); - - const records = await clientWith(logs).getTrace(TRACE_SOURCE, QUERY, OPTIONS); - - expect(records).toHaveLength(10_000); - }); -}); diff --git a/src/core/observability.ts b/src/core/observability.ts deleted file mode 100644 index f014ee400..000000000 --- a/src/core/observability.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { - GetQueryResultsCommand, - StartQueryCommand, - type CloudWatchLogsClient, - type ResultField, -} from "@aws-sdk/client-cloudwatch-logs"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { - CloudWatchQueryError, - InputValidationError, - ProjectStateError, - ResourceNotFoundError, - ResultTruncationError, - type AgentCoreCLIError, -} from "../errors"; -import type { ReadWriteJson } from "../io"; -import type { Project } from "../handlers/project/types"; -import type { CoreObservabilityClient, DeployedRuntime } from "../handlers/runtime/types"; -import { AwsDeploymentTargetsSchema } from "../projectSchemas/aws-targets"; -import { - CloudWatchClient, - ObservabilityClient as GenericObservabilityClient, -} from "./observability/index"; -import { isStackNotFound } from "./project/backends/cdk/environment"; - -// Shared CloudWatch observability helpers. AgentCore Runtimes write their logs -// and OTel telemetry to per-runtime CloudWatch log groups; both the eval flows -// (session discovery, batch results) and the runtime observability commands -// (`runtime logs` / `runtime traces`) read them, so the derivations and the -// Logs Insights query runner live here rather than privately in one feature. - -/** The default runtime endpoint qualifier used when none is specified. */ -export const DEFAULT_ENDPOINT_QUALIFIER = "DEFAULT"; - -// CloudWatch Logs Insights hard ceiling: a query returns at most 100k rows. -export const INSIGHTS_MAX_ROWS = 100_000; - -/** - * CloudWatch log group path for an AgentCore runtime endpoint. AgentCore always - * writes a runtime endpoint's logs and traces to this fixed path, keyed by the - * runtime *id* (mirrors the old CLI's src/cli/aws/cloudwatch.ts derivation). - */ -export function runtimeLogGroup(runtimeId: string, endpoint: string): string { - return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${endpoint}`; -} - -/** - * Strips single quotes so an interpolated id can't break out of the quoted - * Insights filter literal it is embedded in (matches the old CLI). - */ -export function sanitizeQueryValue(value: string): string { - return value.replace(/'/g, ""); -} - -/** - * Row-ceiling policy for {@link runInsightsQuery}: when a query drains `maxRows` - * or more rows the result may be truncated, so the runner fails loudly with the - * caller's error rather than returning a silently partial result. Callers with - * a domain-specific remedy (e.g. eval's "narrow --session-ids") supply their - * own `buildError`. - */ -export interface InsightsRowLimit { - maxRows: number; - buildError: (maxRows: number) => AgentCoreCLIError; -} - -const DEFAULT_ROW_LIMIT: InsightsRowLimit = { - maxRows: INSIGHTS_MAX_ROWS, - buildError: (maxRows) => - new ResultTruncationError( - `CloudWatch Logs Insights returned too many rows (>= ${maxRows}); narrow the time window`, - ), -}; - -/** - * Starts a CloudWatch Logs Insights query, waits for it to finish, then drains - * all result pages. GetQueryResults returns <=10k rows per call, so a large - * result spans multiple pages (nextToken); dropping any would silently return a - * partial result. Fails fast when the row ceiling is hit — see - * {@link InsightsRowLimit}. - */ -export async function runInsightsQuery( - logs: CloudWatchLogsClient, - logGroupNames: string[], - queryString: string, - startSec: number, - endSec: number, - rowLimit: InsightsRowLimit = DEFAULT_ROW_LIMIT, -): Promise { - const started = await logs.send( - new StartQueryCommand({ logGroupNames, queryString, startTime: startSec, endTime: endSec }), - ); - const queryId = started.queryId; - - // Phase 1: wait for completion. A large scan can take minutes, so the deadline is - // generous; each poll costs one cheap GetQueryResults call. - let status = "Running"; - for (let i = 0; i < 300 && status !== "Complete"; i++) { - const result = await logs.send(new GetQueryResultsCommand({ queryId })); - status = result.status ?? "Unknown"; - if (status === "Failed" || status === "Cancelled" || status === "Timeout") { - throw new CloudWatchQueryError(`CloudWatch Logs Insights query ${status.toLowerCase()}`, { - meta: { queryId, status }, - }); - } - if (status !== "Complete") await new Promise((resolve) => setTimeout(resolve, 1000)); - } - if (status !== "Complete") { - throw new CloudWatchQueryError("CloudWatch Logs Insights query did not finish in time", { - meta: { queryId, status }, - }); - } - - // Phase 2: drain pages. Terminates on nextToken; total is bounded by the - // query's own `| limit`. - const rows: ResultField[][] = []; - let nextToken: string | undefined; - do { - const result = await logs.send(new GetQueryResultsCommand({ queryId, nextToken })); - rows.push(...(result.results ?? [])); - nextToken = result.nextToken; - } while (nextToken); - - if (rows.length >= rowLimit.maxRows) { - throw rowLimit.buildError(rowLimit.maxRows); - } - return rows; -} - -const RELATIVE_DURATION_RE = /^(\d+)([smhd])$/; - -const UNIT_TO_MS: Record = { - s: 1_000, - m: 60_000, - h: 3_600_000, - d: 86_400_000, -}; - -/** - * Parses a user-facing time string into epoch milliseconds. - * - * Supported forms (mirrors the old CLI's src/lib/utils/time-parser.ts): - * - "now" - * - Relative durations, meaning that long *ago*: "30s", "5m", "1h", "2d" - * - Epoch milliseconds: "1709391000000" (13+ digits) - * - Anything Date.parse accepts, e.g. ISO 8601: "2026-03-02T14:30:00Z" - * - * The reference clock is injectable for tests. - */ -export function parseTimeString(input: string, now: () => number = Date.now): number { - const trimmed = input.trim(); - if (trimmed === "") { - throw new InputValidationError("Time string cannot be empty"); - } - - if (trimmed === "now") { - return now(); - } - - const match = RELATIVE_DURATION_RE.exec(trimmed); - if (match) { - const value = parseInt(match[1]!, 10); - const ms = UNIT_TO_MS[match[2]!]!; - return now() - value * ms; - } - - // Epoch milliseconds: all digits, at least 13 of them — shorter all-digit - // strings fall through to Date parsing below, like the old CLI. - if (/^\d{13,}$/.test(trimmed)) { - return parseInt(trimmed, 10); - } - - const date = new Date(trimmed); - if (!isNaN(date.getTime())) { - return date.getTime(); - } - - throw new InputValidationError( - `Invalid time string: "${input}". Use relative durations (5m, 1h, 2d), ISO 8601, epoch ms, or "now".`, - ); -} - -/** - * Reads one stack's outputs via CloudFormation DescribeStacks, returning - * undefined when the stack does not exist. Injectable so unit tests never call - * AWS. - */ -export type DescribeStackOutputs = ( - stackName: string, - region: string, -) => Promise<{ OutputKey?: string; OutputValue?: string }[] | undefined>; - -// Real describer: lazily imports the CloudFormation SDK (kept off the CLI -// startup path, like the CDK backend's stackReader) and resolves credentials -// through the SDK's default provider chain, matching every other client -// factory in src/core/factories.tsx. -const describeStackOutputsWithSdk: DescribeStackOutputs = async (stackName, region) => { - const { CloudFormationClient, DescribeStacksCommand } = - await import("@aws-sdk/client-cloudformation"); - const client = new CloudFormationClient({ region }); - try { - const response = await client.send(new DescribeStacksCommand({ StackName: stackName })); - return response.Stacks?.[0]?.Outputs ?? []; - } catch (error) { - // A missing stack surfaces as a thrown ValidationError, not an empty result. - if (isStackNotFound(error)) return undefined; - throw error; - } finally { - client.destroy(); - } -}; - -// The vended CDK app names project stacks `AgentCore--` with -// underscores sanitized to hyphens (see src/assets/cdk/bin/cdk.ts). Deriving it -// here lets deployed state be read live from CloudFormation without a local -// state file. -function targetStackName(projectName: string, targetName: string): string { - const sanitize = (name: string) => name.replace(/_/g, "-"); - return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; -} - -// The L3 constructs export each runtime's id as a stack output whose -// CDK-generated logical id ends in `RuntimeIdOutput` plus an optional 8-char -// uppercase-hex uniquifier (e.g. ApplicationAgentHelloWorldRuntimeIdOutput1CCED486). -const RUNTIME_ID_OUTPUT_RE = /RuntimeIdOutput([0-9A-F]{8})?$/; - -export interface ObservabilityClientDeps { - /** Reads agentcore/aws-targets.json. */ - readJson: ReadWriteJson; - /** Stack-output reader; defaults to a live CloudFormation DescribeStacks. */ - describeStackOutputs?: DescribeStackOutputs; -} - -/** - * Project-resolution APIs retained for the existing project command path. - * Shared observability operations are inherited from the generic client. - */ -export class ObservabilityClient - extends GenericObservabilityClient - implements CoreObservabilityClient -{ - private readonly readJson: ReadWriteJson; - private readonly describeStackOutputs: DescribeStackOutputs; - - constructor(cloudWatch: CloudWatchClient, deps: ObservabilityClientDeps) { - super(cloudWatch); - this.readJson = deps.readJson; - this.describeStackOutputs = deps.describeStackOutputs ?? describeStackOutputsWithSdk; - } - - /** - * Resolves the single deployed runtime of `project`'s `targetName` target by - * reading the target's CloudFormation stack outputs. Exactly one deployed - * runtime resolves; none or several fail with guidance (pass --id to choose). - * The returned region is the deployment target's — that is where the stack - * and its log groups live. - */ - async resolveDeployedRuntime(project: Project, targetName: string): Promise { - const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); - if (!existsSync(targetsPath)) { - throw new ProjectStateError( - `Project '${project.name}' has no deployment targets (${targetsPath} not found). ` + - `Run 'agentcore project deploy' first, or pass --id .`, - ); - } - const targets = await this.readJson.read(targetsPath, AwsDeploymentTargetsSchema); - const target = targets.find((candidate) => candidate.name === targetName); - if (!target) { - throw new ProjectStateError( - `Project '${project.name}' has no deployment target named '${targetName}'. ` + - `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ") || "none"}.`, - ); - } - - const stackName = targetStackName(project.name, target.name); - const outputs = await this.describeStackOutputs(stackName, target.region); - if (outputs === undefined) { - throw new ProjectStateError( - `Stack '${stackName}' is not deployed in ${target.region}. ` + - `Run 'agentcore project deploy' first, or pass --id .`, - ); - } - - const runtimeIds = outputs - .filter((output) => output.OutputKey && RUNTIME_ID_OUTPUT_RE.test(output.OutputKey)) - .map((output) => output.OutputValue) - .filter((value): value is string => Boolean(value)); - - if (runtimeIds.length === 0) { - throw new ResourceNotFoundError( - `Stack '${stackName}' in ${target.region} exports no runtime ids. ` + - `Deploy a runtime first, or pass --id .`, - ); - } - if (runtimeIds.length > 1) { - throw new InputValidationError( - `Project '${project.name}' has multiple deployed runtimes; choose one with ` + - `--id: ${runtimeIds.join(", ")}`, - ); - } - - return { - runtimeId: runtimeIds[0]!, - region: target.region, - stackName, - targetName: target.name, - }; - } -} diff --git a/src/core/observability/index.ts b/src/core/observability/index.ts index 46527288c..6240857eb 100644 --- a/src/core/observability/index.ts +++ b/src/core/observability/index.ts @@ -1,5 +1,6 @@ export { CloudWatchClient } from "./cloudWatchClient"; export { ObservabilityClient } from "./client"; +export { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "./runtime"; export { INSIGHTS_MAX_ROWS, runInsightsQuery, @@ -9,6 +10,7 @@ export { export { TRACE_RECORD_LIMIT } from "./traces"; export type { CloudWatchLogEvent, + CoreObservabilityClient, GetTraceQuery, InsightsQuery, InsightsQueryRow, diff --git a/src/core/observability/runtime.test.ts b/src/core/observability/runtime.test.ts new file mode 100644 index 000000000..6f5169cac --- /dev/null +++ b/src/core/observability/runtime.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test"; +import { runtimeLogGroup } from "./runtime"; + +test("runtimeLogGroup derives the fixed per-runtime endpoint path", () => { + expect(runtimeLogGroup("my_agent-AbC123XyZ9", "DEFAULT")).toBe( + "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", + ); +}); diff --git a/src/core/observability/runtime.ts b/src/core/observability/runtime.ts new file mode 100644 index 000000000..c94a031eb --- /dev/null +++ b/src/core/observability/runtime.ts @@ -0,0 +1,5 @@ +export const DEFAULT_ENDPOINT_QUALIFIER = "DEFAULT"; + +export function runtimeLogGroup(runtimeId: string, endpoint: string): string { + return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${endpoint}`; +} diff --git a/src/core/observability/types.ts b/src/core/observability/types.ts index 202bdee2e..07ebe705d 100644 --- a/src/core/observability/types.ts +++ b/src/core/observability/types.ts @@ -1,4 +1,5 @@ import type { InsightsRowLimit } from "./insights"; +import type { CoreOptions } from "../types"; /** Explicit CloudWatch Logs location selected by a primitive handler. */ export type LogSource = { @@ -65,3 +66,37 @@ export type TraceSummary = { /** One telemetry record belonging to a trace */ export type TraceRecord = Record; + +/** Shared observability operations over an explicitly resolved log source. */ +export interface CoreObservabilityClient { + searchLogs( + source: LogSource, + query: LogSearchQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncIterable; + tailLogs( + source: LogSource, + query: LogTailQuery, + options: CoreOptions, + signal: AbortSignal, + ): AsyncIterable; + queryLogs( + source: LogSource, + query: InsightsQuery, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; + listTraces( + source: LogSource, + query: ListTracesQuery, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; + getTrace( + source: LogSource, + query: GetTraceQuery, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; +} diff --git a/src/handlers/harness/logs/index.tsx b/src/handlers/harness/logs/index.tsx index 634244964..075429f78 100644 --- a/src/handlers/harness/logs/index.tsx +++ b/src/handlers/harness/logs/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../core/observability"; +import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../core/observability/index"; import type { AppIO } from "../../../io"; import { flag } from "../../../router"; import { createLogsHandler } from "../../observability/logs"; @@ -13,6 +13,7 @@ const harnessFlags = [ export const createHarnessLogsHandler = (core: Core, io: AppIO) => createLogsHandler(io, { + name: "logs", description: "stream or search a harness's logs", flags: harnessFlags, read: async (ctx, flags, request, signal) => { diff --git a/src/handlers/harness/traces/get/index.tsx b/src/handlers/harness/traces/get/index.tsx index daa1be085..4133a6200 100644 --- a/src/handlers/harness/traces/get/index.tsx +++ b/src/handlers/harness/traces/get/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability"; +import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability/index"; import type { AppIO } from "../../../../io"; import { flag } from "../../../../router"; import { resolveTraceOutputPath } from "../../../observability/traceOutputPath"; diff --git a/src/handlers/harness/traces/list/index.tsx b/src/handlers/harness/traces/list/index.tsx index 02dd56c44..3d27e0ad3 100644 --- a/src/handlers/harness/traces/list/index.tsx +++ b/src/handlers/harness/traces/list/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability"; +import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability/index"; import type { AppIO } from "../../../../io"; import { flag } from "../../../../router"; import { createListTracesHandler } from "../../../observability/traces"; diff --git a/src/handlers/observability/logs.ts b/src/handlers/observability/logs.ts index 4a149012a..c5f5724f4 100644 --- a/src/handlers/observability/logs.ts +++ b/src/handlers/observability/logs.ts @@ -64,6 +64,7 @@ export type LogsReadResult = { export function createLogsHandler[]>( io: AppIO, config: { + name: string; description: string; flags: F; read( @@ -77,7 +78,7 @@ export function createLogsHandler[]>( const flags = [...config.flags, ...logFlags] as const; return createHandler({ - name: "logs", + name: config.name, description: config.description, flags, handle: async (ctx, values) => { diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index e50a4f040..f899d52bf 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -18,6 +18,7 @@ import type { ProjectManager } from "./types"; import { createAddProjectResourceHandler } from "./add"; import { createExportProjectResourceHandler } from "./export"; import { createProjectInvokeHandler } from "./invoke"; +import { createProjectLogHandler } from "./log"; type ProjectHandlerConfig = { core: Core; @@ -99,6 +100,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router ), ); project.handler(createProjectInvokeHandler(core, io)); + project.handler(createProjectLogHandler(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/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx index d7320fd02..c3d8a8b30 100644 --- a/src/handlers/project/invoke/harness.tsx +++ b/src/handlers/project/invoke/harness.tsx @@ -7,7 +7,7 @@ import { JsonKey, RegionKey } from "../../keys"; import { invokeHarnessTurn } from "../../harness/invoke/operation"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { selectProjectResource } from "./selection"; +import { selectProjectResource } from "../selection"; export const createProjectInvokeHarnessHandler = ( core: Core, @@ -34,7 +34,7 @@ export const createProjectInvokeHarnessHandler = ( ], handle: async (ctx, flags) => { const project = ctx.require(ProjectKey); - const name = selectProjectResource(project, "harness", flags.name); + const name = selectProjectResource(project, "harness", flags.name, "invoke"); const deployed = await core.projectManager.resolveDeployedResource(project, { target: flags.target, resourceType: "harness", diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx index e3976dcc8..f266a1a6b 100644 --- a/src/handlers/project/invoke/runtime.tsx +++ b/src/handlers/project/invoke/runtime.tsx @@ -17,7 +17,7 @@ import { import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { selectProjectResource } from "./selection"; +import { selectProjectResource } from "../selection"; export const createProjectInvokeRuntimeHandler = ( core: Core, @@ -130,7 +130,7 @@ export const createProjectInvokeRuntimeHandler = ( return; } - const name = selectProjectResource(project, "runtime", flags.name); + const name = selectProjectResource(project, "runtime", flags.name, "invoke"); const deployed = await core.projectManager.resolveDeployedResource(project, { target: flags.target ?? "default", resourceType: "runtime", diff --git a/src/handlers/project/log/index.ts b/src/handlers/project/log/index.ts new file mode 100644 index 000000000..0317a6fc3 --- /dev/null +++ b/src/handlers/project/log/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 { 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)); +} diff --git a/src/handlers/project/log/runtime.test.tsx b/src/handlers/project/log/runtime.test.tsx new file mode 100644 index 000000000..e68704f75 --- /dev/null +++ b/src/handlers/project/log/runtime.test.tsx @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ProjectBackend, ResolveDeployedResourcesBackendInput } from "../../../core/project"; +import type { LogSource } from "../../../core/observability/index"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { + createSilentLogger, + initProject, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { createRootHandler } from "../../index"; + +const cleanups: Array<() => Promise> = []; +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(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function inProject( + runtimes: readonly unknown[], + targets = [DEFAULT_TARGET, PRODUCTION_TARGET], +) { + const { projectRoot, cleanup } = await initProject({ + name: "orders", + flags: ["--template", "empty"], + prefix: "agentcore-project-log-", + }); + cleanups.push(cleanup); + const spec = ProjectSpecSchema.parse({ + name: "orders", + version: 1, + runtimes, + }); + await writeFile(join(projectRoot, "agentcore", "agentcore.json"), JSON.stringify(spec)); + await writeFile(join(projectRoot, "agentcore", "aws-targets.json"), JSON.stringify(targets)); +} + +function backend(options: { deployed?: boolean } = {}) { + const calls: ResolveDeployedResourcesBackendInput[] = []; + const value: ProjectBackend = { + async *build() {}, + async *deploy() { + yield* []; + return { outputs: {} }; + }, + async resolveDeployedResources(project, input) { + calls.push(input); + if (options.deployed === false) return []; + return project.spec.runtimes.map(({ name }) => ({ + resourceType: "runtime" as const, + name, + id: `${name}-AbCdEf1234`, + target: input.target, + })); + }, + async resolveProjectResources() { + throw new Error("project log resolves 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", + "log", + "runtime", + ...args, + "--region", + "us-east-1", + ]), + }; +} + +describe("project log runtime", () => { + test("resolves the only logical Runtime and tails it in the target region", async () => { + await inProject([RUNTIMES[0]]); + const resolved = backend(); + const subject = command(resolved.value); + subject.core.observability.logEvents = [ + { timestamp: new Date("2026-09-10T12:00:00Z"), message: "ready" }, + ]; + + await subject.run(); + + expect(resolved.calls).toEqual([{ target: DEFAULT_TARGET }]); + expect(subject.core.observability.calls).toHaveLength(1); + const call = subject.core.observability.calls[0]!; + expect(call.method).toBe("tailLogs"); + expect(call.args[0] as LogSource).toEqual({ + logGroupName: "/aws/bedrock-agentcore/runtimes/checkout-AbCdEf1234-DEFAULT", + }); + expect(call.args[1]).toEqual({ filterPattern: undefined }); + expect(call.args[2]).toEqual({ region: DEFAULT_TARGET.region, endpointUrl: undefined }); + expect(subject.io.stderr()).toContain( + "Streaming logs for Runtime 'checkout' on target 'default'... (Ctrl+C to stop)", + ); + expect(subject.io.stdout()).toBe("2026-09-10T12:00:00.000Z ready"); + }); + + test("selects a named Runtime and deployment target for a bounded search", async () => { + await inProject(RUNTIMES); + const resolved = backend(); + const subject = command(resolved.value); + + await subject.run([ + "--name", + "inventory", + "--target", + "production", + "--qualifier", + "BLUE", + "--since", + "1h", + "--limit", + "25", + ]); + + expect(resolved.calls).toEqual([{ target: PRODUCTION_TARGET }]); + const call = subject.core.observability.calls[0]!; + expect(call.method).toBe("searchLogs"); + expect(call.args[0]).toEqual({ + logGroupName: "/aws/bedrock-agentcore/runtimes/inventory-AbCdEf1234-BLUE", + }); + expect(call.args[1]).toMatchObject({ limit: 25 }); + expect(call.args[2]).toEqual({ region: PRODUCTION_TARGET.region, endpointUrl: undefined }); + }); + + test("requires --name when the project declares several Runtimes", async () => { + await inProject(RUNTIMES); + const resolved = backend(); + const subject = command(resolved.value); + + await expect(subject.run(["--since", "1h"])).rejects.toThrow( + "Project has multiple Runtimes. Specify --name: checkout, inventory.", + ); + + expect(resolved.calls).toEqual([]); + expect(subject.core.observability.calls).toEqual([]); + }); + + test("reports when the selected logical Runtime is not deployed", async () => { + await inProject([RUNTIMES[0]]); + const resolved = backend({ deployed: false }); + const subject = command(resolved.value); + + await expect(subject.run(["--since", "1h"])).rejects.toThrow( + "Runtime 'checkout' is not deployed to target 'default'.", + ); + + expect(subject.core.observability.calls).toEqual([]); + }); +}); diff --git a/src/handlers/project/log/runtime.tsx b/src/handlers/project/log/runtime.tsx new file mode 100644 index 000000000..fa5a2c86b --- /dev/null +++ b/src/handlers/project/log/runtime.tsx @@ -0,0 +1,51 @@ +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 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; + +export const createProjectRuntimeLogHandler = (core: Core, io: AppIO) => + createLogsHandler(io, { + name: "runtime", + description: "stream or search logs for a Runtime in the current project", + flags: projectRuntimeFlags, + read: async (ctx, flags, request, signal) => { + const project = ctx.require(ProjectKey); + const name = selectProjectResource(project, "runtime", flags.name, "inspect logs for"); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "runtime", + name, + }); + const options = { + ...coreOptsFromCtx(ctx), + region: deployed.target.region, + }; + const source = { + logGroupName: runtimeLogGroup(deployed.id, 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 Runtime '${name}' on target '${deployed.target.name}'... ` + + "(Ctrl+C to stop)", + }; + }, + }); diff --git a/src/handlers/project/invoke/selection.ts b/src/handlers/project/selection.ts similarity index 89% rename from src/handlers/project/invoke/selection.ts rename to src/handlers/project/selection.ts index b0ec3275e..e83b46012 100644 --- a/src/handlers/project/invoke/selection.ts +++ b/src/handlers/project/selection.ts @@ -1,5 +1,5 @@ -import { InputValidationError, ResourceNotFoundError } from "../../../errors"; -import type { Project, ProjectInvokableResource } from "../types"; +import { InputValidationError, ResourceNotFoundError } from "../../errors"; +import type { Project, ProjectInvokableResource } from "./types"; export function projectResourceNames( project: Project, @@ -14,6 +14,7 @@ export function selectProjectResource( project: Project, resourceType: ProjectInvokableResource, name: string | undefined, + operation: string, ): string { const names = projectResourceNames(project, resourceType); const label = resourceType === "runtime" ? "Runtime" : "Harness"; @@ -26,7 +27,7 @@ export function selectProjectResource( } if (names.length === 1) return names[0]!; if (names.length === 0) { - throw new InputValidationError(`This project has no ${label}s to invoke.`); + throw new InputValidationError(`This project has no ${label}s to ${operation}.`); } throw new InputValidationError( `Project has multiple ${label}s. Specify --name: ${names.join(", ")}.`, diff --git a/src/handlers/runtime/logs/index.tsx b/src/handlers/runtime/logs/index.tsx index bea96272f..2569d8114 100644 --- a/src/handlers/runtime/logs/index.tsx +++ b/src/handlers/runtime/logs/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../core/observability"; +import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../core/observability/index"; import type { AppIO } from "../../../io"; import { flag } from "../../../router"; import { createLogsHandler } from "../../observability/logs"; @@ -20,6 +20,7 @@ const runtimeFlags = [ */ export const createRuntimeLogsHandler = (core: Core, io: AppIO) => createLogsHandler(io, { + name: "logs", description: "stream or search a Runtime's logs", flags: runtimeFlags, read: (ctx, flags, request, signal) => { diff --git a/src/handlers/runtime/resolveRuntimeTarget.test.ts b/src/handlers/runtime/resolveRuntimeTarget.test.ts deleted file mode 100644 index c46184597..000000000 --- a/src/handlers/runtime/resolveRuntimeTarget.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { InputValidationError } from "../../errors"; -import { RegionKey } from "../keys"; -import { ValueContext } from "../../router"; -import type { Core } from "../types"; -import type { Project } from "../project/types"; -import type { DeployedRuntime } from "./types"; -import { resolveRuntimeTarget } from "./resolveRuntimeTarget"; - -const ctx = ValueContext.EmptyContext().withValue(RegionKey, "us-east-1"); - -const PROJECT = { name: "Proj", rootPath: "/proj", spec: {} } as unknown as Project; - -const DEPLOYED: DeployedRuntime = { - runtimeId: "proj_agent-AbC123XyZ9", - region: "eu-west-1", - stackName: "AgentCore-Proj-default", - targetName: "default", -}; - -function stubCore(config: { - resolve: () => Promise; - deployed?: DeployedRuntime; -}): { core: Core; observabilityCalls: unknown[][] } { - const observabilityCalls: unknown[][] = []; - const core = { - projectManager: { resolve: config.resolve }, - observability: { - resolveDeployedRuntime: async (project: Project, targetName: string) => { - observabilityCalls.push([project, targetName]); - return config.deployed ?? DEPLOYED; - }, - }, - } as unknown as Core; - return { core, observabilityCalls }; -} - -describe("resolveRuntimeTarget", () => { - test("an explicit --id wins and keeps the ambient region", async () => { - const { core, observabilityCalls } = stubCore({ resolve: async () => undefined }); - - const target = await resolveRuntimeTarget(core, ctx, "explicit-id", tmpdir()); - - expect(target.runtimeId).toBe("explicit-id"); - expect(target.options).toEqual({ region: "us-east-1", endpointUrl: undefined }); - expect(target.project).toBeUndefined(); - expect(observabilityCalls).toHaveLength(0); - }); - - test("an explicit --id attaches the enclosing project as context", async () => { - const { core } = stubCore({ resolve: async () => PROJECT }); - - const target = await resolveRuntimeTarget(core, ctx, "explicit-id", "/proj/somewhere"); - - expect(target.project).toBe(PROJECT); - }); - - test("an explicit --id survives a broken project spec", async () => { - const { core } = stubCore({ - resolve: async () => { - throw new Error("agentcore.json is corrupt"); - }, - }); - - const target = await resolveRuntimeTarget(core, ctx, "explicit-id", tmpdir()); - - expect(target.runtimeId).toBe("explicit-id"); - expect(target.project).toBeUndefined(); - }); - - test("without --id the project's default-target runtime resolves, region included", async () => { - const { core, observabilityCalls } = stubCore({ resolve: async () => PROJECT }); - - const target = await resolveRuntimeTarget(core, ctx, undefined, "/proj/app"); - - expect(observabilityCalls).toEqual([[PROJECT, "default"]]); - expect(target.runtimeId).toBe("proj_agent-AbC123XyZ9"); - // The deployment target's region wins: the stack and log groups live there. - expect(target.options.region).toBe("eu-west-1"); - expect(target.project).toBe(PROJECT); - }); - - test("without --id and outside a project, a usage error demands --id", async () => { - const { core } = stubCore({ resolve: async () => undefined }); - const outside = mkdtempSync(join(tmpdir(), "no-project-")); - - await expect(resolveRuntimeTarget(core, ctx, undefined, outside)).rejects.toThrow( - InputValidationError, - ); - await expect(resolveRuntimeTarget(core, ctx, undefined, outside)).rejects.toThrow( - "required option '--id ' not specified", - ); - }); -}); diff --git a/src/handlers/runtime/resolveRuntimeTarget.ts b/src/handlers/runtime/resolveRuntimeTarget.ts deleted file mode 100644 index 8f0e7c6d4..000000000 --- a/src/handlers/runtime/resolveRuntimeTarget.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { InputValidationError } from "../../errors"; -import { ExitCode } from "../../runnable"; -import type { Context } from "../../router"; -import type { CoreOptions } from "../../core/types"; -import { DEFAULT_TARGET_NAME } from "../../projectSchemas/aws-targets"; -import type { Core } from "../types"; -import { coreOptsFromCtx } from "../utils"; -import type { Project } from "../project/types"; - -export interface RuntimeTarget { - runtimeId: string; - /** CoreOptions to use for this runtime's CloudWatch reads. */ - options: CoreOptions; - /** The enclosing project, when the command ran inside one. */ - project?: Project; -} - -/** - * Resolves which runtime an observability command (`runtime logs` / - * `runtime traces`) addresses. An explicit --id wins and works anywhere; without - * one the enclosing project's deployed runtime is resolved live from its - * CloudFormation stack outputs (default target). Outside a project, --id is - * required. - * - * When resolving automatically, the deployment target's region overrides the - * ambient one: the stack and its log groups live there. - */ -export async function resolveRuntimeTarget( - core: Core, - ctx: Context, - id: string | undefined, - cwd: string = process.cwd(), -): Promise { - const options = coreOptsFromCtx(ctx); - - if (id !== undefined) { - // With an explicit --id the project is only context (e.g. default output - // paths); a broken project spec must not block addressing a runtime - // directly. - const project = await core.projectManager.resolve({ filePath: cwd }).catch(() => undefined); - return { runtimeId: id, options, project }; - } - - const project = await core.projectManager.resolve({ filePath: cwd }); - if (!project) { - throw new InputValidationError( - "required option '--id ' not specified " + - "(run inside an AgentCore project to resolve the deployed runtime automatically)", - { exitCode: ExitCode.USAGE }, - ); - } - - const deployed = await core.observability.resolveDeployedRuntime(project, DEFAULT_TARGET_NAME); - return { - runtimeId: deployed.runtimeId, - options: { ...options, region: deployed.region }, - project, - }; -} diff --git a/src/handlers/runtime/traces/get/index.tsx b/src/handlers/runtime/traces/get/index.tsx index a94274719..4134b318f 100644 --- a/src/handlers/runtime/traces/get/index.tsx +++ b/src/handlers/runtime/traces/get/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability"; +import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability/index"; import type { AppIO } from "../../../../io"; import { flag } from "../../../../router"; import { createGetTraceHandler } from "../../../observability/traces"; diff --git a/src/handlers/runtime/traces/list/index.tsx b/src/handlers/runtime/traces/list/index.tsx index 8f3a875bb..719109b76 100644 --- a/src/handlers/runtime/traces/list/index.tsx +++ b/src/handlers/runtime/traces/list/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability"; +import { DEFAULT_ENDPOINT_QUALIFIER, runtimeLogGroup } from "../../../../core/observability/index"; import type { AppIO } from "../../../../io"; import { flag } from "../../../../router"; import { createListTracesHandler } from "../../../observability/traces"; diff --git a/src/handlers/runtime/types.tsx b/src/handlers/runtime/types.tsx index 273afe7a6..3d695e91e 100644 --- a/src/handlers/runtime/types.tsx +++ b/src/handlers/runtime/types.tsx @@ -5,20 +5,7 @@ import type { ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { - CloudWatchLogEvent, - GetTraceQuery, - InsightsQuery, - InsightsQueryRow, - ListTracesQuery, - LogSearchQuery, - LogSource, - LogTailQuery, - TraceRecord, - TraceSummary, -} from "../../core/observability/types"; import type { CoreOptions } from "../../core/types"; -import type { Project } from "../project/types"; export type RuntimeInvokeRequest = { runtimeId: string; @@ -117,46 +104,3 @@ export interface CoreRuntimeClient { options: CoreOptions, ): Promise; } - -/** A project runtime resolved live from its CloudFormation stack outputs. */ -export type DeployedRuntime = { - runtimeId: string; - /** The deployment target's region — where the stack and log groups live. */ - region: string; - stackName: string; - targetName: string; -}; - -export interface CoreObservabilityClient { - resolveDeployedRuntime(project: Project, targetName: string): Promise; - searchLogs( - source: LogSource, - query: LogSearchQuery, - options: CoreOptions, - signal?: AbortSignal, - ): AsyncIterable; - tailLogs( - source: LogSource, - query: LogTailQuery, - options: CoreOptions, - signal: AbortSignal, - ): AsyncIterable; - queryLogs( - source: LogSource, - query: InsightsQuery, - options: CoreOptions, - signal?: AbortSignal, - ): Promise; - listTraces( - source: LogSource, - query: ListTracesQuery, - options: CoreOptions, - signal?: AbortSignal, - ): Promise; - getTrace( - source: LogSource, - query: GetTraceQuery, - options: CoreOptions, - signal?: AbortSignal, - ): Promise; -} diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index c41a301cb..d0b64eac1 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -4,7 +4,8 @@ import type { CorePolicyClient } from "./gateway/policy/types.tsx"; import type { CoreHarnessClient } from "./harness/types.tsx"; import type { CoreIdentityClient } from "./identity/types.tsx"; import type { CoreMemoryClient } from "./memory/types.tsx"; -import type { CoreObservabilityClient, CoreRuntimeClient } from "./runtime/types.tsx"; +import type { CoreObservabilityClient } from "../core/observability/types.ts"; +import type { CoreRuntimeClient } from "./runtime/types.tsx"; import type { Context } from "../router"; import type { CoreFetch } from "../core/types"; import type { ProjectManager } from "./project/types.ts"; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index cf3bdd18e..bdfca1742 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -139,6 +139,7 @@ import type { import type { CoreMemoryClient } from "../handlers/memory/types"; import type { CloudWatchLogEvent, + CoreObservabilityClient, GetTraceQuery, InsightsQuery, InsightsQueryRow, @@ -150,9 +151,7 @@ import type { TraceSummary, } from "../core/observability/types"; import type { - CoreObservabilityClient, CoreRuntimeClient, - DeployedRuntime, RuntimeInvokeRequest, RuntimeInvokeResponse, RuntimeShellRequest, @@ -189,7 +188,7 @@ import type { import { isTerminalStatus } from "../core/batchEvaluationResults"; import { abortable } from "../core/abortable"; import type { CoreFetch, CoreOptions, CreateCloudFormationClient } from "../core/types"; -import type { Project, ProjectManager } from "../handlers/project/types"; +import type { ProjectManager } from "../handlers/project/types"; import type { CorePolicyClient, GeneratePolicyInput, @@ -2379,27 +2378,15 @@ export class TestEvalClient implements CoreEvalClient { } // TestObservabilityClient is a controllable CoreObservabilityClient: seed -// `logEvents` / `resolveDeployedRuntimeResponse`, or set `error` to force the -// next call to throw. Every call is recorded on `calls`. +// `logEvents`, or set `error` to force the next call to throw. Every call is +// recorded on `calls`. export class TestObservabilityClient implements CoreObservabilityClient { calls: { method: string; args: unknown[] }[] = []; error: Error | undefined; - resolveDeployedRuntimeResponse: DeployedRuntime = { - runtimeId: "project_runtime-0000000000", - region: "us-west-2", - stackName: "AgentCore-project-default", - targetName: "default", - }; logEvents: CloudWatchLogEvent[] = []; queryRows: InsightsQueryRow[] = []; - async resolveDeployedRuntime(project: Project, targetName: string): Promise { - this.calls.push({ method: "resolveDeployedRuntime", args: [project, targetName] }); - if (this.error) throw this.error; - return this.resolveDeployedRuntimeResponse; - } - async *searchLogs( source: LogSource, query: LogSearchQuery,