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
3,148 changes: 1,617 additions & 1,531 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@
},
"dependencies": {
"@aws-cdk/toolkit-lib": "^1.16.0",
"@aws-sdk/client-application-signals": "^3.1003.0",
"@aws-sdk/client-bedrock-agentcore": "^3.893.0",
"@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/client-xray": "^3.1003.0",
"@aws-sdk/credential-providers": "^3.893.0",
"@commander-js/extra-typings": "^14.0.0",
"@smithy/shared-ini-file-loader": "^4.4.2",
Expand Down
230 changes: 230 additions & 0 deletions src/cli/aws/__tests__/transaction-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { enableTransactionSearch } from '../transaction-search.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { mockAppSignalsSend, mockLogsSend, mockXRaySend } = vi.hoisted(() => ({
mockAppSignalsSend: vi.fn(),
mockLogsSend: vi.fn(),
mockXRaySend: vi.fn(),
}));

vi.mock('@aws-sdk/client-application-signals', () => ({
ApplicationSignalsClient: class {
send = mockAppSignalsSend;
},
StartDiscoveryCommand: class {
constructor(public input: unknown) {}
},
}));

vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({
CloudWatchLogsClient: class {
send = mockLogsSend;
},
DescribeResourcePoliciesCommand: class {
constructor(public input: unknown) {}
},
PutResourcePolicyCommand: class {
constructor(public input: unknown) {}
},
}));

vi.mock('@aws-sdk/client-xray', () => ({
XRayClient: class {
send = mockXRaySend;
},
GetTraceSegmentDestinationCommand: class {
constructor(public input: unknown) {}
},
UpdateTraceSegmentDestinationCommand: class {
constructor(public input: unknown) {}
},
UpdateIndexingRuleCommand: class {
constructor(public input: unknown) {}
},
}));

vi.mock('../account', () => ({
getCredentialProvider: vi.fn().mockReturnValue({}),
}));

describe('enableTransactionSearch', () => {
beforeEach(() => {
vi.clearAllMocks();
});

function setupAllSuccess(options?: { destination?: string; hasPolicy?: boolean }) {
mockAppSignalsSend.mockResolvedValue({});
mockLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => {
if (cmd.constructor.name === 'DescribeResourcePoliciesCommand') {
return Promise.resolve({
resourcePolicies: options?.hasPolicy ? [{ policyName: 'TransactionSearchXRayAccess' }] : [],
});
}
return Promise.resolve({});
});
mockXRaySend.mockImplementation((cmd: { constructor: { name: string } }) => {
if (cmd.constructor.name === 'GetTraceSegmentDestinationCommand') {
return Promise.resolve({ Destination: options?.destination ?? 'XRay' });
}
return Promise.resolve({});
});
}

it('succeeds when all steps complete', async () => {
setupAllSuccess();

const result = await enableTransactionSearch('us-east-1', '123456789012');

expect(result).toEqual({ success: true });
expect(mockAppSignalsSend).toHaveBeenCalledOnce();
expect(mockLogsSend).toHaveBeenCalled();
expect(mockXRaySend).toHaveBeenCalled();
});

it('creates resource policy when it does not exist', async () => {
setupAllSuccess({ hasPolicy: false });

await enableTransactionSearch('us-east-1', '123456789012');

// DescribeResourcePolicies + PutResourcePolicy
expect(mockLogsSend).toHaveBeenCalledTimes(2);
const putCmd = mockLogsSend.mock.calls[1]![0];
expect(putCmd.input.policyName).toBe('TransactionSearchXRayAccess');
const doc = JSON.parse(putCmd.input.policyDocument);
expect(doc.Statement[0].Resource).toEqual([
'arn:aws:logs:us-east-1:123456789012:log-group:aws/spans:*',
'arn:aws:logs:us-east-1:123456789012:log-group:/aws/application-signals/data:*',
]);
});

it('skips resource policy creation when it already exists', async () => {
setupAllSuccess({ hasPolicy: true });

await enableTransactionSearch('us-east-1', '123456789012');

// Only DescribeResourcePolicies, no PutResourcePolicy
expect(mockLogsSend).toHaveBeenCalledOnce();
});

it('updates trace destination when not CloudWatchLogs', async () => {
setupAllSuccess({ destination: 'XRay' });

await enableTransactionSearch('us-east-1', '123456789012');

expect(mockXRaySend).toHaveBeenCalledTimes(3);
const updateCmd = mockXRaySend.mock.calls[1]![0];
expect(updateCmd.input).toEqual({ Destination: 'CloudWatchLogs' });
});

it('skips trace destination update when already CloudWatchLogs', async () => {
setupAllSuccess({ destination: 'CloudWatchLogs' });

await enableTransactionSearch('us-east-1', '123456789012');

expect(mockXRaySend).toHaveBeenCalledTimes(2);
// First call is GetTraceSegmentDestination, second is UpdateIndexingRule (no update in between)
const secondCmd = mockXRaySend.mock.calls[1]![0];
expect(secondCmd.input).toEqual({
Name: 'Default',
Rule: { Probabilistic: { DesiredSamplingPercentage: 100 } },
});
});

it('sets indexing to 100% on Default rule by default', async () => {
setupAllSuccess();

await enableTransactionSearch('us-east-1', '123456789012');

const lastXRayCall = mockXRaySend.mock.calls[mockXRaySend.mock.calls.length - 1]![0];
expect(lastXRayCall.input).toEqual({
Name: 'Default',
Rule: { Probabilistic: { DesiredSamplingPercentage: 100 } },
});
});

it('uses custom indexing percentage when provided', async () => {
setupAllSuccess();

await enableTransactionSearch('us-east-1', '123456789012', { indexingPercentage: 50 });

const lastXRayCall = mockXRaySend.mock.calls[mockXRaySend.mock.calls.length - 1]![0];
expect(lastXRayCall.input).toEqual({
Name: 'Default',
Rule: { Probabilistic: { DesiredSamplingPercentage: 50 } },
});
});

describe('error handling', () => {
it('returns error when Application Signals fails with AccessDeniedException', async () => {
const error = new Error('Not authorized');
error.name = 'AccessDeniedException';
mockAppSignalsSend.mockRejectedValue(error);

const result = await enableTransactionSearch('us-east-1', '123456789012');

expect(result.success).toBe(false);
expect(result.error).toContain('Insufficient permissions to enable Application Signals');
});

it('returns error when Application Signals fails with generic error', async () => {
mockAppSignalsSend.mockRejectedValue(new Error('Service unavailable'));

const result = await enableTransactionSearch('us-east-1', '123456789012');

expect(result.success).toBe(false);
expect(result.error).toContain('Failed to enable Application Signals');
});

it('returns error when CloudWatch Logs policy fails with AccessDenied', async () => {
mockAppSignalsSend.mockResolvedValue({});
const error = new Error('Not authorized');
error.name = 'AccessDenied';
mockLogsSend.mockRejectedValue(error);

const result = await enableTransactionSearch('us-east-1', '123456789012');

expect(result.success).toBe(false);
expect(result.error).toContain('Insufficient permissions to configure CloudWatch Logs policy');
});

it('returns error when trace destination fails', async () => {
mockAppSignalsSend.mockResolvedValue({});
mockLogsSend.mockResolvedValue({ resourcePolicies: [] });
mockXRaySend.mockRejectedValue(new Error('X-Ray error'));

const result = await enableTransactionSearch('us-east-1', '123456789012');

expect(result.success).toBe(false);
expect(result.error).toContain('Failed to configure trace destination');
});

it('returns error when indexing rule update fails', async () => {
mockAppSignalsSend.mockResolvedValue({});
mockLogsSend.mockResolvedValue({ resourcePolicies: [] });
let callCount = 0;
mockXRaySend.mockImplementation(() => {
callCount++;
if (callCount === 1) {
// GetTraceSegmentDestination succeeds
return Promise.resolve({ Destination: 'CloudWatchLogs' });
}
// UpdateIndexingRule fails
return Promise.reject(new Error('Indexing error'));
});

const result = await enableTransactionSearch('us-east-1', '123456789012');

expect(result.success).toBe(false);
expect(result.error).toContain('Failed to configure indexing rules');
});

it('does not proceed to later steps when an earlier step fails', async () => {
mockAppSignalsSend.mockRejectedValue(new Error('fail'));

await enableTransactionSearch('us-east-1', '123456789012');

expect(mockLogsSend).not.toHaveBeenCalled();
expect(mockXRaySend).not.toHaveBeenCalled();
});
});
});
5 changes: 5 additions & 0 deletions src/cli/aws/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export {
type GetAgentRuntimeStatusOptions,
} from './agentcore-control';
export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch';
export {
enableTransactionSearch,
type TransactionSearchEnableOptions,
type TransactionSearchEnableResult,
} from './transaction-search';
export {
DEFAULT_RUNTIME_USER_ID,
invokeAgentRuntime,
Expand Down
114 changes: 114 additions & 0 deletions src/cli/aws/transaction-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { getErrorMessage, isAccessDeniedError } from '../errors';
import { getCredentialProvider } from './account';
import { ApplicationSignalsClient, StartDiscoveryCommand } from '@aws-sdk/client-application-signals';
import {
CloudWatchLogsClient,
DescribeResourcePoliciesCommand,
PutResourcePolicyCommand,
} from '@aws-sdk/client-cloudwatch-logs';
import {
GetTraceSegmentDestinationCommand,
UpdateIndexingRuleCommand,
UpdateTraceSegmentDestinationCommand,
XRayClient,
} from '@aws-sdk/client-xray';

export interface TransactionSearchEnableOptions {
indexingPercentage?: number;
}

export interface TransactionSearchEnableResult {
success: boolean;
error?: string;
}

const RESOURCE_POLICY_NAME = 'TransactionSearchXRayAccess';
const DEFAULT_INDEXING_PERCENTAGE = 100;

/**
* Enable CloudWatch Transaction Search:
* 1. Start Application Signals discovery (idempotent)
* 2. Create CloudWatch Logs resource policy for X-Ray access (if needed)
* 3. Set trace segment destination to CloudWatchLogs
* 4. Set indexing percentage (default 100%)
*
* All operations are idempotent — safe to call on every deploy.
*/
export async function enableTransactionSearch(
region: string,
accountId: string,
options?: TransactionSearchEnableOptions
): Promise<TransactionSearchEnableResult> {
const indexingPercentage = options?.indexingPercentage ?? DEFAULT_INDEXING_PERCENTAGE;
const credentials = getCredentialProvider();

// Step 1: Enable Application Signals (creates service-linked role, idempotent)
try {
const appSignalsClient = new ApplicationSignalsClient({ region, credentials });
await appSignalsClient.send(new StartDiscoveryCommand({}));
} catch (err: unknown) {
const context = isAccessDeniedError(err) ? 'Insufficient permissions to' : 'Failed to';
return { success: false, error: `${context} enable Application Signals: ${getErrorMessage(err)}` };
}

// Step 2: Create CloudWatch Logs resource policy for X-Ray (if needed)
try {
const logsClient = new CloudWatchLogsClient({ region, credentials });
const policiesResult = await logsClient.send(new DescribeResourcePoliciesCommand({}));
const hasPolicy = policiesResult.resourcePolicies?.some(p => p.policyName === RESOURCE_POLICY_NAME);

if (!hasPolicy) {
const policyDocument = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Sid: 'TransactionSearchXRayAccess',
Effect: 'Allow',
Principal: { Service: 'xray.amazonaws.com' },
Action: 'logs:PutLogEvents',
Resource: [
`arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`,
`arn:aws:logs:${region}:${accountId}:log-group:/aws/application-signals/data:*`,
],
Condition: {
ArnLike: { 'aws:SourceArn': `arn:aws:xray:${region}:${accountId}:*` },
StringEquals: { 'aws:SourceAccount': accountId },
},
},
],
});
await logsClient.send(new PutResourcePolicyCommand({ policyName: RESOURCE_POLICY_NAME, policyDocument }));
}
} catch (err: unknown) {
const context = isAccessDeniedError(err) ? 'Insufficient permissions to' : 'Failed to';
return { success: false, error: `${context} configure CloudWatch Logs policy: ${getErrorMessage(err)}` };
}

const xrayClient = new XRayClient({ region, credentials });

// Step 3: Set trace segment destination to CloudWatchLogs
try {
const destResult = await xrayClient.send(new GetTraceSegmentDestinationCommand({}));
if (destResult.Destination !== 'CloudWatchLogs') {
await xrayClient.send(new UpdateTraceSegmentDestinationCommand({ Destination: 'CloudWatchLogs' }));
}
} catch (err: unknown) {
const context = isAccessDeniedError(err) ? 'Insufficient permissions to' : 'Failed to';
return { success: false, error: `${context} configure trace destination: ${getErrorMessage(err)}` };
}

// Step 4: Set indexing percentage on the built-in Default rule (always exists, idempotent)
try {
await xrayClient.send(
new UpdateIndexingRuleCommand({
Name: 'Default',
Rule: { Probabilistic: { DesiredSamplingPercentage: indexingPercentage } },
})
);
} catch (err: unknown) {
const context = isAccessDeniedError(err) ? 'Insufficient permissions to' : 'Failed to';
return { success: false, error: `${context} configure indexing rules: ${getErrorMessage(err)}` };
}

return { success: true };
}
Loading
Loading