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 87948c358..d4477fb39 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -7,6 +7,11 @@ import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; +import { + CloudWatchSourceReader, + ObservabilityClient, + RuntimeSourceResolver, +} from "./observability"; import { RuntimeClient } from "./runtime"; import type { AwsClients, @@ -67,6 +72,7 @@ export class CoreClient implements AwsClients { readonly runtime: RuntimeClient; readonly gateway: GatewayClient; readonly eval: EvalClient; + readonly observability: ObservabilityClient; readonly projectManager: ProjectManager; @@ -88,6 +94,10 @@ export class CoreClient implements AwsClients { this.logger.child({ module: "eval" }), config.newSessionId, ); + this.observability = new ObservabilityClient( + { runtime: new RuntimeSourceResolver() }, + new CloudWatchSourceReader(this), + ); this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), @@ -132,7 +142,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/client.test.ts b/src/core/observability/client.test.ts new file mode 100644 index 000000000..4116de32a --- /dev/null +++ b/src/core/observability/client.test.ts @@ -0,0 +1,144 @@ +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 { 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; + }, + }; + 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"); + }); +}); diff --git a/src/core/observability/client.ts b/src/core/observability/client.ts new file mode 100644 index 000000000..4154d11a7 --- /dev/null +++ b/src/core/observability/client.ts @@ -0,0 +1,148 @@ +import type { CoreOptions } from "../types"; +import type { + LogSource, + ObservableResourceRef, + ObservabilitySourceResolver, + ObservabilitySourceResolverRegistry, + ResolvedObservabilityTarget, + ResolvedResourceIdentity, +} from "./resolver"; +import type { 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; +} + +/** + * 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); + } + } + } + + 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..f23098eb0 --- /dev/null +++ b/src/core/observability/index.ts @@ -0,0 +1,19 @@ +export { ObservabilityClient, type CoreObservabilityClient, type LogRecord } from "./client"; +export { + DEFAULT_RUNTIME_QUALIFIER, + RuntimeSourceResolver, + runtimeLogGroup, + type LogSource, + type ObservableResourceRef, + type ObservabilitySourceResolver, + type ObservabilitySourceResolverRegistry, + type ResolvedObservabilityTarget, + type ResolvedResourceIdentity, +} from "./resolver"; +export { + CloudWatchSourceReader, + type LogSearchQuery, + type LogTailQuery, + type RawLogRecord, + type SourceReader, +} from "./sourceReader"; 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..16b2b24e3 --- /dev/null +++ b/src/core/observability/resolver.ts @@ -0,0 +1,72 @@ +import type { CoreOptions } from "../types"; + +export const DEFAULT_RUNTIME_QUALIFIER = "DEFAULT"; + +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..d16ae2fc0 --- /dev/null +++ b/src/core/observability/sourceReader.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test"; +import { + DescribeLogGroupsCommand, + FilterLogEventsCommand, + ResourceNotFoundException, + StartLiveTailCommand, + 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.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..f4b5aad61 --- /dev/null +++ b/src/core/observability/sourceReader.ts @@ -0,0 +1,179 @@ +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 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 interface SourceReader { + searchLogs( + source: LogSource, + query: LogSearchQuery, + options: CoreOptions, + signal?: AbortSignal, + ): AsyncIterable; + + tailLogs( + source: LogSource, + query: LogTailQuery, + options: CoreOptions, + signal: AbortSignal, + ): AsyncIterable; +} + +/** + * 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; + } + } +} + +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 188f61224..9b32f01b0 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"; @@ -24,6 +25,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); // Add global flags root.groupFlags(RegionKey, DebugKey, JsonKey, EndpointKey); @@ -45,7 +47,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..67dcb965c --- /dev/null +++ b/src/handlers/observability/handlerFactory.ts @@ -0,0 +1,140 @@ +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 { parseTimeString } 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 resource = config.resource.toResource(parsed); + 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 now = Date.now(); + const startTimeMs = + parsed.since !== undefined + ? parseTimeString(parsed.since) + : now - DEFAULT_SEARCH_WINDOW_MS; + const endTimeMs = parsed.until !== undefined ? parseTimeString(parsed.until) : now; + if (searchMode && startTimeMs > endTimeMs) { + throw new InputValidationError("--since must resolve to a time before --until"); + } + + 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 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..1840b5375 --- /dev/null +++ b/src/handlers/observability/time.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { InputValidationError } from "../../errors"; +import { parseTimeString } 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"'); + }); +}); diff --git a/src/handlers/observability/time.ts b/src/handlers/observability/time.ts new file mode 100644 index 000000000..36e5562ef --- /dev/null +++ b/src/handlers/observability/time.ts @@ -0,0 +1,32 @@ +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".', + ); +} diff --git a/src/handlers/observability/types.ts b/src/handlers/observability/types.ts new file mode 100644 index 000000000..c3987556e --- /dev/null +++ b/src/handlers/observability/types.ts @@ -0,0 +1,24 @@ +import type z from "zod"; +import type { ObservableResourceRef } from "../../core/observability"; +import type { 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; + toResource(flags: ResourceFlagValues): Extract; +} + +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 edcc9e4a7..c54bad798 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -1,21 +1,51 @@ +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 { createRuntimeVersionHandler } from "./version"; +import { runtimeIdSchema } from "./invoke/request"; -export function createRuntimeHandler(core: Core, io: AppIO): Router { +const runtimeObservabilityFlags = [ + flag("id", "the ID of the Runtime", runtimeIdSchema), + flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()), +] as const; + +const runtimeObservabilityResource = { + flags: runtimeObservabilityFlags, + toResource: (flags) => ({ + kind: "runtime", + id: flags.id, + ...(flags.qualifier ? { qualifier: flags.qualifier } : {}), + }), +} satisfies ObservableResourceCommand<"runtime", typeof runtimeObservabilityFlags>; + +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(createRuntimeEndpointHandler(core, io)) + .handler( + observabilityHandlers.createLogsHandler({ + resource: runtimeObservabilityResource, + }), + ); } diff --git a/src/handlers/runtime/logs.test.tsx b/src/handlers/runtime/logs.test.tsx new file mode 100644 index 000000000..21e27093b --- /dev/null +++ b/src/handlers/runtime/logs.test.tsx @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; +import type { LogRecord } from "../../core/observability"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; + +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 { route } = testLogsCommand(); + + await expect(route(["runtime", "logs", "--since", `${SINCE_MS}`])).rejects.toThrow( + "required option '--id ' not specified", + ); + }); +}); diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 4c2c9a8a1..73d5ff90b 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -77,6 +77,7 @@ describe("runtime command hierarchy", () => { "invoke", "version", "endpoint", + "logs", ]); expect( runtime diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index f129805a8..5d51357cd 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -4,6 +4,7 @@ import type { CoreHarnessClient } from "./harness/types.tsx"; import type { CoreIdentityClient } from "./identity/types.tsx"; import type { CoreMemoryClient } from "./memory/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"; @@ -14,6 +15,7 @@ export interface Core { runtime: CoreRuntimeClient; gateway: CoreGatewayClient; eval: CoreEvalClient; + observability: CoreObservabilityClient; projectManager: ProjectManager; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 8fcce7a46..428654c2e 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -160,6 +160,13 @@ import type { import { isTerminalStatus } from "../core/batchEvaluationResults"; import { abortable } from "../core/abortable"; import type { CoreOptions, CreateCloudFormationClient } from "../core/types"; +import type { + CoreObservabilityClient, + LogRecord, + LogSearchQuery, + LogTailQuery, + ObservableResourceRef, +} from "../core/observability"; import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; @@ -2222,6 +2229,40 @@ export class TestEvalClient implements CoreEvalClient { } } +export class TestObservabilityClient implements CoreObservabilityClient { + readonly calls: RecordedCall[] = []; + logRecords: LogRecord[] = []; + error?: Error; + + 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; + yield* this.logRecords; + } + + async *tailLogs( + resource: ObservableResourceRef, + query: LogTailQuery, + options: CoreOptions, + signal: AbortSignal, + ): AsyncGenerator { + this.calls.push({ + method: "tailLogs", + args: [resource, query, options, signal], + }); + if (this.error) throw this.error; + yield* this.logRecords; + } +} + // TestCoreClient implements the Core contract with fully controllable sub-clients. export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); @@ -2230,6 +2271,7 @@ export class TestCoreClient implements Core { readonly runtime = new TestRuntimeClient(); readonly gateway = new TestGatewayClient(); readonly eval = new TestEvalClient(); + readonly observability = new TestObservabilityClient(); readonly projectManager: ProjectManager; // Commands the project manager would have run (npm install, git init, ...), diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 44de6961e..a09bc81f3 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -9,6 +9,7 @@ export { TestMemoryClient, TestRuntimeClient, TestEvalClient, + TestObservabilityClient, type RecordedCall, } from "./TestCoreClient"; export { StreamController } from "./StreamController";