diff --git a/.changeset/eleven-snails-move.md b/.changeset/eleven-snails-move.md
new file mode 100644
index 0000000000..1ee858b376
--- /dev/null
+++ b/.changeset/eleven-snails-move.md
@@ -0,0 +1,9 @@
+---
+'@aws-amplify/ai-constructs': minor
+'@aws-amplify/backend-ai': minor
+'@aws-amplify/backend-output-schemas': minor
+'@aws-amplify/backend': patch
+'@aws-amplify/sandbox': patch
+---
+
+Stream conversation logs in sandbox
diff --git a/.prettierignore b/.prettierignore
index c4daa53d7c..d7e58b0dd3 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1,5 @@
# Ignore artifacts:
+.amplify
build
coverage
bin
@@ -10,6 +11,7 @@ verdaccio-cache
expected-cdk-out
.changeset/pre.json
concurrent_workspace_script_cache.json
+packages/integration-tests/src/e2e-tests
scripts/components/api-changes-validator/test-resources/working-directory
/test-projects
-testDir
\ No newline at end of file
+testDir
diff --git a/package-lock.json b/package-lock.json
index 9bca614a8d..4c4620c988 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31448,12 +31448,15 @@
"version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
+ "@aws-amplify/backend-output-schemas": "^1.2.1",
+ "@aws-amplify/platform-core": "^1.1.0",
"@aws-amplify/plugin-types": "^1.0.1",
"@aws-sdk/client-bedrock-runtime": "^3.622.0",
"@smithy/types": "^3.3.0",
"json-schema-to-ts": "^3.1.1"
},
"devDependencies": {
+ "@aws-amplify/backend-output-storage": "^1.1.2",
"typescript": "^5.0.0"
},
"peerDependencies": {
@@ -32274,13 +32277,11 @@
"@aws-amplify/deployed-backend-client": "^1.4.1",
"@aws-amplify/platform-core": "^1.0.6",
"@aws-amplify/plugin-types": "^1.2.2",
- "@aws-sdk/client-cloudformation": "^3.624.0",
"@aws-sdk/client-cloudwatch-logs": "^3.624.0",
"@aws-sdk/client-lambda": "^3.624.0",
"@aws-sdk/client-ssm": "^3.624.0",
"@aws-sdk/credential-providers": "^3.624.0",
"@aws-sdk/types": "^3.609.0",
- "@aws-sdk/util-arn-parser": "^3.568.0",
"@parcel/watcher": "^2.4.1",
"debounce-promise": "^3.1.2",
"glob": "^10.2.7",
diff --git a/packages/ai-constructs/API.md b/packages/ai-constructs/API.md
index e90b8ccf16..04b61c0baf 100644
--- a/packages/ai-constructs/API.md
+++ b/packages/ai-constructs/API.md
@@ -6,6 +6,8 @@
///
+import { AIConversationOutput } from '@aws-amplify/backend-output-schemas';
+import { BackendOutputStorageStrategy } from '@aws-amplify/plugin-types';
import * as bedrock from '@aws-sdk/client-bedrock-runtime';
import { Construct } from 'constructs';
import { FunctionResources } from '@aws-amplify/plugin-types';
@@ -51,6 +53,7 @@ type ConversationHandlerFunctionProps = {
modelId: string;
region?: string;
}>;
+ outputStorageStrategy?: BackendOutputStorageStrategy;
};
// @public (undocumented)
diff --git a/packages/ai-constructs/package.json b/packages/ai-constructs/package.json
index 2d5668775f..e4d3fede0f 100644
--- a/packages/ai-constructs/package.json
+++ b/packages/ai-constructs/package.json
@@ -26,12 +26,15 @@
},
"license": "Apache-2.0",
"dependencies": {
+ "@aws-amplify/backend-output-schemas": "^1.2.1",
+ "@aws-amplify/platform-core": "^1.1.0",
"@aws-amplify/plugin-types": "^1.0.1",
"@aws-sdk/client-bedrock-runtime": "^3.622.0",
"@smithy/types": "^3.3.0",
"json-schema-to-ts": "^3.1.1"
},
"devDependencies": {
+ "@aws-amplify/backend-output-storage": "^1.1.2",
"typescript": "^5.0.0"
},
"peerDependencies": {
diff --git a/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts b/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts
index 07cfa8404a..93b87e8ea3 100644
--- a/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts
+++ b/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts
@@ -4,6 +4,7 @@ import { App, Stack } from 'aws-cdk-lib';
import { ConversationHandlerFunction } from './conversation_handler_construct';
import { Template } from 'aws-cdk-lib/assertions';
import path from 'path';
+import { StackMetadataBackendOutputStorageStrategy } from '@aws-amplify/backend-output-storage';
void describe('Conversation Handler Function construct', () => {
void it('creates handler with log group with JWT token redacting policy', () => {
@@ -140,6 +141,56 @@ void describe('Conversation Handler Function construct', () => {
});
});
+ void it('does not store output if output strategy is absent', () => {
+ const app = new App();
+ const stack = new Stack(app);
+ new ConversationHandlerFunction(stack, 'conversationHandler', {
+ models: [
+ {
+ modelId: 'testModelId',
+ },
+ ],
+ outputStorageStrategy: undefined,
+ });
+ const template = Template.fromStack(stack);
+ const output = template.findOutputs(
+ 'definedConversationHandlers'
+ ).definedConversationHandlers;
+ assert.ok(!output);
+ });
+
+ void it('stores output if output strategy is present', () => {
+ const app = new App();
+ const stack = new Stack(app);
+ new ConversationHandlerFunction(stack, 'conversationHandler', {
+ models: [
+ {
+ modelId: 'testModelId',
+ },
+ ],
+ outputStorageStrategy: new StackMetadataBackendOutputStorageStrategy(
+ stack
+ ),
+ });
+ const template = Template.fromStack(stack);
+ const outputValue = template.findOutputs('definedConversationHandlers')
+ .definedConversationHandlers.Value;
+ assert.deepStrictEqual(outputValue, {
+ 'Fn::Join': [
+ '',
+ [
+ '["',
+ {
+ /* eslint-disable spellcheck/spell-checker */
+ Ref: 'conversationHandlerconversationHandlerFunction45BC2E1F',
+ /* eslint-enable spellcheck/spell-checker */
+ },
+ '"]',
+ ],
+ ],
+ });
+ });
+
void it('throws if entry is not absolute', () => {
const app = new App();
const stack = new Stack(app);
diff --git a/packages/ai-constructs/src/conversation/conversation_handler_construct.ts b/packages/ai-constructs/src/conversation/conversation_handler_construct.ts
index 57b33f779f..b3e32d894d 100644
--- a/packages/ai-constructs/src/conversation/conversation_handler_construct.ts
+++ b/packages/ai-constructs/src/conversation/conversation_handler_construct.ts
@@ -1,7 +1,15 @@
-import { FunctionResources, ResourceProvider } from '@aws-amplify/plugin-types';
-import { Duration, Stack } from 'aws-cdk-lib';
+import {
+ BackendOutputStorageStrategy,
+ FunctionResources,
+ ResourceProvider,
+} from '@aws-amplify/plugin-types';
+import { Duration, Stack, Tags } from 'aws-cdk-lib';
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
-import { CfnFunction, Runtime as LambdaRuntime } from 'aws-cdk-lib/aws-lambda';
+import {
+ CfnFunction,
+ Runtime as LambdaRuntime,
+ LoggingFormat,
+} from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import {
CustomDataIdentifier,
@@ -11,6 +19,11 @@ import {
} from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import path from 'path';
+import { TagName } from '@aws-amplify/platform-core';
+import {
+ AIConversationOutput,
+ aiConversationOutputKey,
+} from '@aws-amplify/backend-output-schemas';
const resourcesRoot = path.normalize(path.join(__dirname, 'runtime'));
const defaultHandlerFilePath = path.join(resourcesRoot, 'default_handler.js');
@@ -21,6 +34,10 @@ export type ConversationHandlerFunctionProps = {
modelId: string;
region?: string;
}>;
+ /**
+ * @internal
+ */
+ outputStorageStrategy?: BackendOutputStorageStrategy;
};
/**
@@ -53,6 +70,8 @@ export class ConversationHandlerFunction
throw new Error('Entry must be absolute path');
}
+ Tags.of(this).add(TagName.FRIENDLY_NAME, id);
+
const conversationHandler = new NodejsFunction(
this,
`conversationHandlerFunction`,
@@ -67,6 +86,7 @@ export class ConversationHandlerFunction
// For custom entry we do bundle SDK as we can't control version customer is coding against.
bundleAwsSDK: !!this.props.entry,
},
+ loggingFormat: LoggingFormat.JSON,
logGroup: new LogGroup(this, 'conversationHandlerFunctionLogGroup', {
retention: RetentionDays.INFINITE,
dataProtectionPolicy: new DataProtectionPolicy({
@@ -105,5 +125,23 @@ export class ConversationHandlerFunction
) as CfnFunction,
},
};
+
+ this.storeOutput(this.props.outputStorageStrategy);
}
+
+ /**
+ * Append conversation handler to defined functions.
+ */
+ private storeOutput = (
+ outputStorageStrategy:
+ | BackendOutputStorageStrategy
+ | undefined
+ ): void => {
+ outputStorageStrategy?.appendToBackendOutputList(aiConversationOutputKey, {
+ version: '1',
+ payload: {
+ definedConversationHandlers: this.resources.lambda.functionName,
+ },
+ });
+ };
}
diff --git a/packages/ai-constructs/src/conversation/runtime/bedrock_converse_adapter.ts b/packages/ai-constructs/src/conversation/runtime/bedrock_converse_adapter.ts
index a2185ea0cc..721c9e09d6 100644
--- a/packages/ai-constructs/src/conversation/runtime/bedrock_converse_adapter.ts
+++ b/packages/ai-constructs/src/conversation/runtime/bedrock_converse_adapter.ts
@@ -41,7 +41,8 @@ export class BedrockConverseAdapter {
eventToolsProvider = new ConversationTurnEventToolsProvider(event),
private readonly messageHistoryRetriever = new ConversationMessageHistoryRetriever(
event
- )
+ ),
+ private readonly logger = console
) {
this.executableTools = [
...eventToolsProvider.getEventTools(),
@@ -91,9 +92,16 @@ export class BedrockConverseAdapter {
inferenceConfig: inferenceConfiguration,
toolConfig,
};
+ this.logger.info('Sending Bedrock Converse request');
+ this.logger.debug('Bedrock Converse request:', converseCommandInput);
bedrockResponse = await this.bedrockClient.send(
new ConverseCommand(converseCommandInput)
);
+ this.logger.info(
+ `Received Bedrock Converse response, requestId=${bedrockResponse.$metadata.requestId}`,
+ bedrockResponse.usage
+ );
+ this.logger.debug('Bedrock Converse response:', bedrockResponse);
if (bedrockResponse.output?.message) {
messages.push(bedrockResponse.output?.message);
}
@@ -194,7 +202,11 @@ export class BedrockConverseAdapter {
);
}
try {
+ this.logger.info(`Invoking tool ${tool.name}`);
+ this.logger.debug('Tool input:', toolUseBlock.toolUse.input);
const toolResponse = await tool.execute(toolUseBlock.toolUse.input);
+ this.logger.info(`Received response from ${tool.name} tool`);
+ this.logger.debug(toolResponse);
return {
role: 'user',
content: [
diff --git a/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.test.ts b/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.test.ts
index 8231b8daef..d30be4d7df 100644
--- a/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.test.ts
+++ b/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.test.ts
@@ -47,9 +47,11 @@ void describe('Conversation turn executor', () => {
const consoleErrorMock = mock.fn();
const consoleLogMock = mock.fn();
+ const consoleDebugMock = mock.fn();
const consoleMock = {
error: consoleErrorMock,
log: consoleLogMock,
+ debug: consoleDebugMock,
} as unknown as Console;
await new ConversationTurnExecutor(
@@ -100,9 +102,11 @@ void describe('Conversation turn executor', () => {
const consoleErrorMock = mock.fn();
const consoleLogMock = mock.fn();
+ const consoleDebugMock = mock.fn();
const consoleMock = {
error: consoleErrorMock,
log: consoleLogMock,
+ debug: consoleDebugMock,
} as unknown as Console;
await assert.rejects(
@@ -164,9 +168,11 @@ void describe('Conversation turn executor', () => {
const consoleErrorMock = mock.fn();
const consoleLogMock = mock.fn();
+ const consoleDebugMock = mock.fn();
const consoleMock = {
error: consoleErrorMock,
log: consoleLogMock,
+ debug: consoleDebugMock,
} as unknown as Console;
await assert.rejects(
diff --git a/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.ts b/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.ts
index 60613c6f28..99e66c5f74 100644
--- a/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.ts
+++ b/packages/ai-constructs/src/conversation/runtime/conversation_turn_executor.ts
@@ -29,6 +29,7 @@ export class ConversationTurnExecutor {
this.logger.log(
`Handling conversation turn event, currentMessageId=${this.event.currentMessageId}, conversationId=${this.event.conversationId}`
);
+ this.logger.debug('Event received:', this.event);
const assistantResponse = await this.bedrockConverseAdapter.askBedrock();
diff --git a/packages/ai-constructs/src/conversation/runtime/conversation_turn_response_sender.ts b/packages/ai-constructs/src/conversation/runtime/conversation_turn_response_sender.ts
index d3bb9accdc..4ec45030b0 100644
--- a/packages/ai-constructs/src/conversation/runtime/conversation_turn_response_sender.ts
+++ b/packages/ai-constructs/src/conversation/runtime/conversation_turn_response_sender.ts
@@ -23,18 +23,17 @@ export class ConversationTurnResponseSender {
private readonly graphqlRequestExecutor = new GraphqlRequestExecutor(
event.graphqlApiEndpoint,
event.request.headers.authorization
- )
+ ),
+ private readonly logger = console
) {}
sendResponse = async (message: ContentBlock[]) => {
- const { query, variables } = this.createMutationRequest(message);
+ const responseMutationRequest = this.createMutationRequest(message);
+ this.logger.debug('Sending response mutation:', responseMutationRequest);
await this.graphqlRequestExecutor.executeGraphql<
MutationResponseInput,
void
- >({
- query,
- variables,
- });
+ >(responseMutationRequest);
};
private createMutationRequest = (content: ContentBlock[]) => {
diff --git a/packages/ai-constructs/tsconfig.json b/packages/ai-constructs/tsconfig.json
index c07fe67565..fd5619c21a 100644
--- a/packages/ai-constructs/tsconfig.json
+++ b/packages/ai-constructs/tsconfig.json
@@ -9,5 +9,10 @@
"outDir": "lib",
"allowJs": true
},
- "references": [{ "path": "../plugin-types" }]
+ "references": [
+ { "path": "../backend-output-schemas" },
+ { "path": "../platform-core" },
+ { "path": "../plugin-types" },
+ { "path": "../backend-output-storage" }
+ ]
}
diff --git a/packages/backend-ai/src/conversation/factory.test.ts b/packages/backend-ai/src/conversation/factory.test.ts
index aa6f76fc7d..b3a239432a 100644
--- a/packages/backend-ai/src/conversation/factory.test.ts
+++ b/packages/backend-ai/src/conversation/factory.test.ts
@@ -158,8 +158,8 @@ void describe('ConversationHandlerFactory', () => {
});
factory.getInstance(getInstanceProps);
const template = Template.fromStack(rootStack);
- const outputValue =
- template.findOutputs('definedFunctions').definedFunctions.Value;
+ const outputValue = template.findOutputs('definedConversationHandlers')
+ .definedConversationHandlers.Value;
assert.deepStrictEqual(outputValue, {
['Fn::Join']: [
'',
diff --git a/packages/backend-ai/src/conversation/factory.ts b/packages/backend-ai/src/conversation/factory.ts
index 0a095204de..ddbb925597 100644
--- a/packages/backend-ai/src/conversation/factory.ts
+++ b/packages/backend-ai/src/conversation/factory.ts
@@ -1,7 +1,4 @@
-import {
- FunctionOutput,
- functionOutputKey,
-} from '@aws-amplify/backend-output-schemas';
+import { AIConversationOutput } from '@aws-amplify/backend-output-schemas';
import {
BackendOutputStorageStrategy,
ConstructContainerEntryGenerator,
@@ -25,7 +22,7 @@ class ConversationHandlerFunctionGenerator
constructor(
private readonly props: DefineConversationHandlerFunctionProps,
- private readonly outputStorageStrategy: BackendOutputStorageStrategy
+ private readonly outputStorageStrategy: BackendOutputStorageStrategy
) {}
generateContainerEntry = ({ scope }: GenerateContainerEntryProps) => {
@@ -43,33 +40,15 @@ class ConversationHandlerFunctionGenerator
region: model.region,
};
}),
+ outputStorageStrategy: this.outputStorageStrategy,
};
const conversationHandlerFunction = new ConversationHandlerFunction(
scope,
this.props.name,
constructProps
);
- this.storeOutput(this.outputStorageStrategy, conversationHandlerFunction);
return conversationHandlerFunction;
};
-
- /**
- * Append conversation handler to defined functions.
- * Explicitly defined custom handler is customer's function and should be visible
- * in the outputs.
- */
- private storeOutput = (
- outputStorageStrategy: BackendOutputStorageStrategy,
- conversationHandlerFunction: ConversationHandlerFunction
- ): void => {
- outputStorageStrategy.appendToBackendOutputList(functionOutputKey, {
- version: '1',
- payload: {
- definedFunctions:
- conversationHandlerFunction.resources.lambda.functionName,
- },
- });
- };
}
class ConversationHandlerFunctionFactory
diff --git a/packages/backend-output-schemas/API.md b/packages/backend-output-schemas/API.md
index 41fbd8abed..6fd3f07d27 100644
--- a/packages/backend-output-schemas/API.md
+++ b/packages/backend-output-schemas/API.md
@@ -6,6 +6,12 @@
import { z } from 'zod';
+// @public (undocumented)
+export type AIConversationOutput = z.infer;
+
+// @public
+export const aiConversationOutputKey = "AWS::Amplify::AI::Conversation";
+
// @public (undocumented)
export type AuthOutput = z.infer;
@@ -340,6 +346,26 @@ export const unifiedBackendOutputSchema: z.ZodObject<{
definedFunctions: string;
};
}>]>>;
+ "AWS::Amplify::AI::Conversation": z.ZodOptional;
+ payload: z.ZodObject<{
+ definedConversationHandlers: z.ZodString;
+ }, "strip", z.ZodTypeAny, {
+ definedConversationHandlers: string;
+ }, {
+ definedConversationHandlers: string;
+ }>;
+ }, "strip", z.ZodTypeAny, {
+ version: "1";
+ payload: {
+ definedConversationHandlers: string;
+ };
+ }, {
+ version: "1";
+ payload: {
+ definedConversationHandlers: string;
+ };
+ }>]>>;
}, "strip", z.ZodTypeAny, {
"AWS::Amplify::Platform"?: {
version: "1";
@@ -405,6 +431,12 @@ export const unifiedBackendOutputSchema: z.ZodObject<{
definedFunctions: string;
};
} | undefined;
+ "AWS::Amplify::AI::Conversation"?: {
+ version: "1";
+ payload: {
+ definedConversationHandlers: string;
+ };
+ } | undefined;
}, {
"AWS::Amplify::Platform"?: {
version: "1";
@@ -470,8 +502,36 @@ export const unifiedBackendOutputSchema: z.ZodObject<{
definedFunctions: string;
};
} | undefined;
+ "AWS::Amplify::AI::Conversation"?: {
+ version: "1";
+ payload: {
+ definedConversationHandlers: string;
+ };
+ } | undefined;
}>;
+// @public (undocumented)
+export const versionedAIConversationOutputSchema: z.ZodDiscriminatedUnion<"version", [z.ZodObject<{
+ version: z.ZodLiteral<"1">;
+ payload: z.ZodObject<{
+ definedConversationHandlers: z.ZodString;
+ }, "strip", z.ZodTypeAny, {
+ definedConversationHandlers: string;
+ }, {
+ definedConversationHandlers: string;
+ }>;
+}, "strip", z.ZodTypeAny, {
+ version: "1";
+ payload: {
+ definedConversationHandlers: string;
+ };
+}, {
+ version: "1";
+ payload: {
+ definedConversationHandlers: string;
+ };
+}>]>;
+
// @public (undocumented)
export const versionedAuthOutputSchema: z.ZodDiscriminatedUnion<"version", [z.ZodObject<{
version: z.ZodLiteral<"1">;
diff --git a/packages/backend-output-schemas/src/ai/conversation/index.ts b/packages/backend-output-schemas/src/ai/conversation/index.ts
new file mode 100644
index 0000000000..29b0d41397
--- /dev/null
+++ b/packages/backend-output-schemas/src/ai/conversation/index.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod';
+import { aiConversationOutputSchema as aiConversationOutputSchemaV1 } from './v1';
+
+export const versionedAIConversationOutputSchema = z.discriminatedUnion(
+ 'version',
+ [
+ aiConversationOutputSchemaV1,
+ // this is where additional function major version schemas would go
+ ]
+);
+
+export type AIConversationOutput = z.infer<
+ typeof versionedAIConversationOutputSchema
+>;
diff --git a/packages/backend-output-schemas/src/ai/conversation/v1.ts b/packages/backend-output-schemas/src/ai/conversation/v1.ts
new file mode 100644
index 0000000000..cb50b29cda
--- /dev/null
+++ b/packages/backend-output-schemas/src/ai/conversation/v1.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+export const aiConversationOutputSchema = z.object({
+ version: z.literal('1'),
+ payload: z.object({
+ definedConversationHandlers: z.string(), // JSON array as string
+ }),
+});
diff --git a/packages/backend-output-schemas/src/index.ts b/packages/backend-output-schemas/src/index.ts
index ec7cef0209..11bfb14cd2 100644
--- a/packages/backend-output-schemas/src/index.ts
+++ b/packages/backend-output-schemas/src/index.ts
@@ -5,6 +5,7 @@ import { versionedStorageOutputSchema } from './storage/index.js';
import { versionedStackOutputSchema } from './stack/index.js';
import { versionedCustomOutputSchema } from './custom';
import { versionedFunctionOutputSchema } from './function/index.js';
+import { versionedAIConversationOutputSchema } from './ai/conversation/index.js';
/**
* The auth, graphql and storage exports here are duplicated from the submodule exports in the package.json file
@@ -84,6 +85,20 @@ export * from './function/index.js';
*/
export const functionOutputKey = 'AWS::Amplify::Function';
+/**
+ * ---------- AI conversation exports ----------
+ */
+
+/**
+ * re-export the AI conversation output schema
+ */
+export * from './ai/conversation/index.js';
+
+/**
+ * Expected key that AI conversation output is stored under
+ */
+export const aiConversationOutputKey = 'AWS::Amplify::AI::Conversation';
+
/**
* ---------- Unified exports ----------
*/
@@ -99,6 +114,7 @@ export const unifiedBackendOutputSchema = z.object({
[storageOutputKey]: versionedStorageOutputSchema.optional(),
[customOutputKey]: versionedCustomOutputSchema.optional(),
[functionOutputKey]: versionedFunctionOutputSchema.optional(),
+ [aiConversationOutputKey]: versionedAIConversationOutputSchema.optional(),
});
/**
* This type is a subset of the BackendOutput type that is exposed by the platform.
diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json
index 2ca57e592f..5f90daa9f8 100644
--- a/packages/sandbox/package.json
+++ b/packages/sandbox/package.json
@@ -26,13 +26,11 @@
"@aws-amplify/deployed-backend-client": "^1.4.1",
"@aws-amplify/platform-core": "^1.0.6",
"@aws-amplify/plugin-types": "^1.2.2",
- "@aws-sdk/client-cloudformation": "^3.624.0",
"@aws-sdk/client-cloudwatch-logs": "^3.624.0",
"@aws-sdk/client-lambda": "^3.624.0",
"@aws-sdk/client-ssm": "^3.624.0",
"@aws-sdk/credential-providers": "^3.624.0",
"@aws-sdk/types": "^3.609.0",
- "@aws-sdk/util-arn-parser": "^3.568.0",
"@parcel/watcher": "^2.4.1",
"debounce-promise": "^3.1.2",
"glob": "^10.2.7",
diff --git a/packages/sandbox/src/lambda_function_log_streamer.test.ts b/packages/sandbox/src/lambda_function_log_streamer.test.ts
index 7ba897fea2..6072e57adc 100644
--- a/packages/sandbox/src/lambda_function_log_streamer.test.ts
+++ b/packages/sandbox/src/lambda_function_log_streamer.test.ts
@@ -3,21 +3,16 @@ import { LambdaFunctionLogStreamer } from './lambda_function_log_streamer.js';
import assert from 'node:assert';
import { BackendOutputClient } from '@aws-amplify/deployed-backend-client';
-import {
- CloudFormationClient,
- DescribeStacksOutput,
-} from '@aws-sdk/client-cloudformation';
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs';
import {
+ GetFunctionCommand,
+ GetFunctionCommandOutput,
LambdaClient,
- ListTagsCommand,
- ListTagsCommandOutput,
} from '@aws-sdk/client-lambda';
import { CloudWatchLogEventMonitor } from './cloudwatch_logs_monitor.js';
import { Printer } from '@aws-amplify/cli-core';
import { BackendIdentifier, BackendOutput } from '@aws-amplify/plugin-types';
import { TagName } from '@aws-amplify/platform-core';
-import { parse as parseArn } from '@aws-sdk/util-arn-parser';
void describe('LambdaFunctionLogStreamer', () => {
const region = 'test-region';
@@ -25,20 +20,10 @@ void describe('LambdaFunctionLogStreamer', () => {
'func1FullName',
'func2FullName',
]);
-
- // CFN default implementation
- const cfnClientMock = new CloudFormationClient({ region });
- const cfnClientSendMock = mock.fn(() => {
- return Promise.resolve({
- Stacks: [
- {
- StackId:
- 'arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/uuid',
- },
- ],
- } as DescribeStacksOutput);
- });
- mock.method(cfnClientMock, 'send', cfnClientSendMock);
+ const definedConversationHandlers = JSON.stringify([
+ 'conversationHandler1FullName',
+ 'conversationHandler2FullName',
+ ]);
// CW default implementation
const cloudWatchClientMock = new CloudWatchLogsClient({ region });
@@ -48,15 +33,26 @@ void describe('LambdaFunctionLogStreamer', () => {
// Lambda default implementation.
// Given a resource Arn with lambda function name with `FullName` suffix, this will return the function name with `friendlyName` as suffix
const lambdaClientMock = new LambdaClient({ region });
- const lambdaClientSendMock = mock.fn((listTagsCommand: ListTagsCommand) => {
- return Promise.resolve({
- Tags: {
- [TagName.FRIENDLY_NAME]: parseArn(listTagsCommand.input.Resource ?? '')
- .resource?.split(':')[1]
- .replace('FullName', 'FriendlyName'),
- } as unknown as ListTagsCommandOutput,
- });
- });
+ const lambdaClientSendMock = mock.fn(
+ (getFunctionCommand: GetFunctionCommand) => {
+ return Promise.resolve({
+ Configuration: {
+ LoggingConfig: {
+ LogGroup: `/aws/lambda/${
+ getFunctionCommand.input.FunctionName ?? ''
+ }`,
+ },
+ },
+ Tags: {
+ [TagName.FRIENDLY_NAME]:
+ getFunctionCommand.input.FunctionName?.replace(
+ 'FullName',
+ 'FriendlyName'
+ ),
+ } as unknown as GetFunctionCommandOutput,
+ });
+ }
+ );
mock.method(lambdaClientMock, 'send', lambdaClientSendMock);
// backendOutputClient default implementation
@@ -74,6 +70,12 @@ void describe('LambdaFunctionLogStreamer', () => {
},
version: '1',
},
+ ['AWS::Amplify::AI::Conversation']: {
+ payload: {
+ definedConversationHandlers: definedConversationHandlers,
+ },
+ version: '1',
+ },
} as BackendOutput);
}),
};
@@ -91,14 +93,12 @@ void describe('LambdaFunctionLogStreamer', () => {
const classUnderTest = new LambdaFunctionLogStreamer(
lambdaClientMock,
- cfnClientMock,
cloudWatchLogMonitorMock as unknown as CloudWatchLogEventMonitor,
backendOutputClientMock as unknown as BackendOutputClient,
printer as unknown as Printer
);
beforeEach(() => {
- cfnClientSendMock.mock.resetCalls();
cloudWatchClientSendMock.mock.resetCalls();
lambdaClientSendMock.mock.resetCalls();
backendOutputClientMock.getOutput.mock.resetCalls();
@@ -147,26 +147,34 @@ void describe('LambdaFunctionLogStreamer', () => {
assert.strictEqual(lambdaClientSendMock.mock.callCount(), 0);
});
- void it('calls logs monitor with all the customer defined functions if no function name filter is provided', async () => {
+ void it('calls logs monitor with all the customer defined functions and conversation handlers if no function name filter is provided', async () => {
await classUnderTest.startStreamingLogs(testSandboxBackendId, {
enabled: true,
});
// assert that lambda calls to retrieve tags were with the right function arn
- assert.strictEqual(lambdaClientSendMock.mock.callCount(), 2);
+ assert.strictEqual(lambdaClientSendMock.mock.callCount(), 4);
assert.strictEqual(
- lambdaClientSendMock.mock.calls[0].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func1FullName'
+ lambdaClientSendMock.mock.calls[0].arguments[0].input.FunctionName,
+ 'func1FullName'
);
assert.strictEqual(
- lambdaClientSendMock.mock.calls[1].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func2FullName'
+ lambdaClientSendMock.mock.calls[1].arguments[0].input.FunctionName,
+ 'func2FullName'
+ );
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[2].arguments[0].input.FunctionName,
+ 'conversationHandler1FullName'
+ );
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[3].arguments[0].input.FunctionName,
+ 'conversationHandler2FullName'
);
// assert that logs groups were added to the monitor and was then called activate
assert.strictEqual(
cloudWatchLogMonitorMock.addLogGroups.mock.callCount(),
- 2
+ 4
);
assert.strictEqual(
cloudWatchLogMonitorMock.addLogGroups.mock.calls[0].arguments[0],
@@ -184,6 +192,22 @@ void describe('LambdaFunctionLogStreamer', () => {
cloudWatchLogMonitorMock.addLogGroups.mock.calls[1].arguments[1],
'/aws/lambda/func2FullName'
);
+ assert.strictEqual(
+ cloudWatchLogMonitorMock.addLogGroups.mock.calls[2].arguments[0],
+ 'conversationHandler1FriendlyName'
+ );
+ assert.strictEqual(
+ cloudWatchLogMonitorMock.addLogGroups.mock.calls[2].arguments[1],
+ '/aws/lambda/conversationHandler1FullName'
+ );
+ assert.strictEqual(
+ cloudWatchLogMonitorMock.addLogGroups.mock.calls[3].arguments[0],
+ 'conversationHandler2FriendlyName'
+ );
+ assert.strictEqual(
+ cloudWatchLogMonitorMock.addLogGroups.mock.calls[3].arguments[1],
+ '/aws/lambda/conversationHandler2FullName'
+ );
assert.strictEqual(cloudWatchLogMonitorMock.activate.mock.callCount(), 1);
});
@@ -197,14 +221,22 @@ void describe('LambdaFunctionLogStreamer', () => {
// assert that lambda calls to retrieve tags were with the right function arn
// We do it for all customer defined functions, filtering happens after
- assert.strictEqual(lambdaClientSendMock.mock.callCount(), 2);
+ assert.strictEqual(lambdaClientSendMock.mock.callCount(), 4);
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[0].arguments[0].input.FunctionName,
+ 'func1FullName'
+ );
assert.strictEqual(
- lambdaClientSendMock.mock.calls[0].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func1FullName'
+ lambdaClientSendMock.mock.calls[1].arguments[0].input.FunctionName,
+ 'func2FullName'
);
assert.strictEqual(
- lambdaClientSendMock.mock.calls[1].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func2FullName'
+ lambdaClientSendMock.mock.calls[2].arguments[0].input.FunctionName,
+ 'conversationHandler1FullName'
+ );
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[3].arguments[0].input.FunctionName,
+ 'conversationHandler2FullName'
);
// assert that logs groups were added to the monitor for only filtered functions and was then called activate
@@ -231,14 +263,22 @@ void describe('LambdaFunctionLogStreamer', () => {
// assert that lambda calls to retrieve tags were with the right function arn
// We do it for all customer defined functions, filtering happens after
- assert.strictEqual(lambdaClientSendMock.mock.callCount(), 2);
+ assert.strictEqual(lambdaClientSendMock.mock.callCount(), 4);
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[0].arguments[0].input.FunctionName,
+ 'func1FullName'
+ );
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[1].arguments[0].input.FunctionName,
+ 'func2FullName'
+ );
assert.strictEqual(
- lambdaClientSendMock.mock.calls[0].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func1FullName'
+ lambdaClientSendMock.mock.calls[2].arguments[0].input.FunctionName,
+ 'conversationHandler1FullName'
);
assert.strictEqual(
- lambdaClientSendMock.mock.calls[1].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func2FullName'
+ lambdaClientSendMock.mock.calls[3].arguments[0].input.FunctionName,
+ 'conversationHandler2FullName'
);
// assert that logs groups were added to the monitor for only filtered functions and was then called activate
@@ -273,14 +313,22 @@ void describe('LambdaFunctionLogStreamer', () => {
// assert that lambda calls to retrieve tags were with the right function arn
// We do it for all customer defined functions, filtering happens after
- assert.strictEqual(lambdaClientSendMock.mock.callCount(), 2);
+ assert.strictEqual(lambdaClientSendMock.mock.callCount(), 4);
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[0].arguments[0].input.FunctionName,
+ 'func1FullName'
+ );
+ assert.strictEqual(
+ lambdaClientSendMock.mock.calls[1].arguments[0].input.FunctionName,
+ 'func2FullName'
+ );
assert.strictEqual(
- lambdaClientSendMock.mock.calls[0].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func1FullName'
+ lambdaClientSendMock.mock.calls[2].arguments[0].input.FunctionName,
+ 'conversationHandler1FullName'
);
assert.strictEqual(
- lambdaClientSendMock.mock.calls[1].arguments[0].input.Resource,
- 'arn:aws:lambda:us-west-2:123456789012:function:func2FullName'
+ lambdaClientSendMock.mock.calls[3].arguments[0].input.FunctionName,
+ 'conversationHandler2FullName'
);
// assert that no logs groups were added to the monitor
diff --git a/packages/sandbox/src/lambda_function_log_streamer.ts b/packages/sandbox/src/lambda_function_log_streamer.ts
index 7468849596..ee3ac62019 100644
--- a/packages/sandbox/src/lambda_function_log_streamer.ts
+++ b/packages/sandbox/src/lambda_function_log_streamer.ts
@@ -1,17 +1,10 @@
import { LogLevel, Printer } from '@aws-amplify/cli-core';
import { BackendOutputClient } from '@aws-amplify/deployed-backend-client';
-import {
- BackendIdentifierConversions,
- TagName,
-} from '@aws-amplify/platform-core';
+import { TagName } from '@aws-amplify/platform-core';
import { BackendIdentifier, BackendOutput } from '@aws-amplify/plugin-types';
-import {
- CloudFormationClient,
- DescribeStacksCommand,
-} from '@aws-sdk/client-cloudformation';
-import { LambdaClient, ListTagsCommand } from '@aws-sdk/client-lambda';
+
+import { GetFunctionCommand, LambdaClient } from '@aws-sdk/client-lambda';
import { CloudWatchLogEventMonitor } from './cloudwatch_logs_monitor.js';
-import { build as buildArn, parse as parseArn } from '@aws-sdk/util-arn-parser';
import { SandboxFunctionStreamingOptions } from './sandbox.js';
/**
@@ -24,7 +17,6 @@ export class LambdaFunctionLogStreamer {
*/
constructor(
private readonly lambda: LambdaClient,
- private readonly cfnClient: CloudFormationClient,
private readonly logsMonitor: CloudWatchLogEventMonitor,
private readonly backendOutputClient: BackendOutputClient,
private readonly printer: Printer
@@ -50,37 +42,38 @@ export class LambdaFunctionLogStreamer {
const definedFunctionsPayload =
backendOutput['AWS::Amplify::Function']?.payload.definedFunctions;
+ const definedConversationHandlersPayload =
+ backendOutput['AWS::Amplify::AI::Conversation']?.payload
+ .definedConversationHandlers;
const deployedFunctionNames = definedFunctionsPayload
? (JSON.parse(definedFunctionsPayload) as string[])
: [];
-
- // To use list-tags API we need to convert function name to function Arn since it only accepts ARN as input
- const deployedFunctionNameToArnMap = await this.getFunctionArnFromNames(
- sandboxBackendId,
- deployedFunctionNames
+ deployedFunctionNames.push(
+ ...(definedConversationHandlersPayload
+ ? (JSON.parse(definedConversationHandlersPayload) as string[])
+ : [])
);
- if (!deployedFunctionNameToArnMap) {
- this.printer.log(
- `[Sandbox] Could not find any function in stack ${BackendIdentifierConversions.toStackName(
- sandboxBackendId
- )}. Streaming function logs will be turned off.`,
- LogLevel.DEBUG
- );
- return;
- }
-
- for (const entry of deployedFunctionNameToArnMap) {
- const listTagsResponse = await this.lambda.send(
- new ListTagsCommand({
- Resource: entry.arn,
+ for (const functionName of deployedFunctionNames) {
+ const getFunctionResponse = await this.lambda.send(
+ new GetFunctionCommand({
+ FunctionName: functionName,
})
);
+ const logGroupName =
+ getFunctionResponse.Configuration?.LoggingConfig?.LogGroup;
+ if (!logGroupName) {
+ this.printer.log(
+ `[Sandbox] Could not find logGroup for lambda function ${functionName}. Logs will not be streamed for this function.`,
+ LogLevel.DEBUG
+ );
+ continue;
+ }
const friendlyFunctionName =
- listTagsResponse.Tags?.[TagName.FRIENDLY_NAME];
+ getFunctionResponse.Tags?.[TagName.FRIENDLY_NAME];
if (!friendlyFunctionName) {
this.printer.log(
- `[Sandbox] Could not find user defined name for lambda function ${entry.name}. Logs will not be streamed for this function.`,
+ `[Sandbox] Could not find user defined name for lambda function ${functionName}. Logs will not be streamed for this function.`,
LogLevel.DEBUG
);
continue;
@@ -109,11 +102,7 @@ export class LambdaFunctionLogStreamer {
}
if (shouldStreamLogs) {
- this.logsMonitor?.addLogGroups(
- friendlyFunctionName,
- // a CW log group is implicitly created for each lambda function with the lambda function's name
- `/aws/lambda/${entry.name}`
- );
+ this.logsMonitor?.addLogGroups(friendlyFunctionName, logGroupName);
} else {
this.printer.log(
`[Sandbox] Skipping logs streaming for function ${friendlyFunctionName} since it did not match any filters. To stream logs for this function, ensure at least one of your logs-filters match this function name.`,
@@ -136,50 +125,4 @@ export class LambdaFunctionLogStreamer {
);
this.logsMonitor?.pause();
};
-
- /**
- * Adds functionArn for each function name provided. All the ARN components are taken from the root stack Arn
- * @param sandboxBackendId backendId for retrieving the root stack
- * @param functionNames Name of the functions for which ARN needs to be generated
- * @returns An object containing function name and ARN for each function name provided
- */
- private getFunctionArnFromNames = async (
- sandboxBackendId: BackendIdentifier,
- functionNames?: string[]
- ) => {
- if (!functionNames || functionNames.length === 0) {
- return;
- }
-
- const rootStackResources = await this.cfnClient.send(
- new DescribeStacksCommand({
- StackName: BackendIdentifierConversions.toStackName(sandboxBackendId),
- })
- );
-
- if (!rootStackResources?.Stacks?.[0]?.StackId) {
- this.printer.log(
- `[Sandbox] Cannot load root stack for Id ${BackendIdentifierConversions.toStackName(
- sandboxBackendId
- )}. Streaming function logs will be turned off.`,
- LogLevel.DEBUG
- );
- return;
- }
-
- const arnParts = parseArn(rootStackResources.Stacks[0].StackId);
-
- return functionNames.map((name) => {
- return {
- name,
- arn: buildArn({
- resource: `function:${name}`,
- service: 'lambda',
- accountId: arnParts.accountId,
- partition: arnParts.partition,
- region: arnParts.region,
- }),
- };
- });
- };
}
diff --git a/packages/sandbox/src/sandbox_singleton_factory.ts b/packages/sandbox/src/sandbox_singleton_factory.ts
index b4d872ab27..f1f36344e8 100644
--- a/packages/sandbox/src/sandbox_singleton_factory.ts
+++ b/packages/sandbox/src/sandbox_singleton_factory.ts
@@ -14,7 +14,6 @@ import { LambdaClient } from '@aws-sdk/client-lambda';
import { BackendOutputClientFactory } from '@aws-amplify/deployed-backend-client';
import { LambdaFunctionLogStreamer } from './lambda_function_log_streamer.js';
import { CloudWatchLogEventMonitor } from './cloudwatch_logs_monitor.js';
-import { CloudFormationClient } from '@aws-sdk/client-cloudformation';
/**
* Factory to create a new sandbox
@@ -41,7 +40,6 @@ export class SandboxSingletonFactory {
packageManagerControllerFactory.getPackageManagerController(),
this.format
);
- const cfnClient = new CloudFormationClient();
this.instance = new FileWatchingSandbox(
this.sandboxIdResolver,
new AmplifySandboxExecutor(
@@ -52,7 +50,6 @@ export class SandboxSingletonFactory {
new SSMClient(),
new LambdaFunctionLogStreamer(
new LambdaClient(),
- cfnClient,
new CloudWatchLogEventMonitor(new CloudWatchLogsClient()),
BackendOutputClientFactory.getInstance(),
this.printer