Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ coverage
*.lcov

# logs
logs
/logs
*.log
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@aws-sdk/client-bedrock-agentcore-control": "^3.893.0",
"@aws-sdk/client-bedrock-runtime": "^3.893.0",
"@aws-sdk/client-cloudformation": "^3.893.0",
"@aws-sdk/client-cloudwatch-logs": "^3.893.0",
"@aws-sdk/client-resource-groups-tagging-api": "^3.893.0",
"@aws-sdk/client-sts": "^3.893.0",
"@aws-sdk/credential-providers": "^3.893.0",
Expand Down
130 changes: 130 additions & 0 deletions src/cli/aws/cloudwatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { getCredentialProvider } from './account';
import { CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand } from '@aws-sdk/client-cloudwatch-logs';

export interface LogEvent {
timestamp: number;
message: string;
}

export interface StreamLogsOptions {
logGroupName: string;
region: string;
accountId: string;
filterPattern?: string;
abortSignal?: AbortSignal;
}

export interface SearchLogsOptions {
logGroupName: string;
region: string;
startTimeMs: number;
endTimeMs: number;
filterPattern?: string;
limit?: number;
}

/**
* Stream logs in real-time using StartLiveTail.
* Auto-reconnects on 3-hour session timeout.
*/
export async function* streamLogs(options: StreamLogsOptions): AsyncGenerator<LogEvent> {
const { logGroupName, region, accountId, filterPattern, abortSignal } = options;

// StartLiveTail requires ARN format for logGroupIdentifiers
const logGroupArn = `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`;

while (!abortSignal?.aborted) {
const client = new CloudWatchLogsClient({
region,
credentials: getCredentialProvider(),
});

const command = new StartLiveTailCommand({
logGroupIdentifiers: [logGroupArn],
...(filterPattern ? { logEventFilterPattern: filterPattern } : {}),
});

const response = await client.send(command, {
abortSignal,
});

if (!response.responseStream) {
return;
}

let sessionTimedOut = false;

try {
for await (const event of response.responseStream) {
if (abortSignal?.aborted) break;

if ('sessionUpdate' in event && event.sessionUpdate) {
const logEvents = event.sessionUpdate.sessionResults ?? [];
for (const logEvent of logEvents) {
yield {
timestamp: logEvent.timestamp ?? Date.now(),
message: logEvent.message ?? '',
};
}
}

if ('SessionTimeoutException' in event) {
sessionTimedOut = true;
break;
}
}
} catch (err: unknown) {
if (abortSignal?.aborted) return;

const errorName = (err as { name?: string })?.name;
if (errorName === 'SessionTimeoutException') {
sessionTimedOut = true;
} else {
throw err;
}
}

// Auto-reconnect on session timeout
if (!sessionTimedOut) return;
}
}

/**
* Search logs using FilterLogEvents with pagination.
*/
export async function* searchLogs(options: SearchLogsOptions): AsyncGenerator<LogEvent> {
const { logGroupName, region, startTimeMs, endTimeMs, filterPattern, limit } = options;

const client = new CloudWatchLogsClient({
region,
credentials: getCredentialProvider(),
});

let nextToken: string | undefined;
let yielded = 0;

do {
const command = new FilterLogEventsCommand({
logGroupName,
startTime: startTimeMs,
endTime: endTimeMs,
...(filterPattern ? { filterPattern } : {}),
...(nextToken ? { nextToken } : {}),
...(limit ? { limit: Math.min(limit - yielded, 10000) } : {}),
});

const response = await client.send(command);

for (const event of response.events ?? []) {
if (limit && yielded >= limit) return;

yield {
timestamp: event.timestamp ?? Date.now(),
message: event.message ?? '',
};
yielded++;
}

nextToken = response.nextToken;
} while (nextToken && (!limit || yielded < limit));
}
1 change: 1 addition & 0 deletions src/cli/aws/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
type AgentRuntimeStatusResult,
type GetAgentRuntimeStatusOptions,
} from './agentcore-control';
export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch';
export {
DEFAULT_RUNTIME_USER_ID,
invokeAgentRuntime,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { registerDeploy } from './commands/deploy';
import { registerDev } from './commands/dev';
import { registerHelp } from './commands/help';
import { registerInvoke } from './commands/invoke';
import { registerLogs } from './commands/logs';
import { registerPackage } from './commands/package';
import { registerRemove } from './commands/remove';
import { registerStatus } from './commands/status';
Expand Down Expand Up @@ -129,6 +130,7 @@ export function registerCommands(program: Command) {
registerCreate(program);
registerHelp(program);
registerInvoke(program);
registerLogs(program);
registerPackage(program);
registerRemove(program);
registerStatus(program);
Expand Down
217 changes: 217 additions & 0 deletions src/cli/commands/logs/__tests__/action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { detectMode, formatLogLine, resolveAgentContext } from '../action';
import type { LogsContext } from '../action';
import { describe, expect, it } from 'vitest';

describe('detectMode', () => {
it('returns "stream" when no time flags', () => {
expect(detectMode({})).toBe('stream');
});

it('returns "search" when --since is provided', () => {
expect(detectMode({ since: '1h' })).toBe('search');
});

it('returns "search" when --until is provided', () => {
expect(detectMode({ until: 'now' })).toBe('search');
});

it('returns "search" when both --since and --until are provided', () => {
expect(detectMode({ since: '1h', until: 'now' })).toBe('search');
});
});

describe('formatLogLine', () => {
const event = { timestamp: 1709391000000, message: 'Hello world' };

it('formats human-readable line with timestamp', () => {
const line = formatLogLine(event, false);
expect(line).toContain('Hello world');
expect(line).toContain('2024-03-02');
});

it('formats JSON line', () => {
const line = formatLogLine(event, true);
const parsed = JSON.parse(line);
expect(parsed.message).toBe('Hello world');
expect(parsed.timestamp).toBeDefined();
});
});

describe('resolveAgentContext', () => {
// Use 'as any' to avoid branded type issues with FilePath/DirectoryPath
const makeContext = (overrides?: Partial<LogsContext>): LogsContext => ({
project: {
name: 'TestProject',
version: 1,
agents: [
{
type: 'AgentCoreRuntime' as const,
name: 'MyAgent',
build: 'CodeZip' as const,
entrypoint: 'main.py' as any,
codeLocation: './agents/my-agent' as any,
runtimeVersion: 'PYTHON_3_12' as const,
},
],
memories: [],
credentials: [],
},
deployedState: {
targets: {
default: {
resources: {
agents: {
MyAgent: {
runtimeId: 'rt-123',
runtimeArn: 'arn:aws:bedrock:us-east-1:123:runtime/rt-123',
roleArn: 'arn:aws:iam::123:role/test',
},
},
},
},
},
},
awsTargets: [{ name: 'default', account: '123456789012', region: 'us-east-1' as const }],
...overrides,
});

it('auto-selects single agent', () => {
const result = resolveAgentContext(makeContext(), {});
expect(result.success).toBe(true);
if (result.success) {
expect(result.agentContext.agentName).toBe('MyAgent');
expect(result.agentContext.agentId).toBe('rt-123');
expect(result.agentContext.accountId).toBe('123456789012');
expect(result.agentContext.logGroupName).toContain('rt-123');
}
});

it('errors for multiple agents without --agent flag', () => {
const context = makeContext({
project: {
name: 'TestProject',
version: 1,
agents: [
{
type: 'AgentCoreRuntime' as const,
name: 'AgentA',
build: 'CodeZip' as const,
entrypoint: 'main.py' as any,
codeLocation: './agents/a' as any,
runtimeVersion: 'PYTHON_3_12' as const,
},
{
type: 'AgentCoreRuntime' as const,
name: 'AgentB',
build: 'CodeZip' as const,
entrypoint: 'main.py' as any,
codeLocation: './agents/b' as any,
runtimeVersion: 'PYTHON_3_12' as const,
},
],
memories: [],
credentials: [],
},
});
const result = resolveAgentContext(context, {});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('Multiple agents found');
expect(result.error).toContain('AgentA');
expect(result.error).toContain('AgentB');
}
});

it('selects correct agent with --agent flag from multiple agents', () => {
const context = makeContext({
project: {
name: 'TestProject',
version: 1,
agents: [
{
type: 'AgentCoreRuntime' as const,
name: 'AgentA',
build: 'CodeZip' as const,
entrypoint: 'main.py' as any,
codeLocation: './agents/a' as any,
runtimeVersion: 'PYTHON_3_12' as const,
},
{
type: 'AgentCoreRuntime' as const,
name: 'AgentB',
build: 'CodeZip' as const,
entrypoint: 'main.py' as any,
codeLocation: './agents/b' as any,
runtimeVersion: 'PYTHON_3_12' as const,
},
],
memories: [],
credentials: [],
},
deployedState: {
targets: {
default: {
resources: {
agents: {
AgentA: {
runtimeId: 'rt-aaa',
runtimeArn: 'arn:aws:bedrock:us-east-1:123:runtime/rt-aaa',
roleArn: 'arn:aws:iam::123:role/test',
},
AgentB: {
runtimeId: 'rt-bbb',
runtimeArn: 'arn:aws:bedrock:us-east-1:123:runtime/rt-bbb',
roleArn: 'arn:aws:iam::123:role/test',
},
},
},
},
},
},
});
const result = resolveAgentContext(context, { agent: 'AgentB' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.agentContext.agentName).toBe('AgentB');
expect(result.agentContext.agentId).toBe('rt-bbb');
}
});

it('errors for unknown agent name', () => {
const result = resolveAgentContext(makeContext(), { agent: 'UnknownAgent' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("Agent 'UnknownAgent' not found");
}
});

it('errors when no agents defined', () => {
const context = makeContext({
project: { name: 'TestProject', version: 1, agents: [], memories: [], credentials: [] },
});
const result = resolveAgentContext(context, {});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('No agents defined');
}
});

it('errors when agent is not deployed', () => {
const context = makeContext({
deployedState: {
targets: {
default: {
resources: {
agents: {},
},
},
},
},
});
const result = resolveAgentContext(context, {});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('is not deployed');
}
});
});
Loading
Loading