From 8083268c2c67a9a5f84cc01ea7e3195628f60356 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Mon, 31 Aug 2026 10:53:57 -0400 Subject: [PATCH 1/4] feat: add observability abstractions and wire to runtime --- src/core/core.test.ts | 1 + src/core/index.tsx | 22 +- src/core/observability.test.ts | 687 ------------------ src/core/observability.ts | 568 --------------- src/core/observability/client.test.ts | 177 +++++ src/core/observability/client.ts | 176 +++++ src/core/observability/index.ts | 28 + src/core/observability/insights.test.ts | 83 +++ src/core/observability/insights.ts | 81 +++ src/core/observability/resolver.test.ts | 49 ++ src/core/observability/resolver.ts | 73 ++ src/core/observability/sourceReader.test.ts | 319 ++++++++ src/core/observability/sourceReader.ts | 228 ++++++ src/core/types.tsx | 6 +- src/handlers/index.tsx | 4 +- .../observability/filterPattern.test.ts | 12 + src/handlers/observability/filterPattern.ts | 20 + src/handlers/observability/handlerFactory.ts | 137 ++++ src/handlers/observability/time.test.ts | 49 ++ src/handlers/observability/time.ts | 51 ++ src/handlers/observability/types.ts | 33 + src/handlers/runtime/index.tsx | 74 +- src/handlers/runtime/logs.test.tsx | 223 ++++++ .../runtime/logs/filterPattern.test.ts | 23 - src/handlers/runtime/logs/filterPattern.ts | 31 - src/handlers/runtime/logs/index.tsx | 116 --- src/handlers/runtime/logs/logs.test.tsx | 184 ----- .../runtime/resolveRuntimeTarget.test.ts | 93 ++- src/handlers/runtime/resolveRuntimeTarget.ts | 28 +- src/handlers/runtime/traces/get/index.tsx | 73 +- src/handlers/runtime/traces/list/index.tsx | 38 +- src/handlers/runtime/traces/traces.test.tsx | 66 +- src/handlers/runtime/types.tsx | 74 -- src/handlers/types.tsx | 3 +- src/testing/TestCoreClient.tsx | 97 ++- src/testing/index.tsx | 1 + 36 files changed, 2074 insertions(+), 1854 deletions(-) delete mode 100644 src/core/observability.test.ts delete mode 100644 src/core/observability.ts create mode 100644 src/core/observability/client.test.ts create mode 100644 src/core/observability/client.ts create mode 100644 src/core/observability/index.ts create mode 100644 src/core/observability/insights.test.ts create mode 100644 src/core/observability/insights.ts create mode 100644 src/core/observability/resolver.test.ts create mode 100644 src/core/observability/resolver.ts create mode 100644 src/core/observability/sourceReader.test.ts create mode 100644 src/core/observability/sourceReader.ts create mode 100644 src/handlers/observability/filterPattern.test.ts create mode 100644 src/handlers/observability/filterPattern.ts create mode 100644 src/handlers/observability/handlerFactory.ts create mode 100644 src/handlers/observability/time.test.ts create mode 100644 src/handlers/observability/time.ts create mode 100644 src/handlers/observability/types.ts create mode 100644 src/handlers/runtime/logs.test.tsx delete mode 100644 src/handlers/runtime/logs/filterPattern.test.ts delete mode 100644 src/handlers/runtime/logs/filterPattern.ts delete mode 100644 src/handlers/runtime/logs/index.tsx delete mode 100644 src/handlers/runtime/logs/logs.test.tsx diff --git a/src/core/core.test.ts b/src/core/core.test.ts index e8390a470..c5cc5bec4 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -209,6 +209,7 @@ test("exposes feature sub-clients", () => { expect(core.harness).toBeDefined(); expect(core.memory).toBeDefined(); expect(core.gateway).toBeDefined(); + expect(core.observability).toBeDefined(); }); test("getEvent sends a GetEventCommand on the data client", async () => { diff --git a/src/core/index.tsx b/src/core/index.tsx index 5a6bab9fb..b719b2314 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -7,9 +7,12 @@ import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; -import { ObservabilityClient } from "./observability"; +import { + CloudWatchSourceReader, + ObservabilityClient, + RuntimeSourceResolver, +} from "./observability"; import { RuntimeClient } from "./runtime"; -import { FsReadWriteJson } from "../io"; import type { AwsClients, ClientConfig, @@ -96,15 +99,10 @@ export class CoreClient implements AwsClients { config.newSessionId, config.now, ); - - // 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(this, { - readJson: new FsReadWriteJson({ - logger: this.logger.child({ module: "observability" }), - }), - }); + this.observability = new ObservabilityClient( + { runtime: new RuntimeSourceResolver() }, + new CloudWatchSourceReader(this), + ); this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), @@ -150,7 +148,7 @@ export class CoreClient implements AwsClients { } // logs returns the CloudWatch Logs client for `config`, creating and caching it - // on first use (used to read batch-evaluation result log streams). + // on first use for customer-facing observability and evaluation result streams. logs(config: ClientConfig): CloudWatchLogsClient { const key = cacheKey(config); let client = this.logsClients.get(key); diff --git a/src/core/observability.test.ts b/src/core/observability.test.ts deleted file mode 100644 index 3758af14d..000000000 --- a/src/core/observability.test.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - DescribeLogGroupsCommand, - FilterLogEventsCommand, - GetQueryResultsCommand, - ResourceNotFoundException, - StartLiveTailCommand, - StartQueryCommand, - type CloudWatchLogsClient, - type StartLiveTailResponseStream, -} 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, - parseTimeString, - runInsightsQuery, - runtimeLogGroup, - sanitizeQueryValue, - type DescribeStackOutputs, -} from "./observability"; - -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"); - }); -}); - -describe("parseTimeString", () => { - const NOW = 1_700_000_000_000; - const now = () => NOW; - - test('parses "now" as the current time', () => { - expect(parseTimeString("now", now)).toBe(NOW); - }); - - test("parses relative durations for every unit as that long ago", () => { - expect(parseTimeString("30s", now)).toBe(NOW - 30_000); - expect(parseTimeString("5m", now)).toBe(NOW - 5 * 60_000); - expect(parseTimeString("1h", now)).toBe(NOW - 3_600_000); - expect(parseTimeString("2d", now)).toBe(NOW - 2 * 86_400_000); - }); - - test("parses epoch milliseconds (13+ digits) literally", () => { - expect(parseTimeString("1709391000000", now)).toBe(1_709_391_000_000); - }); - - test("parses ISO 8601 timestamps", () => { - expect(parseTimeString("2026-03-02T14:30:00Z", now)).toBe(Date.parse("2026-03-02T14:30:00Z")); - }); - - test("trims surrounding whitespace", () => { - expect(parseTimeString(" 15m ", now)).toBe(NOW - 15 * 60_000); - }); - - test("rejects empty input with a typed error", () => { - expect(() => parseTimeString(" ", now)).toThrow(InputValidationError); - expect(() => parseTimeString("", now)).toThrow("Time string cannot be empty"); - }); - - test("rejects garbage with a typed error naming the accepted forms", () => { - expect(() => parseTimeString("yesterday-ish", now)).toThrow(InputValidationError); - expect(() => parseTimeString("5x", now)).toThrow( - 'Invalid time string: "5x". Use relative durations (5m, 1h, 2d), ISO 8601, epoch ms, or "now".', - ); - }); -}); - -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(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); - }); -}); - -describe("ObservabilityClient.searchRuntimeLogs", () => { - const SEARCH = { - runtimeId: "my_agent-AbC123XyZ9", - startTimeMs: 1_000, - endTimeMs: 2_000, - }; - - async function collect(events: AsyncGenerator<{ timestamp: number; message: string }>) { - const out: { timestamp: number; message: string }[] = []; - for await (const event of events) out.push(event); - return out; - } - - test("paginates FilterLogEvents to completion, oldest to newest", async () => { - const inputs: unknown[] = []; - const logs = fakeLogs(async (command) => { - expect(command).toBeInstanceOf(FilterLogEventsCommand); - const input = (command as FilterLogEventsCommand).input; - inputs.push(input); - if (input.nextToken === "page-2") { - return { events: [{ timestamp: 3, message: "three" }] }; - } - return { - events: [ - { timestamp: 1, message: "one" }, - { timestamp: 2, message: "two" }, - ], - nextToken: "page-2", - }; - }); - - const events = await collect(clientWith(logs).searchRuntimeLogs(SEARCH, OPTIONS)); - - expect(events).toEqual([ - { timestamp: 1, message: "one" }, - { timestamp: 2, message: "two" }, - { timestamp: 3, message: "three" }, - ]); - expect(inputs[0]).toEqual({ - logGroupName: "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", - startTime: 1_000, - endTime: 2_000, - }); - expect(inputs[1]).toMatchObject({ nextToken: "page-2" }); - }); - - test("caps yielded events at limit and requests no more than needed", async () => { - const limits: (number | undefined)[] = []; - const logs = fakeLogs(async (command) => { - const input = (command as FilterLogEventsCommand).input; - limits.push(input.limit); - if (input.nextToken === "page-2") { - return { - events: [ - { timestamp: 3, message: "three" }, - { timestamp: 4, message: "four" }, - ], - }; - } - return { - events: [ - { timestamp: 1, message: "one" }, - { timestamp: 2, message: "two" }, - ], - nextToken: "page-2", - }; - }); - - const events = await collect( - clientWith(logs).searchRuntimeLogs({ ...SEARCH, limit: 3 }, OPTIONS), - ); - - expect(events.map((event) => event.message)).toEqual(["one", "two", "three"]); - expect(limits).toEqual([3, 1]); - }); - - test("passes the filter pattern through to FilterLogEvents", async () => { - const logs = fakeLogs(async (command) => { - expect((command as FilterLogEventsCommand).input.filterPattern).toBe("ERROR database"); - return { events: [] }; - }); - - await collect( - clientWith(logs).searchRuntimeLogs({ ...SEARCH, filterPattern: "ERROR database" }, OPTIONS), - ); - }); - - test("translates a missing log group into invoked-yet guidance", async () => { - const logs = fakeLogs(async () => { - throw new ResourceNotFoundException({ - message: "The specified log group does not exist.", - $metadata: {}, - }); - }); - - await expect(collect(clientWith(logs).searchRuntimeLogs(SEARCH, OPTIONS))).rejects.toThrow( - "No logs found for runtime 'my_agent-AbC123XyZ9': log group " + - "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT does not exist. " + - "Has the runtime been invoked yet?", - ); - }); -}); - -describe("ObservabilityClient.streamRuntimeLogs", () => { - const STREAM = { runtimeId: "my_agent-AbC123XyZ9" }; - const LOG_GROUP = "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT"; - const GROUP_ARN = `arn:aws:logs:us-east-1:111122223333:log-group:${LOG_GROUP}`; - - type LiveTailEvent = Partial; - - function liveTailLogs( - sessions: (LiveTailEvent[] | Error)[], - groups: { logGroupName?: string; logGroupArn?: string; arn?: string }[] = [ - { logGroupName: LOG_GROUP, logGroupArn: GROUP_ARN }, - ], - ) { - const starts: unknown[] = []; - const logs = fakeLogs(async (command) => { - if (command instanceof DescribeLogGroupsCommand) { - expect(command.input.logGroupNamePrefix).toBe(LOG_GROUP); - return { logGroups: groups }; - } - expect(command).toBeInstanceOf(StartLiveTailCommand); - starts.push((command as StartLiveTailCommand).input); - const session = sessions[starts.length - 1] ?? []; - return { - responseStream: (async function* () { - if (session instanceof Error) throw session; - yield* session as StartLiveTailResponseStream[]; - })(), - }; - }); - return { logs, starts }; - } - - function update(...messages: string[]): LiveTailEvent { - return { - sessionUpdate: { - sessionResults: messages.map((message, i) => ({ timestamp: 1_000 + i, message })), - }, - }; - } - - async function collect(client: ObservabilityClient, signal: AbortSignal) { - const out: string[] = []; - for await (const event of client.streamRuntimeLogs(STREAM, OPTIONS, signal)) { - out.push(event.message); - } - return out; - } - - test("yields live-tail session updates and stops when the stream ends normally", async () => { - const { logs, starts } = liveTailLogs([[update("one", "two"), update("three")]]); - - const messages = await collect(clientWith(logs), new AbortController().signal); - - expect(messages).toEqual(["one", "two", "three"]); - expect(starts).toEqual([{ logGroupIdentifiers: [GROUP_ARN] }]); - }); - - test("reconnects when the session reports a timeout event", async () => { - const { logs, starts } = liveTailLogs([ - [update("one"), { SessionTimeoutException: { name: "SessionTimeoutException" } } as never], - [update("two")], - ]); - - const messages = await collect(clientWith(logs), new AbortController().signal); - - expect(messages).toEqual(["one", "two"]); - expect(starts).toHaveLength(2); - }); - - test("reconnects when the stream throws a session timeout", async () => { - const timeout = Object.assign(new Error("session timed out"), { - name: "SessionTimeoutException", - }); - const { logs, starts } = liveTailLogs([timeout, [update("after-reconnect")]]); - - const messages = await collect(clientWith(logs), new AbortController().signal); - - expect(messages).toEqual(["after-reconnect"]); - expect(starts).toHaveLength(2); - }); - - test("propagates non-timeout stream errors", async () => { - const { logs } = liveTailLogs([new Error("stream exploded")]); - - await expect(collect(clientWith(logs), new AbortController().signal)).rejects.toThrow( - "stream exploded", - ); - }); - - test("returns cleanly when aborted mid-session", async () => { - const controller = new AbortController(); - const { logs, starts } = liveTailLogs([ - [update("one"), { SessionTimeoutException: { name: "SessionTimeoutException" } } as never], - ]); - - const messages: string[] = []; - for await (const event of clientWith(logs).streamRuntimeLogs( - STREAM, - OPTIONS, - controller.signal, - )) { - messages.push(event.message); - controller.abort(); - } - - // The timeout after the abort must not trigger a reconnect. - expect(messages).toEqual(["one"]); - expect(starts).toHaveLength(1); - }); - - test("passes the filter pattern to the live tail", async () => { - const { logs, starts } = liveTailLogs([[]]); - - for await (const _ of clientWith(logs).streamRuntimeLogs( - { ...STREAM, filterPattern: "ERROR" }, - OPTIONS, - new AbortController().signal, - )) { - // drain - } - - expect(starts).toEqual([{ logGroupIdentifiers: [GROUP_ARN], logEventFilterPattern: "ERROR" }]); - }); - - test("strips the legacy ARN's trailing :* when the modern field is absent", async () => { - const { logs, starts } = liveTailLogs( - [[]], - [{ logGroupName: LOG_GROUP, arn: `${GROUP_ARN}:*` }], - ); - - await collect(clientWith(logs), new AbortController().signal); - - expect(starts).toEqual([{ logGroupIdentifiers: [GROUP_ARN] }]); - }); - - test("fails with invoked-yet guidance when the log group does not exist", async () => { - const { logs } = liveTailLogs([[]], []); - - await expect(collect(clientWith(logs), new AbortController().signal)).rejects.toThrow( - "Has the runtime been invoked yet?", - ); - }); -}); - -// 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 }; -} - -describe("ObservabilityClient.listRuntimeTraces", () => { - const INPUT = { - runtimeId: "my_agent-AbC123XyZ9", - 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).listRuntimeTraces(INPUT, OPTIONS); - - expect(queries).toHaveLength(1); - expect(queries[0]!.logGroupNames).toEqual([ - "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", - ]); - // 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).listRuntimeTraces(INPUT, 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 }, - ]); - }); - - test("translates a missing log group into invoked-yet guidance", async () => { - const logs = fakeLogs(async () => { - throw new ResourceNotFoundException({ message: "no such group", $metadata: {} }); - }); - - await expect(clientWith(logs).listRuntimeTraces(INPUT, OPTIONS)).rejects.toThrow( - "Has the runtime been invoked yet?", - ); - }); -}); - -describe("ObservabilityClient.getRuntimeTrace", () => { - const INPUT = { - runtimeId: "my_agent-AbC123XyZ9", - 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).getRuntimeTrace({ ...INPUT, 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).getRuntimeTrace(INPUT, 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).getRuntimeTrace(INPUT, OPTIONS)).rejects.toThrow( - "No trace data found for trace ID: 68b2fabc0000000000abcdef", - ); - }); -}); diff --git a/src/core/observability.ts b/src/core/observability.ts deleted file mode 100644 index 81736d6e1..000000000 --- a/src/core/observability.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { - DescribeLogGroupsCommand, - FilterLogEventsCommand, - GetQueryResultsCommand, - ResourceNotFoundException, - StartLiveTailCommand, - 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, - GetRuntimeTraceInput, - ListRuntimeTracesInput, - RuntimeLogEvent, - SearchRuntimeLogsInput, - StreamRuntimeLogsInput, - TraceRecord, - TraceSummary, -} from "../handlers/runtime/types"; -import { AwsDeploymentTargetsSchema } from "../projectSchemas/aws-targets"; -import { isStackNotFound } from "./project/backends/cdk/environment"; -import type { AwsClients, CoreOptions } from "./types"; -import { toClientConfig } from "./utils"; - -// 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; -} - -/** - * ObservabilityClient reads the CloudWatch-backed telemetry of deployed - * AgentCore Runtimes: live-tail and search over the per-runtime log group, and - * resolution of a project's deployed runtime id from its CloudFormation stack - * outputs (runtime ids are not persisted locally, so the stack is the source - * of truth). - */ -export class ObservabilityClient implements CoreObservabilityClient { - private readonly clients: AwsClients; - private readonly readJson: ReadWriteJson; - private readonly describeStackOutputs: DescribeStackOutputs; - - constructor(clients: AwsClients, deps: ObservabilityClientDeps) { - this.clients = clients; - 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, - }; - } - - /** - * Live-tails a runtime's log group via StartLiveTail, yielding events as they - * arrive. A live-tail session is server-capped (~3h); when it times out a new - * session is started transparently, so the stream runs until `signal` aborts - * (in which case the generator simply returns). - */ - async *streamRuntimeLogs( - input: StreamRuntimeLogsInput, - options: CoreOptions, - signal: AbortSignal, - ): AsyncGenerator { - const logGroupName = runtimeLogGroup(input.runtimeId, DEFAULT_ENDPOINT_QUALIFIER); - const logs = this.clients.logs(toClientConfig(options)); - - // StartLiveTail addresses log groups by ARN. DescribeLogGroups resolves it - // without hand-assembling one (partition/account), and doubles as the - // existence check so a never-invoked runtime fails with guidance instead of - // an opaque service error. - const described = await logs.send( - new DescribeLogGroupsCommand({ logGroupNamePrefix: logGroupName }), - { abortSignal: signal }, - ); - const group = (described.logGroups ?? []).find( - (candidate) => candidate.logGroupName === logGroupName, - ); - // The legacy `arn` field carries a trailing `:*` that StartLiveTail rejects. - const logGroupArn = group?.logGroupArn ?? group?.arn?.replace(/:\*$/, ""); - if (!logGroupArn) { - throw missingLogGroupError(input.runtimeId, logGroupName); - } - - while (!signal.aborted) { - let response; - try { - response = await logs.send( - new StartLiveTailCommand({ - logGroupIdentifiers: [logGroupArn], - ...(input.filterPattern ? { logEventFilterPattern: input.filterPattern } : {}), - }), - { abortSignal: signal }, - ); - } catch (error) { - if (signal.aborted) return; - throw error; - } - if (!response.responseStream) return; - - let sessionTimedOut = false; - try { - for await (const event of response.responseStream) { - if (signal.aborted) return; - if (event.sessionUpdate) { - for (const logEvent of event.sessionUpdate.sessionResults ?? []) { - yield { - timestamp: logEvent.timestamp ?? Date.now(), - message: logEvent.message ?? "", - }; - } - } - if (event.SessionTimeoutException) { - sessionTimedOut = true; - break; - } - } - } catch (error) { - if (signal.aborted) return; - if ((error as { name?: string }).name === "SessionTimeoutException") { - sessionTimedOut = true; - } else { - throw error; - } - } - - // A stream that ended without timing out was closed deliberately - // (server-side or by the caller); only a timeout warrants a reconnect. - if (!sessionTimedOut) return; - } - } - - /** - * Searches a runtime's log group over a closed time window via - * FilterLogEvents, paginating to completion and yielding events oldest to - * newest. `limit` caps the total number of events yielded. - */ - async *searchRuntimeLogs( - input: SearchRuntimeLogsInput, - options: CoreOptions, - signal?: AbortSignal, - ): AsyncGenerator { - const logGroupName = runtimeLogGroup(input.runtimeId, DEFAULT_ENDPOINT_QUALIFIER); - const logs = this.clients.logs(toClientConfig(options)); - - let nextToken: string | undefined; - let yielded = 0; - do { - let response; - try { - response = await logs.send( - new FilterLogEventsCommand({ - logGroupName, - startTime: input.startTimeMs, - endTime: input.endTimeMs, - ...(input.filterPattern ? { filterPattern: input.filterPattern } : {}), - ...(nextToken ? { nextToken } : {}), - // FilterLogEvents accepts at most 10k events per page. - ...(input.limit ? { limit: Math.min(input.limit - yielded, 10_000) } : {}), - }), - { abortSignal: signal }, - ); - } catch (error) { - if (error instanceof ResourceNotFoundException) { - throw missingLogGroupError(input.runtimeId, logGroupName, error); - } - throw error; - } - - for (const event of response.events ?? []) { - if (input.limit !== undefined && yielded >= input.limit) return; - yield { timestamp: event.timestamp ?? Date.now(), message: event.message ?? "" }; - yielded++; - } - nextToken = response.nextToken; - } while (nextToken && (input.limit === undefined || yielded < input.limit)); - } - - /** - * Lists the runtime's recent traces by aggregating its telemetry records with - * a Logs Insights `stats … by traceId` query (mirrors the old CLI's - * list-traces operation), newest first. - */ - async listRuntimeTraces( - input: ListRuntimeTracesInput, - options: CoreOptions, - ): Promise { - const logGroupName = runtimeLogGroup(input.runtimeId, DEFAULT_ENDPOINT_QUALIFIER); - // Infrastructure records carry an empty traceId; excluding them before the - // aggregation keeps them from occupying one of the `limit` buckets (the old - // CLI filtered afterwards, silently returning one trace fewer). - const queryString = - `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 ${Math.floor(input.limit)}`; - - const rows = await this.runTraceQuery(input, logGroupName, queryString, options); - - const traces: TraceSummary[] = []; - for (const row of rows) { - const fields = fieldMap(row); - if (!fields.traceId) continue; - traces.push({ - traceId: fields.traceId, - timestamp: fields.lastSeen ?? fields.firstSeen ?? "unknown", - sessionId: fields.sessionId, - spanCount: fields.spanCount, - }); - } - return traces; - } - - /** - * Downloads every log record belonging to one trace, oldest first. The - * `@message` body is JSON-parsed when possible; other Insights fields pass - * through as returned. - */ - async getRuntimeTrace(input: GetRuntimeTraceInput, options: CoreOptions): Promise { - if (!TRACE_ID_PATTERN.test(input.traceId)) { - throw new InputValidationError( - "Invalid trace ID format. Expected a hex string (e.g., abc123def456).", - { meta: { traceId: input.traceId } }, - ); - } - - const logGroupName = runtimeLogGroup(input.runtimeId, DEFAULT_ENDPOINT_QUALIFIER); - const queryString = - `fields @timestamp, @message\n` + - `| filter traceId = '${sanitizeQueryValue(input.traceId)}'\n` + - `| sort @timestamp asc\n` + - `| limit 10000`; - - const rows = await this.runTraceQuery(input, logGroupName, queryString, options); - if (rows.length === 0) { - throw new ResourceNotFoundError(`No trace data found for trace ID: ${input.traceId}`, { - meta: { traceId: input.traceId }, - }); - } - - return rows.map((row) => { - const record: TraceRecord = fieldMap(row); - const message = record["@message"]; - if (typeof message === "string") { - try { - record["@message"] = JSON.parse(message); - } catch { - // Keep the original string when the body is not valid JSON. - } - } - return record; - }); - } - - private async runTraceQuery( - input: { runtimeId: string; startTimeMs: number; endTimeMs: number }, - logGroupName: string, - queryString: string, - options: CoreOptions, - ): Promise { - const logs = this.clients.logs(toClientConfig(options)); - const startSec = Math.floor(input.startTimeMs / 1000); - const endSec = Math.floor(input.endTimeMs / 1000); - try { - return await runInsightsQuery(logs, [logGroupName], queryString, startSec, endSec); - } catch (error) { - if (error instanceof ResourceNotFoundException) { - throw missingLogGroupError(input.runtimeId, logGroupName, error); - } - throw error; - } - } -} - -// Trace ids are hex strings, optionally dash-separated (mirrors the old CLI). -const TRACE_ID_PATTERN = /^[a-fA-F0-9-]+$/; - -// fieldMap flattens one Insights result row into a name -> value record. -function fieldMap(row: ResultField[]): Record { - const fields: Record = {}; - for (const field of row) { - if (field.field && field.value !== undefined) fields[field.field] = field.value; - } - return fields; -} - -function missingLogGroupError( - runtimeId: string, - logGroupName: string, - cause?: unknown, -): ResourceNotFoundError { - return new ResourceNotFoundError( - `No logs found for runtime '${runtimeId}': log group ${logGroupName} does not exist. ` + - `Has the runtime been invoked yet?`, - { cause, meta: { runtimeId, logGroupName } }, - ); -} diff --git a/src/core/observability/client.test.ts b/src/core/observability/client.test.ts new file mode 100644 index 000000000..e6da9b392 --- /dev/null +++ b/src/core/observability/client.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test"; +import type { CoreOptions } from "../types"; +import { ObservabilityClient, type LogRecord } from "./client"; +import type { LogSource, ObservabilitySourceResolverRegistry } from "./resolver"; +import type { + InsightsQuery, + LogSearchQuery, + LogTailQuery, + RawLogRecord, + SourceReader, +} from "./sourceReader"; + +const SOURCE: LogSource = { + provider: "cloudwatch", + logGroupName: "/aws/runtime-1", +}; +const OPTIONS = { region: "us-east-1" }; + +async function collect(records: AsyncIterable) { + const result: LogRecord[] = []; + for await (const record of records) result.push(record); + return result; +} + +function createClient(rawRecords: RawLogRecord[]) { + const calls: { method: string; args: unknown[] }[] = []; + const resolvers: ObservabilitySourceResolverRegistry = { + runtime: { + resolve: async (...args) => { + calls.push({ method: "resolve", args }); + return { + resource: { + kind: "runtime", + id: args[0].id, + qualifier: args[0].qualifier ?? "DEFAULT", + }, + logs: [SOURCE], + }; + }, + }, + }; + const reader: SourceReader = { + async *searchLogs( + source: LogSource, + query: LogSearchQuery, + options: CoreOptions, + signal?: AbortSignal, + ) { + calls.push({ + method: "searchLogs", + args: [source, query, options, signal], + }); + yield* rawRecords; + }, + async *tailLogs( + source: LogSource, + query: LogTailQuery, + options: CoreOptions, + signal: AbortSignal, + ) { + calls.push({ + method: "tailLogs", + args: [source, query, options, signal], + }); + yield* rawRecords; + }, + async queryLogs( + source: LogSource, + query: InsightsQuery, + options: CoreOptions, + signal?: AbortSignal, + ) { + calls.push({ + method: "queryLogs", + args: [source, query, options, signal], + }); + return [{ traceId: "trace-1" }]; + }, + }; + return { client: new ObservabilityClient(resolvers, reader), calls }; +} + +describe("ObservabilityClient", () => { + test("resolves, reads, and normalizes common log metadata", async () => { + const raw = { + timestamp: 1_709_391_000_000, + ingestionTime: 1_709_391_000_100, + logStreamName: "runtime-stream", + message: JSON.stringify({ + traceId: "trace-1", + spanId: "span-1", + parentSpanId: "parent-1", + severityText: "INFO", + attributes: { "session.id": "session-1" }, + }), + raw: { eventId: "event-1" }, + }; + const { client, calls } = createClient([raw]); + const signal = new AbortController().signal; + const query = { startTimeMs: 1, endTimeMs: 2 }; + + const records = await collect( + client.searchLogs( + { kind: "runtime", id: "runtime-1", qualifier: "blue" }, + query, + OPTIONS, + signal, + ), + ); + + expect(calls.map((call) => call.method)).toEqual(["resolve", "searchLogs"]); + expect(records).toEqual([ + { + timestamp: new Date(1_709_391_000_000), + ingestionTime: new Date(1_709_391_000_100), + message: raw.message, + correlation: { + traceId: "trace-1", + spanId: "span-1", + parentSpanId: "parent-1", + sessionId: "session-1", + }, + severity: "INFO", + source: { + provider: "cloudwatch", + resource: { + kind: "runtime", + id: "runtime-1", + qualifier: "blue", + }, + logGroupName: SOURCE.logGroupName, + logStreamName: "runtime-stream", + }, + raw: { eventId: "event-1" }, + }, + ]); + }); + + test("uses the same orchestration path for Live Tail records", async () => { + const { client, calls } = createClient([ + { + timestamp: 1, + message: "plain text", + raw: { message: "plain text" }, + }, + ]); + const signal = new AbortController().signal; + + const records = await collect( + client.tailLogs( + { kind: "runtime", id: "runtime-1" }, + { filterPattern: "ERROR" }, + OPTIONS, + signal, + ), + ); + + expect(calls.map((call) => call.method)).toEqual(["resolve", "tailLogs"]); + expect(records[0]).not.toHaveProperty("correlation"); + expect(records[0]).not.toHaveProperty("severity"); + }); + + test("resolves resources before executing generic queries", async () => { + const { client, calls } = createClient([]); + const query = { + queryString: "fields traceId", + startTimeMs: 1_000, + endTimeMs: 2_000, + }; + + await expect( + client.queryLogs({ kind: "runtime", id: "runtime-1", qualifier: "blue" }, query, OPTIONS), + ).resolves.toEqual([{ traceId: "trace-1" }]); + expect(calls.map((call) => call.method)).toEqual(["resolve", "queryLogs"]); + expect(calls[1]!.args[1]).toBe(query); + }); +}); diff --git a/src/core/observability/client.ts b/src/core/observability/client.ts new file mode 100644 index 000000000..1c01fa666 --- /dev/null +++ b/src/core/observability/client.ts @@ -0,0 +1,176 @@ +import type { CoreOptions } from "../types"; +import type { + LogSource, + ObservableResourceRef, + ObservabilitySourceResolver, + ObservabilitySourceResolverRegistry, + ResolvedObservabilityTarget, + ResolvedResourceIdentity, +} from "./resolver"; +import type { + InsightsQuery, + InsightsQueryRow, + LogSearchQuery, + LogTailQuery, + RawLogRecord, + SourceReader, +} from "./sourceReader"; + +export interface LogRecord { + timestamp: Date; + message: string; + correlation?: { + traceId?: string; + spanId?: string; + parentSpanId?: string; + sessionId?: string; + }; + severity?: string; + ingestionTime?: Date; + source: { + provider: "cloudwatch"; + resource: ResolvedResourceIdentity; + logGroupName: string; + logStreamName?: string; + }; + raw?: unknown; +} + +export interface CoreObservabilityClient { + searchLogs( + resource: ObservableResourceRef, + query: LogSearchQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncIterable; + + tailLogs( + resource: ObservableResourceRef, + query: LogTailQuery, + options: CoreOptions, + signal: AbortSignal, + ): AsyncIterable; + + queryLogs( + resource: ObservableResourceRef, + query: InsightsQuery, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; +} + +/** + * Shared entry point for logs. It orchestrates resolution and provider reads, + * then normalizes provider events into the stable record contract. + */ +export class ObservabilityClient implements CoreObservabilityClient { + constructor( + private readonly resolvers: ObservabilitySourceResolverRegistry, + private readonly sourceReader: SourceReader, + ) {} + + async *searchLogs( + resource: ObservableResourceRef, + query: LogSearchQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncGenerator { + const target = await this.resolve(resource, options, signal); + for (const source of target.logs) { + for await (const raw of this.sourceReader.searchLogs(source, query, options, signal)) { + yield toLogRecord(target.resource, source, raw); + } + } + } + + async *tailLogs( + resource: ObservableResourceRef, + query: LogTailQuery, + options: CoreOptions, + signal: AbortSignal, + ): AsyncGenerator { + const target = await this.resolve(resource, options, signal); + for (const source of target.logs) { + for await (const raw of this.sourceReader.tailLogs(source, query, options, signal)) { + yield toLogRecord(target.resource, source, raw); + } + } + } + + async queryLogs( + resource: ObservableResourceRef, + query: InsightsQuery, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + const target = await this.resolve(resource, options, signal); + const rows: InsightsQueryRow[] = []; + for (const source of target.logs) { + rows.push(...(await this.sourceReader.queryLogs(source, query, options, signal))); + } + return rows; + } + + private resolve( + resource: ObservableResourceRef, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + const resolver = this.resolvers[resource.kind] as ObservabilitySourceResolver; + return resolver.resolve(resource, options, signal); + } +} + +function toLogRecord( + resource: ResolvedResourceIdentity, + source: LogSource, + record: RawLogRecord, +): LogRecord { + const metadata = extractCommonMetadata(record.message); + return { + timestamp: new Date(record.timestamp), + message: record.message, + ...metadata, + ...(record.ingestionTime !== undefined + ? { ingestionTime: new Date(record.ingestionTime) } + : {}), + source: { + provider: source.provider, + resource, + logGroupName: source.logGroupName, + ...(record.logStreamName ? { logStreamName: record.logStreamName } : {}), + }, + raw: record.raw, + }; +} + +function extractCommonMetadata(message: string): Pick { + let parsed: Record; + try { + parsed = JSON.parse(message) as Record; + } catch { + return {}; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + + const attributes = + parsed.attributes && typeof parsed.attributes === "object" && !Array.isArray(parsed.attributes) + ? (parsed.attributes as Record) + : {}; + const stringValue = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + const correlation = { + traceId: stringValue(parsed.traceId), + spanId: stringValue(parsed.spanId), + parentSpanId: stringValue(parsed.parentSpanId), + sessionId: stringValue(parsed.sessionId) ?? stringValue(attributes["session.id"]), + }; + const hasCorrelation = Object.values(correlation).some((value) => value !== undefined); + const severity = + stringValue(parsed.severityText) ?? stringValue(parsed.severity) ?? stringValue(parsed.level); + + return { + ...(hasCorrelation ? { correlation } : {}), + ...(severity ? { severity } : {}), + }; +} diff --git a/src/core/observability/index.ts b/src/core/observability/index.ts new file mode 100644 index 000000000..2093813f4 --- /dev/null +++ b/src/core/observability/index.ts @@ -0,0 +1,28 @@ +export { ObservabilityClient, type CoreObservabilityClient, type LogRecord } from "./client"; +export { + INSIGHTS_MAX_ROWS, + runInsightsQuery, + sanitizeQueryValue, + type InsightsRowLimit, +} from "./insights"; +export { + DEFAULT_ENDPOINT_QUALIFIER, + DEFAULT_RUNTIME_QUALIFIER, + RuntimeSourceResolver, + runtimeLogGroup, + type LogSource, + type ObservableResourceRef, + type ObservabilitySourceResolver, + type ObservabilitySourceResolverRegistry, + type ResolvedObservabilityTarget, + type ResolvedResourceIdentity, +} from "./resolver"; +export { + CloudWatchSourceReader, + type InsightsQuery, + type InsightsQueryRow, + type LogSearchQuery, + type LogTailQuery, + type RawLogRecord, + type SourceReader, +} from "./sourceReader"; diff --git a/src/core/observability/insights.test.ts b/src/core/observability/insights.test.ts new file mode 100644 index 000000000..603b4124d --- /dev/null +++ b/src/core/observability/insights.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + GetQueryResultsCommand, + StartQueryCommand, + type CloudWatchLogsClient, +} from "@aws-sdk/client-cloudwatch-logs"; +import { CloudWatchQueryError, InputValidationError, ResultTruncationError } from "../../errors"; +import { runInsightsQuery, sanitizeQueryValue } from "./insights"; + +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("waits for completion and drains every result page", async () => { + 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" }; + } + const input = (command as GetQueryResultsCommand).input; + if (input.nextToken === "page-2") { + return { status: "Complete", results: [row("@message", "second")] }; + } + return { + status: "Complete", + results: [row("@message", "first")], + nextToken: "page-2", + }; + }); + + await expect( + runInsightsQuery(logs, ["/aws/group-a", "/aws/group-b"], "fields @message", 100, 200), + ).resolves.toEqual([row("@message", "first"), row("@message", "second")]); + }); + + test("throws a typed error for terminal failure states", async () => { + const logs = fakeLogs(async (command) => + command instanceof StartQueryCommand ? { queryId: "q-2" } : { status: "Failed" }, + ); + + await expect(runInsightsQuery(logs, ["/aws/g"], "q", 0, 1)).rejects.toThrow( + CloudWatchQueryError, + ); + }); + + test("applies caller-provided row-ceiling errors", async () => { + const logs = fakeLogs(async (command) => + command instanceof StartQueryCommand + ? { queryId: "q-3" } + : { 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"); + await expect( + runInsightsQuery(logs, ["/aws/g"], "q", 0, 1, { + maxRows: 1, + buildError: () => new InputValidationError("narrow the scope"), + }), + ).rejects.toThrow(InputValidationError); + }); +}); + +test("sanitizeQueryValue strips quotes from interpolated values", () => { + expect(sanitizeQueryValue("abc'| drop '123")).toBe("abc| drop 123"); + expect(sanitizeQueryValue("clean-id")).toBe("clean-id"); +}); diff --git a/src/core/observability/insights.ts b/src/core/observability/insights.ts new file mode 100644 index 000000000..7b2fe7967 --- /dev/null +++ b/src/core/observability/insights.ts @@ -0,0 +1,81 @@ +import { + GetQueryResultsCommand, + StartQueryCommand, + type CloudWatchLogsClient, + type ResultField, +} from "@aws-sdk/client-cloudwatch-logs"; +import { CloudWatchQueryError, ResultTruncationError, type AgentCoreCLIError } from "../../errors"; + +export const INSIGHTS_MAX_ROWS = 100_000; + +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`, + ), +}; + +export function sanitizeQueryValue(value: string): string { + return value.replace(/'/g, ""); +} + +export async function runInsightsQuery( + logs: CloudWatchLogsClient, + logGroupNames: string[], + queryString: string, + startSec: number, + endSec: number, + rowLimit: InsightsRowLimit = DEFAULT_ROW_LIMIT, + signal?: AbortSignal, +): Promise { + const started = await logs.send( + new StartQueryCommand({ + logGroupNames, + queryString, + startTime: startSec, + endTime: endSec, + }), + { abortSignal: signal }, + ); + const queryId = started.queryId; + + let status = "Running"; + for (let i = 0; i < 300 && status !== "Complete"; i++) { + const result = await logs.send(new GetQueryResultsCommand({ queryId }), { + abortSignal: signal, + }); + 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 }, + }); + } + + const rows: ResultField[][] = []; + let nextToken: string | undefined; + do { + const result = await logs.send(new GetQueryResultsCommand({ queryId, nextToken }), { + abortSignal: signal, + }); + rows.push(...(result.results ?? [])); + nextToken = result.nextToken; + } while (nextToken); + + if (rows.length >= rowLimit.maxRows) { + throw rowLimit.buildError(rowLimit.maxRows); + } + return rows; +} diff --git a/src/core/observability/resolver.test.ts b/src/core/observability/resolver.test.ts new file mode 100644 index 000000000..ed78a0fc2 --- /dev/null +++ b/src/core/observability/resolver.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_RUNTIME_QUALIFIER, RuntimeSourceResolver, runtimeLogGroup } from "./resolver"; + +describe("RuntimeSourceResolver", () => { + const resolver = new RuntimeSourceResolver(); + + test("defaults the qualifier and resolves the Runtime log group", async () => { + const target = await resolver.resolve( + { kind: "runtime", id: "my_agent-AbC123XyZ9" }, + { region: "us-east-1" }, + ); + + expect(target).toEqual({ + resource: { + kind: "runtime", + id: "my_agent-AbC123XyZ9", + qualifier: DEFAULT_RUNTIME_QUALIFIER, + }, + logs: [ + { + provider: "cloudwatch", + logGroupName: "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", + }, + ], + }); + }); + + test("uses an explicitly selected endpoint qualifier", async () => { + const target = await resolver.resolve( + { + kind: "runtime", + id: "my_agent-AbC123XyZ9", + qualifier: "production", + }, + { region: "us-east-1" }, + ); + + expect(target.resource.qualifier).toBe("production"); + expect(target.logs[0]?.logGroupName).toBe( + "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-production", + ); + }); +}); + +test("runtimeLogGroup derives the service-defined location", () => { + expect(runtimeLogGroup("runtime-1", "blue")).toBe( + "/aws/bedrock-agentcore/runtimes/runtime-1-blue", + ); +}); diff --git a/src/core/observability/resolver.ts b/src/core/observability/resolver.ts new file mode 100644 index 000000000..a1889cef2 --- /dev/null +++ b/src/core/observability/resolver.ts @@ -0,0 +1,73 @@ +import type { CoreOptions } from "../types"; + +export const DEFAULT_RUNTIME_QUALIFIER = "DEFAULT"; +export const DEFAULT_ENDPOINT_QUALIFIER = DEFAULT_RUNTIME_QUALIFIER; + +export type ObservableResourceRef = { + kind: "runtime"; + id: string; + qualifier?: string; +}; + +export type ResolvedResourceIdentity = { + kind: ObservableResourceRef["kind"]; + id: string; + qualifier?: string; +}; + +export type LogSource = { + provider: "cloudwatch"; + logGroupName: string; +}; + +export interface ResolvedObservabilityTarget { + resource: ResolvedResourceIdentity; + logs: readonly LogSource[]; +} + +export interface ObservabilitySourceResolver { + resolve( + resource: R, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; +} + +export type ObservabilitySourceResolverRegistry = { + [K in ObservableResourceRef["kind"]]: ObservabilitySourceResolver< + Extract + >; +}; + +export function runtimeLogGroup(runtimeId: string, qualifier: string): string { + return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${qualifier}`; +} + +/** + * Resolves Runtime identity into the CloudWatch locations used by generic log + * operations. CloudWatch access remains the source reader's responsibility. + */ +export class RuntimeSourceResolver implements ObservabilitySourceResolver< + Extract +> { + async resolve( + resource: Extract, + _options: CoreOptions, + _signal?: AbortSignal, + ): Promise { + const qualifier = resource.qualifier ?? DEFAULT_RUNTIME_QUALIFIER; + return { + resource: { + kind: "runtime", + id: resource.id, + qualifier, + }, + logs: [ + { + provider: "cloudwatch", + logGroupName: runtimeLogGroup(resource.id, qualifier), + }, + ], + }; + } +} diff --git a/src/core/observability/sourceReader.test.ts b/src/core/observability/sourceReader.test.ts new file mode 100644 index 000000000..ff812a16a --- /dev/null +++ b/src/core/observability/sourceReader.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, test } from "bun:test"; +import { + DescribeLogGroupsCommand, + FilterLogEventsCommand, + GetQueryResultsCommand, + ResourceNotFoundException, + StartLiveTailCommand, + StartQueryCommand, + type CloudWatchLogsClient, + type StartLiveTailResponseStream, +} from "@aws-sdk/client-cloudwatch-logs"; +import type { ClientConfig } from "../types"; +import { CloudWatchSourceReader, type RawLogRecord } from "./sourceReader"; + +const SOURCE = { + provider: "cloudwatch" as const, + logGroupName: "/aws/bedrock-agentcore/runtimes/runtime-1-DEFAULT", +}; +const OPTIONS = { + region: "us-west-2", + endpointUrl: "https://logs.test", +}; + +type Send = (command: unknown, options?: unknown) => Promise; + +function readerWith(send: Send) { + const configs: ClientConfig[] = []; + const logs = { send } as unknown as CloudWatchLogsClient; + const reader = new CloudWatchSourceReader({ + logs: (config) => { + configs.push(config); + return logs; + }, + }); + return { reader, configs }; +} + +async function collect(records: AsyncIterable) { + const result: RawLogRecord[] = []; + for await (const record of records) result.push(record); + return result; +} + +describe("CloudWatchSourceReader.searchLogs", () => { + test("paginates, preserves provider metadata, and uses the configured client", async () => { + const inputs: unknown[] = []; + const { reader, configs } = readerWith(async (command) => { + expect(command).toBeInstanceOf(FilterLogEventsCommand); + const input = (command as FilterLogEventsCommand).input; + inputs.push(input); + if (input.nextToken === "page-2") { + return { + events: [ + { + timestamp: 3, + ingestionTime: 4, + logStreamName: "stream-b", + message: "three", + eventId: "event-3", + }, + ], + }; + } + return { + events: [ + { timestamp: 1, message: "one" }, + { timestamp: 2, message: "two" }, + ], + nextToken: "page-2", + }; + }); + + const records = await collect( + reader.searchLogs( + SOURCE, + { + startTimeMs: 1_000, + endTimeMs: 2_000, + filterPattern: "ERROR database", + }, + OPTIONS, + ), + ); + + expect(configs).toEqual([{ region: "us-west-2", endpoint: "https://logs.test" }]); + expect(inputs).toEqual([ + { + logGroupName: SOURCE.logGroupName, + startTime: 1_000, + endTime: 2_000, + filterPattern: "ERROR database", + }, + { + logGroupName: SOURCE.logGroupName, + startTime: 1_000, + endTime: 2_000, + filterPattern: "ERROR database", + nextToken: "page-2", + }, + ]); + expect(records.map(({ timestamp, message }) => ({ timestamp, message }))).toEqual([ + { timestamp: 1, message: "one" }, + { timestamp: 2, message: "two" }, + { timestamp: 3, message: "three" }, + ]); + expect(records[2]).toMatchObject({ + ingestionTime: 4, + logStreamName: "stream-b", + raw: { eventId: "event-3" }, + }); + }); + + test("applies a total limit across CloudWatch pages", async () => { + const requestedLimits: (number | undefined)[] = []; + const { reader } = readerWith(async (command) => { + const input = (command as FilterLogEventsCommand).input; + requestedLimits.push(input.limit); + return input.nextToken + ? { + events: [ + { timestamp: 3, message: "three" }, + { timestamp: 4, message: "four" }, + ], + } + : { + events: [ + { timestamp: 1, message: "one" }, + { timestamp: 2, message: "two" }, + ], + nextToken: "page-2", + }; + }); + + const records = await collect( + reader.searchLogs(SOURCE, { startTimeMs: 1, endTimeMs: 2, limit: 3 }, OPTIONS), + ); + + expect(records.map((record) => record.message)).toEqual(["one", "two", "three"]); + expect(requestedLimits).toEqual([3, 1]); + }); + + test("translates a missing group into customer guidance", async () => { + const { reader } = readerWith(async () => { + throw new ResourceNotFoundException({ + message: "missing", + $metadata: {}, + }); + }); + + await expect( + collect(reader.searchLogs(SOURCE, { startTimeMs: 1, endTimeMs: 2 }, OPTIONS)), + ).rejects.toThrow( + `CloudWatch log group ${SOURCE.logGroupName} does not exist. ` + + "Has the resource been invoked or emitted logs yet?", + ); + }); +}); + +describe("CloudWatchSourceReader.queryLogs", () => { + test("runs an Insights query and flattens result fields", async () => { + const { reader } = readerWith(async (command) => { + if (command instanceof StartQueryCommand) return { queryId: "query-1" }; + expect(command).toBeInstanceOf(GetQueryResultsCommand); + return { + status: "Complete", + results: [ + [ + { field: "traceId", value: "trace-1" }, + { field: "spanCount", value: "3" }, + ], + ], + }; + }); + + await expect( + reader.queryLogs( + SOURCE, + { + queryString: "fields traceId", + startTimeMs: 1_000, + endTimeMs: 2_999, + }, + OPTIONS, + ), + ).resolves.toEqual([{ traceId: "trace-1", spanCount: "3" }]); + }); + + test("translates a missing query log group", async () => { + const { reader } = readerWith(async () => { + throw new ResourceNotFoundException({ message: "missing", $metadata: {} }); + }); + + await expect( + reader.queryLogs( + SOURCE, + { queryString: "fields @message", startTimeMs: 1_000, endTimeMs: 2_000 }, + OPTIONS, + ), + ).rejects.toThrow("Has the resource been invoked or emitted logs yet?"); + }); +}); + +describe("CloudWatchSourceReader.tailLogs", () => { + const GROUP_ARN = "arn:aws:logs:us-west-2:111122223333:log-group:" + SOURCE.logGroupName; + + type LiveTailEvent = Partial; + + function liveTailReader( + sessions: (LiveTailEvent[] | Error)[], + groups: { logGroupName?: string; logGroupArn?: string; arn?: string }[] = [ + { logGroupName: SOURCE.logGroupName, logGroupArn: GROUP_ARN }, + ], + ) { + const starts: unknown[] = []; + const { reader } = readerWith(async (command) => { + if (command instanceof DescribeLogGroupsCommand) { + expect(command.input.logGroupNamePrefix).toBe(SOURCE.logGroupName); + return { logGroups: groups }; + } + expect(command).toBeInstanceOf(StartLiveTailCommand); + starts.push((command as StartLiveTailCommand).input); + const session = sessions[starts.length - 1] ?? []; + return { + responseStream: (async function* () { + if (session instanceof Error) throw session; + yield* session as StartLiveTailResponseStream[]; + })(), + }; + }); + return { reader, starts }; + } + + function update(...messages: string[]): LiveTailEvent { + return { + sessionUpdate: { + sessionResults: messages.map((message, index) => ({ + timestamp: 1_000 + index, + message, + logStreamName: "stream-a", + })), + }, + }; + } + + test("resolves the exact ARN and yields Live Tail updates", async () => { + const { reader, starts } = liveTailReader([[update("one", "two"), update("three")]]); + + const records = await collect( + reader.tailLogs(SOURCE, { filterPattern: "ERROR" }, OPTIONS, new AbortController().signal), + ); + + expect(records.map((record) => record.message)).toEqual(["one", "two", "three"]); + expect(starts).toEqual([ + { + logGroupIdentifiers: [GROUP_ARN], + logEventFilterPattern: "ERROR", + }, + ]); + }); + + test("reconnects after the service times out a session", async () => { + const { reader, starts } = liveTailReader([ + [ + update("one"), + { + SessionTimeoutException: { name: "SessionTimeoutException" }, + } as never, + ], + [update("two")], + ]); + + const records = await collect( + reader.tailLogs(SOURCE, {}, OPTIONS, new AbortController().signal), + ); + + expect(records.map((record) => record.message)).toEqual(["one", "two"]); + expect(starts).toHaveLength(2); + }); + + test("stops cleanly when the caller aborts an active session", async () => { + const controller = new AbortController(); + const { reader, starts } = liveTailReader([ + [ + update("one"), + { + SessionTimeoutException: { name: "SessionTimeoutException" }, + } as never, + ], + ]); + const messages: string[] = []; + + for await (const record of reader.tailLogs(SOURCE, {}, OPTIONS, controller.signal)) { + messages.push(record.message); + controller.abort(); + } + + expect(messages).toEqual(["one"]); + expect(starts).toHaveLength(1); + }); + + test("strips the legacy ARN suffix", async () => { + const { reader, starts } = liveTailReader( + [[]], + [{ logGroupName: SOURCE.logGroupName, arn: `${GROUP_ARN}:*` }], + ); + + await collect(reader.tailLogs(SOURCE, {}, OPTIONS, new AbortController().signal)); + + expect(starts).toEqual([{ logGroupIdentifiers: [GROUP_ARN] }]); + }); + + test("fails before starting a session when the group is absent", async () => { + const { reader } = liveTailReader([[]], []); + + await expect( + collect(reader.tailLogs(SOURCE, {}, OPTIONS, new AbortController().signal)), + ).rejects.toThrow("Has the resource been invoked or emitted logs yet?"); + }); +}); diff --git a/src/core/observability/sourceReader.ts b/src/core/observability/sourceReader.ts new file mode 100644 index 000000000..b2d7d99d8 --- /dev/null +++ b/src/core/observability/sourceReader.ts @@ -0,0 +1,228 @@ +import { + DescribeLogGroupsCommand, + FilterLogEventsCommand, + ResourceNotFoundException, + StartLiveTailCommand, + type FilteredLogEvent, + type LiveTailSessionLogEvent, +} from "@aws-sdk/client-cloudwatch-logs"; +import { ResourceNotFoundError } from "../../errors"; +import type { AwsClients, CoreOptions } from "../types"; +import { toClientConfig } from "../utils"; +import { runInsightsQuery, type InsightsRowLimit } from "./insights"; +import type { LogSource } from "./resolver"; + +export type RawLogRecord = { + timestamp: number; + message: string; + ingestionTime?: number; + logStreamName?: string; + raw: FilteredLogEvent | LiveTailSessionLogEvent; +}; + +export type LogSearchQuery = { + startTimeMs: number; + endTimeMs: number; + filterPattern?: string; + limit?: number; +}; + +export type LogTailQuery = { + filterPattern?: string; +}; + +export type InsightsQuery = { + queryString: string; + startTimeMs: number; + endTimeMs: number; + rowLimit?: InsightsRowLimit; +}; + +export type InsightsQueryRow = Record; + +export interface SourceReader { + 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; +} + +/** + * Executes CloudWatch operations against resolved descriptors. It has no + * knowledge of Runtime or any other AgentCore resource type. + */ +export class CloudWatchSourceReader implements SourceReader { + constructor(private readonly clients: Pick) {} + + async *searchLogs( + source: LogSource, + query: LogSearchQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncGenerator { + if (query.limit !== undefined && query.limit <= 0) return; + + const logs = this.clients.logs(toClientConfig(options)); + let nextToken: string | undefined; + let yielded = 0; + + do { + const requestToken = nextToken; + let response; + try { + response = await logs.send( + new FilterLogEventsCommand({ + logGroupName: source.logGroupName, + startTime: query.startTimeMs, + endTime: query.endTimeMs, + ...(query.filterPattern ? { filterPattern: query.filterPattern } : {}), + ...(requestToken ? { nextToken: requestToken } : {}), + ...(query.limit ? { limit: Math.min(query.limit - yielded, 10_000) } : {}), + }), + { abortSignal: signal }, + ); + } catch (error) { + if (error instanceof ResourceNotFoundException) { + throw missingLogGroupError(source, error); + } + throw error; + } + + for (const event of response.events ?? []) { + if (query.limit !== undefined && yielded >= query.limit) return; + yield toRawLogRecord(event); + yielded++; + } + + nextToken = response.nextToken; + if (nextToken === requestToken) return; + } while (nextToken && (query.limit === undefined || yielded < query.limit)); + } + + async *tailLogs( + source: LogSource, + query: LogTailQuery, + options: CoreOptions, + signal: AbortSignal, + ): AsyncGenerator { + const logs = this.clients.logs(toClientConfig(options)); + const described = await logs.send( + new DescribeLogGroupsCommand({ logGroupNamePrefix: source.logGroupName }), + { abortSignal: signal }, + ); + const group = (described.logGroups ?? []).find( + (candidate) => candidate.logGroupName === source.logGroupName, + ); + // The legacy ARN field includes a suffix that StartLiveTail rejects. + const logGroupArn = group?.logGroupArn ?? group?.arn?.replace(/:\*$/, ""); + if (!logGroupArn) { + throw missingLogGroupError(source); + } + + while (!signal.aborted) { + let response; + try { + response = await logs.send( + new StartLiveTailCommand({ + logGroupIdentifiers: [logGroupArn], + ...(query.filterPattern ? { logEventFilterPattern: query.filterPattern } : {}), + }), + { abortSignal: signal }, + ); + } catch (error) { + if (signal.aborted) return; + throw error; + } + if (!response.responseStream) return; + + let sessionTimedOut = false; + try { + for await (const event of response.responseStream) { + if (signal.aborted) return; + for (const logEvent of event.sessionUpdate?.sessionResults ?? []) { + yield toRawLogRecord(logEvent); + } + if (event.SessionTimeoutException) { + sessionTimedOut = true; + break; + } + } + } catch (error) { + if (signal.aborted) return; + if ((error as { name?: string }).name === "SessionTimeoutException") { + sessionTimedOut = true; + } else { + throw error; + } + } + + if (!sessionTimedOut) return; + } + } + + async queryLogs( + source: LogSource, + query: InsightsQuery, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + const logs = this.clients.logs(toClientConfig(options)); + try { + const rows = await runInsightsQuery( + logs, + [source.logGroupName], + query.queryString, + Math.floor(query.startTimeMs / 1000), + Math.floor(query.endTimeMs / 1000), + query.rowLimit, + signal, + ); + return rows.map((row) => { + const result: InsightsQueryRow = {}; + for (const field of row) { + if (field.field && field.value !== undefined) result[field.field] = field.value; + } + return result; + }); + } catch (error) { + if (error instanceof ResourceNotFoundException) { + throw missingLogGroupError(source, error); + } + throw error; + } + } +} + +function toRawLogRecord(event: FilteredLogEvent | LiveTailSessionLogEvent): RawLogRecord { + return { + timestamp: event.timestamp ?? Date.now(), + message: event.message ?? "", + ...(event.ingestionTime !== undefined ? { ingestionTime: event.ingestionTime } : {}), + ...(event.logStreamName ? { logStreamName: event.logStreamName } : {}), + raw: event, + }; +} + +function missingLogGroupError(source: LogSource, cause?: unknown): ResourceNotFoundError { + return new ResourceNotFoundError( + `CloudWatch log group ${source.logGroupName} does not exist. ` + + "Has the resource been invoked or emitted logs yet?", + { cause, meta: { logGroupName: source.logGroupName } }, + ); +} diff --git a/src/core/types.tsx b/src/core/types.tsx index 9e36a7c17..f38951aa3 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -50,8 +50,8 @@ export interface AwsClients { control(config: ClientConfig): BedrockAgentCoreControlClient; data(config: ClientConfig): BedrockAgentCoreClient; iam(config: ClientConfig): IAMClient; - // logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation - // results to. CloudWatch is a distinct service from the AgentCore data plane, - // so it gets its own client/factory rather than reusing `data`. + // logs reads AgentCore operational logs, traces, and evaluation result + // streams. CloudWatch is distinct from the AgentCore data plane, so it gets + // its own client/factory rather than reusing `data`. logs(config: ClientConfig): CloudWatchLogsClient; } diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index a2ff49e03..ee7d4f6c4 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -8,6 +8,7 @@ import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; +import { ObservabilityHandlerFactory } from "./observability/handlerFactory"; import { renderTui } from "../tui"; import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; import type { AppIO } from "../io"; @@ -25,6 +26,7 @@ export interface RootHandlerConfig { export function createRootHandler(core: Core, config: RootHandlerConfig): Router { const { io, logger } = config; const root = new Router("agentcore", "the platform for production AI agents"); + const observabilityHandlers = new ObservabilityHandlerFactory(core.observability, io); // `agentcore --version` prints the build-time package version. root.version(PACKAGE_VERSION); @@ -49,7 +51,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router // Install sub handlers root.handler(createHarnessHandler(core, io)); root.handler(createIdentityHandler(core, io)); - root.handler(createRuntimeHandler(core, io)); + root.handler(createRuntimeHandler(core, io, observabilityHandlers)); root.handler(createMemoryHandler(core, io)); root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); diff --git a/src/handlers/observability/filterPattern.test.ts b/src/handlers/observability/filterPattern.test.ts new file mode 100644 index 000000000..2e14da905 --- /dev/null +++ b/src/handlers/observability/filterPattern.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { buildFilterPattern } from "./filterPattern"; + +describe("buildFilterPattern", () => { + test("returns no pattern without filters", () => { + expect(buildFilterPattern({})).toBeUndefined(); + }); + + test("combines normalized level and query filters", () => { + expect(buildFilterPattern({ level: "error", query: '"timed out"' })).toBe('ERROR "timed out"'); + }); +}); diff --git a/src/handlers/observability/filterPattern.ts b/src/handlers/observability/filterPattern.ts new file mode 100644 index 000000000..58660c932 --- /dev/null +++ b/src/handlers/observability/filterPattern.ts @@ -0,0 +1,20 @@ +export const LOG_LEVELS = ["error", "warn", "info", "debug"] as const; + +export type LogLevel = (typeof LOG_LEVELS)[number]; + +const LEVEL_PATTERNS: Record = { + error: "ERROR", + warn: "WARN", + info: "INFO", + debug: "DEBUG", +}; + +export function buildFilterPattern(options: { + level?: LogLevel; + query?: string; +}): string | undefined { + const parts: string[] = []; + if (options.level) parts.push(LEVEL_PATTERNS[options.level]); + if (options.query) parts.push(options.query); + return parts.length > 0 ? parts.join(" ") : undefined; +} diff --git a/src/handlers/observability/handlerFactory.ts b/src/handlers/observability/handlerFactory.ts new file mode 100644 index 000000000..0c14bc464 --- /dev/null +++ b/src/handlers/observability/handlerFactory.ts @@ -0,0 +1,137 @@ +import z from "zod"; +import type { + CoreObservabilityClient, + LogRecord, + ObservableResourceRef, +} from "../../core/observability"; +import { InputValidationError } from "../../errors"; +import type { AppIO } from "../../io"; +import { createHandler, flag, type Flag } from "../../router"; +import { withUserCancellation } from "../../runnable"; +import { JsonRendererKey } from "../../tui"; +import { JsonKey } from "../keys"; +import { coreOptsFromCtx } from "../utils"; +import { buildFilterPattern, LOG_LEVELS } from "./filterPattern"; +import { resolveTimeWindow } from "./time"; +import type { + ObservableResourceCommand, + ObservabilityHandlerFactories, + ResourceFlagValues, +} from "./types"; + +const DEFAULT_SEARCH_WINDOW_MS = 3_600_000; + +const levelSchema = z + .preprocess( + (value) => (typeof value === "string" ? value.toLowerCase() : value), + z.enum(LOG_LEVELS), + ) + .optional(); + +const logFlags = [ + flag( + "since", + 'search window start: "5m", "1h", ISO 8601, epoch ms, or "now"', + z.string().min(1).optional(), + ), + flag( + "until", + 'search window end: "5m", "1h", ISO 8601, epoch ms, or "now"', + z.string().min(1).optional(), + ), + flag("tail", "tail new log records", z.boolean().default(false)), + flag("level", `filter by log level (${LOG_LEVELS.join(", ")})`, levelSchema), + flag("query", "CloudWatch Logs filter pattern", z.string().optional()), + flag( + "limit", + "maximum number of log records to return in search mode", + z.number().int().positive().optional(), + ), +] as const; + +type LogFlagValues = ResourceFlagValues; + +/** + * Builds reusable logs command behavior. Primitive routers contribute only + * identity flags and conversion to an ObservableResourceRef. + */ +export class ObservabilityHandlerFactory implements ObservabilityHandlerFactories { + constructor( + private readonly client: CoreObservabilityClient, + private readonly io: AppIO, + ) {} + + createLogsHandler< + K extends ObservableResourceRef["kind"], + F extends readonly Flag[], + >(config: { resource: ObservableResourceCommand }) { + const flags = [...config.resource.flags, ...logFlags] as const; + + return createHandler({ + name: "logs", + description: "stream or search resource logs", + flags, + handle: async (ctx, values) => { + const parsed = values as unknown as ResourceFlagValues & LogFlagValues; + const searchMode = parsed.since !== undefined || parsed.until !== undefined; + if (parsed.tail && searchMode) { + throw new InputValidationError("--tail cannot be combined with --since or --until"); + } + if (!searchMode && parsed.limit !== undefined) { + throw new InputValidationError( + "--limit applies to search mode; add --since and/or --until", + ); + } + + const filterPattern = buildFilterPattern({ + level: parsed.level, + query: parsed.query, + }); + const { startTimeMs, endTimeMs } = resolveTimeWindow({ + since: parsed.since, + until: parsed.until, + defaultWindowMs: DEFAULT_SEARCH_WINDOW_MS, + }); + + const json = ctx.require(JsonKey); + const renderer = ctx.require(JsonRendererKey); + const writeRecord = (record: LogRecord) => { + if (json) { + renderer.renderJsonLine(record); + } else { + this.io.stdout.write( + `${record.timestamp.toISOString()} ${record.message.trimEnd()}\n`, + ); + } + }; + + await withUserCancellation(async (signal) => { + const target = await config.resource.resolve(parsed, ctx); + const { resource } = target; + const options = target.options ?? coreOptsFromCtx(ctx); + if (searchMode) { + const records = this.client.searchLogs( + resource, + { + startTimeMs, + endTimeMs, + filterPattern, + limit: parsed.limit, + }, + options, + signal, + ); + for await (const record of records) writeRecord(record); + return; + } + + this.io.stderr.write( + `Streaming logs for ${resource.kind} ${resource.id}... (Ctrl+C to stop)\n`, + ); + const records = this.client.tailLogs(resource, { filterPattern }, options, signal); + for await (const record of records) writeRecord(record); + }); + }, + }); + } +} diff --git a/src/handlers/observability/time.test.ts b/src/handlers/observability/time.test.ts new file mode 100644 index 000000000..f47f85cdd --- /dev/null +++ b/src/handlers/observability/time.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { InputValidationError } from "../../errors"; +import { parseTimeString, resolveTimeWindow } from "./time"; + +describe("parseTimeString", () => { + const now = () => 1_700_000_000_000; + + test("parses relative, epoch, ISO, and now values", () => { + expect(parseTimeString("30s", now)).toBe(1_699_999_970_000); + expect(parseTimeString("5m", now)).toBe(1_699_999_700_000); + expect(parseTimeString("1h", now)).toBe(1_699_996_400_000); + expect(parseTimeString("2d", now)).toBe(1_699_827_200_000); + expect(parseTimeString("1709391000000", now)).toBe(1_709_391_000_000); + expect(parseTimeString("2026-03-02T14:30:00Z", now)).toBe(Date.parse("2026-03-02T14:30:00Z")); + expect(parseTimeString("now", now)).toBe(1_700_000_000_000); + }); + + test("rejects empty and invalid values with typed guidance", () => { + expect(() => parseTimeString(" ", now)).toThrow(InputValidationError); + expect(() => parseTimeString("5x", now)).toThrow('Invalid time string: "5x"'); + }); +}); + +describe("resolveTimeWindow", () => { + test("derives the default start from a historical end", () => { + expect( + resolveTimeWindow( + { + until: "1709391000000", + defaultWindowMs: 3_600_000, + }, + () => 1_800_000_000_000, + ), + ).toEqual({ + startTimeMs: 1_709_387_400_000, + endTimeMs: 1_709_391_000_000, + }); + }); + + test("rejects an inverted window", () => { + expect(() => + resolveTimeWindow({ + since: "1709391000000", + until: "1709381000000", + defaultWindowMs: 3_600_000, + }), + ).toThrow("--since must resolve to a time before --until"); + }); +}); diff --git a/src/handlers/observability/time.ts b/src/handlers/observability/time.ts new file mode 100644 index 000000000..ed0999f21 --- /dev/null +++ b/src/handlers/observability/time.ts @@ -0,0 +1,51 @@ +import { InputValidationError } from "../../errors"; + +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, +}; + +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 relative = RELATIVE_DURATION_RE.exec(trimmed); + if (relative) { + return now() - parseInt(relative[1]!, 10) * UNIT_TO_MS[relative[2]!]!; + } + if (/^\d{13,}$/.test(trimmed)) return parseInt(trimmed, 10); + + const timestamp = Date.parse(trimmed); + if (!Number.isNaN(timestamp)) return timestamp; + + throw new InputValidationError( + `Invalid time string: "${input}". Use relative durations (5m, 1h, 2d), ` + + 'ISO 8601, epoch ms, or "now".', + ); +} + +export function resolveTimeWindow( + input: { + since?: string; + until?: string; + defaultWindowMs: number; + }, + now: () => number = Date.now, +): { startTimeMs: number; endTimeMs: number } { + const referenceTime = now(); + const parse = (value: string) => parseTimeString(value, () => referenceTime); + const endTimeMs = input.until === undefined ? referenceTime : parse(input.until); + const startTimeMs = + input.since === undefined ? endTimeMs - input.defaultWindowMs : parse(input.since); + if (startTimeMs > endTimeMs) { + throw new InputValidationError("--since must resolve to a time before --until"); + } + return { startTimeMs, endTimeMs }; +} diff --git a/src/handlers/observability/types.ts b/src/handlers/observability/types.ts new file mode 100644 index 000000000..b471942ed --- /dev/null +++ b/src/handlers/observability/types.ts @@ -0,0 +1,33 @@ +import type z from "zod"; +import type { ObservableResourceRef } from "../../core/observability"; +import type { CoreOptions } from "../../core/types"; +import type { Context, Flag, Handler } from "../../router"; + +export type ResourceFlagValues[]> = { + [E in F[number] as E["name"]]: E extends Flag ? z.infer> : never; +}; + +export interface ObservableResourceCommand< + K extends ObservableResourceRef["kind"], + F extends readonly Flag[], +> { + flags: F; + resolve( + flags: ResourceFlagValues, + ctx: Context, + ): ResolvedObservableResource | Promise>; +} + +export type ResolvedObservableResource = { + resource: Extract; + options?: CoreOptions; +}; + +export interface ObservabilityHandlerFactories { + createLogsHandler< + K extends ObservableResourceRef["kind"], + F extends readonly Flag[], + >(config: { + resource: ObservableResourceCommand; + }): Handler; +} diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 471af6aba..8dbb2a05e 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -1,30 +1,68 @@ +import z from "zod"; import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; -import { Router } from "../../router"; +import { flag, Router } from "../../router"; import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import type { Core } from "../types"; +import type { + ObservableResourceCommand, + ObservabilityHandlerFactories, +} from "../observability/types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; import { createInvokeRuntimeHandler } from "./invoke"; import { createListRuntimesHandler } from "./list"; -import { createRuntimeLogsHandler } from "./logs"; +import { runtimeIdSchema } from "./invoke/request"; +import { resolveRuntimeTarget } from "./resolveRuntimeTarget"; import { createRuntimeTracesHandler } from "./traces"; import { createRuntimeVersionHandler } from "./version"; -export function createRuntimeHandler(core: Core, io: AppIO): Router { - return ( - new Router("runtime", "inspect AgentCore Runtimes") - .use(withTuiOnEmptyFlagsAndArgs(core, io)) - .default(renderTui(core, io)) - // logs and traces are headless-only: a bare `runtime logs` means "follow - // the project runtime's logs", so neither may fall into the TUI. - .supportedTuiCommands("get", "list", "invoke", "version", "endpoint") - .handler(createGetRuntimeHandler(core)) - .handler(createListRuntimesHandler(core)) - .handler(createInvokeRuntimeHandler(core, io)) - .handler(createRuntimeVersionHandler(core, io)) - .handler(createRuntimeEndpointHandler(core, io)) - .handler(createRuntimeLogsHandler(core, io)) - .handler(createRuntimeTracesHandler(core, io)) - ); +const runtimeObservabilityFlags = [ + flag( + "id", + "the ID of the Runtime (defaults to the project's deployed Runtime)", + runtimeIdSchema.optional(), + ), + flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()), +] as const; + +function runtimeObservabilityResource( + core: Core, +): ObservableResourceCommand<"runtime", typeof runtimeObservabilityFlags> { + return { + flags: runtimeObservabilityFlags, + resolve: async (flags, ctx) => { + const target = await resolveRuntimeTarget(core, ctx, flags.id); + return { + resource: { + kind: "runtime", + id: target.runtimeId, + ...(flags.qualifier ? { qualifier: flags.qualifier } : {}), + }, + options: target.options, + }; + }, + }; +} + +export function createRuntimeHandler( + core: Core, + io: AppIO, + observabilityHandlers: ObservabilityHandlerFactories, +): Router { + return new Router("runtime", "inspect AgentCore Runtimes") + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) + .supportedTuiCommands("get", "list", "invoke", "version", "endpoint") + .handler(createGetRuntimeHandler(core)) + .handler(createListRuntimesHandler(core)) + .handler(createInvokeRuntimeHandler(core, io)) + .handler(createRuntimeVersionHandler(core, io)) + .handler(createRuntimeEndpointHandler(core, io)) + .handler( + observabilityHandlers.createLogsHandler({ + resource: runtimeObservabilityResource(core), + }), + ) + .handler(createRuntimeTracesHandler(core, io)); } diff --git a/src/handlers/runtime/logs.test.tsx b/src/handlers/runtime/logs.test.tsx new file mode 100644 index 000000000..cb0fc8620 --- /dev/null +++ b/src/handlers/runtime/logs.test.tsx @@ -0,0 +1,223 @@ +import { describe, expect, test } from "bun:test"; +import type { LogRecord } from "../../core/observability"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; +import type { Project } from "../project/types"; + +const REGION = "us-west-2"; +const SINCE_MS = 1_709_391_000_000; +const UNTIL_MS = 1_709_394_600_000; + +function logRecord(overrides: Partial = {}): LogRecord { + return { + timestamp: new Date(SINCE_MS), + message: "hello", + source: { + provider: "cloudwatch", + resource: { + kind: "runtime", + id: "my_agent-AbC123XyZ9", + qualifier: "DEFAULT", + }, + logGroupName: "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", + }, + raw: { eventId: "event-1" }, + ...overrides, + }; +} + +function testLogsCommand() { + const core = new TestCoreClient(); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + return { + core, + io, + route: (args: string[]) => root.route(["bun", "agentcore", ...args, "--region", REGION]), + }; +} + +describe("runtime logs", () => { + test("maps Runtime identity and shared search flags into the core client", async () => { + const { core, io, route } = testLogsCommand(); + core.observability.logRecords = [ + logRecord({ message: "hello world\n" }), + logRecord({ + timestamp: new Date(SINCE_MS + 1_000), + message: "second line", + }), + ]; + + await route([ + "runtime", + "logs", + "--id", + "my_agent-AbC123XyZ9", + "--qualifier", + "blue", + "--since", + `${SINCE_MS}`, + "--until", + `${UNTIL_MS}`, + "--level", + "ERROR", + "--query", + "database", + "--limit", + "25", + ]); + + expect(core.observability.calls).toHaveLength(1); + expect(core.observability.calls[0]).toMatchObject({ + method: "searchLogs", + args: [ + { + kind: "runtime", + id: "my_agent-AbC123XyZ9", + qualifier: "blue", + }, + { + startTimeMs: SINCE_MS, + endTimeMs: UNTIL_MS, + filterPattern: "ERROR database", + limit: 25, + }, + { region: REGION, endpointUrl: undefined }, + expect.any(AbortSignal), + ], + }); + expect(io.stdout()).toBe( + "2024-03-02T14:50:00.000Z hello world\n" + "2024-03-02T14:50:01.000Z second line", + ); + }); + + test("--json emits the generic LogRecord contract as JSON Lines", async () => { + const { core, io, route } = testLogsCommand(); + core.observability.logRecords = [ + logRecord({ + correlation: { traceId: "trace-1" }, + severity: "INFO", + }), + ]; + + await route([ + "runtime", + "logs", + "--id", + "my_agent-AbC123XyZ9", + "--since", + `${SINCE_MS}`, + "--json", + ]); + + expect(JSON.parse(io.stdout())).toEqual({ + timestamp: "2024-03-02T14:50:00.000Z", + message: "hello", + correlation: { traceId: "trace-1" }, + severity: "INFO", + source: { + provider: "cloudwatch", + resource: { + kind: "runtime", + id: "my_agent-AbC123XyZ9", + qualifier: "DEFAULT", + }, + logGroupName: "/aws/bedrock-agentcore/runtimes/my_agent-AbC123XyZ9-DEFAULT", + }, + raw: { eventId: "event-1" }, + }); + }); + + test("tails by default and accepts an explicit --tail", async () => { + const { core, io, route } = testLogsCommand(); + core.observability.logRecords = [logRecord({ message: "tailed" })]; + + await route(["runtime", "logs", "--id", "my_agent-AbC123XyZ9", "--tail"]); + + expect(core.observability.calls[0]).toMatchObject({ + method: "tailLogs", + args: [ + { kind: "runtime", id: "my_agent-AbC123XyZ9" }, + { filterPattern: undefined }, + { region: REGION, endpointUrl: undefined }, + expect.any(AbortSignal), + ], + }); + expect(io.stderr()).toContain( + "Streaming logs for runtime my_agent-AbC123XyZ9... (Ctrl+C to stop)", + ); + expect(io.stdout()).toBe("2024-03-02T14:50:00.000Z tailed"); + }); + + test("rejects conflicting mode and time inputs", async () => { + const { route } = testLogsCommand(); + + await expect( + route(["runtime", "logs", "--id", "runtime-1", "--tail", "--since", "1h"]), + ).rejects.toThrow("--tail cannot be combined with --since or --until"); + + await expect( + route([ + "runtime", + "logs", + "--id", + "runtime-1", + "--since", + `${UNTIL_MS}`, + "--until", + `${SINCE_MS}`, + ]), + ).rejects.toThrow("--since must resolve to a time before --until"); + }); + + test("requires a Runtime ID", async () => { + const { core, route } = testLogsCommand(); + core.projectManager.resolve = async () => undefined; + + await expect(route(["runtime", "logs", "--since", `${SINCE_MS}`])).rejects.toThrow( + "required option '--id ' not specified", + ); + }); + + test("auto-resolves the project's deployed Runtime through the project manager", async () => { + const { core, route } = testLogsCommand(); + const project = { name: "Proj", rootPath: "/proj", spec: {} } as unknown as Project; + core.projectManager.resolve = async () => project; + core.projectManager.resolveDeployedResources = async () => ({ + resources: [{ resourceType: "runtime", name: "agent", id: "deployed-runtime" }], + target: { + name: "default", + account: "111122223333", + region: "eu-west-1", + }, + }); + + await route(["runtime", "logs", "--since", `${SINCE_MS}`]); + + const call = core.observability.calls[0]!; + expect(call.method).toBe("searchLogs"); + expect(call.args[0]).toEqual({ kind: "runtime", id: "deployed-runtime" }); + expect(call.args[2]).toEqual({ region: "eu-west-1", endpointUrl: undefined }); + }); + + test("anchors the default one-hour window to a historical --until", async () => { + const { core, route } = testLogsCommand(); + + await route(["runtime", "logs", "--id", "runtime-1", "--until", `${UNTIL_MS}`]); + + expect(core.observability.calls[0]!.args[1]).toMatchObject({ + startTimeMs: UNTIL_MS - 3_600_000, + endTimeMs: UNTIL_MS, + }); + }); +}); diff --git a/src/handlers/runtime/logs/filterPattern.test.ts b/src/handlers/runtime/logs/filterPattern.test.ts deleted file mode 100644 index 3cc2b6ee2..000000000 --- a/src/handlers/runtime/logs/filterPattern.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildFilterPattern } from "./filterPattern"; - -describe("buildFilterPattern", () => { - test("returns undefined when neither level nor query is set", () => { - expect(buildFilterPattern({})).toBeUndefined(); - }); - - test("maps each level to its uppercase token", () => { - expect(buildFilterPattern({ level: "error" })).toBe("ERROR"); - expect(buildFilterPattern({ level: "warn" })).toBe("WARN"); - expect(buildFilterPattern({ level: "info" })).toBe("INFO"); - expect(buildFilterPattern({ level: "debug" })).toBe("DEBUG"); - }); - - test("passes the query through as-is", () => { - expect(buildFilterPattern({ query: '"timed out"' })).toBe('"timed out"'); - }); - - test("combines level and query with a space (implicit AND)", () => { - expect(buildFilterPattern({ level: "error", query: "database" })).toBe("ERROR database"); - }); -}); diff --git a/src/handlers/runtime/logs/filterPattern.ts b/src/handlers/runtime/logs/filterPattern.ts deleted file mode 100644 index b8eb156e5..000000000 --- a/src/handlers/runtime/logs/filterPattern.ts +++ /dev/null @@ -1,31 +0,0 @@ -// CloudWatch Logs filter-pattern assembly for `runtime logs`, ported from the -// old CLI's src/cli/commands/logs/filter-pattern.ts. - -export const LOG_LEVELS = ["error", "warn", "info", "debug"] as const; - -export type LogLevel = (typeof LOG_LEVELS)[number]; - -// Runtime log lines embed their level as uppercase text (ERROR, WARN, ...), so -// a level filter is just that token in the pattern. -const LEVEL_MAP: Record = { - error: "ERROR", - warn: "WARN", - info: "INFO", - debug: "DEBUG", -}; - -/** - * Builds a CloudWatch Logs filter pattern from the --level and --query options. - * The level maps to its uppercase token; the query passes through as-is; both - * combine with a space, which CloudWatch treats as an implicit AND. Returns - * undefined when neither is set (no server-side filtering). - */ -export function buildFilterPattern(options: { - level?: LogLevel; - query?: string; -}): string | undefined { - const parts: string[] = []; - if (options.level) parts.push(LEVEL_MAP[options.level]); - if (options.query) parts.push(options.query); - return parts.length > 0 ? parts.join(" ") : undefined; -} diff --git a/src/handlers/runtime/logs/index.tsx b/src/handlers/runtime/logs/index.tsx deleted file mode 100644 index c35346e67..000000000 --- a/src/handlers/runtime/logs/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import z from "zod"; -import { parseTimeString } from "../../../core/observability"; -import { InputValidationError } from "../../../errors"; -import type { AppIO } from "../../../io"; -import { createHandler, flag } from "../../../router"; -import { withUserCancellation } from "../../../runnable"; -import { JsonRendererKey } from "../../../tui"; -import { JsonKey } from "../../keys"; -import type { Core } from "../../types"; -import { runtimeIdSchema } from "../invoke/request"; -import { resolveRuntimeTarget } from "../resolveRuntimeTarget"; -import type { RuntimeLogEvent } from "../types"; -import { buildFilterPattern, LOG_LEVELS } from "./filterPattern"; - -// Search mode's default window when only one bound is given: the last hour. -const DEFAULT_SEARCH_WINDOW_MS = 3_600_000; - -const levelSchema = z - .preprocess( - (value) => (typeof value === "string" ? value.toLowerCase() : value), - z.enum(LOG_LEVELS), - ) - .optional(); - -const timeSchema = z.string().min(1).optional(); - -/** - * `runtime logs` follows a deployed runtime's CloudWatch log group live - * (default), or searches a past time window when --since/--until is given. - * Follow mode runs until Ctrl+C, which exits with the conventional SIGINT - * status (130). - */ -export const createRuntimeLogsHandler = (core: Core, io: AppIO) => - createHandler({ - name: "logs", - description: "stream or search a Runtime's logs", - flags: [ - flag( - "id", - "the ID of the Runtime (defaults to the project's deployed runtime)", - runtimeIdSchema.optional(), - ), - flag( - "since", - 'search window start: "5m", "1h", "2d", ISO 8601, epoch ms, or "now" (default 1h ago; enables search mode)', - timeSchema, - ), - flag( - "until", - 'search window end: "5m", "1h", "2d", ISO 8601, epoch ms, or "now" (default now; enables search mode)', - timeSchema, - ), - flag("level", `filter by log level (${LOG_LEVELS.join(", ")})`, levelSchema), - flag("query", "server-side text filter", z.string().optional()), - flag( - "limit", - "maximum number of log lines to return (search mode)", - z.number().int().positive().optional(), - ), - ], - handle: async (ctx, flags) => { - const json = ctx.require(JsonKey); - const renderer = ctx.require(JsonRendererKey); - - // --since / --until switch from live tail to a bounded search. - const searchMode = flags.since !== undefined || flags.until !== undefined; - if (!searchMode && flags.limit !== undefined) { - throw new InputValidationError( - "--limit applies to search mode; add --since and/or --until", - ); - } - const filterPattern = buildFilterPattern({ level: flags.level, query: flags.query }); - const startTimeMs = - flags.since !== undefined - ? parseTimeString(flags.since) - : Date.now() - DEFAULT_SEARCH_WINDOW_MS; - const endTimeMs = flags.until !== undefined ? parseTimeString(flags.until) : Date.now(); - - const writeEvent = (event: RuntimeLogEvent) => { - const timestamp = new Date(event.timestamp).toISOString(); - if (json) { - renderer.renderJsonLine({ timestamp, message: event.message }); - } else { - io.stdout.write(`${timestamp} ${event.message.trimEnd()}\n`); - } - }; - - await withUserCancellation(async (signal) => { - const target = await resolveRuntimeTarget(core, ctx, flags.id); - - if (searchMode) { - const events = core.observability.searchRuntimeLogs( - { - runtimeId: target.runtimeId, - startTimeMs, - endTimeMs, - filterPattern, - limit: flags.limit, - }, - target.options, - signal, - ); - for await (const event of events) writeEvent(event); - return; - } - - io.stderr.write(`Streaming logs for runtime ${target.runtimeId}... (Ctrl+C to stop)\n`); - const events = core.observability.streamRuntimeLogs( - { runtimeId: target.runtimeId, filterPattern }, - target.options, - signal, - ); - for await (const event of events) writeEvent(event); - }); - }, - }); diff --git a/src/handlers/runtime/logs/logs.test.tsx b/src/handlers/runtime/logs/logs.test.tsx deleted file mode 100644 index cf3c7d2e4..000000000 --- a/src/handlers/runtime/logs/logs.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createSilentLogger, TestCoreClient, testIO } from "../../../testing"; -import { TestGlobalConfigAccessor } from "../../../testing/globalConfig"; -import { createRootHandler } from "../../index"; -import type { SearchRuntimeLogsInput, StreamRuntimeLogsInput } from "../types"; - -const REGION = "us-west-2"; - -// Fixed epoch bounds keep the tests clock-independent. -const SINCE_MS = 1_709_391_000_000; -const UNTIL_MS = 1_709_394_600_000; - -function testLogsCommand() { - const core = new TestCoreClient(); - const io = testIO(); - const root = createRootHandler(core, { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - - return { - core, - io, - route: (args: string[]) => root.route(["node", "agentcore", ...args, "--region", REGION]), - }; -} - -describe("runtime logs", () => { - test("searches when --since/--until are given and renders human lines", async () => { - const { core, io, route } = testLogsCommand(); - core.observability.logEvents = [ - { timestamp: SINCE_MS, message: "hello world\n" }, - { timestamp: SINCE_MS + 1_000, message: "second line" }, - ]; - - await route([ - "runtime", - "logs", - "--id", - "my_agent-AbC123XyZ9", - "--since", - `${SINCE_MS}`, - "--until", - `${UNTIL_MS}`, - ]); - - expect(core.observability.calls).toHaveLength(1); - const call = core.observability.calls[0]!; - expect(call.method).toBe("searchRuntimeLogs"); - expect(call.args[0] as SearchRuntimeLogsInput).toEqual({ - runtimeId: "my_agent-AbC123XyZ9", - startTimeMs: SINCE_MS, - endTimeMs: UNTIL_MS, - filterPattern: undefined, - limit: undefined, - }); - expect(call.args[1]).toEqual({ region: REGION, endpointUrl: undefined }); - - // Human mode: ` ` with the trailing newline normalized. - expect(io.stdout()).toBe( - "2024-03-02T14:50:00.000Z hello world\n2024-03-02T14:50:01.000Z second line", - ); - }); - - test("--json emits one JSON object per event (JSON Lines)", async () => { - const { core, io, route } = testLogsCommand(); - core.observability.logEvents = [{ timestamp: SINCE_MS, message: "hello" }]; - - await route([ - "runtime", - "logs", - "--id", - "my_agent-AbC123XyZ9", - "--since", - `${SINCE_MS}`, - "--json", - ]); - - expect(io.stdout()).toBe('{"timestamp":"2024-03-02T14:50:00.000Z","message":"hello"}'); - }); - - test("level and query compose into a CloudWatch filter pattern", async () => { - const { core, route } = testLogsCommand(); - - await route([ - "runtime", - "logs", - "--id", - "rt-1", - "--since", - "1709391000000", - "--level", - "ERROR", - "--query", - "database", - "--limit", - "25", - ]); - - const input = core.observability.calls[0]!.args[0] as SearchRuntimeLogsInput; - // --level is case-insensitive, like the old CLI. - expect(input.filterPattern).toBe("ERROR database"); - expect(input.limit).toBe(25); - }); - - test("rejects an invalid --level", async () => { - const { route } = testLogsCommand(); - - await expect(route(["runtime", "logs", "--id", "rt-1", "--level", "loud"])).rejects.toThrow( - "Invalid value for option '--level'", - ); - }); - - test("follows by default, announcing the stream on stderr", async () => { - const { core, io, route } = testLogsCommand(); - core.observability.logEvents = [{ timestamp: SINCE_MS, message: "tailed" }]; - - await route(["runtime", "logs", "--id", "my_agent-AbC123XyZ9"]); - - expect(core.observability.calls).toHaveLength(1); - const call = core.observability.calls[0]!; - expect(call.method).toBe("streamRuntimeLogs"); - expect(call.args[0] as StreamRuntimeLogsInput).toEqual({ - runtimeId: "my_agent-AbC123XyZ9", - filterPattern: undefined, - }); - expect(io.stderr()).toContain( - "Streaming logs for runtime my_agent-AbC123XyZ9... (Ctrl+C to stop)", - ); - expect(io.stdout()).toBe("2024-03-02T14:50:00.000Z tailed"); - }); - - test("rejects --limit outside search mode", async () => { - const { route } = testLogsCommand(); - - await expect(route(["runtime", "logs", "--id", "rt-1", "--limit", "5"])).rejects.toThrow( - "--limit applies to search mode; add --since and/or --until", - ); - }); - - test("rejects an unparseable --since", async () => { - const { route } = testLogsCommand(); - - await expect( - route(["runtime", "logs", "--id", "rt-1", "--since", "yesterday-ish"]), - ).rejects.toThrow('Invalid time string: "yesterday-ish"'); - }); - - test("auto-resolves the project's deployed runtime when --id is omitted", async () => { - const { core, route } = testLogsCommand(); - - // A minimal-but-valid project for the on-disk project resolution. - const root = mkdtempSync(join(tmpdir(), "logs-project-")); - mkdirSync(join(root, "agentcore"), { recursive: true }); - writeFileSync( - join(root, "agentcore", "agentcore.json"), - JSON.stringify({ name: "LogsProj", version: 1 }), - ); - - const previousCwd = process.cwd(); - process.chdir(root); - try { - await route(["runtime", "logs", "--since", `${SINCE_MS}`]); - } finally { - process.chdir(previousCwd); - } - - const [resolveCall, searchCall] = core.observability.calls; - expect(resolveCall!.method).toBe("resolveDeployedRuntime"); - expect(resolveCall!.args[1]).toBe("default"); - expect(searchCall!.method).toBe("searchRuntimeLogs"); - // The stubbed deployed runtime (and its target region) win over --region. - expect((searchCall!.args[0] as SearchRuntimeLogsInput).runtimeId).toBe( - "project_runtime-0000000000", - ); - expect(searchCall!.args[1]).toMatchObject({ - region: core.observability.resolveDeployedRuntimeResponse.region, - }); - }); -}); diff --git a/src/handlers/runtime/resolveRuntimeTarget.test.ts b/src/handlers/runtime/resolveRuntimeTarget.test.ts index c46184597..2be25100e 100644 --- a/src/handlers/runtime/resolveRuntimeTarget.test.ts +++ b/src/handlers/runtime/resolveRuntimeTarget.test.ts @@ -3,87 +3,100 @@ 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 { RegionKey } from "../keys"; +import type { Core } 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", +const TARGET = { + name: "default", + account: "111122223333", region: "eu-west-1", - stackName: "AgentCore-Proj-default", - targetName: "default", +} as const; +const RUNTIME = { + resourceType: "runtime" as const, + name: "agent", + id: "proj_agent-AbC123XyZ9", }; function stubCore(config: { resolve: () => Promise; - deployed?: DeployedRuntime; -}): { core: Core; observabilityCalls: unknown[][] } { - const observabilityCalls: unknown[][] = []; + resources?: { resourceType: "runtime" | "harness"; name: string; id: string }[]; +}): { core: Core; deployedCalls: unknown[][] } { + const deployedCalls: unknown[][] = []; const core = { - projectManager: { resolve: config.resolve }, - observability: { - resolveDeployedRuntime: async (project: Project, targetName: string) => { - observabilityCalls.push([project, targetName]); - return config.deployed ?? DEPLOYED; + projectManager: { + resolve: config.resolve, + resolveDeployedResources: async (project: Project, input: { target: string }) => { + deployedCalls.push([project, input]); + return { + resources: config.resources ?? [RUNTIME], + target: TARGET, + }; }, }, } as unknown as Core; - return { core, observabilityCalls }; + return { core, deployedCalls }; } describe("resolveRuntimeTarget", () => { test("an explicit --id wins and keeps the ambient region", async () => { - const { core, observabilityCalls } = stubCore({ resolve: async () => undefined }); + const { core, deployedCalls } = 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); + expect(deployedCalls).toHaveLength(0); }); - test("an explicit --id attaches the enclosing project as context", async () => { - const { core } = stubCore({ resolve: async () => PROJECT }); + test("an explicit --id attaches project context and tolerates a broken project", async () => { + const withProject = stubCore({ resolve: async () => PROJECT }); + expect( + (await resolveRuntimeTarget(withProject.core, ctx, "explicit-id", "/proj/app")).project, + ).toBe(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({ + const broken = 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(); + expect((await resolveRuntimeTarget(broken.core, ctx, "explicit-id", tmpdir())).project).toBe( + undefined, + ); }); - test("without --id the project's default-target runtime resolves, region included", async () => { - const { core, observabilityCalls } = stubCore({ resolve: async () => PROJECT }); + test("resolves the project's default-target Runtime and deployment region", async () => { + const { core, deployedCalls } = 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(deployedCalls).toEqual([[PROJECT, { target: "default" }]]); + expect(target.runtimeId).toBe(RUNTIME.id); 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 () => { + test("requires an explicit id when zero or multiple Runtimes are deployed", async () => { + const none = stubCore({ resolve: async () => PROJECT, resources: [] }); + await expect(resolveRuntimeTarget(none.core, ctx, undefined, "/proj/app")).rejects.toThrow( + "has no Runtime deployed", + ); + + const multiple = stubCore({ + resolve: async () => PROJECT, + resources: [RUNTIME, { ...RUNTIME, name: "other", id: "other-runtime" }], + }); + await expect(resolveRuntimeTarget(multiple.core, ctx, undefined, "/proj/app")).rejects.toThrow( + `choose one with --id: ${RUNTIME.id}, other-runtime`, + ); + }); + + test("outside a project, a usage error demands --id", async () => { const { core } = stubCore({ resolve: async () => undefined }); const outside = mkdtempSync(join(tmpdir(), "no-project-")); diff --git a/src/handlers/runtime/resolveRuntimeTarget.ts b/src/handlers/runtime/resolveRuntimeTarget.ts index 8f0e7c6d4..14f71bfb9 100644 --- a/src/handlers/runtime/resolveRuntimeTarget.ts +++ b/src/handlers/runtime/resolveRuntimeTarget.ts @@ -1,4 +1,4 @@ -import { InputValidationError } from "../../errors"; +import { InputValidationError, ResourceNotFoundError } from "../../errors"; import { ExitCode } from "../../runnable"; import type { Context } from "../../router"; import type { CoreOptions } from "../../core/types"; @@ -18,9 +18,8 @@ export interface RuntimeTarget { /** * 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. + * one the enclosing project's default deployment is resolved through the + * project manager. 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. @@ -50,10 +49,25 @@ export async function resolveRuntimeTarget( ); } - const deployed = await core.observability.resolveDeployedRuntime(project, DEFAULT_TARGET_NAME); + const deployed = await core.projectManager.resolveDeployedResources(project, { + target: DEFAULT_TARGET_NAME, + }); + const runtimes = deployed.resources.filter(({ resourceType }) => resourceType === "runtime"); + if (runtimes.length === 0) { + throw new ResourceNotFoundError( + `Project '${project.name}' has no Runtime deployed to target '${DEFAULT_TARGET_NAME}'. ` + + "Deploy a Runtime first, or pass --id .", + ); + } + if (runtimes.length > 1) { + throw new InputValidationError( + `Project '${project.name}' has multiple deployed Runtimes; choose one with ` + + `--id: ${runtimes.map(({ id: runtimeId }) => runtimeId).join(", ")}`, + ); + } return { - runtimeId: deployed.runtimeId, - options: { ...options, region: deployed.region }, + runtimeId: runtimes[0]!.id, + options: { ...options, region: deployed.target.region }, project, }; } diff --git a/src/handlers/runtime/traces/get/index.tsx b/src/handlers/runtime/traces/get/index.tsx index ab8da87bc..5cbe3ec27 100644 --- a/src/handlers/runtime/traces/get/index.tsx +++ b/src/handlers/runtime/traces/get/index.tsx @@ -1,18 +1,28 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import z from "zod"; -import { parseTimeString } from "../../../../core/observability"; -import { FileWriteError } from "../../../../errors"; +import { sanitizeQueryValue } from "../../../../core/observability"; +import { + FileWriteError, + InputValidationError, + ResourceNotFoundError, + ResultTruncationError, +} from "../../../../errors"; import type { AppIO } from "../../../../io"; import { argument, createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import { JsonKey } from "../../../keys"; +import { resolveTimeWindow } from "../../../observability/time"; import type { Core } from "../../../types"; import { runtimeIdSchema } from "../../invoke/request"; import { resolveRuntimeTarget } from "../../resolveRuntimeTarget"; +import type { TraceRecord } from "../../types"; import { DEFAULT_TRACES_WINDOW_MS } from "../index"; import { resolveTraceOutputPath } from "./outputPath"; +const TRACE_ID_PATTERN = /^[a-fA-F0-9-]+$/; +const TRACE_RECORD_LIMIT = 10_000; + export const createGetRuntimeTraceHandler = (core: Core, io: AppIO) => createHandler({ name: "get", @@ -24,6 +34,7 @@ export const createGetRuntimeTraceHandler = (core: Core, io: AppIO) => "the ID of the Runtime (defaults to the project's deployed runtime)", runtimeIdSchema.optional(), ), + flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()), flag( "output", "the output file path (default: agentcore/.cli/traces/-.json in a project)", @@ -42,17 +53,61 @@ export const createGetRuntimeTraceHandler = (core: Core, io: AppIO) => ], handle: async (ctx, flags, args) => { const traceId = args["trace-id"]; - const startTimeMs = - flags.since !== undefined - ? parseTimeString(flags.since) - : Date.now() - DEFAULT_TRACES_WINDOW_MS; - const endTimeMs = flags.until !== undefined ? parseTimeString(flags.until) : Date.now(); + if (!TRACE_ID_PATTERN.test(traceId)) { + throw new InputValidationError( + "Invalid trace ID format. Expected a hex string (e.g., abc123def456).", + { meta: { traceId } }, + ); + } + const { startTimeMs, endTimeMs } = resolveTimeWindow({ + since: flags.since, + until: flags.until, + defaultWindowMs: DEFAULT_TRACES_WINDOW_MS, + }); const target = await resolveRuntimeTarget(core, ctx, flags.id); - const records = await core.observability.getRuntimeTrace( - { runtimeId: target.runtimeId, traceId, startTimeMs, endTimeMs }, + const queryString = + `fields @timestamp, @message\n` + + `| filter traceId = '${sanitizeQueryValue(traceId)}'\n` + + `| sort @timestamp asc\n` + + `| limit ${TRACE_RECORD_LIMIT}`; + const rows = await core.observability.queryLogs( + { + kind: "runtime", + id: target.runtimeId, + ...(flags.qualifier ? { qualifier: flags.qualifier } : {}), + }, + { + queryString, + startTimeMs, + endTimeMs, + rowLimit: { + maxRows: TRACE_RECORD_LIMIT, + buildError: (maxRows) => + new ResultTruncationError( + `Trace ${traceId} contains at least ${maxRows} records; narrow --since/--until`, + ), + }, + }, target.options, ); + if (rows.length === 0) { + throw new ResourceNotFoundError(`No trace data found for trace ID: ${traceId}`, { + meta: { traceId }, + }); + } + const records: TraceRecord[] = rows.map((row) => { + const record: TraceRecord = { ...row }; + const message = record["@message"]; + if (typeof message === "string") { + try { + record["@message"] = JSON.parse(message); + } catch { + // Keep non-JSON messages unchanged. + } + } + return record; + }); const filePath = resolveTraceOutputPath({ output: flags.output, diff --git a/src/handlers/runtime/traces/list/index.tsx b/src/handlers/runtime/traces/list/index.tsx index 36246cea1..14496d07a 100644 --- a/src/handlers/runtime/traces/list/index.tsx +++ b/src/handlers/runtime/traces/list/index.tsx @@ -1,9 +1,9 @@ import z from "zod"; -import { parseTimeString } from "../../../../core/observability"; import type { AppIO } from "../../../../io"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import { JsonKey } from "../../../keys"; +import { resolveTimeWindow } from "../../../observability/time"; import type { Core } from "../../../types"; import { runtimeIdSchema } from "../../invoke/request"; import { resolveRuntimeTarget } from "../../resolveRuntimeTarget"; @@ -50,6 +50,7 @@ export const createListRuntimeTracesHandler = (core: Core, io: AppIO) => "the ID of the Runtime (defaults to the project's deployed runtime)", runtimeIdSchema.optional(), ), + flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()), flag("limit", "maximum number of traces to display", z.number().int().positive().default(20)), flag( "since", @@ -63,17 +64,38 @@ export const createListRuntimeTracesHandler = (core: Core, io: AppIO) => ), ], handle: async (ctx, flags) => { - const startTimeMs = - flags.since !== undefined - ? parseTimeString(flags.since) - : Date.now() - DEFAULT_TRACES_WINDOW_MS; - const endTimeMs = flags.until !== undefined ? parseTimeString(flags.until) : Date.now(); + const { startTimeMs, endTimeMs } = resolveTimeWindow({ + since: flags.since, + until: flags.until, + defaultWindowMs: DEFAULT_TRACES_WINDOW_MS, + }); const target = await resolveRuntimeTarget(core, ctx, flags.id); - const traces = await core.observability.listRuntimeTraces( - { runtimeId: target.runtimeId, startTimeMs, endTimeMs, limit: flags.limit }, + const queryString = + `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 ${Math.floor(flags.limit)}`; + const rows = await core.observability.queryLogs( + { + kind: "runtime", + id: target.runtimeId, + ...(flags.qualifier ? { qualifier: flags.qualifier } : {}), + }, + { queryString, startTimeMs, endTimeMs }, target.options, ); + const traces: TraceSummary[] = []; + for (const fields of rows) { + if (!fields.traceId) continue; + traces.push({ + traceId: fields.traceId, + timestamp: fields.lastSeen ?? fields.firstSeen ?? "unknown", + sessionId: fields.sessionId, + spanCount: fields.spanCount, + }); + } if (ctx.require(JsonKey)) { ctx.require(JsonRendererKey).renderJson({ traces }); diff --git a/src/handlers/runtime/traces/traces.test.tsx b/src/handlers/runtime/traces/traces.test.tsx index 487d7c8f7..fe5646019 100644 --- a/src/handlers/runtime/traces/traces.test.tsx +++ b/src/handlers/runtime/traces/traces.test.tsx @@ -6,7 +6,6 @@ import { join, resolve } from "node:path"; import { createSilentLogger, TestCoreClient, testIO } from "../../../testing"; import { TestGlobalConfigAccessor } from "../../../testing/globalConfig"; import { createRootHandler } from "../../index"; -import type { GetRuntimeTraceInput, ListRuntimeTracesInput } from "../types"; import { formatTraceTable, formatTraceTimestamp } from "./list"; import { resolveTraceOutputPath } from "./get/outputPath"; import type { Project } from "../../project/types"; @@ -27,21 +26,21 @@ function testTracesCommand() { return { core, io, - route: (args: string[]) => root.route(["node", "agentcore", ...args, "--region", REGION]), + route: (args: string[]) => root.route(["bun", "agentcore", ...args, "--region", REGION]), }; } describe("runtime traces list", () => { test("queries the window and renders a table", async () => { const { core, io, route } = testTracesCommand(); - core.observability.traceSummaries = [ + core.observability.queryRows = [ { traceId: "abc123", - timestamp: "1709391000000", + lastSeen: "1709391000000", sessionId: "session-1", spanCount: "7", }, - { traceId: "def456", timestamp: "not-a-number" }, + { traceId: "def456", lastSeen: "not-a-number" }, ]; await route([ @@ -50,6 +49,8 @@ describe("runtime traces list", () => { "list", "--id", "my_agent-AbC123XyZ9", + "--qualifier", + "blue", "--since", `${SINCE_MS}`, "--until", @@ -60,13 +61,17 @@ describe("runtime traces list", () => { expect(core.observability.calls).toHaveLength(1); const call = core.observability.calls[0]!; - expect(call.method).toBe("listRuntimeTraces"); - expect(call.args[0] as ListRuntimeTracesInput).toEqual({ - runtimeId: "my_agent-AbC123XyZ9", + expect(call.method).toBe("queryLogs"); + expect(call.args[0]).toEqual({ + kind: "runtime", + id: "my_agent-AbC123XyZ9", + qualifier: "blue", + }); + expect(call.args[1]).toMatchObject({ startTimeMs: SINCE_MS, endTimeMs: UNTIL_MS, - limit: 5, }); + expect((call.args[1] as { queryString: string }).queryString).toContain("| limit 5"); const [header, first, second] = io.stdout().split("\n"); expect(header).toMatch(/^TRACE ID\s+TIMESTAMP\s+SESSION ID$/); @@ -80,12 +85,14 @@ describe("runtime traces list", () => { await route(["runtime", "traces", "list", "--id", "rt-1", "--since", `${SINCE_MS}`]); - expect((core.observability.calls[0]!.args[0] as ListRuntimeTracesInput).limit).toBe(20); + expect((core.observability.calls[0]!.args[1] as { queryString: string }).queryString).toContain( + "| limit 20", + ); }); test("--json renders a single JSON document", async () => { const { core, io, route } = testTracesCommand(); - core.observability.traceSummaries = [{ traceId: "abc123", timestamp: "1709391000000" }]; + core.observability.queryRows = [{ traceId: "abc123", lastSeen: "1709391000000" }]; await route(["runtime", "traces", "list", "--id", "rt-1", "--json"]); @@ -108,8 +115,8 @@ describe("runtime traces list", () => { describe("runtime traces get", () => { test("downloads the records, writes the JSON file, and prints its path", async () => { const { core, io, route } = testTracesCommand(); - core.observability.traceRecords = [ - { "@timestamp": "2026-08-30 12:00:00.000", "@message": { body: "hello" } }, + core.observability.queryRows = [ + { "@timestamp": "2026-08-30 12:00:00.000", "@message": '{"body":"hello"}' }, ]; const output = join(mkdtempSync(join(tmpdir(), "trace-out-")), "nested", "trace.json"); @@ -127,12 +134,18 @@ describe("runtime traces get", () => { ]); const call = core.observability.calls[0]!; - expect(call.method).toBe("getRuntimeTrace"); - expect(call.args[0] as GetRuntimeTraceInput).toMatchObject({ - runtimeId: "my_agent-AbC123XyZ9", - traceId: "abc123def456", + expect(call.method).toBe("queryLogs"); + expect(call.args[0]).toEqual({ + kind: "runtime", + id: "my_agent-AbC123XyZ9", + }); + expect(call.args[1]).toMatchObject({ startTimeMs: SINCE_MS, }); + expect((call.args[1] as { queryString: string }).queryString).toContain( + "| filter traceId = 'abc123def456'", + ); + expect((call.args[1] as { rowLimit: { maxRows: number } }).rowLimit.maxRows).toBe(10_000); expect(io.stdout()).toBe(output); expect(io.stderr()).toContain("Saved 1 records for trace abc123def456"); @@ -143,7 +156,7 @@ describe("runtime traces get", () => { test("--json reports the file path and record count", async () => { const { core, io, route } = testTracesCommand(); - core.observability.traceRecords = [{ "@message": "a" }, { "@message": "b" }]; + core.observability.queryRows = [{ "@message": "a" }, { "@message": "b" }]; const output = join(mkdtempSync(join(tmpdir(), "trace-out-")), "trace.json"); await route([ @@ -169,6 +182,23 @@ describe("runtime traces get", () => { "No trace data found for trace ID: abc123", ); }); + + test("rejects malformed trace IDs before querying", async () => { + const { core, route } = testTracesCommand(); + + await expect( + route(["runtime", "traces", "get", "not'a$trace", "--id", "rt-1"]), + ).rejects.toThrow("Invalid trace ID format"); + expect(core.observability.calls).toHaveLength(0); + }); + + test("fails when the query returns no trace records", async () => { + const { route } = testTracesCommand(); + + await expect(route(["runtime", "traces", "get", "abc123", "--id", "rt-1"])).rejects.toThrow( + "No trace data found for trace ID: abc123", + ); + }); }); describe("resolveTraceOutputPath", () => { diff --git a/src/handlers/runtime/types.tsx b/src/handlers/runtime/types.tsx index 45dcb5378..bc9ebfe27 100644 --- a/src/handlers/runtime/types.tsx +++ b/src/handlers/runtime/types.tsx @@ -6,7 +6,6 @@ import type { ListAgentRuntimeVersionsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; -import type { Project } from "../project/types"; export type RuntimeInvokeRequest = { runtimeId: string; @@ -82,40 +81,6 @@ export interface CoreRuntimeClient { ): Promise; } -/** One CloudWatch log event from a runtime's log group. */ -export type RuntimeLogEvent = { - /** Epoch milliseconds. */ - timestamp: number; - message: string; -}; - -/** 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 type StreamRuntimeLogsInput = { - runtimeId: string; - /** CloudWatch Logs filter pattern applied server-side. */ - filterPattern?: string; -}; - -export type SearchRuntimeLogsInput = { - runtimeId: string; - /** Window start, epoch milliseconds (inclusive). */ - startTimeMs: number; - /** Window end, epoch milliseconds (inclusive). */ - endTimeMs: number; - /** CloudWatch Logs filter pattern applied server-side. */ - filterPattern?: string; - /** Maximum number of events to yield. */ - limit?: number; -}; - /** One trace aggregated from a runtime's telemetry, newest first. */ export type TraceSummary = { traceId: string; @@ -131,42 +96,3 @@ export type TraceSummary = { * `@timestamp`, `@ptr`) pass through as returned. */ export type TraceRecord = Record; - -export type ListRuntimeTracesInput = { - runtimeId: string; - /** Window start, epoch milliseconds. */ - startTimeMs: number; - /** Window end, epoch milliseconds. */ - endTimeMs: number; - /** Maximum number of traces to return. */ - limit: number; -}; - -export type GetRuntimeTraceInput = { - runtimeId: string; - traceId: string; - /** Window start, epoch milliseconds. */ - startTimeMs: number; - /** Window end, epoch milliseconds. */ - endTimeMs: number; -}; - -export interface CoreObservabilityClient { - resolveDeployedRuntime(project: Project, targetName: string): Promise; - /** Live-tails the runtime's log group until `signal` aborts. */ - streamRuntimeLogs( - input: StreamRuntimeLogsInput, - options: CoreOptions, - signal: AbortSignal, - ): AsyncGenerator; - /** Searches the runtime's log group over a time window, oldest to newest. */ - searchRuntimeLogs( - input: SearchRuntimeLogsInput, - options: CoreOptions, - signal?: AbortSignal, - ): AsyncGenerator; - /** Lists recent traces in the runtime's log group, newest first. */ - listRuntimeTraces(input: ListRuntimeTracesInput, options: CoreOptions): Promise; - /** Downloads every log record of one trace, oldest first. */ - getRuntimeTrace(input: GetRuntimeTraceInput, options: CoreOptions): Promise; -} diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 3d76827b8..2d0a0289e 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -3,7 +3,8 @@ import type { CoreGatewayClient } from "./gateway/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 { CoreRuntimeClient } from "./runtime/types.tsx"; +import type { CoreObservabilityClient } from "../core/observability"; import type { Context } from "../router"; import type { ProjectManager } from "./project/types.ts"; import type { DescribeBedrockAgent } from "../core/project/bedrockAgent"; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 6641c7159..023cd0cd3 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -128,18 +128,9 @@ import type { } from "../handlers/identity/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; import type { - CoreObservabilityClient, CoreRuntimeClient, - DeployedRuntime, - GetRuntimeTraceInput, - ListRuntimeTracesInput, RuntimeInvokeRequest, RuntimeInvokeResponse, - RuntimeLogEvent, - SearchRuntimeLogsInput, - StreamRuntimeLogsInput, - TraceRecord, - TraceSummary, } from "../handlers/runtime/types"; import type { BatchEvaluationDetail, @@ -172,7 +163,16 @@ import type { import { isTerminalStatus } from "../core/batchEvaluationResults"; import { abortable } from "../core/abortable"; import type { CoreOptions, CreateCloudFormationClient } from "../core/types"; -import type { Project, ProjectManager } from "../handlers/project/types"; +import type { + CoreObservabilityClient, + InsightsQuery, + InsightsQueryRow, + LogRecord, + LogSearchQuery, + LogTailQuery, + ObservableResourceRef, +} from "../core/observability"; +import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; @@ -2265,63 +2265,52 @@ 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`. 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: RuntimeLogEvent[] = []; + readonly calls: RecordedCall[] = []; + logRecords: LogRecord[] = []; + queryRows: InsightsQueryRow[] = []; + error?: Error; - async resolveDeployedRuntime(project: Project, targetName: string): Promise { - this.calls.push({ method: "resolveDeployedRuntime", args: [project, targetName] }); + async *searchLogs( + resource: ObservableResourceRef, + query: LogSearchQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncGenerator { + this.calls.push({ + method: "searchLogs", + args: [resource, query, options, signal], + }); if (this.error) throw this.error; - return this.resolveDeployedRuntimeResponse; + yield* this.logRecords; } - async *streamRuntimeLogs( - input: StreamRuntimeLogsInput, + async *tailLogs( + resource: ObservableResourceRef, + query: LogTailQuery, options: CoreOptions, signal: AbortSignal, - ): AsyncGenerator { - this.calls.push({ method: "streamRuntimeLogs", args: [input, options, signal] }); + ): AsyncGenerator { + this.calls.push({ + method: "tailLogs", + args: [resource, query, options, signal], + }); if (this.error) throw this.error; - yield* this.logEvents; + yield* this.logRecords; } - async *searchRuntimeLogs( - input: SearchRuntimeLogsInput, + async queryLogs( + resource: ObservableResourceRef, + query: InsightsQuery, options: CoreOptions, signal?: AbortSignal, - ): AsyncGenerator { - this.calls.push({ method: "searchRuntimeLogs", args: [input, options, signal] }); - if (this.error) throw this.error; - yield* this.logEvents; - } - - traceSummaries: TraceSummary[] = []; - traceRecords: TraceRecord[] = []; - - async listRuntimeTraces( - input: ListRuntimeTracesInput, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "listRuntimeTraces", args: [input, options] }); - if (this.error) throw this.error; - return this.traceSummaries; - } - - async getRuntimeTrace(input: GetRuntimeTraceInput, options: CoreOptions): Promise { - this.calls.push({ method: "getRuntimeTrace", args: [input, options] }); + ): Promise { + this.calls.push({ + method: "queryLogs", + args: [resource, query, options, signal], + }); if (this.error) throw this.error; - return this.traceRecords; + return this.queryRows; } } diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 06ede485c..54f91db52 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -16,6 +16,7 @@ export { TestMemoryClient, TestRuntimeClient, TestEvalClient, + TestObservabilityClient, type RecordedCall, } from "./TestCoreClient"; export { StreamController } from "./StreamController"; From 11c447f556c8769cd3e2f3defec2b082a6b32101 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Tue, 1 Sep 2026 15:43:06 -0400 Subject: [PATCH 2/4] fix: simplify log handler and resolution classes --- src/core/observability/client.ts | 14 +- src/core/observability/index.ts | 1 - src/core/observability/resolver.ts | 27 ++-- src/handlers/index.tsx | 4 +- src/handlers/observability/handlerFactory.ts | 137 ------------------- src/handlers/observability/logs.ts | 122 +++++++++++++++++ src/handlers/observability/types.ts | 11 +- src/handlers/runtime/index.tsx | 18 +-- 8 files changed, 143 insertions(+), 191 deletions(-) delete mode 100644 src/handlers/observability/handlerFactory.ts create mode 100644 src/handlers/observability/logs.ts diff --git a/src/core/observability/client.ts b/src/core/observability/client.ts index 1c01fa666..32d4b72ca 100644 --- a/src/core/observability/client.ts +++ b/src/core/observability/client.ts @@ -2,10 +2,8 @@ import type { CoreOptions } from "../types"; import type { LogSource, ObservableResourceRef, - ObservabilitySourceResolver, ObservabilitySourceResolverRegistry, ResolvedObservabilityTarget, - ResolvedResourceIdentity, } from "./resolver"; import type { InsightsQuery, @@ -16,7 +14,8 @@ import type { SourceReader, } from "./sourceReader"; -export interface LogRecord { +/** CLI read contract normalized from provider-specific log events. */ +export type LogRecord = { timestamp: Date; message: string; correlation?: { @@ -29,12 +28,12 @@ export interface LogRecord { ingestionTime?: Date; source: { provider: "cloudwatch"; - resource: ResolvedResourceIdentity; + resource: ObservableResourceRef; logGroupName: string; logStreamName?: string; }; raw?: unknown; -} +}; export interface CoreObservabilityClient { searchLogs( @@ -116,13 +115,12 @@ export class ObservabilityClient implements CoreObservabilityClient { options: CoreOptions, signal?: AbortSignal, ): Promise { - const resolver = this.resolvers[resource.kind] as ObservabilitySourceResolver; - return resolver.resolve(resource, options, signal); + return this.resolvers[resource.kind].resolve(resource, options, signal); } } function toLogRecord( - resource: ResolvedResourceIdentity, + resource: ObservableResourceRef, source: LogSource, record: RawLogRecord, ): LogRecord { diff --git a/src/core/observability/index.ts b/src/core/observability/index.ts index 2093813f4..ea84dc743 100644 --- a/src/core/observability/index.ts +++ b/src/core/observability/index.ts @@ -15,7 +15,6 @@ export { type ObservabilitySourceResolver, type ObservabilitySourceResolverRegistry, type ResolvedObservabilityTarget, - type ResolvedResourceIdentity, } from "./resolver"; export { CloudWatchSourceReader, diff --git a/src/core/observability/resolver.ts b/src/core/observability/resolver.ts index a1889cef2..19f2e96d2 100644 --- a/src/core/observability/resolver.ts +++ b/src/core/observability/resolver.ts @@ -9,35 +9,28 @@ export type ObservableResourceRef = { qualifier?: string; }; -export type ResolvedResourceIdentity = { - kind: ObservableResourceRef["kind"]; - id: string; - qualifier?: string; -}; - export type LogSource = { provider: "cloudwatch"; logGroupName: string; }; export interface ResolvedObservabilityTarget { - resource: ResolvedResourceIdentity; + resource: ObservableResourceRef; logs: readonly LogSource[]; } -export interface ObservabilitySourceResolver { +export interface ObservabilitySourceResolver { resolve( - resource: R, + resource: ObservableResourceRef, options: CoreOptions, signal?: AbortSignal, ): Promise; } -export type ObservabilitySourceResolverRegistry = { - [K in ObservableResourceRef["kind"]]: ObservabilitySourceResolver< - Extract - >; -}; +export type ObservabilitySourceResolverRegistry = Record< + ObservableResourceRef["kind"], + ObservabilitySourceResolver +>; export function runtimeLogGroup(runtimeId: string, qualifier: string): string { return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${qualifier}`; @@ -47,11 +40,9 @@ export function runtimeLogGroup(runtimeId: string, qualifier: string): string { * Resolves Runtime identity into the CloudWatch locations used by generic log * operations. CloudWatch access remains the source reader's responsibility. */ -export class RuntimeSourceResolver implements ObservabilitySourceResolver< - Extract -> { +export class RuntimeSourceResolver implements ObservabilitySourceResolver { async resolve( - resource: Extract, + resource: ObservableResourceRef, _options: CoreOptions, _signal?: AbortSignal, ): Promise { diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index ee7d4f6c4..a2ff49e03 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -8,7 +8,6 @@ import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; -import { ObservabilityHandlerFactory } from "./observability/handlerFactory"; import { renderTui } from "../tui"; import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; import type { AppIO } from "../io"; @@ -26,7 +25,6 @@ export interface RootHandlerConfig { export function createRootHandler(core: Core, config: RootHandlerConfig): Router { const { io, logger } = config; const root = new Router("agentcore", "the platform for production AI agents"); - const observabilityHandlers = new ObservabilityHandlerFactory(core.observability, io); // `agentcore --version` prints the build-time package version. root.version(PACKAGE_VERSION); @@ -51,7 +49,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router // Install sub handlers root.handler(createHarnessHandler(core, io)); root.handler(createIdentityHandler(core, io)); - root.handler(createRuntimeHandler(core, io, observabilityHandlers)); + root.handler(createRuntimeHandler(core, io)); root.handler(createMemoryHandler(core, io)); root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); diff --git a/src/handlers/observability/handlerFactory.ts b/src/handlers/observability/handlerFactory.ts deleted file mode 100644 index 0c14bc464..000000000 --- a/src/handlers/observability/handlerFactory.ts +++ /dev/null @@ -1,137 +0,0 @@ -import z from "zod"; -import type { - CoreObservabilityClient, - LogRecord, - ObservableResourceRef, -} from "../../core/observability"; -import { InputValidationError } from "../../errors"; -import type { AppIO } from "../../io"; -import { createHandler, flag, type Flag } from "../../router"; -import { withUserCancellation } from "../../runnable"; -import { JsonRendererKey } from "../../tui"; -import { JsonKey } from "../keys"; -import { coreOptsFromCtx } from "../utils"; -import { buildFilterPattern, LOG_LEVELS } from "./filterPattern"; -import { resolveTimeWindow } from "./time"; -import type { - ObservableResourceCommand, - ObservabilityHandlerFactories, - ResourceFlagValues, -} from "./types"; - -const DEFAULT_SEARCH_WINDOW_MS = 3_600_000; - -const levelSchema = z - .preprocess( - (value) => (typeof value === "string" ? value.toLowerCase() : value), - z.enum(LOG_LEVELS), - ) - .optional(); - -const logFlags = [ - flag( - "since", - 'search window start: "5m", "1h", ISO 8601, epoch ms, or "now"', - z.string().min(1).optional(), - ), - flag( - "until", - 'search window end: "5m", "1h", ISO 8601, epoch ms, or "now"', - z.string().min(1).optional(), - ), - flag("tail", "tail new log records", z.boolean().default(false)), - flag("level", `filter by log level (${LOG_LEVELS.join(", ")})`, levelSchema), - flag("query", "CloudWatch Logs filter pattern", z.string().optional()), - flag( - "limit", - "maximum number of log records to return in search mode", - z.number().int().positive().optional(), - ), -] as const; - -type LogFlagValues = ResourceFlagValues; - -/** - * Builds reusable logs command behavior. Primitive routers contribute only - * identity flags and conversion to an ObservableResourceRef. - */ -export class ObservabilityHandlerFactory implements ObservabilityHandlerFactories { - constructor( - private readonly client: CoreObservabilityClient, - private readonly io: AppIO, - ) {} - - createLogsHandler< - K extends ObservableResourceRef["kind"], - F extends readonly Flag[], - >(config: { resource: ObservableResourceCommand }) { - const flags = [...config.resource.flags, ...logFlags] as const; - - return createHandler({ - name: "logs", - description: "stream or search resource logs", - flags, - handle: async (ctx, values) => { - const parsed = values as unknown as ResourceFlagValues & LogFlagValues; - const searchMode = parsed.since !== undefined || parsed.until !== undefined; - if (parsed.tail && searchMode) { - throw new InputValidationError("--tail cannot be combined with --since or --until"); - } - if (!searchMode && parsed.limit !== undefined) { - throw new InputValidationError( - "--limit applies to search mode; add --since and/or --until", - ); - } - - const filterPattern = buildFilterPattern({ - level: parsed.level, - query: parsed.query, - }); - const { startTimeMs, endTimeMs } = resolveTimeWindow({ - since: parsed.since, - until: parsed.until, - defaultWindowMs: DEFAULT_SEARCH_WINDOW_MS, - }); - - const json = ctx.require(JsonKey); - const renderer = ctx.require(JsonRendererKey); - const writeRecord = (record: LogRecord) => { - if (json) { - renderer.renderJsonLine(record); - } else { - this.io.stdout.write( - `${record.timestamp.toISOString()} ${record.message.trimEnd()}\n`, - ); - } - }; - - await withUserCancellation(async (signal) => { - const target = await config.resource.resolve(parsed, ctx); - const { resource } = target; - const options = target.options ?? coreOptsFromCtx(ctx); - if (searchMode) { - const records = this.client.searchLogs( - resource, - { - startTimeMs, - endTimeMs, - filterPattern, - limit: parsed.limit, - }, - options, - signal, - ); - for await (const record of records) writeRecord(record); - return; - } - - this.io.stderr.write( - `Streaming logs for ${resource.kind} ${resource.id}... (Ctrl+C to stop)\n`, - ); - const records = this.client.tailLogs(resource, { filterPattern }, options, signal); - for await (const record of records) writeRecord(record); - }); - }, - }); - } -} diff --git a/src/handlers/observability/logs.ts b/src/handlers/observability/logs.ts new file mode 100644 index 000000000..e5cc186e9 --- /dev/null +++ b/src/handlers/observability/logs.ts @@ -0,0 +1,122 @@ +import z from "zod"; +import type { + CoreObservabilityClient, + LogRecord, + ObservableResourceRef, +} from "../../core/observability"; +import { InputValidationError } from "../../errors"; +import type { AppIO } from "../../io"; +import { createHandler, flag, type Flag } from "../../router"; +import { withUserCancellation } from "../../runnable"; +import { JsonRendererKey } from "../../tui"; +import { JsonKey } from "../keys"; +import { coreOptsFromCtx } from "../utils"; +import { buildFilterPattern, LOG_LEVELS } from "./filterPattern"; +import { resolveTimeWindow } from "./time"; +import type { ObservableResourceCommand, ResourceFlagValues } from "./types"; + +const DEFAULT_SEARCH_WINDOW_MS = 3_600_000; + +const levelSchema = z + .preprocess( + (value) => (typeof value === "string" ? value.toLowerCase() : value), + z.enum(LOG_LEVELS), + ) + .optional(); + +const logFlags = [ + flag( + "since", + 'search window start: "5m", "1h", ISO 8601, epoch ms, or "now"', + z.string().min(1).optional(), + ), + flag( + "until", + 'search window end: "5m", "1h", ISO 8601, epoch ms, or "now"', + z.string().min(1).optional(), + ), + flag("tail", "tail new log records", z.boolean().default(false)), + flag("level", `filter by log level (${LOG_LEVELS.join(", ")})`, levelSchema), + flag("query", "CloudWatch Logs filter pattern", z.string().optional()), + flag( + "limit", + "maximum number of log records to return in search mode", + z.number().int().positive().optional(), + ), +] as const; + +type LogFlagValues = ResourceFlagValues; + +/** + * Builds reusable logs command behavior. Primitive routers contribute only + * identity flags and conversion to an ObservableResourceRef. + */ +export function createLogsHandler< + K extends ObservableResourceRef["kind"], + F extends readonly Flag[], +>(client: CoreObservabilityClient, io: AppIO, resourceCommand: ObservableResourceCommand) { + const flags = [...resourceCommand.flags, ...logFlags] as const; + + return createHandler({ + name: "logs", + description: "stream or search resource logs", + flags, + handle: async (ctx, values) => { + const parsed = values as unknown as ResourceFlagValues & LogFlagValues; + const searchMode = parsed.since !== undefined || parsed.until !== undefined; + if (parsed.tail && searchMode) { + throw new InputValidationError("--tail cannot be combined with --since or --until"); + } + if (!searchMode && parsed.limit !== undefined) { + throw new InputValidationError( + "--limit applies to search mode; add --since and/or --until", + ); + } + + const filterPattern = buildFilterPattern({ + level: parsed.level, + query: parsed.query, + }); + const { startTimeMs, endTimeMs } = resolveTimeWindow({ + since: parsed.since, + until: parsed.until, + defaultWindowMs: DEFAULT_SEARCH_WINDOW_MS, + }); + + const json = ctx.require(JsonKey); + const renderer = ctx.require(JsonRendererKey); + const writeRecord = (record: LogRecord) => { + if (json) { + renderer.renderJsonLine(record); + } else { + io.stdout.write(`${record.timestamp.toISOString()} ${record.message.trimEnd()}\n`); + } + }; + + await withUserCancellation(async (signal) => { + const target = await resourceCommand.resolve(parsed, ctx); + const { resource } = target; + const options = target.options ?? coreOptsFromCtx(ctx); + if (searchMode) { + const records = client.searchLogs( + resource, + { + startTimeMs, + endTimeMs, + filterPattern, + limit: parsed.limit, + }, + options, + signal, + ); + for await (const record of records) writeRecord(record); + return; + } + + io.stderr.write(`Streaming logs for ${resource.kind} ${resource.id}... (Ctrl+C to stop)\n`); + const records = client.tailLogs(resource, { filterPattern }, options, signal); + for await (const record of records) writeRecord(record); + }); + }, + }); +} diff --git a/src/handlers/observability/types.ts b/src/handlers/observability/types.ts index b471942ed..8e175aac0 100644 --- a/src/handlers/observability/types.ts +++ b/src/handlers/observability/types.ts @@ -1,7 +1,7 @@ import type z from "zod"; import type { ObservableResourceRef } from "../../core/observability"; import type { CoreOptions } from "../../core/types"; -import type { Context, Flag, Handler } from "../../router"; +import type { Context, Flag } from "../../router"; export type ResourceFlagValues[]> = { [E in F[number] as E["name"]]: E extends Flag ? z.infer> : never; @@ -22,12 +22,3 @@ export type ResolvedObservableResource resource: Extract; options?: CoreOptions; }; - -export interface ObservabilityHandlerFactories { - createLogsHandler< - K extends ObservableResourceRef["kind"], - F extends readonly Flag[], - >(config: { - resource: ObservableResourceCommand; - }): Handler; -} diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 8dbb2a05e..3a15e8066 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -4,10 +4,8 @@ import { flag, Router } from "../../router"; import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import type { Core } from "../types"; -import type { - ObservableResourceCommand, - ObservabilityHandlerFactories, -} from "../observability/types"; +import { createLogsHandler } from "../observability/logs"; +import type { ObservableResourceCommand } from "../observability/types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; import { createInvokeRuntimeHandler } from "./invoke"; @@ -45,11 +43,7 @@ function runtimeObservabilityResource( }; } -export function createRuntimeHandler( - core: Core, - io: AppIO, - observabilityHandlers: ObservabilityHandlerFactories, -): Router { +export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) @@ -59,10 +53,6 @@ export function createRuntimeHandler( .handler(createInvokeRuntimeHandler(core, io)) .handler(createRuntimeVersionHandler(core, io)) .handler(createRuntimeEndpointHandler(core, io)) - .handler( - observabilityHandlers.createLogsHandler({ - resource: runtimeObservabilityResource(core), - }), - ) + .handler(createLogsHandler(core.observability, io, runtimeObservabilityResource(core))) .handler(createRuntimeTracesHandler(core, io)); } From 6f832ffd43b797e1b50cf702a2414b4e12666283 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Tue, 1 Sep 2026 16:05:14 -0400 Subject: [PATCH 3/4] chore: add observability logs golden fixture test --- ...lterLogEventsCommand.17df600aa3fcb910.json | 13 +++ .../__fixtures__/logs-search-json.golden.json | 26 ++++++ .../__fixtures__/logs-search.golden.txt | 1 + src/handlers/runtime/logs.fixture.test.tsx | 80 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/handlers/runtime/__fixtures__/FilterLogEventsCommand.17df600aa3fcb910.json create mode 100644 src/handlers/runtime/__fixtures__/logs-search-json.golden.json create mode 100644 src/handlers/runtime/__fixtures__/logs-search.golden.txt create mode 100644 src/handlers/runtime/logs.fixture.test.tsx diff --git a/src/handlers/runtime/__fixtures__/FilterLogEventsCommand.17df600aa3fcb910.json b/src/handlers/runtime/__fixtures__/FilterLogEventsCommand.17df600aa3fcb910.json new file mode 100644 index 000000000..7701503fe --- /dev/null +++ b/src/handlers/runtime/__fixtures__/FilterLogEventsCommand.17df600aa3fcb910.json @@ -0,0 +1,13 @@ +{ + "events": [ + { + "logStreamName": "2026/08/12/[runtime-logs-67ebf93b-65e3-4127-9e13-483b239f256a]c7aba76f-c59f-4c8f-9e59-2872aea6dc45", + "timestamp": 1786555392053, + "message": "{\"timestamp\": \"2026-08-12T17:23:12.053Z\", \"level\": \"INFO\", \"message\": \"Returning streaming response (generator) (0.000s)\", \"logger\": \"bedrock_agentcore.app\", \"requestId\": \"37dcffe9-22bd-4a40-a18d-6cfbcc14a6d1\", \"sessionId\": \"67ebf93b-65e3-4127-9e13-483b239f256a\"}", + "ingestionTime": 1786555392873, + "eventId": "39841516581234934748312552609710478377045077922388246529" + } + ], + "searchedLogStreams": [], + "nextToken": "Bxkq6kVGFtq2y_MoigeqscPOdhXVbhiVtLoAmXb5jCreh6VSmn_zY7_b0sChd8dRESndx7N3wXVbpuIqdmNLkJpOqr-yUSOaZWF4_SfBMCKpa712QZRkZMBvlz6Zlf_KFhA4JI0um3l4ZnBohPfJ-EiDg23EyMn3LuVmDGPyslZHBzYyveSO7ePjizO2a8lydQgGIP47tglwQHwmaN_ou7RUU_APNaAohfFSoilGzq79ZmPOtzMAZIAsPXBKlG1gwreo2MRY9R7z7CBfdwdgcppoU9xXT5leGBh6fURYH-UD6hH7zSo5D_5VTFHC_5EXh0nXzUGI--D4-ACtc1cN5znhgo_aO3yqg2X6JVrLiZc" +} \ No newline at end of file diff --git a/src/handlers/runtime/__fixtures__/logs-search-json.golden.json b/src/handlers/runtime/__fixtures__/logs-search-json.golden.json new file mode 100644 index 000000000..a6dc403e5 --- /dev/null +++ b/src/handlers/runtime/__fixtures__/logs-search-json.golden.json @@ -0,0 +1,26 @@ +{ + "timestamp": "2026-08-12T17:23:12.053Z", + "message": "{\"timestamp\": \"2026-08-12T17:23:12.053Z\", \"level\": \"INFO\", \"message\": \"Returning streaming response (generator) (0.000s)\", \"logger\": \"bedrock_agentcore.app\", \"requestId\": \"37dcffe9-22bd-4a40-a18d-6cfbcc14a6d1\", \"sessionId\": \"67ebf93b-65e3-4127-9e13-483b239f256a\"}", + "correlation": { + "sessionId": "67ebf93b-65e3-4127-9e13-483b239f256a" + }, + "severity": "INFO", + "ingestionTime": "2026-08-12T17:23:12.873Z", + "source": { + "provider": "cloudwatch", + "resource": { + "kind": "runtime", + "id": "asdf_MyAgent-3s5axvBC6Q", + "qualifier": "DEFAULT" + }, + "logGroupName": "/aws/bedrock-agentcore/runtimes/asdf_MyAgent-3s5axvBC6Q-DEFAULT", + "logStreamName": "2026/08/12/[runtime-logs-67ebf93b-65e3-4127-9e13-483b239f256a]c7aba76f-c59f-4c8f-9e59-2872aea6dc45" + }, + "raw": { + "logStreamName": "2026/08/12/[runtime-logs-67ebf93b-65e3-4127-9e13-483b239f256a]c7aba76f-c59f-4c8f-9e59-2872aea6dc45", + "timestamp": 1786555392053, + "message": "{\"timestamp\": \"2026-08-12T17:23:12.053Z\", \"level\": \"INFO\", \"message\": \"Returning streaming response (generator) (0.000s)\", \"logger\": \"bedrock_agentcore.app\", \"requestId\": \"37dcffe9-22bd-4a40-a18d-6cfbcc14a6d1\", \"sessionId\": \"67ebf93b-65e3-4127-9e13-483b239f256a\"}", + "ingestionTime": 1786555392873, + "eventId": "39841516581234934748312552609710478377045077922388246529" + } +} \ No newline at end of file diff --git a/src/handlers/runtime/__fixtures__/logs-search.golden.txt b/src/handlers/runtime/__fixtures__/logs-search.golden.txt new file mode 100644 index 000000000..fef51377a --- /dev/null +++ b/src/handlers/runtime/__fixtures__/logs-search.golden.txt @@ -0,0 +1 @@ +2026-08-12T17:23:12.053Z {"timestamp": "2026-08-12T17:23:12.053Z", "level": "INFO", "message": "Returning streaming response (generator) (0.000s)", "logger": "bedrock_agentcore.app", "requestId": "37dcffe9-22bd-4a40-a18d-6cfbcc14a6d1", "sessionId": "67ebf93b-65e3-4127-9e13-483b239f256a"} \ No newline at end of file diff --git a/src/handlers/runtime/logs.fixture.test.tsx b/src/handlers/runtime/logs.fixture.test.tsx new file mode 100644 index 000000000..5f79d6ea3 --- /dev/null +++ b/src/handlers/runtime/logs.fixture.test.tsx @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../core"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; + +const REGION = "us-west-2"; +const FIXTURES = join(import.meta.dir, "__fixtures__"); +const FIXTURE_RUNTIME_ID = "asdf_MyAgent-3s5axvBC6Q"; +const FIXTURE_SESSION_ID = "67ebf93b-65e3-4127-9e13-483b239f256a"; +const WINDOW_START = "2026-08-12T00:00:00Z"; +const WINDOW_END = "2026-08-13T00:00:00Z"; + +// RECORD=1 bun test src/handlers/runtime/logs.fixture.test.tsx +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["bun", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +const SEARCH_ARGS = [ + "runtime", + "logs", + "--id", + FIXTURE_RUNTIME_ID, + "--since", + WINDOW_START, + "--until", + WINDOW_END, + "--query", + `"${FIXTURE_SESSION_ID}"`, + "--limit", + "1", +]; + +test("runtime logs renders recorded search results", async () => { + const stdout = await run(SEARCH_ARGS); + + expect(stdout).not.toBe(""); + matchGolden(FIXTURES, "logs-search.golden.txt", stdout); +}); + +test("runtime logs renders normalized records as JSON Lines", async () => { + const stdout = await run([...SEARCH_ARGS, "--json"]); + + expect(JSON.parse(stdout)).toMatchObject({ + source: { + provider: "cloudwatch", + resource: { + kind: "runtime", + id: FIXTURE_RUNTIME_ID, + qualifier: "DEFAULT", + }, + }, + }); + matchGolden(FIXTURES, "logs-search-json.golden.json", stdout); +}); From c40047ef0b4b49edf150ac4aaa3d6bca97c1162c Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Tue, 1 Sep 2026 16:22:10 -0400 Subject: [PATCH 4/4] chore: use shared cloudWatch client in batch eval APIs --- src/core/batchEvaluationResults.test.ts | 128 +++++------------- src/core/batchEvaluationResults.tsx | 82 ++++------- src/core/eval.tsx | 13 +- src/core/index.tsx | 4 +- src/core/observability/client.test.ts | 3 + src/core/observability/index.ts | 2 + src/core/observability/sourceReader.test.ts | 89 ++++++++++++ src/core/observability/sourceReader.ts | 98 +++++++++++++- .../batch-evaluation.fixture.test.tsx | 8 +- 9 files changed, 263 insertions(+), 164 deletions(-) diff --git a/src/core/batchEvaluationResults.test.ts b/src/core/batchEvaluationResults.test.ts index c39f34214..ae307c0a4 100644 --- a/src/core/batchEvaluationResults.test.ts +++ b/src/core/batchEvaluationResults.test.ts @@ -1,47 +1,38 @@ import { test, expect } from "bun:test"; -import type { CloudWatchLogsClient, OutputLogEvent } from "@aws-sdk/client-cloudwatch-logs"; -import { ResultTruncationError } from "../errors"; +import type { OutputLogEvent } from "@aws-sdk/client-cloudwatch-logs"; import { createSilentLogger } from "../testing"; +import type { LogStreamReadQuery, LogStreamSource, SourceReader } from "./observability"; import { isTerminalStatus, parseEvaluationLogEvent, readEvaluationResults, } from "./batchEvaluationResults"; -// fakeLogs returns a CloudWatchLogsClient that serves `events` as a single page, -// then signals exhaustion by echoing the same nextForwardToken on the next call — -// exactly how GetLogEvents ends pagination. Records the tokens it was called with. -function fakeLogs(events: OutputLogEvent[]): CloudWatchLogsClient { - let served = false; +const SOURCE: LogStreamSource = { + provider: "cloudwatch", + logGroupName: "lg", + logStreamName: "ls", +}; +const OPTIONS = { region: "us-west-2" }; + +function fakeReader( + events: OutputLogEvent[], + onRead?: (source: LogStreamSource, query: LogStreamReadQuery) => void, +): Pick { return { - send: async () => { - if (!served) { - served = true; - return { events, nextForwardToken: "t-end" }; + async *readLogStream(source, query) { + onRead?.(source, query); + for (const event of events) { + yield { + timestamp: event.timestamp ?? 0, + message: event.message ?? "", + ...(event.ingestionTime !== undefined ? { ingestionTime: event.ingestionTime } : {}), + logStreamName: source.logStreamName, + raw: event, + }; } - return { events: [], nextForwardToken: "t-end" }; // token unchanged → done - }, - } as unknown as CloudWatchLogsClient; -} - -// fakePagedLogs serves each element of `pages` on successive calls, advancing the -// forward token per page and repeating the last token once to end. Captures every -// nextToken the caller sent, so a test can assert the loop paged correctly. -function fakePagedLogs(pages: OutputLogEvent[][]): { - client: CloudWatchLogsClient; - tokens: (string | undefined)[]; -} { - const tokens: (string | undefined)[] = []; - let call = 0; - const client = { - send: async (command: { input: { nextToken?: string } }) => { - tokens.push(command.input.nextToken); - const i = call++; - if (i < pages.length) return { events: pages[i], nextForwardToken: `t-${i}` }; - return { events: [], nextForwardToken: `t-${pages.length - 1}` }; // repeat last → done }, - } as unknown as CloudWatchLogsClient; - return { client, tokens }; + }; } // A realistic stream shaped after the real `gen_ai.evaluation.result` records @@ -88,7 +79,13 @@ test("isTerminalStatus recognizes the terminal arm only", () => { }); test("readEvaluationResults keeps level + scope so sessions and traces are distinguishable", async () => { - const results = await readEvaluationResults(fakeLogs(EVENTS), "lg", "ls", createSilentLogger()); + const reads: { source: LogStreamSource; query: LogStreamReadQuery }[] = []; + const results = await readEvaluationResults( + fakeReader(EVENTS, (source, query) => reads.push({ source, query })), + SOURCE, + OPTIONS, + createSilentLogger(), + ); // The non-JSON control line is skipped; the two evaluation records parse. expect(results).toHaveLength(2); @@ -107,35 +104,7 @@ test("readEvaluationResults keeps level + scope so sessions and traces are disti traceId: "4bf92f3577b34da6a3ce929d0e0e4736", }); expect(results.map((r) => r.level)).toEqual(["Session", "Trace"]); -}); - -test("readEvaluationResults follows pagination until the forward token stops advancing", async () => { - const page = (name: string): OutputLogEvent => ({ - message: JSON.stringify({ - attributes: { - "gen_ai.evaluation.name": name, - "aws.bedrock_agentcore.evaluation_level": "Trace", - "session.id": "s1", - }, - }), - }); - const { client, tokens } = fakePagedLogs([ - [page("Builtin.Correctness")], - [page("Builtin.Helpfulness")], - [page("Builtin.Faithfulness")], - ]); - - const results = await readEvaluationResults(client, "lg", "ls", createSilentLogger()); - - // All three pages' records are collected. - expect(results.map((r) => r.evaluatorId)).toEqual([ - "Builtin.Correctness", - "Builtin.Helpfulness", - "Builtin.Faithfulness", - ]); - // First call has no token; later calls carry the prior page's forward token; a - // final call detects the repeated token and stops. - expect(tokens).toEqual([undefined, "t-0", "t-1", "t-2"]); + expect(reads).toEqual([{ source: SOURCE, query: { maxPages: 100 } }]); }); // Real-log-shape validation lives in the fixture-backed command-flow test @@ -145,45 +114,18 @@ test("readEvaluationResults follows pagination until the forward token stops adv test("readEvaluationResults skips lines without an evaluation name", async () => { const results = await readEvaluationResults( - fakeLogs([ + fakeReader([ { message: JSON.stringify({ attributes: { "some.other.metric": 1 } }) }, { message: "" }, { message: undefined }, ]), - "lg", - "ls", + SOURCE, + OPTIONS, createSilentLogger(), ); expect(results).toEqual([]); }); -test("readEvaluationResults throws (not silently truncates) when it hits the page cap", async () => { - // Token advances on every call, so the loop never detects exhaustion and runs - // into MAX_RESULT_PAGES. It must throw so the caller surfaces truncation, rather - // than returning the accumulated partial list as if it were complete. - let call = 0; - const everAdvancing = { - send: async () => ({ - events: [ - { - message: JSON.stringify({ - attributes: { "gen_ai.evaluation.name": "Builtin.Correctness" }, - }), - }, - ], - nextForwardToken: `t-${call++}`, // always changes → never exhausts - }), - } as unknown as CloudWatchLogsClient; - - const err = await readEvaluationResults(everAdvancing, "lg", "ls", createSilentLogger()).then( - () => undefined, - (e) => e as ResultTruncationError, - ); - expect(err).toBeInstanceOf(ResultTruncationError); - expect(err?.message).toMatch(/incomplete/); - expect(err?.source).toBe("internal"); // our page cap, not a user or service fault -}); - test("parseEvaluationLogEvent warns on and skips an unparseable line", () => { const warnings: string[] = []; const logger = createSilentLogger(); diff --git a/src/core/batchEvaluationResults.tsx b/src/core/batchEvaluationResults.tsx index 2c6d6131c..cc2f61914 100644 --- a/src/core/batchEvaluationResults.tsx +++ b/src/core/batchEvaluationResults.tsx @@ -1,15 +1,11 @@ -import { GetLogEventsCommand, type CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; -import { ResultTruncationError } from "../errors"; import type { BatchEvaluationResultEntry } from "../handlers/eval/types"; import type { Logger } from "../logging"; +import type { CoreOptions } from "./types"; +import type { LogStreamSource, SourceReader } from "./observability"; -// Per-session batch-evaluation result retrieval, mirroring -// core/onlineEvalExecutionRole.tsx's pattern: a self-contained module that takes -// an injected AWS client (here CloudWatchLogsClient) and owns one slice of Core's -// behavior. A completed batch evaluation writes each score as an OTel-shaped log -// record to a per-job CloudWatch stream; this module reads that stream and parses -// the records. EvalClient calls readEvaluationResults with the client from -// `this.clients.logs(...)` and the log group + stream from the job's outputConfig. +// A completed batch evaluation writes each score as an OTel-shaped log record to +// a per-job CloudWatch stream. The shared source reader owns provider access and +// pagination; this module owns the Eval-specific result shape. // Terminal batch-evaluation statuses — after these, results are final and worth // retrieving. Mirrors the AgentCore BatchEvaluationStatus enum's terminal arm. @@ -19,65 +15,33 @@ export function isTerminalStatus(status?: string): boolean { return !!status && TERMINAL_STATUSES.has(status); } -// GetLogEvents returns at most 1 MB / 10,000 events per call, so a job with many -// results spans multiple pages. This caps the page loop as a safety valve against -// a non-advancing token (see below). At 10k events/page it allows ~1M results, but -// the 1 MB limit binds first — large explanations can cap a page well under 10k, so -// this is not a "far beyond any real job" ceiling. Hitting it means the results are -// truncated, which we surface as an error (see below) rather than silently -// returning a partial list as if complete. +// At 10k events/page this allows ~1M results, but CloudWatch's 1 MB response limit +// can bind first. Hitting the cap throws from the shared source reader rather than +// returning a partial result set as if it were complete. const MAX_RESULT_PAGES = 100; // readEvaluationResults reads and parses the per-session/-trace/-tool scores from -// a completed batch evaluation's CloudWatch result stream, following pagination to -// completion. Throws if the stream exceeds MAX_RESULT_PAGES (results would be -// truncated) — see the throw site. The caller supplies the log group and stream -// name from the job's GetBatchEvaluation outputConfig (the service-selected values -// — we do not derive the stream name, since its format is not part of the SDK -// contract). +// a completed batch evaluation's CloudWatch result stream. The caller supplies +// the exact group and stream returned by GetBatchEvaluation; no resource resolver +// is involved. export async function readEvaluationResults( - logs: CloudWatchLogsClient, - logGroupName: string, - logStreamName: string, + sourceReader: Pick, + source: LogStreamSource, + options: CoreOptions, logger: Logger, ): Promise { const results: BatchEvaluationResultEntry[] = []; - // Page forward from the head. GetLogEvents echoes the input token back as - // nextForwardToken once the stream is exhausted, so the loop ends when the - // token stops advancing. startFromHead is only honored on the first call (no - // token); subsequent calls are positioned by the token. - let token: string | undefined; - for (let page = 0; page < MAX_RESULT_PAGES; page++) { - const response = await logs.send( - new GetLogEventsCommand({ - logGroupName, - logStreamName, - startFromHead: true, - nextToken: token, - }), - ); - - for (const event of response.events ?? []) { - if (!event.message) continue; - const entry = parseEvaluationLogEvent(event.message, logger); - if (entry) results.push(entry); - } - - const next = response.nextForwardToken; - if (!next || next === token) return results; // exhausted: token stopped advancing - token = next; + for await (const record of sourceReader.readLogStream( + source, + { maxPages: MAX_RESULT_PAGES }, + options, + )) { + if (!record.message) continue; + const entry = parseEvaluationLogEvent(record.message, logger); + if (entry) results.push(entry); } - - // Cap reached with the token still advancing: the stream has more pages than we - // read, so `results` is truncated. Throw rather than return the partial list — - // getBatchEvaluation catches this into `resultsError`, which the CLI surfaces as - // a stderr warning (stdout metadata stays clean), the same customer-visible path - // as any other CloudWatch read failure. A silent partial list would read as - // complete. - throw new ResultTruncationError( - `batch-evaluation results exceed ${MAX_RESULT_PAGES} CloudWatch pages; retrieved ${results.length} results are incomplete`, - ); + return results; } // parseEvaluationLogEvent turns one CloudWatch result-log message into a result diff --git a/src/core/eval.tsx b/src/core/eval.tsx index 7c5032bd5..176729b08 100644 --- a/src/core/eval.tsx +++ b/src/core/eval.tsx @@ -109,12 +109,14 @@ import { ResourceNotFoundError, } from "../errors"; import { + CloudWatchSourceReader, DEFAULT_ENDPOINT_QUALIFIER, INSIGHTS_MAX_ROWS, runInsightsQuery, runtimeLogGroup, sanitizeQueryValue, type InsightsRowLimit, + type SourceReader, } from "./observability"; import type { BatchEvaluationDetail, @@ -233,6 +235,7 @@ export class EvalClient implements CoreEvalClient { private readonly logger: Logger = noopLogger, private readonly newSessionId: () => string = randomUUID, private readonly now: () => number = () => Date.now(), + private readonly sourceReader: SourceReader = new CloudWatchSourceReader(clients), ) {} async createEvaluator( @@ -432,9 +435,13 @@ export class EvalClient implements CoreEvalClient { try { detail.results = await readEvaluationResults( - this.clients.logs({ region: options.region }), - cw.logGroupName, - cw.logStreamName, + this.sourceReader, + { + provider: "cloudwatch", + logGroupName: cw.logGroupName, + logStreamName: cw.logStreamName, + }, + options, this.logger, ); return { detail }; diff --git a/src/core/index.tsx b/src/core/index.tsx index b719b2314..b4d114e7c 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -87,6 +87,7 @@ export class CoreClient implements AwsClients { this.createLogsClient = config.createLogsClient; this.logger = config.logger; const fetch = config.fetch ?? globalThis.fetch; + const sourceReader = new CloudWatchSourceReader(this); this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" })); this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" })); // EvalClient shares the injected fetch: dataset content is served from a @@ -98,10 +99,11 @@ export class CoreClient implements AwsClients { this.logger.child({ module: "eval" }), config.newSessionId, config.now, + sourceReader, ); this.observability = new ObservabilityClient( { runtime: new RuntimeSourceResolver() }, - new CloudWatchSourceReader(this), + sourceReader, ); this.projectManager = new FsProjectManager({ diff --git a/src/core/observability/client.test.ts b/src/core/observability/client.test.ts index e6da9b392..2ef5e18f1 100644 --- a/src/core/observability/client.test.ts +++ b/src/core/observability/client.test.ts @@ -64,6 +64,9 @@ function createClient(rawRecords: RawLogRecord[]) { }); yield* rawRecords; }, + async *readLogStream() { + yield* rawRecords; + }, async queryLogs( source: LogSource, query: InsightsQuery, diff --git a/src/core/observability/index.ts b/src/core/observability/index.ts index ea84dc743..e585965f1 100644 --- a/src/core/observability/index.ts +++ b/src/core/observability/index.ts @@ -21,6 +21,8 @@ export { type InsightsQuery, type InsightsQueryRow, type LogSearchQuery, + type LogStreamReadQuery, + type LogStreamSource, type LogTailQuery, type RawLogRecord, type SourceReader, diff --git a/src/core/observability/sourceReader.test.ts b/src/core/observability/sourceReader.test.ts index ff812a16a..fd446fdc6 100644 --- a/src/core/observability/sourceReader.test.ts +++ b/src/core/observability/sourceReader.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { DescribeLogGroupsCommand, FilterLogEventsCommand, + GetLogEventsCommand, GetQueryResultsCommand, ResourceNotFoundException, StartLiveTailCommand, @@ -9,6 +10,7 @@ import { type CloudWatchLogsClient, type StartLiveTailResponseStream, } from "@aws-sdk/client-cloudwatch-logs"; +import { ResultTruncationError } from "../../errors"; import type { ClientConfig } from "../types"; import { CloudWatchSourceReader, type RawLogRecord } from "./sourceReader"; @@ -16,6 +18,10 @@ const SOURCE = { provider: "cloudwatch" as const, logGroupName: "/aws/bedrock-agentcore/runtimes/runtime-1-DEFAULT", }; +const STREAM_SOURCE = { + ...SOURCE, + logStreamName: "batch-evaluation/results", +}; const OPTIONS = { region: "us-west-2", endpointUrl: "https://logs.test", @@ -156,6 +162,89 @@ describe("CloudWatchSourceReader.searchLogs", () => { }); }); +describe("CloudWatchSourceReader.readLogStream", () => { + test("reads an exact stream to exhaustion and preserves stream metadata", async () => { + const inputs: unknown[] = []; + const { reader, configs } = readerWith(async (command) => { + expect(command).toBeInstanceOf(GetLogEventsCommand); + const input = (command as GetLogEventsCommand).input; + inputs.push(input); + if (input.nextToken === "page-2") { + return { + events: [{ timestamp: 2, ingestionTime: 3, message: "two" }], + nextForwardToken: "end", + }; + } + if (input.nextToken === "end") { + return { events: [], nextForwardToken: "end" }; + } + return { + events: [{ timestamp: 1, message: "one" }], + nextForwardToken: "page-2", + }; + }); + + const records = await collect(reader.readLogStream(STREAM_SOURCE, {}, OPTIONS)); + + expect(configs).toEqual([{ region: "us-west-2", endpoint: "https://logs.test" }]); + expect(inputs).toEqual([ + { + logGroupName: SOURCE.logGroupName, + logStreamName: STREAM_SOURCE.logStreamName, + startFromHead: true, + }, + { + logGroupName: SOURCE.logGroupName, + logStreamName: STREAM_SOURCE.logStreamName, + startFromHead: true, + nextToken: "page-2", + }, + { + logGroupName: SOURCE.logGroupName, + logStreamName: STREAM_SOURCE.logStreamName, + startFromHead: true, + nextToken: "end", + }, + ]); + expect(records.map(({ timestamp, message }) => ({ timestamp, message }))).toEqual([ + { timestamp: 1, message: "one" }, + { timestamp: 2, message: "two" }, + ]); + expect(records[1]).toMatchObject({ + ingestionTime: 3, + logStreamName: STREAM_SOURCE.logStreamName, + }); + }); + + test("throws rather than returning incomplete records at the page cap", async () => { + let call = 0; + const { reader } = readerWith(async () => ({ + events: [{ timestamp: call, message: `event-${call}` }], + nextForwardToken: `page-${call++}`, + })); + + const error = await collect(reader.readLogStream(STREAM_SOURCE, { maxPages: 2 }, OPTIONS)).then( + () => undefined, + (cause) => cause as ResultTruncationError, + ); + + expect(error).toBeInstanceOf(ResultTruncationError); + expect(error?.message).toContain("retrieved records are incomplete"); + expect(error?.source).toBe("internal"); + }); + + test("translates a missing exact stream into customer guidance", async () => { + const { reader } = readerWith(async () => { + throw new ResourceNotFoundException({ message: "missing", $metadata: {} }); + }); + + await expect(collect(reader.readLogStream(STREAM_SOURCE, {}, OPTIONS))).rejects.toThrow( + `CloudWatch log stream ${STREAM_SOURCE.logStreamName} does not exist in log group ` + + `${STREAM_SOURCE.logGroupName}.`, + ); + }); +}); + describe("CloudWatchSourceReader.queryLogs", () => { test("runs an Insights query and flattens result fields", async () => { const { reader } = readerWith(async (command) => { diff --git a/src/core/observability/sourceReader.ts b/src/core/observability/sourceReader.ts index b2d7d99d8..801d028ba 100644 --- a/src/core/observability/sourceReader.ts +++ b/src/core/observability/sourceReader.ts @@ -1,12 +1,14 @@ import { DescribeLogGroupsCommand, FilterLogEventsCommand, + GetLogEventsCommand, ResourceNotFoundException, StartLiveTailCommand, type FilteredLogEvent, type LiveTailSessionLogEvent, + type OutputLogEvent, } from "@aws-sdk/client-cloudwatch-logs"; -import { ResourceNotFoundError } from "../../errors"; +import { ResourceNotFoundError, ResultTruncationError } from "../../errors"; import type { AwsClients, CoreOptions } from "../types"; import { toClientConfig } from "../utils"; import { runInsightsQuery, type InsightsRowLimit } from "./insights"; @@ -17,7 +19,7 @@ export type RawLogRecord = { message: string; ingestionTime?: number; logStreamName?: string; - raw: FilteredLogEvent | LiveTailSessionLogEvent; + raw: FilteredLogEvent | LiveTailSessionLogEvent | OutputLogEvent; }; export type LogSearchQuery = { @@ -31,6 +33,14 @@ export type LogTailQuery = { filterPattern?: string; }; +export type LogStreamSource = LogSource & { + logStreamName: string; +}; + +export type LogStreamReadQuery = { + maxPages?: number; +}; + export type InsightsQuery = { queryString: string; startTimeMs: number; @@ -55,6 +65,13 @@ export interface SourceReader { signal: AbortSignal, ): AsyncIterable; + readLogStream( + source: LogStreamSource, + query: LogStreamReadQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncIterable; + queryLogs( source: LogSource, query: InsightsQuery, @@ -176,6 +193,60 @@ export class CloudWatchSourceReader implements SourceReader { } } + async *readLogStream( + source: LogStreamSource, + query: LogStreamReadQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncGenerator { + if (query.maxPages !== undefined && query.maxPages <= 0) return; + + const logs = this.clients.logs(toClientConfig(options)); + let nextToken: string | undefined; + let pagesRead = 0; + + while (query.maxPages === undefined || pagesRead < query.maxPages) { + const requestToken = nextToken; + let response; + try { + response = await logs.send( + new GetLogEventsCommand({ + logGroupName: source.logGroupName, + logStreamName: source.logStreamName, + startFromHead: true, + ...(requestToken ? { nextToken: requestToken } : {}), + }), + { abortSignal: signal }, + ); + } catch (error) { + if (error instanceof ResourceNotFoundException) { + throw missingLogStreamError(source, error); + } + throw error; + } + + pagesRead++; + for (const event of response.events ?? []) { + yield toRawLogRecord(event, source.logStreamName); + } + + nextToken = response.nextForwardToken; + if (!nextToken || nextToken === requestToken) return; + } + + throw new ResultTruncationError( + `CloudWatch log stream ${source.logStreamName} exceeds ${query.maxPages} pages; ` + + "retrieved records are incomplete", + { + meta: { + logGroupName: source.logGroupName, + logStreamName: source.logStreamName, + maxPages: query.maxPages, + }, + }, + ); + } + async queryLogs( source: LogSource, query: InsightsQuery, @@ -209,12 +280,17 @@ export class CloudWatchSourceReader implements SourceReader { } } -function toRawLogRecord(event: FilteredLogEvent | LiveTailSessionLogEvent): RawLogRecord { +function toRawLogRecord( + event: FilteredLogEvent | LiveTailSessionLogEvent | OutputLogEvent, + defaultLogStreamName?: string, +): RawLogRecord { + const logStreamName = + ("logStreamName" in event ? event.logStreamName : undefined) ?? defaultLogStreamName; return { timestamp: event.timestamp ?? Date.now(), message: event.message ?? "", ...(event.ingestionTime !== undefined ? { ingestionTime: event.ingestionTime } : {}), - ...(event.logStreamName ? { logStreamName: event.logStreamName } : {}), + ...(logStreamName ? { logStreamName } : {}), raw: event, }; } @@ -226,3 +302,17 @@ function missingLogGroupError(source: LogSource, cause?: unknown): ResourceNotFo { cause, meta: { logGroupName: source.logGroupName } }, ); } + +function missingLogStreamError(source: LogStreamSource, cause?: unknown): ResourceNotFoundError { + return new ResourceNotFoundError( + `CloudWatch log stream ${source.logStreamName} does not exist in log group ` + + `${source.logGroupName}.`, + { + cause, + meta: { + logGroupName: source.logGroupName, + logStreamName: source.logStreamName, + }, + }, + ); +} diff --git a/src/handlers/eval/batch-evaluation/batch-evaluation.fixture.test.tsx b/src/handlers/eval/batch-evaluation/batch-evaluation.fixture.test.tsx index 034e3ac4d..512733e26 100644 --- a/src/handlers/eval/batch-evaluation/batch-evaluation.fixture.test.tsx +++ b/src/handlers/eval/batch-evaluation/batch-evaluation.fixture.test.tsx @@ -24,10 +24,10 @@ const FIXTURES = join(import.meta.dir, "__fixtures__"); // re-recording. // // This suite exercises the real seam end to end: parsing → handler → CoreClient → -// GetBatchEvaluation (data plane) → readEvaluationResults → GetLogEvents (the -// createLogsClient fixture seam). The TestCoreClient suite (batch-evaluation.test.tsx) -// covers the edges that can't be recorded on demand: a non-terminal job, a -// CloudWatch read failure, and list pagination. +// GetBatchEvaluation (data plane) → readEvaluationResults → CloudWatchSourceReader +// → GetLogEvents (the createLogsClient fixture seam). The TestCoreClient suite +// (batch-evaluation.test.tsx) covers the edges that can't be recorded on demand: +// a non-terminal job, a CloudWatch read failure, and list pagination. const FIXTURE_JOB_ID = "GTProbe2_1786034545579-8ffefc851e"; // A well-formed but absent id, to reach the not-found path without a