Skip to content

Commit b10b2c7

Browse files
feat: add agentcore traces command and trace link in invoke TUI (#493)
* feat: add `agentcore traces` command and trace link in invoke TUI Add traces support with three features: 1. `agentcore traces list` - Lists recent traces for a deployed agent by querying CloudWatch Logs Insights. Supports --agent, --target, and --limit options. Displays trace ID, timestamp, and session ID. 2. `agentcore traces get <traceId>` - Downloads a full trace to a JSON file from CloudWatch Logs. Supports --agent, --target, and --output options. Saves OpenTelemetry span data to agentcore/.cli/traces/. 3. Trace console link in invoke TUI header - Shows the CloudWatch console URL for viewing traces alongside the existing log link. Both commands also print the CloudWatch console URL for quick access to the trace dashboard. New dependency: @aws-sdk/client-cloudwatch-logs * chore: remove --target option from traces commands Multiple targets are not currently supported, so remove the --target flag from both `traces list` and `traces get` to avoid exposing an unsupported concept. The commands now always use the single deployed target. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: increase get-trace query timeout to 60s and add timeout detection The polling loop only waited 30 seconds and silently fell through to a misleading "No trace data found" error on timeout. Increase to 60s and add an explicit timeout check matching the pattern in list-traces.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: increase list-traces query timeout to 60s Match the get-trace timeout increase for consistency. Large log groups may need more than 30 seconds for CWL Insights queries to complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: show full trace ID and session ID in traces list output Truncating IDs made them unusable as input to `traces get`. Display the full values so users can copy-paste trace IDs directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract shared agent resolution + add --since/--until to traces list Extract duplicate agent resolution logic into a shared resolve-agent utility (src/cli/operations/resolve-agent.ts) used by both logs and traces commands. Move time-parser to shared utils (src/lib/utils/time-parser.ts). Add --since and --until flags to `traces list` for custom time range queries instead of the hardcoded 12h window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract DEFAULT endpoint name to shared constant Move the repeated 'DEFAULT' endpoint name string to DEFAULT_ENDPOINT_NAME in cli/constants.ts and use it across logs/action.ts, get-trace.ts, list-traces.ts, and trace-url.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: hoist traceUrl computation out of inline IIFE in InvokeScreen Compute traceUrl at the top of the render block instead of using an inline IIFE in the JSX. Simplifies the template to a straightforward conditional render. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: validate --limit is a number in traces list Return an error early if the user passes a non-numeric value for --limit instead of silently passing NaN to the CloudWatch query. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: validate traceId format before interpolating into CWL query Reject non-hex trace IDs early to prevent query syntax injection and give a clearer error for malformed input. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0214f86 commit b10b2c7

21 files changed

Lines changed: 984 additions & 388 deletions

package-lock.json

Lines changed: 302 additions & 304 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { registerLogs } from './commands/logs';
88
import { registerPackage } from './commands/package';
99
import { registerRemove } from './commands/remove';
1010
import { registerStatus } from './commands/status';
11+
import { registerTraces } from './commands/traces';
1112
import { registerUpdate } from './commands/update';
1213
import { registerValidate } from './commands/validate';
1314
import { PACKAGE_VERSION } from './constants';
@@ -135,6 +136,7 @@ export function registerCommands(program: Command) {
135136
registerPackage(program);
136137
const removeCmd = registerRemove(program);
137138
registerStatus(program);
139+
registerTraces(program);
138140
registerUpdate(program);
139141
registerValidate(program);
140142

src/cli/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export { registerInvoke } from './invoke';
77
export { registerPackage } from './package';
88
export { registerRemove } from './remove';
99
export { registerStatus } from './status';
10+
export { registerTraces } from './traces';
1011
export { registerUpdate } from './update';

src/cli/commands/logs/__tests__/action.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { detectMode, formatLogLine, resolveAgentContext } from '../action';
2-
import type { LogsContext } from '../action';
2+
import type { DeployedProjectConfig } from '../action';
33
import { describe, expect, it } from 'vitest';
44

55
describe('detectMode', () => {
@@ -39,7 +39,7 @@ describe('formatLogLine', () => {
3939

4040
describe('resolveAgentContext', () => {
4141
// Use 'as any' to avoid branded type issues with FilePath/DirectoryPath
42-
const makeContext = (overrides?: Partial<LogsContext>): LogsContext => ({
42+
const makeContext = (overrides?: Partial<DeployedProjectConfig>): DeployedProjectConfig => ({
4343
project: {
4444
name: 'TestProject',
4545
version: 1,

src/cli/commands/logs/__tests__/time-parser.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseTimeString } from '../time-parser';
1+
import { parseTimeString } from '../../../../lib/utils/time-parser';
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33

44
describe('parseTimeString', () => {

src/cli/commands/logs/action.ts

Lines changed: 17 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import { ConfigIO } from '../../../lib';
2-
import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema';
1+
import { parseTimeString } from '../../../lib/utils';
32
import { searchLogs, streamLogs } from '../../aws/cloudwatch';
3+
import { DEFAULT_ENDPOINT_NAME } from '../../constants';
4+
import type { DeployedProjectConfig } from '../../operations/resolve-agent';
5+
import { loadDeployedProjectConfig, resolveAgent } from '../../operations/resolve-agent';
46
import { VALID_LEVELS, buildFilterPattern } from './filter-pattern';
5-
import { parseTimeString } from './time-parser';
67
import type { LogsOptions } from './types';
78

8-
export interface LogsContext {
9-
project: AgentCoreProjectSpec;
10-
deployedState: DeployedState;
11-
awsTargets: AwsDeploymentTargets;
12-
}
9+
export type { DeployedProjectConfig };
1310

1411
export interface AgentContext {
1512
agentId: string;
@@ -25,17 +22,6 @@ export interface LogsResult {
2522
error?: string;
2623
}
2724

28-
/**
29-
* Loads configuration required for logs
30-
*/
31-
export async function loadLogsConfig(configIO: ConfigIO = new ConfigIO()): Promise<LogsContext> {
32-
return {
33-
project: await configIO.readProjectSpec(),
34-
deployedState: await configIO.readDeployedState(),
35-
awsTargets: await configIO.readAWSDeploymentTargets(),
36-
};
37-
}
38-
3925
/**
4026
* Detect whether to stream or search based on options
4127
*/
@@ -61,73 +47,23 @@ export function formatLogLine(event: { timestamp: number; message: string }, jso
6147
* Resolve agent context from config + options
6248
*/
6349
export function resolveAgentContext(
64-
context: LogsContext,
50+
context: DeployedProjectConfig,
6551
options: LogsOptions
6652
): { success: true; agentContext: AgentContext } | { success: false; error: string } {
67-
const { project, deployedState, awsTargets } = context;
68-
69-
if (project.agents.length === 0) {
70-
return { success: false, error: 'No agents defined in agentcore.json' };
71-
}
72-
73-
// Resolve agent
74-
const agentNames = project.agents.map(a => a.name);
75-
76-
if (!options.agent && project.agents.length > 1) {
77-
return {
78-
success: false,
79-
error: `Multiple agents found. Use --agent to specify one: ${agentNames.join(', ')}`,
80-
};
53+
const result = resolveAgent(context, options);
54+
if (!result.success) {
55+
return { success: false, error: result.error };
8156
}
82-
83-
const agentSpec = options.agent ? project.agents.find(a => a.name === options.agent) : project.agents[0];
84-
85-
if (options.agent && !agentSpec) {
86-
return {
87-
success: false,
88-
error: `Agent '${options.agent}' not found. Available: ${agentNames.join(', ')}`,
89-
};
90-
}
91-
92-
if (!agentSpec) {
93-
return { success: false, error: 'No agents defined in agentcore.json' };
94-
}
95-
96-
// Resolve target
97-
const targetNames = Object.keys(deployedState.targets);
98-
if (targetNames.length === 0) {
99-
return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' };
100-
}
101-
const selectedTargetName = targetNames[0]!;
102-
103-
const targetState = deployedState.targets[selectedTargetName];
104-
const targetConfig = awsTargets.find(t => t.name === selectedTargetName);
105-
106-
if (!targetConfig) {
107-
return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` };
108-
}
109-
110-
// Get the deployed state for this specific agent
111-
const agentState = targetState?.resources?.agents?.[agentSpec.name];
112-
113-
if (!agentState) {
114-
return {
115-
success: false,
116-
error: `Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'. Run 'agentcore deploy' first.`,
117-
};
118-
}
119-
120-
const agentId = agentState.runtimeId;
121-
const endpointName = 'DEFAULT';
122-
const logGroupName = `/aws/bedrock-agentcore/runtimes/${agentId}-${endpointName}`;
123-
57+
const { agent } = result;
58+
const endpointName = DEFAULT_ENDPOINT_NAME;
59+
const logGroupName = `/aws/bedrock-agentcore/runtimes/${agent.runtimeId}-${endpointName}`;
12460
return {
12561
success: true,
12662
agentContext: {
127-
agentId,
128-
agentName: agentSpec.name,
129-
accountId: targetConfig.account,
130-
region: targetConfig.region,
63+
agentId: agent.runtimeId,
64+
agentName: agent.agentName,
65+
accountId: agent.accountId,
66+
region: agent.region,
13167
endpointName,
13268
logGroupName,
13369
},
@@ -146,7 +82,7 @@ export async function handleLogs(options: LogsOptions): Promise<LogsResult> {
14682
};
14783
}
14884

149-
const context = await loadLogsConfig();
85+
const context = await loadDeployedProjectConfig();
15086
const resolution = resolveAgentContext(context, options);
15187

15288
if (!resolution.success) {

src/cli/commands/traces/action.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { parseTimeString } from '../../../lib/utils';
2+
import type { DeployedProjectConfig } from '../../operations/resolve-agent';
3+
import { resolveAgent } from '../../operations/resolve-agent';
4+
import { buildTraceConsoleUrl, getTrace, listTraces } from '../../operations/traces';
5+
import type { TracesGetOptions, TracesListOptions } from './types';
6+
7+
export interface TracesListResult {
8+
success: boolean;
9+
agentName?: string;
10+
targetName?: string;
11+
consoleUrl?: string;
12+
traces?: { traceId: string; timestamp: string; sessionId?: string }[];
13+
error?: string;
14+
}
15+
16+
export async function handleTracesList(
17+
context: DeployedProjectConfig,
18+
options: TracesListOptions
19+
): Promise<TracesListResult> {
20+
const resolved = resolveAgent(context, options);
21+
if (!resolved.success) {
22+
return { success: false, error: resolved.error };
23+
}
24+
25+
const { agent } = resolved;
26+
27+
const consoleUrl = buildTraceConsoleUrl({
28+
region: agent.region,
29+
accountId: agent.accountId,
30+
runtimeId: agent.runtimeId,
31+
agentName: agent.agentName,
32+
});
33+
34+
const limit = options.limit ? parseInt(options.limit, 10) : 20;
35+
if (isNaN(limit)) {
36+
return { success: false, error: '--limit must be a number' };
37+
}
38+
39+
// Parse time options
40+
let startTime: number | undefined;
41+
let endTime: number | undefined;
42+
if (options.since) {
43+
startTime = parseTimeString(options.since);
44+
}
45+
if (options.until) {
46+
endTime = parseTimeString(options.until);
47+
}
48+
49+
const result = await listTraces({
50+
region: agent.region,
51+
runtimeId: agent.runtimeId,
52+
agentName: agent.agentName,
53+
limit,
54+
startTime,
55+
endTime,
56+
});
57+
58+
if (!result.success) {
59+
return { success: false, error: result.error, consoleUrl };
60+
}
61+
62+
return {
63+
success: true,
64+
agentName: agent.agentName,
65+
targetName: agent.targetName,
66+
consoleUrl,
67+
traces: result.traces,
68+
};
69+
}
70+
71+
export interface TracesGetResult {
72+
success: boolean;
73+
agentName?: string;
74+
targetName?: string;
75+
consoleUrl?: string;
76+
filePath?: string;
77+
error?: string;
78+
}
79+
80+
export async function handleTracesGet(
81+
context: DeployedProjectConfig,
82+
traceId: string,
83+
options: TracesGetOptions
84+
): Promise<TracesGetResult> {
85+
const resolved = resolveAgent(context, options);
86+
if (!resolved.success) {
87+
return { success: false, error: resolved.error };
88+
}
89+
90+
const { agent } = resolved;
91+
92+
const consoleUrl = buildTraceConsoleUrl({
93+
region: agent.region,
94+
accountId: agent.accountId,
95+
runtimeId: agent.runtimeId,
96+
agentName: agent.agentName,
97+
});
98+
99+
// Parse time options
100+
let startTime: number | undefined;
101+
let endTime: number | undefined;
102+
if (options.since) {
103+
startTime = parseTimeString(options.since);
104+
}
105+
if (options.until) {
106+
endTime = parseTimeString(options.until);
107+
}
108+
109+
const result = await getTrace({
110+
region: agent.region,
111+
runtimeId: agent.runtimeId,
112+
agentName: agent.agentName,
113+
traceId,
114+
outputPath: options.output,
115+
startTime,
116+
endTime,
117+
});
118+
119+
if (!result.success) {
120+
return { success: false, error: result.error, consoleUrl };
121+
}
122+
123+
return {
124+
success: true,
125+
agentName: agent.agentName,
126+
targetName: agent.targetName,
127+
consoleUrl,
128+
filePath: result.filePath,
129+
};
130+
}

0 commit comments

Comments
 (0)