From 0a5e51c126bac996eb4171a99e22110447e27762 Mon Sep 17 00:00:00 2001 From: Kamil Sobol Date: Fri, 4 Oct 2024 10:05:35 -0700 Subject: [PATCH] Stream conversation logs in sandbox (#2073) * Stream conversation logs in sandbox * fix that * refactor that * and that * this works * Undo the hack * more tests * that * Update packages/sandbox/src/lambda_function_log_streamer.ts Co-authored-by: Amplifiyer <51211245+Amplifiyer@users.noreply.github.com> * use different output * Revert "Undo the hack" This reverts commit 3e8b590a7a9d3957d0bb2a31ab5dcfa5b0c25e1a. * use different outputs * use different outputs * Revert "Revert "Undo the hack"" This reverts commit 3c2d41710cc01de7900d1867cfc551d3be1dcb78. * fix that --------- Co-authored-by: Amplifiyer <51211245+Amplifiyer@users.noreply.github.com> --- .changeset/eleven-snails-move.md | 9 + .prettierignore | 4 +- package-lock.json | 5 +- packages/ai-constructs/API.md | 3 + packages/ai-constructs/package.json | 3 + .../conversation_handler_construct.test.ts | 51 ++++++ .../conversation_handler_construct.ts | 44 ++++- .../runtime/bedrock_converse_adapter.ts | 14 +- .../conversation_turn_executor.test.ts | 6 + .../runtime/conversation_turn_executor.ts | 1 + .../conversation_turn_response_sender.ts | 11 +- packages/ai-constructs/tsconfig.json | 7 +- .../src/conversation/factory.test.ts | 4 +- .../backend-ai/src/conversation/factory.ts | 27 +-- packages/backend-output-schemas/API.md | 60 +++++++ .../src/ai/conversation/index.ts | 14 ++ .../src/ai/conversation/v1.ts | 8 + packages/backend-output-schemas/src/index.ts | 16 ++ packages/sandbox/package.json | 2 - .../src/lambda_function_log_streamer.test.ts | 156 ++++++++++++------ .../src/lambda_function_log_streamer.ts | 109 +++--------- .../sandbox/src/sandbox_singleton_factory.ts | 3 - 22 files changed, 375 insertions(+), 182 deletions(-) create mode 100644 .changeset/eleven-snails-move.md create mode 100644 packages/backend-output-schemas/src/ai/conversation/index.ts create mode 100644 packages/backend-output-schemas/src/ai/conversation/v1.ts 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