diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 3846dea85..c35113e6d 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -9,6 +9,8 @@ import { runWebUI, } from '../../operations/dev/web-ui'; import { listMemoryRecords, retrieveMemoryRecords } from '../../operations/memory'; +import { loadDeployedProjectConfig, resolveAgent } from '../../operations/resolve-agent'; +import { fetchTraceRecords, listTraces } from '../../operations/traces'; import path from 'node:path'; interface DeployedHandlers { @@ -192,6 +194,47 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { ? (agentNameParam, startTime, endTime) => collector.listTraces(agentNameParam, startTime, endTime) : undefined, onGetTrace: collector ? (agentNameParam, traceId) => collector.getTraceSpans(agentNameParam, traceId) : undefined, + onListCloudWatchTraces: async (agentName, _harnessName, startTime, endTime) => { + try { + const configIO = new ConfigIO({ baseDir }); + const context = await loadDeployedProjectConfig(configIO); + const resolved = resolveAgent(context, { runtime: agentName }); + if (!resolved.success) return { success: false, error: resolved.error }; + return listTraces({ + region: resolved.agent.region, + runtimeId: resolved.agent.runtimeId, + agentName: resolved.agent.agentName, + startTime, + endTime, + }); + } catch (err) { + return { + success: false, + error: `Failed to list CloudWatch traces: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, + onGetCloudWatchTrace: async (agentName, _harnessName, traceId, startTime, endTime) => { + try { + const configIO = new ConfigIO({ baseDir }); + const context = await loadDeployedProjectConfig(configIO); + const resolved = resolveAgent(context, { runtime: agentName }); + if (!resolved.success) return { success: false, error: resolved.error }; + return fetchTraceRecords({ + region: resolved.agent.region, + runtimeId: resolved.agent.runtimeId, + traceId, + startTime, + endTime, + includeSpans: true, + }); + } catch (err) { + return { + success: false, + error: `Failed to get CloudWatch trace: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, onListMemoryRecords: async (memoryName, namespace, strategyId) => { const deployed = await resolveDeployedHandlers(baseDir, onLog); if (!deployed.onListMemoryRecords) return { success: false, error: 'No deployed AgentCore Memory found' }; diff --git a/src/cli/operations/dev/web-ui/__tests__/cloudwatch-traces.test.ts b/src/cli/operations/dev/web-ui/__tests__/cloudwatch-traces.test.ts new file mode 100644 index 000000000..f0210f63a --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/cloudwatch-traces.test.ts @@ -0,0 +1,233 @@ +import { handleGetCloudWatchTrace, handleListCloudWatchTraces } from '../handlers/cloudwatch-traces.js'; +import type { RouteContext } from '../handlers/route-context.js'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +function mockRes(): ServerResponse & { _status: number; _headers: Record; _body: string } { + const res = { + _status: 0, + _headers: {} as Record, + _body: '', + writeHead(status: number, headers?: Record) { + res._status = status; + if (headers) Object.assign(res._headers, headers); + return res; + }, + setHeader(name: string, value: string) { + res._headers[name] = value; + }, + end(body?: string) { + if (body) res._body = body; + }, + }; + return res as unknown as ServerResponse & { _status: number; _headers: Record; _body: string }; +} + +function mockReq(url: string): IncomingMessage { + return { url, headers: { host: 'localhost:8081' } } as unknown as IncomingMessage; +} + +function mockCtx(overrides: Partial = {}): RouteContext { + return { + options: { + mode: 'dev', + agents: [], + harnesses: [], + uiPort: 8081, + ...overrides, + }, + runningAgents: new Map(), + startingAgents: new Map(), + agentErrors: new Map(), + setCorsHeaders: vi.fn(), + readBody: vi.fn(), + } as RouteContext; +} + +describe('handleListCloudWatchTraces', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 404 when no handler configured', async () => { + const ctx = mockCtx(); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(404); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('not available'); + }); + + it('returns 400 when neither agentName nor harnessName provided', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('agentName'); + expect(body.error).toContain('harnessName'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('returns 400 when both agentName and harnessName provided', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=a&harnessName=h'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('agentName'); + expect(body.error).toContain('harnessName'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('calls handler with agentName and returns traces', async () => { + const traces = [{ traceId: 't1' }, { traceId: 't2' }]; + const handler = vi.fn().mockResolvedValue({ success: true, traces }); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(200); + expect(handler).toHaveBeenCalledWith('my-agent', undefined, undefined, undefined); + const body = JSON.parse(res._body); + expect(body.success).toBe(true); + expect(body.traces).toEqual(traces); + }); + + it('calls handler with harnessName', async () => { + const handler = vi.fn().mockResolvedValue({ success: true, traces: [] }); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?harnessName=my-harness'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(200); + expect(handler).toHaveBeenCalledWith(undefined, 'my-harness', undefined, undefined); + }); + + it('returns 500 when handler throws', async () => { + const handler = vi.fn().mockRejectedValue(new Error('boom')); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(500); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('Failed to list CloudWatch traces'); + }); + + it('returns 400 for invalid startTime', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent&startTime=notanumber'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('startTime'); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('handleGetCloudWatchTrace', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 404 when no handler configured', async () => { + const ctx = mockCtx(); + const req = mockReq('/api/cloudwatch-traces/abc123?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(404); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('not available'); + }); + + it('returns 400 when traceId is missing', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('traceId'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('returns 400 when neither agentName nor harnessName provided', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/abc123'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('agentName'); + expect(body.error).toContain('harnessName'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('returns 500 when handler throws', async () => { + const handler = vi.fn().mockRejectedValue(new Error('boom')); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/abc123?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(500); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('Failed to get CloudWatch trace'); + }); + + it('calls handler and returns records', async () => { + const records = [{ record: 'data1' }]; + const handler = vi.fn().mockResolvedValue({ success: true, records }); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/abc123?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(200); + expect(handler).toHaveBeenCalledWith('my-agent', undefined, 'abc123', undefined, undefined); + const body = JSON.parse(res._body); + expect(body.success).toBe(true); + expect(body.records).toEqual(records); + }); +}); diff --git a/src/cli/operations/dev/web-ui/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts index 509d834ff..8ba57937e 100644 --- a/src/cli/operations/dev/web-ui/api-types.ts +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -8,6 +8,7 @@ * TODO: Extract these types into a shared package so both repos import * from a single source of truth instead of manually duplicating. */ +import type { CloudWatchSpanRecord, CloudWatchTraceRecord } from '../../traces/types'; // --------------------------------------------------------------------------- // GET /api/status @@ -279,6 +280,39 @@ export interface GetTraceResponse { error?: string; } +// --------------------------------------------------------------------------- +// GET /api/cloudwatch-traces?agentName=xxx|harnessName=xxx +// --------------------------------------------------------------------------- + +/** A single trace entry returned by the CloudWatch traces list endpoint */ +export interface CloudWatchTraceEntry { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount?: string; +} + +/** Response shape for GET /api/cloudwatch-traces */ +export interface ListCloudWatchTracesResponse { + success: boolean; + traces?: CloudWatchTraceEntry[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/cloudwatch-traces/:traceId?agentName=xxx|harnessName=xxx +// --------------------------------------------------------------------------- + +/** Response shape for GET /api/cloudwatch-traces/:traceId */ +export interface GetCloudWatchTraceResponse { + success: boolean; + records?: CloudWatchTraceRecord[]; + spans?: CloudWatchSpanRecord[]; + error?: string; +} + +export type { CloudWatchTraceRecord, CloudWatchSpanRecord } from '../../traces/types'; + // --------------------------------------------------------------------------- // GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz] // --------------------------------------------------------------------------- diff --git a/src/cli/operations/dev/web-ui/handlers/cloudwatch-traces.ts b/src/cli/operations/dev/web-ui/handlers/cloudwatch-traces.ts new file mode 100644 index 000000000..15759b766 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/cloudwatch-traces.ts @@ -0,0 +1,166 @@ +import type { RouteContext } from './route-context'; +import { parseRequestUrl } from './route-context'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * GET /api/cloudwatch-traces?agentName=xxx or ?harnessName=xxx — list recent CloudWatch traces. + * Exactly one of agentName or harnessName must be provided. + */ +export async function handleListCloudWatchTraces( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { param } = parseRequestUrl(req); + const handler = ctx.options.onListCloudWatchTraces; + + if (!handler) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'CloudWatch traces are not available' })); + return; + } + + const agentName = param('agentName'); + const harnessName = param('harnessName'); + + if (!agentName && !harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Either agentName or harnessName query parameter is required' })); + return; + } + + if (agentName && harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: 'Provide either agentName or harnessName, not both', + }) + ); + return; + } + + // Parse optional date range query params (epoch milliseconds) + const startTimeRaw = param('startTime'); + const endTimeRaw = param('endTime'); + const startTime = startTimeRaw ? Number(startTimeRaw) : undefined; + const endTime = endTimeRaw ? Number(endTimeRaw) : undefined; + + if (startTimeRaw && isNaN(startTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'startTime must be a number (epoch milliseconds)' })); + return; + } + if (endTimeRaw && isNaN(endTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'endTime must be a number (epoch milliseconds)' })); + return; + } + + try { + const result = await handler(agentName, harnessName, startTime, endTime); + ctx.setCorsHeaders(res, origin); + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + ctx.options.onLog?.('error', `List CloudWatch traces error: ${err instanceof Error ? err.message : String(err)}`); + ctx.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Failed to list CloudWatch traces' })); + } +} + +/** + * GET /api/cloudwatch-traces/:traceId?agentName=xxx or ?harnessName=xxx — get full CloudWatch trace data. + * Exactly one of agentName or harnessName must be provided. + */ +export async function handleGetCloudWatchTrace( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { pathname, param } = parseRequestUrl(req); + const handler = ctx.options.onGetCloudWatchTrace; + + if (!handler) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'CloudWatch traces are not available' })); + return; + } + + const traceId = pathname.replace('/api/cloudwatch-traces/', ''); + const agentName = param('agentName'); + const harnessName = param('harnessName'); + + if (!traceId) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'traceId is required in the URL path' })); + return; + } + + if (!/^[a-fA-F0-9-]+$/.test(traceId)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Invalid trace ID format' })); + return; + } + + if (!agentName && !harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Either agentName or harnessName query parameter is required' })); + return; + } + + if (agentName && harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: 'Provide either agentName or harnessName, not both', + }) + ); + return; + } + + // Parse optional date range query params (epoch milliseconds) + const startTimeRaw = param('startTime'); + const endTimeRaw = param('endTime'); + const startTime = startTimeRaw ? Number(startTimeRaw) : undefined; + const endTime = endTimeRaw ? Number(endTimeRaw) : undefined; + + if (startTimeRaw && isNaN(startTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'startTime must be a number (epoch milliseconds)' })); + return; + } + if (endTimeRaw && isNaN(endTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'endTime must be a number (epoch milliseconds)' })); + return; + } + + try { + const result = await handler(agentName, harnessName, traceId, startTime, endTime); + ctx.setCorsHeaders(res, origin); + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + ctx.options.onLog?.('error', `Get CloudWatch trace error: ${err instanceof Error ? err.message : String(err)}`); + ctx.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Failed to get CloudWatch trace' })); + } +} diff --git a/src/cli/operations/dev/web-ui/handlers/index.ts b/src/cli/operations/dev/web-ui/handlers/index.ts index 91d2d4d5d..0ae7b4f67 100644 --- a/src/cli/operations/dev/web-ui/handlers/index.ts +++ b/src/cli/operations/dev/web-ui/handlers/index.ts @@ -4,6 +4,7 @@ export { handleResources } from './resources'; export { handleStart } from './start'; export { handleInvocations } from './invocations'; export { handleListTraces, handleGetTrace } from './traces'; +export { handleListCloudWatchTraces, handleGetCloudWatchTrace } from './cloudwatch-traces'; export { handleListMemoryRecords, handleRetrieveMemoryRecords } from './memory'; export { handleMcpProxy } from './mcp-proxy'; export { handleA2AAgentCard } from './a2a-proxy'; diff --git a/src/cli/operations/dev/web-ui/index.ts b/src/cli/operations/dev/web-ui/index.ts index 6901eb31a..b14949008 100644 --- a/src/cli/operations/dev/web-ui/index.ts +++ b/src/cli/operations/dev/web-ui/index.ts @@ -4,6 +4,8 @@ export { type StartHandler, type ListTracesHandler, type GetTraceHandler, + type ListCloudWatchTracesHandler, + type GetCloudWatchTraceHandler, type ListMemoryRecordsHandler, type RetrieveMemoryRecordsHandler, } from './web-server'; @@ -29,6 +31,11 @@ export type { InvocationRequest, ListTracesResponse, GetTraceResponse, + ListCloudWatchTracesResponse, + CloudWatchTraceEntry, + GetCloudWatchTraceResponse, + CloudWatchTraceRecord, + CloudWatchSpanRecord, ListMemoryRecordsResponse, MemoryRecordResponse, RetrieveMemoryRecordsRequest, diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index c3f9c6f36..2b20b2d07 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -4,8 +4,10 @@ import { type AgentError, type AgentInfo, WEB_UI_LOCAL_URL } from './constants'; import { type RouteContext, handleA2AAgentCard, + handleGetCloudWatchTrace, handleGetTrace, handleInvocations, + handleListCloudWatchTraces, handleListMemoryRecords, handleListTraces, handleMcpProxy, @@ -78,6 +80,29 @@ export type GetTraceHandler = ( endTime?: number ) => Promise<{ success: boolean; resourceSpans?: unknown[]; resourceLogs?: unknown[]; error?: string }>; +/** + * Custom handler for GET /api/cloudwatch-traces. + * Returns a list of recent CloudWatch traces for the given agent or harness. + */ +export type ListCloudWatchTracesHandler = ( + agentName: string | undefined, + harnessName: string | undefined, + startTime?: number, + endTime?: number +) => Promise<{ success: boolean; traces?: unknown[]; error?: string }>; + +/** + * Custom handler for GET /api/cloudwatch-traces/:traceId. + * Returns the full CloudWatch trace data for a specific trace. + */ +export type GetCloudWatchTraceHandler = ( + agentName: string | undefined, + harnessName: string | undefined, + traceId: string, + startTime?: number, + endTime?: number +) => Promise<{ success: boolean; records?: unknown[]; spans?: unknown[]; error?: string }>; + /** * Custom handler for GET /api/memory. * Returns a list of memory records for a given memory + namespace. @@ -124,6 +149,10 @@ export interface WebUIOptions { onListTraces?: ListTracesHandler; /** Custom handler for getting a single trace */ onGetTrace?: GetTraceHandler; + /** Custom handler for listing CloudWatch traces */ + onListCloudWatchTraces?: ListCloudWatchTracesHandler; + /** Custom handler for getting a single CloudWatch trace */ + onGetCloudWatchTrace?: GetCloudWatchTraceHandler; /** Custom handler for listing memory records */ onListMemoryRecords?: ListMemoryRecordsHandler; /** Custom handler for searching memory records */ @@ -291,6 +320,10 @@ export class WebUIServer { await handleGetTrace(ctx, req, res, origin); } else if (req.method === 'GET' && req.url?.startsWith('/api/traces')) { await handleListTraces(ctx, req, res, origin); + } else if (req.method === 'GET' && req.url?.startsWith('/api/cloudwatch-traces/')) { + await handleGetCloudWatchTrace(ctx, req, res, origin); + } else if (req.method === 'GET' && req.url?.startsWith('/api/cloudwatch-traces')) { + await handleListCloudWatchTraces(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/api/start') { await handleStart(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/invocations') { diff --git a/src/cli/operations/traces/__tests__/get-trace.test.ts b/src/cli/operations/traces/__tests__/get-trace.test.ts new file mode 100644 index 000000000..c6fda22f4 --- /dev/null +++ b/src/cli/operations/traces/__tests__/get-trace.test.ts @@ -0,0 +1,233 @@ +import { fetchTraceRecords, getTrace } from '../get-trace'; +import type { FetchTraceRecordsOptions } from '../types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({ + CloudWatchLogsClient: class { + send = mockSend; + }, + StartQueryCommand: class { + constructor(public input: unknown) {} + }, + GetQueryResultsCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('../../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +vi.mock('node:fs', () => ({ + default: { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +const baseOptions: FetchTraceRecordsOptions = { + region: 'us-west-2', + runtimeId: 'runtime-123', + traceId: 'abc123def456', + startTime: 1000000, + endTime: 2000000, +}; + +describe('fetchTraceRecords', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns parsed trace records from CloudWatch', async () => { + mockSend + .mockResolvedValueOnce({ queryId: 'q-1' }) // StartQueryCommand + .mockResolvedValueOnce({ + // GetQueryResultsCommand + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"traceId":"abc123","spanId":"span1"}' }, + { field: '@ptr', value: 'ptr-value-1' }, + ], + [ + { field: '@timestamp', value: '2024-01-01T00:00:01Z' }, + { field: '@message', value: '{"traceId":"abc123","spanId":"span2"}' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records).toHaveLength(2); + expect(result.records![0]).toEqual({ + '@timestamp': '2024-01-01T00:00:00Z', + '@message': { traceId: 'abc123', spanId: 'span1' }, + '@ptr': 'ptr-value-1', + }); + expect(result.records![1]).toEqual({ + '@timestamp': '2024-01-01T00:00:01Z', + '@message': { traceId: 'abc123', spanId: 'span2' }, + }); + }); + + it('returns error for invalid trace ID format', async () => { + const result = await fetchTraceRecords({ + ...baseOptions, + traceId: 'invalid!@#$', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid trace ID format'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('returns error when no trace data found', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('No trace data found'); + }); + + it('returns error when query fails to start', async () => { + mockSend.mockResolvedValueOnce({ queryId: undefined }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to start CloudWatch Logs Insights query'); + }); + + it('returns error when query status is Failed', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ status: 'Failed' }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('failed'); + }); + + it('preserves @ptr when present in CloudWatch response', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"key":"val"}' }, + { field: '@ptr', value: 'cw-ptr-123' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records).toHaveLength(1); + expect(result.records![0]!['@ptr']).toBe('cw-ptr-123'); + }); + + it('omits @ptr when not present in CloudWatch response', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"key":"val"}' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records![0]).not.toHaveProperty('@ptr'); + }); + + it('handles non-JSON @message gracefully', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: 'plain text message' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records).toHaveLength(1); + expect(result.records![0]!['@message']).toBe('plain text message'); + }); + + it('handles ResourceNotFoundException', async () => { + const error = new Error('Not found'); + error.name = 'ResourceNotFoundException'; + mockSend.mockRejectedValueOnce(error); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('Log group'); + expect(result.error).toContain('not found'); + }); +}); + +describe('getTrace', () => { + afterEach(() => vi.clearAllMocks()); + + it('calls fetchTraceRecords and writes result to disk', async () => { + const fs = await import('node:fs'); + + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"traceId":"abc123"}' }, + ], + ], + }); + + const result = await getTrace({ + region: 'us-west-2', + runtimeId: 'runtime-123', + agentName: 'my-agent', + traceId: 'abc123def456', + outputPath: '/tmp/test-trace.json', + startTime: 1000000, + endTime: 2000000, + }); + + expect(result.success).toBe(true); + expect(result.filePath).toContain('test-trace.json'); + expect(fs.default.mkdirSync).toHaveBeenCalled(); + expect(fs.default.writeFileSync).toHaveBeenCalledWith('/tmp/test-trace.json', expect.stringContaining('"traceId"')); + }); + + it('returns error from fetchTraceRecords without writing file', async () => { + const fs = await import('node:fs'); + + const result = await getTrace({ + region: 'us-west-2', + runtimeId: 'runtime-123', + agentName: 'my-agent', + traceId: 'invalid!@#$', + startTime: 1000000, + endTime: 2000000, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid trace ID format'); + expect(fs.default.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/operations/traces/__tests__/list-traces.test.ts b/src/cli/operations/traces/__tests__/list-traces.test.ts new file mode 100644 index 000000000..0bbe884de --- /dev/null +++ b/src/cli/operations/traces/__tests__/list-traces.test.ts @@ -0,0 +1,135 @@ +import { listTraces } from '../list-traces'; +import type { ListTracesOptions } from '../types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockRunInsightsQuery } = vi.hoisted(() => ({ + mockRunInsightsQuery: vi.fn(), +})); + +vi.mock('../insights-query', () => ({ + runInsightsQuery: mockRunInsightsQuery, +})); + +const baseOptions: ListTracesOptions = { + region: 'us-west-2', + runtimeId: 'runtime-123', + agentName: 'my-agent', + startTime: 1000000, + endTime: 2000000, +}; + +describe('listTraces', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns trace entries from query results', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [ + { + traceId: 'trace-1', + lastSeen: '2024-01-01T00:05:00Z', + firstSeen: '2024-01-01T00:00:00Z', + spanCount: '12', + sessionId: 'sess-1', + }, + { traceId: 'trace-2', lastSeen: '2024-01-01T00:03:00Z', firstSeen: '2024-01-01T00:01:00Z', spanCount: '5' }, + ], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces).toHaveLength(2); + expect(result.traces![0]).toEqual({ + traceId: 'trace-1', + timestamp: '2024-01-01T00:05:00Z', + sessionId: 'sess-1', + spanCount: '12', + }); + expect(result.traces![1]).toEqual({ + traceId: 'trace-2', + timestamp: '2024-01-01T00:03:00Z', + sessionId: undefined, + spanCount: '5', + }); + }); + + it('filters out rows without traceId', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [ + { traceId: 'trace-1', lastSeen: '2024-01-01T00:00:00Z', spanCount: '3' }, + { lastSeen: '2024-01-01T00:00:00Z', spanCount: '1' }, + { traceId: '', lastSeen: '2024-01-01T00:00:00Z', spanCount: '2' }, + ], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces).toHaveLength(1); + expect(result.traces![0]!.traceId).toBe('trace-1'); + }); + + it('falls back to firstSeen when lastSeen is missing', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [{ traceId: 'trace-1', firstSeen: '2024-01-01T00:00:00Z', spanCount: '1' }], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces![0]!.timestamp).toBe('2024-01-01T00:00:00Z'); + }); + + it('returns empty traces for empty query results', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces).toHaveLength(0); + }); + + it('propagates errors from runInsightsQuery', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: false, + error: 'Log group not found', + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toBe('Log group not found'); + }); + + it('passes correct log group name and default limit', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ success: true, rows: [] }); + + await listTraces(baseOptions); + + expect(mockRunInsightsQuery).toHaveBeenCalledWith({ + region: 'us-west-2', + logGroupName: '/aws/bedrock-agentcore/runtimes/runtime-123-DEFAULT', + startTime: 1000000, + endTime: 2000000, + queryString: expect.stringContaining('limit 20'), + }); + }); + + it('respects custom limit', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ success: true, rows: [] }); + + await listTraces({ ...baseOptions, limit: 50 }); + + expect(mockRunInsightsQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('limit 50'), + }) + ); + }); +}); diff --git a/src/cli/operations/traces/get-trace.ts b/src/cli/operations/traces/get-trace.ts index 85c4471be..a87f10a65 100644 --- a/src/cli/operations/traces/get-trace.ts +++ b/src/cli/operations/traces/get-trace.ts @@ -1,129 +1,186 @@ -import { getCredentialProvider } from '../../aws'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; -import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; +import { runInsightsQuery } from './insights-query'; +import type { + CloudWatchSpanRecord, + CloudWatchTraceRecord, + FetchTraceRecordsOptions, + FetchTraceRecordsResult, + GetTraceOptions, + GetTraceResult, +} from './types'; import fs from 'node:fs'; import path from 'node:path'; -export interface GetTraceOptions { - region: string; - runtimeId: string; - agentName: string; - traceId: string; - outputPath?: string; - startTime?: number; - endTime?: number; -} +const SPANS_LOG_GROUP = 'aws/spans'; +const TRACE_ID_PATTERN = /^[a-fA-F0-9-]+$/; -export interface GetTraceResult { - success: boolean; - filePath?: string; - error?: string; +function runtimeLogGroup(runtimeId: string): string { + return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${DEFAULT_ENDPOINT_NAME}`; } -/** - * Fetches a full trace from CloudWatch Logs and writes it to a JSON file. - * - * Log group naming convention: /aws/bedrock-agentcore/runtimes/{runtimeId}-DEFAULT - * Trace ID is stored in the @message JSON body as "traceId". - */ -export async function getTrace(options: GetTraceOptions): Promise { - const { region, runtimeId, agentName, traceId, outputPath } = options; - - if (!/^[a-fA-F0-9-]+$/.test(traceId)) { +async function fetchSpans( + region: string, + traceId: string, + startTime?: number, + endTime?: number +): Promise<{ success: boolean; spans?: CloudWatchSpanRecord[]; error?: string }> { + if (!TRACE_ID_PATTERN.test(traceId)) { return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; } - const client = new CloudWatchLogsClient({ - credentials: getCredentialProvider(), + const result = await runInsightsQuery({ region, + logGroupName: SPANS_LOG_GROUP, + startTime, + endTime, + queryString: `fields traceId, spanId, parentSpanId, name, kind, + startTimeUnixNano, endTimeUnixNano, durationNano, + status.code as statusCode, + resource.attributes.service.name as serviceName, + attributes.gen_ai.usage.input_tokens as inputTokens, + attributes.gen_ai.usage.output_tokens as outputTokens, + attributes.gen_ai.usage.total_tokens as totalTokens, + attributes.http.status_code as httpStatusCode, + attributes.session.id as sessionId +| filter ispresent(traceId) and ispresent(resource.attributes.service.name) +| filter resource.attributes.aws.service.type = "gen_ai_agent" +| filter traceId = '${traceId}' +| sort startTimeUnixNano asc`, }); - const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${DEFAULT_ENDPOINT_NAME}`; + if (!result.success) return { success: false, error: result.error }; + + const spans: CloudWatchSpanRecord[] = (result.rows ?? []) + .filter(row => row.traceId && row.spanId) + .map(row => ({ + traceId: row.traceId!, + spanId: row.spanId!, + parentSpanId: row.parentSpanId ?? undefined, + name: row.name ?? undefined, + kind: row.kind ?? undefined, + startTimeUnixNano: row.startTimeUnixNano ?? undefined, + endTimeUnixNano: row.endTimeUnixNano ?? undefined, + durationNano: row.durationNano ?? undefined, + statusCode: row.statusCode ?? undefined, + serviceName: row.serviceName ?? undefined, + inputTokens: row.inputTokens ? Number(row.inputTokens) : undefined, + outputTokens: row.outputTokens ? Number(row.outputTokens) : undefined, + totalTokens: row.totalTokens ? Number(row.totalTokens) : undefined, + httpStatusCode: row.httpStatusCode ? Number(row.httpStatusCode) : undefined, + sessionId: row.sessionId ?? undefined, + })); + + return { success: true, spans }; +} + +/** + * Fetches trace records from CloudWatch Logs Insights for a given trace ID. + * Returns typed records for the web UI API. Use `getTrace()` to write raw + * results to a JSON file on disk. + */ +export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Promise { + const { region, runtimeId, traceId, includeSpans } = options; - const now = Date.now(); - const endTime = options.endTime ?? now; - const startTime = options.startTime ?? endTime - 12 * 60 * 60 * 1000; // default: last 12 hours + if (!TRACE_ID_PATTERN.test(traceId)) { + return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + } - try { - const startQuery = await client.send( - new StartQueryCommand({ - logGroupName, - startTime: Math.floor(startTime / 1000), - endTime: Math.floor(endTime / 1000), - queryString: `fields @timestamp, @message + const [recordsResult, spansResult] = await Promise.all([ + runInsightsQuery({ + region, + logGroupName: runtimeLogGroup(runtimeId), + startTime: options.startTime, + endTime: options.endTime, + queryString: `fields @timestamp, @message, @ptr | filter traceId = '${traceId}' | sort @timestamp asc -| limit 1000`, - }) - ); +| limit 10000`, + }), + includeSpans ? fetchSpans(region, traceId, options.startTime, options.endTime) : Promise.resolve(undefined), + ]); - if (!startQuery.queryId) { - return { success: false, error: 'Failed to start CloudWatch Logs Insights query' }; - } + if (!recordsResult.success) { + return { success: false, error: recordsResult.error }; + } - // Poll for results - let traceData: Record[] = []; - let queryStatus = 'Running'; - - for (let i = 0; i < 60; i++) { - await new Promise(resolve => setTimeout(resolve, 1000)); - - const queryResults = await client.send(new GetQueryResultsCommand({ queryId: startQuery.queryId })); - - queryStatus = queryResults.status ?? 'Unknown'; - - if (queryStatus === 'Complete' || queryStatus === 'Failed' || queryStatus === 'Cancelled') { - if (queryStatus !== 'Complete') { - return { success: false, error: `Query ${queryStatus.toLowerCase()}` }; - } - - traceData = (queryResults.results ?? []).map(row => { - const fields: Record = {}; - for (const field of row) { - if (field.field && field.value) { - fields[field.field] = field.value; - } - } - return fields; - }); - break; - } - } + const traceData = recordsResult.rows ?? []; - if (queryStatus === 'Running') { - return { success: false, error: 'Query timed out after 60 seconds' }; - } + if (traceData.length === 0 && (!spansResult || (spansResult.spans ?? []).length === 0)) { + return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + } - if (traceData.length === 0) { - return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + const records: CloudWatchTraceRecord[] = traceData.map(entry => { + let message: unknown = entry['@message'] ?? '{}'; + try { + message = JSON.parse(entry['@message'] ?? '{}'); + } catch { + // Keep original string if not valid JSON } - // Parse @message fields as JSON where possible - const parsedTrace = traceData.map(entry => { - try { - const parsed: unknown = JSON.parse(entry['@message'] ?? '{}'); - return { ...entry, '@message': parsed }; - } catch { - return entry; - } - }); - - // Write to file - const filePath = outputPath ?? path.join('agentcore', '.cli', 'traces', `${agentName}-${traceId}.json`); - - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(parsedTrace, null, 2)); - - return { success: true, filePath: path.resolve(filePath) }; - } catch (error: unknown) { - const err = error as Error; - if (err.name === 'ResourceNotFoundException') { - return { - success: false, - error: `Log group '${logGroupName}' not found. The agent may not have been invoked yet, or traces may not be enabled.`, - }; + const record: CloudWatchTraceRecord = { + '@timestamp': entry['@timestamp'] ?? '', + '@message': message, + }; + + if (entry['@ptr']) { + record['@ptr'] = entry['@ptr']; } - return { success: false, error: err.message ?? String(error) }; + + return record; + }); + + const result: FetchTraceRecordsResult = { success: true, records }; + + if (spansResult?.success && spansResult.spans) { + result.spans = spansResult.spans; } + + return result; +} + +/** + * Fetches a full trace from CloudWatch Logs and writes it to a JSON file. + * Preserves all raw CloudWatch Insights fields in the output file. + */ +export async function getTrace(options: GetTraceOptions): Promise { + const { region, runtimeId, agentName, traceId, outputPath } = options; + + if (!TRACE_ID_PATTERN.test(traceId)) { + return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + } + + const result = await runInsightsQuery({ + region, + logGroupName: runtimeLogGroup(runtimeId), + startTime: options.startTime, + endTime: options.endTime, + queryString: `fields @timestamp, @message +| filter traceId = '${traceId}' +| sort @timestamp asc +| limit 10000`, + }); + if (!result.success) { + return { success: false, error: result.error }; + } + + const traceData = result.rows ?? []; + if (traceData.length === 0) { + return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + } + + const parsedTrace = traceData.map(entry => { + try { + const parsed: unknown = JSON.parse(entry['@message'] ?? '{}'); + return { ...entry, '@message': parsed }; + } catch { + return entry; + } + }); + + const filePath = outputPath ?? path.join('agentcore', '.cli', 'traces', `${agentName}-${traceId}.json`); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(parsedTrace, null, 2)); + + return { success: true, filePath: path.resolve(filePath) }; } diff --git a/src/cli/operations/traces/index.ts b/src/cli/operations/traces/index.ts index bbb013439..cf19dbf9c 100644 --- a/src/cli/operations/traces/index.ts +++ b/src/cli/operations/traces/index.ts @@ -1,3 +1,15 @@ export { buildTraceConsoleUrl } from './trace-url'; -export { listTraces, type TraceEntry, type ListTracesOptions, type ListTracesResult } from './list-traces'; -export { getTrace, type GetTraceOptions, type GetTraceResult } from './get-trace'; +export { listTraces } from './list-traces'; +export { fetchTraceRecords, getTrace } from './get-trace'; +export { runInsightsQuery, type InsightsQueryOptions, type InsightsQueryResult } from './insights-query'; +export type { + CloudWatchSpanRecord, + CloudWatchTraceRecord, + FetchTraceRecordsOptions, + FetchTraceRecordsResult, + GetTraceOptions, + GetTraceResult, + ListTracesOptions, + ListTracesResult, + TraceEntry, +} from './types'; diff --git a/src/cli/operations/traces/insights-query.ts b/src/cli/operations/traces/insights-query.ts new file mode 100644 index 000000000..5a4da2031 --- /dev/null +++ b/src/cli/operations/traces/insights-query.ts @@ -0,0 +1,85 @@ +import { getCredentialProvider } from '../../aws'; +import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; + +const DEFAULT_LOOKBACK_MS = 12 * 60 * 60 * 1000; + +export interface InsightsQueryOptions { + region: string; + logGroupName: string; + queryString: string; + startTime?: number; + endTime?: number; +} + +export interface InsightsQueryResult { + success: boolean; + rows?: Record[]; + error?: string; +} + +async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): Promise { + for (let i = 0; i < 60; i++) { + await new Promise(resolve => setTimeout(resolve, 1000)); + + const queryResults = await client.send(new GetQueryResultsCommand({ queryId })); + const status = queryResults.status ?? 'Unknown'; + + if (status === 'Complete' || status === 'Failed' || status === 'Cancelled') { + if (status !== 'Complete') { + return { success: false, error: `Query ${status.toLowerCase()}` }; + } + + const rows = (queryResults.results ?? []).map(row => { + const fields: Record = {}; + for (const field of row) { + if (field.field && field.value) { + fields[field.field] = field.value; + } + } + return fields; + }); + return { success: true, rows }; + } + } + + return { success: false, error: 'Query timed out after 60 seconds' }; +} + +export async function runInsightsQuery(options: InsightsQueryOptions): Promise { + const { region, logGroupName, queryString } = options; + + const client = new CloudWatchLogsClient({ + credentials: getCredentialProvider(), + region, + }); + + const now = Date.now(); + const endTime = options.endTime ?? now; + const startTime = options.startTime ?? endTime - DEFAULT_LOOKBACK_MS; + + try { + const startQuery = await client.send( + new StartQueryCommand({ + logGroupName, + startTime: Math.floor(startTime / 1000), + endTime: Math.floor(endTime / 1000), + queryString, + }) + ); + + if (!startQuery.queryId) { + return { success: false, error: 'Failed to start CloudWatch Logs Insights query' }; + } + + return await pollQueryResults(client, startQuery.queryId); + } catch (error: unknown) { + const err = error as Error; + if (err.name === 'ResourceNotFoundException') { + return { + success: false, + error: `Log group '${logGroupName}' not found. The agent may not have been invoked yet, or traces may not be enabled.`, + }; + } + return { success: false, error: err.message ?? String(error) }; + } +} diff --git a/src/cli/operations/traces/list-traces.ts b/src/cli/operations/traces/list-traces.ts index 7bff6194a..e2d998578 100644 --- a/src/cli/operations/traces/list-traces.ts +++ b/src/cli/operations/traces/list-traces.ts @@ -1,28 +1,6 @@ -import { getCredentialProvider } from '../../aws'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; -import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; - -export interface TraceEntry { - traceId: string; - timestamp: string; - sessionId?: string; - spanCount?: string; -} - -export interface ListTracesOptions { - region: string; - runtimeId: string; - agentName: string; - limit?: number; - startTime?: number; - endTime?: number; -} - -export interface ListTracesResult { - success: boolean; - traces?: TraceEntry[]; - error?: string; -} +import { runInsightsQuery } from './insights-query'; +import type { ListTracesOptions, ListTracesResult, TraceEntry } from './types'; /** * Lists recent traces for a deployed agent by querying CloudWatch Logs Insights. @@ -33,80 +11,33 @@ export interface ListTracesResult { export async function listTraces(options: ListTracesOptions): Promise { const { region, runtimeId, limit = 20 } = options; - const client = new CloudWatchLogsClient({ - credentials: getCredentialProvider(), - region, - }); - const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${DEFAULT_ENDPOINT_NAME}`; - const now = Date.now(); - const endTime = options.endTime ?? now; - const startTime = options.startTime ?? endTime - 12 * 60 * 60 * 1000; // default: last 12 hours - - try { - const startQuery = await client.send( - new StartQueryCommand({ - logGroupName, - startTime: Math.floor(startTime / 1000), - endTime: Math.floor(endTime / 1000), - queryString: `stats earliest(@timestamp) as firstSeen, latest(@timestamp) as lastSeen, count(*) as spanCount, earliest(attributes.session.id) as sessionId by traceId + const result = await runInsightsQuery({ + region, + logGroupName, + startTime: options.startTime, + endTime: options.endTime, + queryString: `stats earliest(@timestamp) as firstSeen, latest(@timestamp) as lastSeen, count(*) as spanCount, earliest(attributes.session.id) as sessionId by traceId | sort lastSeen desc | limit ${limit}`, - }) - ); - - if (!startQuery.queryId) { - return { success: false, error: 'Failed to start CloudWatch Logs Insights query' }; - } - - // Poll for results - let status = 'Running'; - let results: TraceEntry[] = []; - - for (let i = 0; i < 60; i++) { - await new Promise(resolve => setTimeout(resolve, 1000)); - - const queryResults = await client.send(new GetQueryResultsCommand({ queryId: startQuery.queryId })); - - status = queryResults.status ?? 'Unknown'; - - if (status === 'Complete' || status === 'Failed' || status === 'Cancelled') { - if (status !== 'Complete') { - return { success: false, error: `Query ${status.toLowerCase()}` }; - } + }); - results = (queryResults.results ?? []).map(row => { - const fields: Record = {}; - for (const field of row) { - if (field.field && field.value) { - fields[field.field] = field.value; - } - } - return { - traceId: fields.traceId ?? 'unknown', - timestamp: fields.lastSeen ?? fields.firstSeen ?? 'unknown', - sessionId: fields.sessionId, - spanCount: fields.spanCount, - }; - }); - break; - } - } + if (!result.success) { + return { success: false, error: result.error }; + } - if (status === 'Running') { - return { success: false, error: 'Query timed out after 60 seconds' }; + const traces = (result.rows ?? []).reduce((acc, row) => { + if (row.traceId) { + acc.push({ + traceId: row.traceId, + timestamp: row.lastSeen ?? row.firstSeen ?? 'unknown', + sessionId: row.sessionId, + spanCount: row.spanCount, + }); } + return acc; + }, []); - return { success: true, traces: results }; - } catch (error: unknown) { - const err = error as Error; - if (err.name === 'ResourceNotFoundException') { - return { - success: false, - error: `Log group '${logGroupName}' not found. The agent may not have been invoked yet, or traces may not be enabled.`, - }; - } - return { success: false, error: err.message ?? String(error) }; - } + return { success: true, traces }; } diff --git a/src/cli/operations/traces/types.ts b/src/cli/operations/traces/types.ts new file mode 100644 index 000000000..fae88a83b --- /dev/null +++ b/src/cli/operations/traces/types.ts @@ -0,0 +1,77 @@ +export interface CloudWatchTraceRecord { + '@timestamp': string; + '@message': unknown; + '@ptr'?: string; +} + +export interface CloudWatchSpanRecord { + traceId: string; + spanId: string; + parentSpanId?: string; + name?: string; + kind?: string; + startTimeUnixNano?: string; + endTimeUnixNano?: string; + durationNano?: string; + statusCode?: string; + serviceName?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + httpStatusCode?: number; + sessionId?: string; +} + +export interface FetchTraceRecordsOptions { + region: string; + runtimeId: string; + traceId: string; + startTime?: number; + endTime?: number; + includeSpans?: boolean; +} + +export interface FetchTraceRecordsResult { + success: boolean; + records?: CloudWatchTraceRecord[]; + spans?: CloudWatchSpanRecord[]; + error?: string; +} + +export interface GetTraceOptions { + region: string; + runtimeId: string; + agentName: string; + traceId: string; + outputPath?: string; + startTime?: number; + endTime?: number; +} + +export interface GetTraceResult { + success: boolean; + filePath?: string; + error?: string; +} + +export interface TraceEntry { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount?: string; +} + +export interface ListTracesOptions { + region: string; + runtimeId: string; + agentName: string; + limit?: number; + startTime?: number; + endTime?: number; +} + +export interface ListTracesResult { + success: boolean; + traces?: TraceEntry[]; + error?: string; +}