Skip to content

Commit 7302109

Browse files
feat: add logs command for streaming and searching agent runtime logs (#486)
* feat: add `logs` command for streaming and searching agent runtime logs Add a new `agentcore logs` command that provides real-time streaming and historical search of agent runtime logs via CloudWatch Logs. - Stream mode (default): Uses StartLiveTail for real-time log streaming with auto-reconnect on 3-hour session timeout - Search mode (--since/--until): Uses FilterLogEvents with pagination for bounded time-range queries - Server-side filtering via --level (error/warn/info/debug) and --query - JSON Lines output with --json flag - Agent resolution from project config + deployed state - Time parser supporting relative durations (5m, 1h, 2d), ISO 8601, epoch ms, and "now" - Fix .gitignore to scope `logs` pattern to top-level only * fix: address PR review feedback for logs command - Remove unused `logStreamName` from `LogEvent` interface and yield sites - Fix `--agent` flag test to use multi-agent context for proper disambiguation - Remove dead `--agent-id` option that was registered but never wired up Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 41365e4 commit 7302109

File tree

16 files changed

+825
-1
lines changed

16 files changed

+825
-1
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ coverage
1111
*.lcov
1212

1313
# logs
14-
logs
14+
/logs
1515
*.log
1616
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
1717

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@aws-sdk/client-bedrock-agentcore-control": "^3.893.0",
7373
"@aws-sdk/client-bedrock-runtime": "^3.893.0",
7474
"@aws-sdk/client-cloudformation": "^3.893.0",
75+
"@aws-sdk/client-cloudwatch-logs": "^3.893.0",
7576
"@aws-sdk/client-resource-groups-tagging-api": "^3.893.0",
7677
"@aws-sdk/client-sts": "^3.893.0",
7778
"@aws-sdk/credential-providers": "^3.893.0",

src/cli/aws/cloudwatch.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { getCredentialProvider } from './account';
2+
import { CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand } from '@aws-sdk/client-cloudwatch-logs';
3+
4+
export interface LogEvent {
5+
timestamp: number;
6+
message: string;
7+
}
8+
9+
export interface StreamLogsOptions {
10+
logGroupName: string;
11+
region: string;
12+
accountId: string;
13+
filterPattern?: string;
14+
abortSignal?: AbortSignal;
15+
}
16+
17+
export interface SearchLogsOptions {
18+
logGroupName: string;
19+
region: string;
20+
startTimeMs: number;
21+
endTimeMs: number;
22+
filterPattern?: string;
23+
limit?: number;
24+
}
25+
26+
/**
27+
* Stream logs in real-time using StartLiveTail.
28+
* Auto-reconnects on 3-hour session timeout.
29+
*/
30+
export async function* streamLogs(options: StreamLogsOptions): AsyncGenerator<LogEvent> {
31+
const { logGroupName, region, accountId, filterPattern, abortSignal } = options;
32+
33+
// StartLiveTail requires ARN format for logGroupIdentifiers
34+
const logGroupArn = `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`;
35+
36+
while (!abortSignal?.aborted) {
37+
const client = new CloudWatchLogsClient({
38+
region,
39+
credentials: getCredentialProvider(),
40+
});
41+
42+
const command = new StartLiveTailCommand({
43+
logGroupIdentifiers: [logGroupArn],
44+
...(filterPattern ? { logEventFilterPattern: filterPattern } : {}),
45+
});
46+
47+
const response = await client.send(command, {
48+
abortSignal,
49+
});
50+
51+
if (!response.responseStream) {
52+
return;
53+
}
54+
55+
let sessionTimedOut = false;
56+
57+
try {
58+
for await (const event of response.responseStream) {
59+
if (abortSignal?.aborted) break;
60+
61+
if ('sessionUpdate' in event && event.sessionUpdate) {
62+
const logEvents = event.sessionUpdate.sessionResults ?? [];
63+
for (const logEvent of logEvents) {
64+
yield {
65+
timestamp: logEvent.timestamp ?? Date.now(),
66+
message: logEvent.message ?? '',
67+
};
68+
}
69+
}
70+
71+
if ('SessionTimeoutException' in event) {
72+
sessionTimedOut = true;
73+
break;
74+
}
75+
}
76+
} catch (err: unknown) {
77+
if (abortSignal?.aborted) return;
78+
79+
const errorName = (err as { name?: string })?.name;
80+
if (errorName === 'SessionTimeoutException') {
81+
sessionTimedOut = true;
82+
} else {
83+
throw err;
84+
}
85+
}
86+
87+
// Auto-reconnect on session timeout
88+
if (!sessionTimedOut) return;
89+
}
90+
}
91+
92+
/**
93+
* Search logs using FilterLogEvents with pagination.
94+
*/
95+
export async function* searchLogs(options: SearchLogsOptions): AsyncGenerator<LogEvent> {
96+
const { logGroupName, region, startTimeMs, endTimeMs, filterPattern, limit } = options;
97+
98+
const client = new CloudWatchLogsClient({
99+
region,
100+
credentials: getCredentialProvider(),
101+
});
102+
103+
let nextToken: string | undefined;
104+
let yielded = 0;
105+
106+
do {
107+
const command = new FilterLogEventsCommand({
108+
logGroupName,
109+
startTime: startTimeMs,
110+
endTime: endTimeMs,
111+
...(filterPattern ? { filterPattern } : {}),
112+
...(nextToken ? { nextToken } : {}),
113+
...(limit ? { limit: Math.min(limit - yielded, 10000) } : {}),
114+
});
115+
116+
const response = await client.send(command);
117+
118+
for (const event of response.events ?? []) {
119+
if (limit && yielded >= limit) return;
120+
121+
yield {
122+
timestamp: event.timestamp ?? Date.now(),
123+
message: event.message ?? '',
124+
};
125+
yielded++;
126+
}
127+
128+
nextToken = response.nextToken;
129+
} while (nextToken && (!limit || yielded < limit));
130+
}

src/cli/aws/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
type AgentRuntimeStatusResult,
1414
type GetAgentRuntimeStatusOptions,
1515
} from './agentcore-control';
16+
export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch';
1617
export {
1718
DEFAULT_RUNTIME_USER_ID,
1819
invokeAgentRuntime,

src/cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { registerDeploy } from './commands/deploy';
44
import { registerDev } from './commands/dev';
55
import { registerHelp } from './commands/help';
66
import { registerInvoke } from './commands/invoke';
7+
import { registerLogs } from './commands/logs';
78
import { registerPackage } from './commands/package';
89
import { registerRemove } from './commands/remove';
910
import { registerStatus } from './commands/status';
@@ -129,6 +130,7 @@ export function registerCommands(program: Command) {
129130
registerCreate(program);
130131
registerHelp(program);
131132
registerInvoke(program);
133+
registerLogs(program);
132134
registerPackage(program);
133135
registerRemove(program);
134136
registerStatus(program);
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { detectMode, formatLogLine, resolveAgentContext } from '../action';
2+
import type { LogsContext } from '../action';
3+
import { describe, expect, it } from 'vitest';
4+
5+
describe('detectMode', () => {
6+
it('returns "stream" when no time flags', () => {
7+
expect(detectMode({})).toBe('stream');
8+
});
9+
10+
it('returns "search" when --since is provided', () => {
11+
expect(detectMode({ since: '1h' })).toBe('search');
12+
});
13+
14+
it('returns "search" when --until is provided', () => {
15+
expect(detectMode({ until: 'now' })).toBe('search');
16+
});
17+
18+
it('returns "search" when both --since and --until are provided', () => {
19+
expect(detectMode({ since: '1h', until: 'now' })).toBe('search');
20+
});
21+
});
22+
23+
describe('formatLogLine', () => {
24+
const event = { timestamp: 1709391000000, message: 'Hello world' };
25+
26+
it('formats human-readable line with timestamp', () => {
27+
const line = formatLogLine(event, false);
28+
expect(line).toContain('Hello world');
29+
expect(line).toContain('2024-03-02');
30+
});
31+
32+
it('formats JSON line', () => {
33+
const line = formatLogLine(event, true);
34+
const parsed = JSON.parse(line);
35+
expect(parsed.message).toBe('Hello world');
36+
expect(parsed.timestamp).toBeDefined();
37+
});
38+
});
39+
40+
describe('resolveAgentContext', () => {
41+
// Use 'as any' to avoid branded type issues with FilePath/DirectoryPath
42+
const makeContext = (overrides?: Partial<LogsContext>): LogsContext => ({
43+
project: {
44+
name: 'TestProject',
45+
version: 1,
46+
agents: [
47+
{
48+
type: 'AgentCoreRuntime' as const,
49+
name: 'MyAgent',
50+
build: 'CodeZip' as const,
51+
entrypoint: 'main.py' as any,
52+
codeLocation: './agents/my-agent' as any,
53+
runtimeVersion: 'PYTHON_3_12' as const,
54+
},
55+
],
56+
memories: [],
57+
credentials: [],
58+
},
59+
deployedState: {
60+
targets: {
61+
default: {
62+
resources: {
63+
agents: {
64+
MyAgent: {
65+
runtimeId: 'rt-123',
66+
runtimeArn: 'arn:aws:bedrock:us-east-1:123:runtime/rt-123',
67+
roleArn: 'arn:aws:iam::123:role/test',
68+
},
69+
},
70+
},
71+
},
72+
},
73+
},
74+
awsTargets: [{ name: 'default', account: '123456789012', region: 'us-east-1' as const }],
75+
...overrides,
76+
});
77+
78+
it('auto-selects single agent', () => {
79+
const result = resolveAgentContext(makeContext(), {});
80+
expect(result.success).toBe(true);
81+
if (result.success) {
82+
expect(result.agentContext.agentName).toBe('MyAgent');
83+
expect(result.agentContext.agentId).toBe('rt-123');
84+
expect(result.agentContext.accountId).toBe('123456789012');
85+
expect(result.agentContext.logGroupName).toContain('rt-123');
86+
}
87+
});
88+
89+
it('errors for multiple agents without --agent flag', () => {
90+
const context = makeContext({
91+
project: {
92+
name: 'TestProject',
93+
version: 1,
94+
agents: [
95+
{
96+
type: 'AgentCoreRuntime' as const,
97+
name: 'AgentA',
98+
build: 'CodeZip' as const,
99+
entrypoint: 'main.py' as any,
100+
codeLocation: './agents/a' as any,
101+
runtimeVersion: 'PYTHON_3_12' as const,
102+
},
103+
{
104+
type: 'AgentCoreRuntime' as const,
105+
name: 'AgentB',
106+
build: 'CodeZip' as const,
107+
entrypoint: 'main.py' as any,
108+
codeLocation: './agents/b' as any,
109+
runtimeVersion: 'PYTHON_3_12' as const,
110+
},
111+
],
112+
memories: [],
113+
credentials: [],
114+
},
115+
});
116+
const result = resolveAgentContext(context, {});
117+
expect(result.success).toBe(false);
118+
if (!result.success) {
119+
expect(result.error).toContain('Multiple agents found');
120+
expect(result.error).toContain('AgentA');
121+
expect(result.error).toContain('AgentB');
122+
}
123+
});
124+
125+
it('selects correct agent with --agent flag from multiple agents', () => {
126+
const context = makeContext({
127+
project: {
128+
name: 'TestProject',
129+
version: 1,
130+
agents: [
131+
{
132+
type: 'AgentCoreRuntime' as const,
133+
name: 'AgentA',
134+
build: 'CodeZip' as const,
135+
entrypoint: 'main.py' as any,
136+
codeLocation: './agents/a' as any,
137+
runtimeVersion: 'PYTHON_3_12' as const,
138+
},
139+
{
140+
type: 'AgentCoreRuntime' as const,
141+
name: 'AgentB',
142+
build: 'CodeZip' as const,
143+
entrypoint: 'main.py' as any,
144+
codeLocation: './agents/b' as any,
145+
runtimeVersion: 'PYTHON_3_12' as const,
146+
},
147+
],
148+
memories: [],
149+
credentials: [],
150+
},
151+
deployedState: {
152+
targets: {
153+
default: {
154+
resources: {
155+
agents: {
156+
AgentA: {
157+
runtimeId: 'rt-aaa',
158+
runtimeArn: 'arn:aws:bedrock:us-east-1:123:runtime/rt-aaa',
159+
roleArn: 'arn:aws:iam::123:role/test',
160+
},
161+
AgentB: {
162+
runtimeId: 'rt-bbb',
163+
runtimeArn: 'arn:aws:bedrock:us-east-1:123:runtime/rt-bbb',
164+
roleArn: 'arn:aws:iam::123:role/test',
165+
},
166+
},
167+
},
168+
},
169+
},
170+
},
171+
});
172+
const result = resolveAgentContext(context, { agent: 'AgentB' });
173+
expect(result.success).toBe(true);
174+
if (result.success) {
175+
expect(result.agentContext.agentName).toBe('AgentB');
176+
expect(result.agentContext.agentId).toBe('rt-bbb');
177+
}
178+
});
179+
180+
it('errors for unknown agent name', () => {
181+
const result = resolveAgentContext(makeContext(), { agent: 'UnknownAgent' });
182+
expect(result.success).toBe(false);
183+
if (!result.success) {
184+
expect(result.error).toContain("Agent 'UnknownAgent' not found");
185+
}
186+
});
187+
188+
it('errors when no agents defined', () => {
189+
const context = makeContext({
190+
project: { name: 'TestProject', version: 1, agents: [], memories: [], credentials: [] },
191+
});
192+
const result = resolveAgentContext(context, {});
193+
expect(result.success).toBe(false);
194+
if (!result.success) {
195+
expect(result.error).toContain('No agents defined');
196+
}
197+
});
198+
199+
it('errors when agent is not deployed', () => {
200+
const context = makeContext({
201+
deployedState: {
202+
targets: {
203+
default: {
204+
resources: {
205+
agents: {},
206+
},
207+
},
208+
},
209+
},
210+
});
211+
const result = resolveAgentContext(context, {});
212+
expect(result.success).toBe(false);
213+
if (!result.success) {
214+
expect(result.error).toContain('is not deployed');
215+
}
216+
});
217+
});

0 commit comments

Comments
 (0)