Skip to content

Commit 315df61

Browse files
feat: enable CloudWatch Transaction Search on deploy (#506)
* feat: enable CloudWatch Transaction Search on deploy After a successful deploy with agents, the CLI now checks if CloudWatch Application Signals (which powers Transaction Search) is enabled and auto-enables it via StartDiscovery when --yes is passed. A console URL is added to the next-steps output so users can view traces immediately. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: always enable transaction search on deploy with 100% indexing - Enable transaction search silently on every deploy (no confirmation needed) - Full setup: Application Signals, CW Logs resource policy, CloudWatchLogs destination, 100% indexing - All operations are idempotent - Add opt-out via ~/.agentcore/config.json: { "disableTransactionSearch": true } - Remove visible deploy step — runs silently, warnings logged if setup fails Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: directly update Default indexing rule instead of listing all rules The Default rule always exists — no need to list and loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: simplify disableTransactionSearch config check to only accept true Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove unused ObservabilityState from deployed state schema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove console URL from transaction search setup The trace link is already shown after invoke where it's actually useful. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add unit tests for transaction search setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3b1df62 commit 315df61

11 files changed

Lines changed: 2117 additions & 1517 deletions

File tree

package-lock.json

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

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@
6868
},
6969
"dependencies": {
7070
"@aws-cdk/toolkit-lib": "^1.16.0",
71+
"@aws-sdk/client-application-signals": "^3.1003.0",
7172
"@aws-sdk/client-bedrock-agentcore": "^3.893.0",
7273
"@aws-sdk/client-bedrock-agentcore-control": "^3.893.0",
7374
"@aws-sdk/client-bedrock-runtime": "^3.893.0",
7475
"@aws-sdk/client-cloudformation": "^3.893.0",
7576
"@aws-sdk/client-cloudwatch-logs": "^3.893.0",
7677
"@aws-sdk/client-resource-groups-tagging-api": "^3.893.0",
7778
"@aws-sdk/client-sts": "^3.893.0",
79+
"@aws-sdk/client-xray": "^3.1003.0",
7880
"@aws-sdk/credential-providers": "^3.893.0",
7981
"@commander-js/extra-typings": "^14.0.0",
8082
"@smithy/shared-ini-file-loader": "^4.4.2",
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { enableTransactionSearch } from '../transaction-search.js';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { mockAppSignalsSend, mockLogsSend, mockXRaySend } = vi.hoisted(() => ({
5+
mockAppSignalsSend: vi.fn(),
6+
mockLogsSend: vi.fn(),
7+
mockXRaySend: vi.fn(),
8+
}));
9+
10+
vi.mock('@aws-sdk/client-application-signals', () => ({
11+
ApplicationSignalsClient: class {
12+
send = mockAppSignalsSend;
13+
},
14+
StartDiscoveryCommand: class {
15+
constructor(public input: unknown) {}
16+
},
17+
}));
18+
19+
vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({
20+
CloudWatchLogsClient: class {
21+
send = mockLogsSend;
22+
},
23+
DescribeResourcePoliciesCommand: class {
24+
constructor(public input: unknown) {}
25+
},
26+
PutResourcePolicyCommand: class {
27+
constructor(public input: unknown) {}
28+
},
29+
}));
30+
31+
vi.mock('@aws-sdk/client-xray', () => ({
32+
XRayClient: class {
33+
send = mockXRaySend;
34+
},
35+
GetTraceSegmentDestinationCommand: class {
36+
constructor(public input: unknown) {}
37+
},
38+
UpdateTraceSegmentDestinationCommand: class {
39+
constructor(public input: unknown) {}
40+
},
41+
UpdateIndexingRuleCommand: class {
42+
constructor(public input: unknown) {}
43+
},
44+
}));
45+
46+
vi.mock('../account', () => ({
47+
getCredentialProvider: vi.fn().mockReturnValue({}),
48+
}));
49+
50+
describe('enableTransactionSearch', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks();
53+
});
54+
55+
function setupAllSuccess(options?: { destination?: string; hasPolicy?: boolean }) {
56+
mockAppSignalsSend.mockResolvedValue({});
57+
mockLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => {
58+
if (cmd.constructor.name === 'DescribeResourcePoliciesCommand') {
59+
return Promise.resolve({
60+
resourcePolicies: options?.hasPolicy ? [{ policyName: 'TransactionSearchXRayAccess' }] : [],
61+
});
62+
}
63+
return Promise.resolve({});
64+
});
65+
mockXRaySend.mockImplementation((cmd: { constructor: { name: string } }) => {
66+
if (cmd.constructor.name === 'GetTraceSegmentDestinationCommand') {
67+
return Promise.resolve({ Destination: options?.destination ?? 'XRay' });
68+
}
69+
return Promise.resolve({});
70+
});
71+
}
72+
73+
it('succeeds when all steps complete', async () => {
74+
setupAllSuccess();
75+
76+
const result = await enableTransactionSearch('us-east-1', '123456789012');
77+
78+
expect(result).toEqual({ success: true });
79+
expect(mockAppSignalsSend).toHaveBeenCalledOnce();
80+
expect(mockLogsSend).toHaveBeenCalled();
81+
expect(mockXRaySend).toHaveBeenCalled();
82+
});
83+
84+
it('creates resource policy when it does not exist', async () => {
85+
setupAllSuccess({ hasPolicy: false });
86+
87+
await enableTransactionSearch('us-east-1', '123456789012');
88+
89+
// DescribeResourcePolicies + PutResourcePolicy
90+
expect(mockLogsSend).toHaveBeenCalledTimes(2);
91+
const putCmd = mockLogsSend.mock.calls[1]![0];
92+
expect(putCmd.input.policyName).toBe('TransactionSearchXRayAccess');
93+
const doc = JSON.parse(putCmd.input.policyDocument);
94+
expect(doc.Statement[0].Resource).toEqual([
95+
'arn:aws:logs:us-east-1:123456789012:log-group:aws/spans:*',
96+
'arn:aws:logs:us-east-1:123456789012:log-group:/aws/application-signals/data:*',
97+
]);
98+
});
99+
100+
it('skips resource policy creation when it already exists', async () => {
101+
setupAllSuccess({ hasPolicy: true });
102+
103+
await enableTransactionSearch('us-east-1', '123456789012');
104+
105+
// Only DescribeResourcePolicies, no PutResourcePolicy
106+
expect(mockLogsSend).toHaveBeenCalledOnce();
107+
});
108+
109+
it('updates trace destination when not CloudWatchLogs', async () => {
110+
setupAllSuccess({ destination: 'XRay' });
111+
112+
await enableTransactionSearch('us-east-1', '123456789012');
113+
114+
expect(mockXRaySend).toHaveBeenCalledTimes(3);
115+
const updateCmd = mockXRaySend.mock.calls[1]![0];
116+
expect(updateCmd.input).toEqual({ Destination: 'CloudWatchLogs' });
117+
});
118+
119+
it('skips trace destination update when already CloudWatchLogs', async () => {
120+
setupAllSuccess({ destination: 'CloudWatchLogs' });
121+
122+
await enableTransactionSearch('us-east-1', '123456789012');
123+
124+
expect(mockXRaySend).toHaveBeenCalledTimes(2);
125+
// First call is GetTraceSegmentDestination, second is UpdateIndexingRule (no update in between)
126+
const secondCmd = mockXRaySend.mock.calls[1]![0];
127+
expect(secondCmd.input).toEqual({
128+
Name: 'Default',
129+
Rule: { Probabilistic: { DesiredSamplingPercentage: 100 } },
130+
});
131+
});
132+
133+
it('sets indexing to 100% on Default rule', async () => {
134+
setupAllSuccess();
135+
136+
await enableTransactionSearch('us-east-1', '123456789012');
137+
138+
const lastXRayCall = mockXRaySend.mock.calls[mockXRaySend.mock.calls.length - 1]![0];
139+
expect(lastXRayCall.input).toEqual({
140+
Name: 'Default',
141+
Rule: { Probabilistic: { DesiredSamplingPercentage: 100 } },
142+
});
143+
});
144+
145+
describe('error handling', () => {
146+
it('returns error when Application Signals fails with AccessDeniedException', async () => {
147+
const error = new Error('Not authorized');
148+
error.name = 'AccessDeniedException';
149+
mockAppSignalsSend.mockRejectedValue(error);
150+
151+
const result = await enableTransactionSearch('us-east-1', '123456789012');
152+
153+
expect(result.success).toBe(false);
154+
expect(result.error).toContain('Insufficient permissions to enable Application Signals');
155+
});
156+
157+
it('returns error when Application Signals fails with generic error', async () => {
158+
mockAppSignalsSend.mockRejectedValue(new Error('Service unavailable'));
159+
160+
const result = await enableTransactionSearch('us-east-1', '123456789012');
161+
162+
expect(result.success).toBe(false);
163+
expect(result.error).toContain('Failed to enable Application Signals');
164+
});
165+
166+
it('returns error when CloudWatch Logs policy fails with AccessDenied', async () => {
167+
mockAppSignalsSend.mockResolvedValue({});
168+
const error = new Error('Not authorized');
169+
error.name = 'AccessDenied';
170+
mockLogsSend.mockRejectedValue(error);
171+
172+
const result = await enableTransactionSearch('us-east-1', '123456789012');
173+
174+
expect(result.success).toBe(false);
175+
expect(result.error).toContain('Insufficient permissions to configure CloudWatch Logs policy');
176+
});
177+
178+
it('returns error when trace destination fails', async () => {
179+
mockAppSignalsSend.mockResolvedValue({});
180+
mockLogsSend.mockResolvedValue({ resourcePolicies: [] });
181+
mockXRaySend.mockRejectedValue(new Error('X-Ray error'));
182+
183+
const result = await enableTransactionSearch('us-east-1', '123456789012');
184+
185+
expect(result.success).toBe(false);
186+
expect(result.error).toContain('Failed to configure trace destination');
187+
});
188+
189+
it('returns error when indexing rule update fails', async () => {
190+
mockAppSignalsSend.mockResolvedValue({});
191+
mockLogsSend.mockResolvedValue({ resourcePolicies: [] });
192+
let callCount = 0;
193+
mockXRaySend.mockImplementation(() => {
194+
callCount++;
195+
if (callCount === 1) {
196+
// GetTraceSegmentDestination succeeds
197+
return Promise.resolve({ Destination: 'CloudWatchLogs' });
198+
}
199+
// UpdateIndexingRule fails
200+
return Promise.reject(new Error('Indexing error'));
201+
});
202+
203+
const result = await enableTransactionSearch('us-east-1', '123456789012');
204+
205+
expect(result.success).toBe(false);
206+
expect(result.error).toContain('Failed to configure indexing rules');
207+
});
208+
209+
it('does not proceed to later steps when an earlier step fails', async () => {
210+
mockAppSignalsSend.mockRejectedValue(new Error('fail'));
211+
212+
await enableTransactionSearch('us-east-1', '123456789012');
213+
214+
expect(mockLogsSend).not.toHaveBeenCalled();
215+
expect(mockXRaySend).not.toHaveBeenCalled();
216+
});
217+
});
218+
});

src/cli/aws/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
type GetAgentRuntimeStatusOptions,
1515
} from './agentcore-control';
1616
export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch';
17+
export { enableTransactionSearch, type TransactionSearchEnableResult } from './transaction-search';
1718
export {
1819
DEFAULT_RUNTIME_USER_ID,
1920
invokeAgentRuntime,

src/cli/aws/transaction-search.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { getCredentialProvider } from './account';
2+
import { ApplicationSignalsClient, StartDiscoveryCommand } from '@aws-sdk/client-application-signals';
3+
import {
4+
CloudWatchLogsClient,
5+
DescribeResourcePoliciesCommand,
6+
PutResourcePolicyCommand,
7+
} from '@aws-sdk/client-cloudwatch-logs';
8+
import {
9+
GetTraceSegmentDestinationCommand,
10+
UpdateIndexingRuleCommand,
11+
UpdateTraceSegmentDestinationCommand,
12+
XRayClient,
13+
} from '@aws-sdk/client-xray';
14+
15+
export interface TransactionSearchEnableResult {
16+
success: boolean;
17+
error?: string;
18+
}
19+
20+
const RESOURCE_POLICY_NAME = 'TransactionSearchXRayAccess';
21+
22+
/**
23+
* Enable CloudWatch Transaction Search:
24+
* 1. Start Application Signals discovery (idempotent)
25+
* 2. Create CloudWatch Logs resource policy for X-Ray access (if needed)
26+
* 3. Set trace segment destination to CloudWatchLogs
27+
* 4. Set indexing to 100%
28+
*
29+
* All operations are idempotent — safe to call on every deploy.
30+
*/
31+
export async function enableTransactionSearch(
32+
region: string,
33+
accountId: string
34+
): Promise<TransactionSearchEnableResult> {
35+
const credentials = getCredentialProvider();
36+
37+
// Step 1: Enable Application Signals (creates service-linked role, idempotent)
38+
try {
39+
const appSignalsClient = new ApplicationSignalsClient({ region, credentials });
40+
await appSignalsClient.send(new StartDiscoveryCommand({}));
41+
} catch (err: unknown) {
42+
const code = (err as { name?: string })?.name;
43+
const message = (err as { message?: string })?.message ?? 'Unknown error';
44+
if (code === 'AccessDeniedException' || code === 'AccessDenied') {
45+
return { success: false, error: `Insufficient permissions to enable Application Signals: ${message}` };
46+
}
47+
return { success: false, error: `Failed to enable Application Signals: ${message}` };
48+
}
49+
50+
// Step 2: Create CloudWatch Logs resource policy for X-Ray (if needed)
51+
try {
52+
const logsClient = new CloudWatchLogsClient({ region, credentials });
53+
const policiesResult = await logsClient.send(new DescribeResourcePoliciesCommand({}));
54+
const hasPolicy = policiesResult.resourcePolicies?.some(p => p.policyName === RESOURCE_POLICY_NAME);
55+
56+
if (!hasPolicy) {
57+
const policyDocument = JSON.stringify({
58+
Version: '2012-10-17',
59+
Statement: [
60+
{
61+
Sid: 'TransactionSearchXRayAccess',
62+
Effect: 'Allow',
63+
Principal: { Service: 'xray.amazonaws.com' },
64+
Action: 'logs:PutLogEvents',
65+
Resource: [
66+
`arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`,
67+
`arn:aws:logs:${region}:${accountId}:log-group:/aws/application-signals/data:*`,
68+
],
69+
Condition: {
70+
ArnLike: { 'aws:SourceArn': `arn:aws:xray:${region}:${accountId}:*` },
71+
StringEquals: { 'aws:SourceAccount': accountId },
72+
},
73+
},
74+
],
75+
});
76+
await logsClient.send(new PutResourcePolicyCommand({ policyName: RESOURCE_POLICY_NAME, policyDocument }));
77+
}
78+
} catch (err: unknown) {
79+
const code = (err as { name?: string })?.name;
80+
const message = (err as { message?: string })?.message ?? 'Unknown error';
81+
if (code === 'AccessDeniedException' || code === 'AccessDenied') {
82+
return { success: false, error: `Insufficient permissions to configure CloudWatch Logs policy: ${message}` };
83+
}
84+
return { success: false, error: `Failed to configure CloudWatch Logs policy: ${message}` };
85+
}
86+
87+
const xrayClient = new XRayClient({ region, credentials });
88+
89+
// Step 3: Set trace segment destination to CloudWatchLogs
90+
try {
91+
const destResult = await xrayClient.send(new GetTraceSegmentDestinationCommand({}));
92+
if (destResult.Destination !== 'CloudWatchLogs') {
93+
await xrayClient.send(new UpdateTraceSegmentDestinationCommand({ Destination: 'CloudWatchLogs' }));
94+
}
95+
} catch (err: unknown) {
96+
const code = (err as { name?: string })?.name;
97+
const message = (err as { message?: string })?.message ?? 'Unknown error';
98+
if (code === 'AccessDeniedException' || code === 'AccessDenied') {
99+
return { success: false, error: `Insufficient permissions to configure trace destination: ${message}` };
100+
}
101+
return { success: false, error: `Failed to configure trace destination: ${message}` };
102+
}
103+
104+
// Step 4: Set indexing to 100% on the built-in Default rule (always exists, idempotent)
105+
try {
106+
await xrayClient.send(
107+
new UpdateIndexingRuleCommand({
108+
Name: 'Default',
109+
Rule: { Probabilistic: { DesiredSamplingPercentage: 100 } },
110+
})
111+
);
112+
} catch (err: unknown) {
113+
const code = (err as { name?: string })?.name;
114+
const message = (err as { message?: string })?.message ?? 'Unknown error';
115+
if (code === 'AccessDeniedException' || code === 'AccessDenied') {
116+
return { success: false, error: `Insufficient permissions to configure indexing rules: ${message}` };
117+
}
118+
return { success: false, error: `Failed to configure indexing rules: ${message}` };
119+
}
120+
121+
return { success: true };
122+
}

0 commit comments

Comments
 (0)