├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cdk-stacks ├── .gitignore ├── .npmignore ├── bin │ └── cdk-stacks.ts ├── cdk.json ├── config.params.json ├── configure.js ├── jest.config.js ├── lambdas │ ├── .eslintrc.json │ ├── constants │ │ └── EvaluationFormsConstants.js │ ├── handlers │ │ ├── ContactLens │ │ │ ├── clEventProcessor.js │ │ │ ├── clOutputFileLoader.js │ │ │ └── clRecordWriter.js │ │ ├── EvaluationForms │ │ │ ├── efEventProcessor.js │ │ │ ├── efOutputFileLoader.js │ │ │ └── efRecordWriter.js │ │ ├── Partitioning │ │ │ ├── getPartitioningResults.js │ │ │ ├── partitioningIterator.js │ │ │ ├── pollPartitioningStatus.js │ │ │ └── startPartitioning.js │ │ └── RecordProcessors │ │ │ └── kinesisFirehoseCloudwatchLogsProcessor.js │ ├── lib │ │ ├── CommonUtility.js │ │ └── EFScoringUtil.js │ ├── package-lock.json │ ├── package.json │ └── services │ │ ├── AthenaService.js │ │ ├── ContactLensService.js │ │ ├── EvaluationFormsService.js │ │ ├── FirehoseService.js │ │ ├── PartitioningService.js │ │ ├── S3Service.js │ │ └── SQSService.js ├── lib │ ├── agent-events │ │ └── ae-stack.ts │ ├── cdk-backend-stack.ts │ ├── contact-flow-logs │ │ └── cfl-stack.ts │ ├── contact-lens │ │ └── cl-stack.ts │ ├── contact-trace-records │ │ └── ctr-stack.ts │ ├── evaluation-forms-reporting │ │ └── ef-reporting-stack.ts │ ├── evaluation-forms │ │ └── ef-stack.ts │ ├── infrastructure │ │ └── ssm-params-util.ts │ └── partitioning │ │ └── partitioning-stack.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── cloudformation-template └── evaluation-forms-reporting │ └── agentevaluationquicksight02.yaml ├── contact-flows └── SimpleInboundFlow ├── diagrams ├── AmazonConnectDataAnalyticsSample-Architecture-AE.jpg ├── AmazonConnectDataAnalyticsSample-Architecture-CFL.jpg ├── AmazonConnectDataAnalyticsSample-Architecture-CL.jpg ├── AmazonConnectDataAnalyticsSample-Architecture-CTR.jpg ├── AmazonConnectDataAnalyticsSample-Architecture-EF.jpg ├── AmazonConnectDataAnalyticsSample-Architecture-QuickSight-Athena-Glue.jpg ├── AmazonConnectDataAnalyticsSample-Architecture.drawio └── PartitioningStepFunctionsStateMachine.png └── images ├── AmazonConnectDataAnalyticsSample-SampleEvaluationForm.png ├── ContactFlowAllBlocks.png ├── ContactFlowCallReasonNewOrder.png ├── ContactFlowCallReasonNoSelection.png ├── ContactFlowCallReasonRecentPurchase.png ├── ContactFlowCallReasonRepeatOrder.png ├── ContactFlowEnableLogging.png ├── ContactFlowMainMenuOptions.png ├── ContactFlowMainMenuOptionsOutputs.png ├── ContactFlowMainMenuPrompt.png ├── ContactFlowMainMenuTechnicalDifficulties.png ├── ContactFlowSetBasicQueue.png ├── ContactFlowSetBasicQueueTransferToQueue.png ├── ContactFlowSetContactAttributesToSetBasicQueue.png ├── ContactFlowTechnicalDifficultiesPrompt.png ├── ContactFlowWelcomePrompt.png ├── DisconnectionReason-date-range.png ├── QuickSight-AthenaSource.png ├── QuickSight-AthenaSourceAEAgentStatus.png ├── QuickSight-AthenaSourceAEAgentStatusDirectlyQuery.png ├── QuickSight-AthenaSourceAEAgentStatusQueryName.png ├── QuickSight-AthenaSourceAEAgentStatusVisualizeTable.png ├── QuickSight-AthenaSourceCTRCallsCurrentDay.png ├── QuickSight-AthenaSourceCTRCallsCurrentDayDirectlyQuery.png ├── QuickSight-AthenaSourceCTRCallsCurrentDayQueryName.png ├── QuickSight-AthenaSourceCTRCallsCurrentDayValidate.png ├── QuickSight-AthenaSourceCTRCallsCurrentDayVisualize.png ├── QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReason.png ├── QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReasonPieChart.png ├── QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReasonTable.png ├── QuickSight-EnableAthenaAccess.png ├── QuickSight-EnableS3Access.png ├── QuickSight-GoToAmazonQuickSight.png ├── QuickSight-ManageData.png ├── QuickSight-NewDataSet.png ├── QuickSight-SetRegion.png ├── QuickSight-Share.png ├── QuickSight-ShareAnalysis.png ├── QuickSight-ShareAnalysisDashboardName.png ├── QuickSight-Signup.png ├── QuickSight-StandardVsEnterprise.png ├── ResultSetAE-AgentTrace.png ├── ResultSetAE-AgentsCurrentStatus.png ├── ResultSetAE-AgentsCurrentStatusNumberOfContacts.png ├── ResultSetAE-AgentsCurrentStatusNumberOfContactsContactState.png ├── ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactId.png ├── ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactIdCallReason.png ├── ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactIdCallReasonEnded.png ├── ResultSetCFL-CallReasons.png ├── ResultSetCFL-DTMFInputs.png ├── ResultSetCFL-VisitedNodes.png ├── ResultSetCL-AvgOverallSentiment.png ├── ResultSetCL-AvgOverallSentimentQueue.png ├── ResultSetCL-MatchedCategoryCustomerLoyaltyRisk.png ├── ResultSetCTR-CallReasonAnsweredTimeInQueueAgentHandlingTime.png ├── ResultSetCTR-CallReasonAvgAbandonTimeByDate.png ├── ResultSetCTR-CallReasonAvgAbandonTimeThresholdByDate.png ├── ResultSetCTR-CallReasonByDate.png ├── ResultSetCTR-CurrentDate.png ├── ResultSetCTR-CurrentDateCallReason.png ├── ResultSetEF-CurrentDate.png ├── ResultSetEF-CurrentDateSpecificForm.png ├── ResultSetEF-CurrentDateSpecificFormAvgSectionsScores.png ├── ResultSetEF-CurrentDateSpecificFormSectionsScores.png ├── ResultSetEF-CurrentDateSpecificFormTotalScores.png └── ResultSetEF-CurrentDateSpecificFormTotalScoresAvg.png /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | #ignore cdk-exports.json, as it's generated by CDK 9 | cdk-exports.json 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .eslintcache 24 | /.vscode 25 | .vscode 26 | /.idea 27 | .idea 28 | 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | 33 | # General 34 | .DS_Store 35 | .AppleDouble 36 | .LSOverride 37 | 38 | *.drawio.bkp 39 | 40 | # Icon must end with two \r 41 | # Icon 42 | 43 | 44 | # Thumbnails 45 | ._* 46 | 47 | # Files that might appear in the root of a volume 48 | .DocumentRevisions-V100 49 | .fseventsd 50 | .Spotlight-V100 51 | .TemporaryItems 52 | .Trashes 53 | .VolumeIcon.icns 54 | .com.apple.timemachine.donotpresent 55 | 56 | # Directories potentially created on remote AFP share 57 | .AppleDB 58 | .AppleDesktop 59 | Network Trash Folder 60 | Temporary Items 61 | .apdisk 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.7] - 2023-09-12 8 | - Make Athena queries dynamic for the Glue database name 9 | 10 | ## [1.0.6] - 2023-08-23 11 | - Update dependencies in /cdk-stacks 12 | 13 | ## [1.0.5] - 2023-08-07 14 | - Bump CDK version to 2.88.0 15 | 16 | ## [1.0.4] - 2023-06-20 17 | - Update dependencies in /cdk-stacks 18 | 19 | ## [1.0.3] - 2023-05-31 20 | - Update sample SQL queries in README 21 | 22 | ## [1.0.2] - 2023-04-25 23 | - Update dependencies in /cdk-stacks 24 | 25 | ## [1.0.1] - 2023-04-25 26 | - Gitbash deploy script for Windows OS 27 | 28 | ## [1.0.0] - 2023-04-24 29 | - Initial import 30 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /cdk-stacks/.gitignore: -------------------------------------------------------------------------------- 1 | # *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | 10 | # Parcel default cache directory 11 | .parcel-cache 12 | 13 | # CDK js 14 | cdk-stacks*.js 15 | cdk-backend*.js 16 | ae-stack*.js 17 | cfl-stack*.js 18 | cl-stack*.js 19 | ctr-stack*.js 20 | ef-stack*.js 21 | ssm-params-util*.js 22 | partitioning-stack*.js 23 | cdk-pipeline-stack*.js 24 | cdk-pipeline-stage*.js 25 | 26 | # CDK config, produced by configure.sh 27 | config.cache.json 28 | 29 | # Local template file 30 | template.yaml 31 | 32 | # CDK context - auto-generated 33 | cdk.context.json 34 | 35 | #build folder 36 | build 37 | -------------------------------------------------------------------------------- /cdk-stacks/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /cdk-stacks/bin/cdk-stacks.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import {App, Aspects} from 'aws-cdk-lib'; 4 | import {AwsSolutionsChecks} from 'cdk-nag' 5 | 6 | import {CdkBackendStack} from '../lib/cdk-backend-stack'; 7 | 8 | const {SSMClient} = require('@aws-sdk/client-ssm') 9 | 10 | 11 | const configParams = require('../config.params.json'); 12 | 13 | const app = new App(); 14 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})) 15 | 16 | console.log("Running in stack mode..."); 17 | const cdkBackendStack = new CdkBackendStack(app, configParams['CdkBackendStack'], { 18 | env: {account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION} 19 | }); 20 | 21 | -------------------------------------------------------------------------------- /cdk-stacks/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/cdk-stacks.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 25 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 26 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 27 | "@aws-cdk/aws-iam:minimizePolicies": true, 28 | "@aws-cdk/core:target-partitions": [ 29 | "aws", 30 | "aws-cn" 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cdk-stacks/config.params.json: -------------------------------------------------------------------------------- 1 | { 2 | "CdkAppName": "AmazonConnectDataAnalyticsSample", 3 | "CdkBackendStack": "AmazonConnectDataAnalyticsSampleBackend", 4 | "hierarchy": "/AmazonConnectDataAnalyticsSample/", 5 | "parameters": [ 6 | { 7 | "name": "awsGlueDatabaseName", 8 | "cliFormat": "aws-glue-database-name", 9 | "description": "AWS Glue Database to hold tables for Amazon Connect Data Analytics", 10 | "defaultValue": "AmazonConnectDataAnalyticsDB", 11 | "required": true 12 | }, 13 | { 14 | "name": "ctrStackEnabled", 15 | "cliFormat": "ctr-stack-enabled", 16 | "description": "Set to true to deploy Contact Trace Records (CTR) Stack", 17 | "defaultValue": false, 18 | "required": true, 19 | "boolean": true 20 | }, 21 | { 22 | "name": "ctrPartitioningScheduleEnabled", 23 | "cliFormat": "ctr-partitioning-schedule-enabled", 24 | "description": "Set to true if you want to schedule CTR Partitioning Job in EventBridge", 25 | "defaultValue": true, 26 | "required": true, 27 | "boolean": true, 28 | "parent": "ctrStackEnabled" 29 | }, 30 | { 31 | "name": "aeStackEnabled", 32 | "cliFormat": "ae-stack-enabled", 33 | "description": "Set to true to deploy Agent Events (AE) Stack", 34 | "defaultValue": false, 35 | "required": true, 36 | "boolean": true 37 | }, 38 | { 39 | "name": "aePartitioningScheduleEnabled", 40 | "cliFormat": "ae-partitioning-schedule-enabled", 41 | "description": "Set to true if you want to schedule AE Partitioning Job in EventBridge", 42 | "defaultValue": true, 43 | "required": true, 44 | "boolean": true, 45 | "parent": "aeStackEnabled" 46 | }, 47 | { 48 | "name": "cflStackEnabled", 49 | "cliFormat": "cfl-stack-enabled", 50 | "description": "Set to true to deploy Contact Flow Logs (CFL) Stack", 51 | "defaultValue": false, 52 | "required": true, 53 | "boolean": true 54 | }, 55 | { 56 | "name": "cflPartitioningScheduleEnabled", 57 | "cliFormat": "cfl-partitioning-schedule-enabled", 58 | "description": "Set to true if you want to schedule CFL Partitioning Job in EventBridge", 59 | "defaultValue": true, 60 | "required": true, 61 | "boolean": true, 62 | "parent": "cflStackEnabled" 63 | }, 64 | { 65 | "name": "connectContactFlowLogsCloudWatchLogGroup", 66 | "cliFormat": "connect-contact-flow-logs-cloudwatch-log-group", 67 | "description": "Set Amazon CloudWatch log group where Amazon Connect Contact Flow Logs are stored (i.e. /aws/connect/your-instance-alias)", 68 | "required": true, 69 | "parent": "cflStackEnabled" 70 | }, 71 | { 72 | "name": "clStackEnabled", 73 | "cliFormat": "cl-stack-enabled", 74 | "description": "Set to true to deploy Contact Lens (CL) Stack", 75 | "defaultValue": false, 76 | "required": true, 77 | "boolean": true 78 | }, 79 | { 80 | "name": "connectContactLensS3BucketName", 81 | "cliFormat": "connect-contact-lens-s3-bucket-name", 82 | "description": "The S3 bucket where Amazon Connect stores Contact Lens output files (and Amazon Connect Call Recordings)", 83 | "required": true, 84 | "parent": "clStackEnabled" 85 | }, 86 | { 87 | "name": "clPartitioningScheduleEnabled", 88 | "cliFormat": "cl-partitioning-schedule-enabled", 89 | "description": "Set to true if you want to schedule CL Partitioning Job in EventBridge", 90 | "defaultValue": true, 91 | "required": true, 92 | "boolean": true, 93 | "parent": "clStackEnabled" 94 | }, 95 | { 96 | "name": "efStackEnabled", 97 | "cliFormat": "ef-stack-enabled", 98 | "description": "Set to true to deploy Evaluation Forms (EF) Stack", 99 | "defaultValue": false, 100 | "required": true, 101 | "boolean": true 102 | }, 103 | { 104 | "name": "connectEvaluationFormsS3Location", 105 | "cliFormat": "connect-evaluation-forms-s3-location", 106 | "description": "The S3 bucket/prefix where Amazon Connect stores Evaluation Forms output files (i.e. your-bucket-name/connect/your-instance-alias/ContactEvaluations)", 107 | "required": true, 108 | "parent": "efStackEnabled" 109 | }, 110 | { 111 | "name": "efPartitioningScheduleEnabled", 112 | "cliFormat": "ef-partitioning-schedule-enabled", 113 | "description": "Set to true if you want to schedule EF Partitioning Job in EventBridge", 114 | "defaultValue": true, 115 | "required": true, 116 | "boolean": true, 117 | "parent": "efStackEnabled" 118 | }, 119 | { 120 | "name": "efReportingStackEnabled", 121 | "cliFormat": "ef-reporting-stack-enabled", 122 | "description": "Set to true if you want to deploy resources for the Analyze Amazon Connect Evaluation Form blog", 123 | "defaultValue": false, 124 | "required": true, 125 | "boolean": true, 126 | "parent": "efStackEnabled" 127 | } 128 | ] 129 | } -------------------------------------------------------------------------------- /cdk-stacks/configure.js: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const configParams = require('./config.params.json') 5 | const {SSMClient, PutParameterCommand, GetParametersCommand, DeleteParametersCommand} = require('@aws-sdk/client-ssm') 6 | const ssmClient = new SSMClient() 7 | const fs = require('fs') 8 | const readline = require("readline") 9 | 10 | const SSM_NOT_DEFINED = 'not-defined' 11 | let VERBOSE = false 12 | 13 | 14 | function displayHelp() { 15 | console.log(`\nThis script gets deployment parameters and stores the parameters to AWS System Manager Parameter Store \n`) 16 | 17 | console.log(`Usage:\n`) 18 | console.log(`-i \t Run in interactive mode`) 19 | console.log(`-l \t When running in interactive mode, load the current parameters from AWS System Manager Parameter Store`) 20 | console.log(`-t \t Run Test mode (only creates config.cache.json, but it does not store parameters to AWS System Manager Parameter Store)`) 21 | console.log(`-d \t Delete all AWS SSM Parameters (after CDK stack was destroyed)`) 22 | displayParametersHelp() 23 | process.exit(0) 24 | } 25 | 26 | function displayParametersHelp() { 27 | console.log(`\nParameters: \n`) 28 | configParams.parameters.forEach(param => { 29 | console.log(`--${param.cliFormat} [${param.required ? 'required' : 'optional'}${param.parent ? ' when ' + getParentObject(param).cliFormat : ''}] \n\t\t${wrapText(param.description, 80)}\n`) 30 | }) 31 | } 32 | 33 | function wrapText(s, w) { 34 | return s.replace(new RegExp(`(?![^\\n]{1,${w}}$)([^\\n]{1,${w}})\\s`, 'g'), '$1\n\t\t') 35 | } 36 | 37 | function getParentObject(param) { 38 | return configParams.parameters.find(parent => parent.name === param.parent) 39 | } 40 | 41 | function isParentEnabled(param) { 42 | return configParams.parameters.find(parent => parent.name === param.parent).value === true 43 | } 44 | 45 | function isNotDefined(param) { 46 | return param.value === SSM_NOT_DEFINED 47 | } 48 | 49 | function isUndefinedNullEmpty(value) { 50 | return value === undefined || value === null || (typeof value === 'string' && value.trim() === '') 51 | } 52 | 53 | function throwParameterRequired(param) { 54 | 55 | throw new Error(`Required parameter not provided: [${param.cliFormat}]`) 56 | } 57 | 58 | async function loadParametersSSM() { 59 | console.log(`\nLoading current parameters from AWS System Manager Parameter Store\n`); 60 | 61 | const chunkedParameters = chunkArray(configParams.parameters, 10); 62 | for (let i = 0; i < chunkedParameters.length; i++) { 63 | const getParametersResult = await getParametersSSMBatch(chunkedParameters[i]).catch(error => { 64 | console.log(`ERROR: getParametersSSMBatch: ${error.message}`); 65 | return undefined; 66 | }); 67 | 68 | getParametersResult?.forEach(loadedParam => { 69 | if (loadedParam.Value && loadedParam.Name) { 70 | const configParam = configParams.parameters.find(configParam => configParam.name === /[^/]*$/.exec(loadedParam.Name)[0]); 71 | configParam.value = parseParam(loadedParam.Value); 72 | } 73 | }); 74 | 75 | if (i !== 0 && i % 2 === 0) await wait(1000); 76 | } 77 | 78 | console.log(`\nLoad completed\n`); 79 | } 80 | 81 | async function getParametersSSMBatch(parametersArray) { 82 | if (parametersArray?.length < 1 || parametersArray?.length > 10) throw new Error(`getParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 10 items`); 83 | 84 | const paramNamesArray = parametersArray.map((param) => `${configParams.hierarchy}${param.name}`); 85 | 86 | const getParametersResult = await ssmClient.send(new GetParametersCommand({Names: paramNamesArray})); 87 | 88 | getParametersResult?.InvalidParameters?.forEach(invalidParam => { 89 | console.log(`Error loading parameter: ${invalidParam}`); 90 | }); 91 | 92 | return getParametersResult?.Parameters; 93 | } 94 | 95 | async function storeParametersSSM() { 96 | console.log(`\nStoring parameters to AWS System Manager Parameter Store\n`) 97 | 98 | const chunkedParameters = chunkArray(configParams.parameters, 5); 99 | for (let i = 0; i < chunkedParameters.length; i++) { 100 | await putParametersSSMBatch(chunkedParameters[i]).catch(error => { 101 | console.log(`ERROR: putParametersSSMBatch: ${error.message}`); 102 | }); 103 | await wait(1000); 104 | } 105 | 106 | console.log(`\nStore completed\n`) 107 | } 108 | 109 | async function putParametersSSMBatch(parametersArray) { 110 | if (parametersArray?.length < 1 || parametersArray?.length > 5) throw new Error(`putParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 5 items`); 111 | 112 | for (const param of parametersArray) { 113 | console.log(`\nAWS SSM put ${configParams.hierarchy}${param.name} = ${param.value}`); 114 | //supports only String parameters 115 | const putParameterResult = await ssmClient.send(new PutParameterCommand({ 116 | Type: 'String', 117 | Name: `${configParams.hierarchy}${param.name}`, 118 | Value: param.boolean ? param.value.toString() : param.value, 119 | Overwrite: true 120 | })); 121 | console.log(`Stored param: ${configParams.hierarchy}${param.name} | tier: ${putParameterResult.Tier} | version: ${putParameterResult.Version}\n`); 122 | } 123 | } 124 | 125 | async function deleteParametersSSM() { 126 | console.log(`\nDeleting parameters to AWS System Manager Parameter Store\n`); 127 | 128 | const chunkedParameters = chunkArray(configParams.parameters, 10); 129 | for (let i = 0; i < chunkedParameters.length; i++) { 130 | await deleteParametersSSMBatch(chunkedParameters[i]).catch(error => { 131 | console.log(`ERROR: deleteParametersSSMBatch: ${error.message}`); 132 | }); 133 | await wait(1000); 134 | } 135 | 136 | console.log(`\nDelete completed\n`) 137 | process.exit(0); 138 | } 139 | 140 | async function deleteParametersSSMBatch(parametersArray) { 141 | if (parametersArray?.length < 1 || parametersArray?.length > 10) throw new Error(`deleteParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 10 items`); 142 | 143 | const paramNamesArray = parametersArray.map((param) => `${configParams.hierarchy}${param.name}`); 144 | const deleteParametersResult = await ssmClient.send(new DeleteParametersCommand({Names: paramNamesArray})); 145 | 146 | deleteParametersResult?.InvalidParameters?.forEach(invalidParam => { 147 | console.log(`Error deleting parameter: ${invalidParam}`); 148 | }); 149 | 150 | deleteParametersResult?.DeletedParameters?.forEach(deletedParam => { 151 | console.log(`Deleted param: ${deletedParam}`) 152 | }); 153 | } 154 | 155 | function chunkArray(inputArray, chunkSize) { 156 | let index = 0; 157 | const arrayLength = inputArray.length; 158 | let resultArray = []; 159 | 160 | for (index = 0; index < arrayLength; index += chunkSize) { 161 | let chunkItem = inputArray.slice(index, index + chunkSize); 162 | resultArray.push(chunkItem); 163 | } 164 | 165 | return resultArray; 166 | } 167 | 168 | function wait(time) { 169 | return new Promise((resolve) => { 170 | setTimeout(() => resolve(), time); 171 | }); 172 | } 173 | 174 | async function writeConfigCacheJSON() { 175 | console.log(`\nWriting current parameters to config.cache.json\n`) 176 | 177 | const configCache = {} 178 | for (const param of configParams.parameters) { 179 | configCache[`${configParams.hierarchy}${param.name}`] = param.value 180 | } 181 | 182 | fs.writeFileSync('config.cache.json', JSON.stringify(configCache, null, '\t')) 183 | 184 | console.log(`\nWrite completed\n`) 185 | } 186 | 187 | function checkRequiredParameters() { 188 | 189 | for (const param of configParams.parameters) { 190 | if (param.required && !param.parent && isNotDefined(param)) { 191 | throwParameterRequired(param) 192 | } 193 | 194 | if (param.required && param.parent && isParentEnabled(param) && isNotDefined(param)) { 195 | throwParameterRequired(param) 196 | } 197 | } 198 | } 199 | 200 | function initParameters() { 201 | for (const param of configParams.parameters) { 202 | param.value = isUndefinedNullEmpty(param.defaultValue) ? SSM_NOT_DEFINED : param.defaultValue 203 | } 204 | } 205 | 206 | function displayInputParameters() { 207 | console.log(`\nInput parameters:\n`) 208 | 209 | for (const param of configParams.parameters) { 210 | console.log(`${param.cliFormat} = ${param.value}`) 211 | } 212 | } 213 | 214 | function parseParam(value) { 215 | let tValue = value.trim() 216 | if (typeof tValue === 'string' && tValue.toLocaleLowerCase() === 'true') return true 217 | if (typeof tValue === 'string' && tValue.toLowerCase() === 'false') return false 218 | return tValue 219 | } 220 | 221 | function getArgs() { 222 | const argFlags = {} 223 | const argParams = {} 224 | 225 | process.argv 226 | .slice(2, process.argv.length) 227 | .forEach(arg => { 228 | // long args 229 | if (arg.slice(0, 2) === '--') { 230 | const longArg = arg.split('='); 231 | const longArgFlag = longArg[0].slice(2, longArg[0].length); 232 | const longArgValue = longArg.length > 1 ? parseParam(longArg[1]) : true; 233 | argParams[longArgFlag] = longArgValue; 234 | } 235 | // flags 236 | else if (arg[0] === '-') { 237 | const flags = arg.slice(1, arg.length).split(''); 238 | flags.forEach(flag => { 239 | argFlags[flag] = true; 240 | }); 241 | } 242 | }); 243 | return {argFlags: argFlags, argParams: argParams}; 244 | } 245 | 246 | async function runInteractive(loadSSM = false) { 247 | if (loadSSM) { 248 | await loadParametersSSM() 249 | } 250 | await promptForParameters() 251 | } 252 | 253 | function buildQuestion(question, rl) { 254 | return new Promise((res, rej) => { 255 | rl.question(question, input => { 256 | res(input); 257 | }) 258 | }); 259 | } 260 | 261 | async function promptForParameters() { 262 | console.log(`\nPlease provide your parameters:\n`) 263 | 264 | const rl = readline.createInterface({input: process.stdin, output: process.stdout}); 265 | 266 | for (const param of configParams.parameters) { 267 | if (!param.parent || (param.parent && isParentEnabled(param))) { 268 | const input = await buildQuestion(`${param.cliFormat} [${param.value}]`, rl) 269 | if (input.trim() !== '') { 270 | param.value = parseParam(input) 271 | } 272 | } 273 | } 274 | 275 | rl.close() 276 | } 277 | 278 | function processArgParams(argParams) { 279 | 280 | for (const param of configParams.parameters) { 281 | const argValue = argParams[param.cliFormat] 282 | if (argValue !== undefined) { 283 | param.value = argValue 284 | } 285 | } 286 | } 287 | 288 | async function run() { 289 | try { 290 | const {argFlags, argParams} = getArgs(); 291 | 292 | if (argFlags['v'] === true) { 293 | VERBOSE = true 294 | } 295 | 296 | if (argFlags['h'] === true) { 297 | return displayHelp() 298 | } 299 | 300 | if (argFlags['d'] === true) { 301 | return await deleteParametersSSM() 302 | } 303 | 304 | if (argFlags['t'] === true) { 305 | console.log(`\nRunning in test mode\n`) 306 | } 307 | 308 | initParameters() 309 | 310 | if (argFlags['i'] === true) { 311 | console.log(`\nRunning in interactive mode\n`) 312 | await runInteractive(argFlags['l']) 313 | } else { 314 | processArgParams(argParams) 315 | } 316 | 317 | displayInputParameters() 318 | 319 | checkRequiredParameters() 320 | 321 | writeConfigCacheJSON() 322 | 323 | if (argFlags['t'] !== true) { 324 | await storeParametersSSM() 325 | } 326 | 327 | console.log(`\nConfiguration complete, review your parameters in config.cache.json\n`) 328 | process.exit(0) 329 | } catch (error) { 330 | console.error(`\nError: ${error.message}\n`) 331 | if (VERBOSE) console.log(error) 332 | process.exit(1) 333 | } 334 | 335 | } 336 | 337 | run() 338 | 339 | 340 | 341 | -------------------------------------------------------------------------------- /cdk-stacks/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 13, 9 | "sourceType": "module" 10 | }, 11 | "rules": {} 12 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/constants/EvaluationFormsConstants.js: -------------------------------------------------------------------------------- 1 | export const QuestionTypes = { 2 | SINGLESELECT: 'SINGLESELECT', 3 | NUMERIC: 'NUMERIC', 4 | TEXT: 'TEXT', 5 | } 6 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ContactLens/clEventProcessor.js: -------------------------------------------------------------------------------- 1 | const ContactLensService = require('../../services/ContactLensService'); 2 | 3 | exports.handler = async (event) => { 4 | try { 5 | console.debug(`Event: `, event); 6 | const clEventProcessorResult = await ContactLensService.clEventProcessor(event); 7 | console.info('CL Event Processor result: ', clEventProcessorResult); 8 | return clEventProcessorResult; 9 | } catch (error) { 10 | console.error('CLEventProcessorHandler: ', error); 11 | throw error; 12 | } 13 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ContactLens/clOutputFileLoader.js: -------------------------------------------------------------------------------- 1 | const ContactLensService = require('../../services/ContactLensService'); 2 | 3 | exports.handler = async (event) => { 4 | 5 | try { 6 | console.debug(`Event: `, event); 7 | const clOutputFileLoaderResult = await ContactLensService.clOutputFileLoader(event); 8 | console.info('CL Output File Loader result: ', clOutputFileLoaderResult); 9 | return clOutputFileLoaderResult; 10 | } catch (error) { 11 | console.error('CLOutputFileLoaderHandler: ', error); 12 | throw error; 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ContactLens/clRecordWriter.js: -------------------------------------------------------------------------------- 1 | const ContactLensService = require('../../services/ContactLensService'); 2 | 3 | exports.handler = async (event) => { 4 | try { 5 | console.debug(`Event: `, event); 6 | const clRecordWriterResult = await ContactLensService.clRecordWriter(event); 7 | console.info('CL Record Writer result: ', clRecordWriterResult); 8 | return clRecordWriterResult; 9 | } catch (error) { 10 | console.error('CLRecordWriterHandler: ', error); 11 | throw error; 12 | } 13 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/EvaluationForms/efEventProcessor.js: -------------------------------------------------------------------------------- 1 | const EvaluationFormsService = require('../../services/EvaluationFormsService'); 2 | 3 | exports.handler = async (event) => { 4 | try { 5 | console.debug(`Event: `, event); 6 | const efEventProcessorResult = await EvaluationFormsService.efEventProcessor(event); 7 | console.info('EF Event Processor result: ', efEventProcessorResult); 8 | return efEventProcessorResult; 9 | } catch (error) { 10 | console.error('EFEventProcessorHandler: ', error); 11 | throw error; 12 | } 13 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/EvaluationForms/efOutputFileLoader.js: -------------------------------------------------------------------------------- 1 | const EvaluationFormsService = require('../../services/EvaluationFormsService'); 2 | 3 | exports.handler = async (event) => { 4 | 5 | try { 6 | console.debug(`Event: `, event); 7 | const efOutputFileLoaderResult = await EvaluationFormsService.efOutputFileLoader(event); 8 | console.info('EF Output File Loader result: ', efOutputFileLoaderResult); 9 | return efOutputFileLoaderResult; 10 | } catch (error) { 11 | console.error('EFOutputFileLoaderHandler: ', error); 12 | throw error; 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/EvaluationForms/efRecordWriter.js: -------------------------------------------------------------------------------- 1 | const EvaluationFormsService = require('../../services/EvaluationFormsService'); 2 | 3 | exports.handler = async (event) => { 4 | try { 5 | console.debug(`Event: `, event); 6 | const efRecordWriterResult = await EvaluationFormsService.efRecordWriter(event); 7 | console.info('EF Record Writer result: ', efRecordWriterResult); 8 | return efRecordWriterResult; 9 | } catch (error) { 10 | console.error('EFRecordWriterHandler: ', error); 11 | throw error; 12 | } 13 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/Partitioning/getPartitioningResults.js: -------------------------------------------------------------------------------- 1 | const PartitioningService = require('../../services/PartitioningService'); 2 | 3 | exports.handler = async (event) => { 4 | 5 | try { 6 | console.debug(`Event: `, event); 7 | 8 | const getPartitioningResultsResult = await PartitioningService.getPartitioningResults(event.result.queryExecutionId); 9 | console.info('getPartitioningResultsResult: ', getPartitioningResultsResult); 10 | return getPartitioningResultsResult; 11 | } catch (error) { 12 | console.error('PartitioningService: ', error); 13 | throw error; 14 | } 15 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/Partitioning/partitioningIterator.js: -------------------------------------------------------------------------------- 1 | const PartitioningService = require('../../services/PartitioningService'); 2 | 3 | exports.handler = async (event) => { 4 | 5 | try { 6 | console.debug(`Event: `, event); 7 | 8 | const partitioningIteratorResult = PartitioningService.partitioningIterator(event.iterator.index, event.iterator.step, event.iterator.count, event.iterator.iteratorWaitInit); 9 | console.info('partitioningIteratorResult: ', partitioningIteratorResult); 10 | return partitioningIteratorResult; 11 | } catch (error) { 12 | console.error('PartitioningService: ', error); 13 | throw error; 14 | } 15 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/Partitioning/pollPartitioningStatus.js: -------------------------------------------------------------------------------- 1 | const PartitioningService = require('../../services/PartitioningService'); 2 | 3 | exports.handler = async (event) => { 4 | 5 | try { 6 | console.debug(`Event: `, event); 7 | 8 | const pollPartitioningStatusResult = await PartitioningService.pollPartitioningStatus(event.result.queryExecutionId, event.result.waitTime); 9 | console.info('pollPartitioningStatusResult: ', pollPartitioningStatusResult); 10 | return pollPartitioningStatusResult; 11 | } catch (error) { 12 | console.error('PartitioningService: ', error); 13 | throw error; 14 | } 15 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/Partitioning/startPartitioning.js: -------------------------------------------------------------------------------- 1 | const PartitioningService = require('../../services/PartitioningService'); 2 | 3 | exports.handler = async (event) => { 4 | 5 | try { 6 | console.debug(`Event: `, event); 7 | 8 | const startPartitioningResult = await PartitioningService.startPartitioning(event.s3_bucket, event.s3_prefix, event.table_name, event.overridePartitionLoad); 9 | console.info('startPartitioningResult: ', startPartitioningResult); 10 | return startPartitioningResult; 11 | } catch (error) { 12 | console.error('PartitioningService: ', error); 13 | throw error; 14 | } 15 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/RecordProcessors/kinesisFirehoseCloudwatchLogsProcessor.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014, Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // 3 | // Licensed under the Amazon Software License (the "License"). 4 | // You may not use this file except in compliance with the License. 5 | // A copy of the License is located at 6 | // 7 | // http://aws.amazon.com/asl/ 8 | // 9 | // or in the "license" file accompanying this file. This file is distributed 10 | // on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | // express or implied. See the License for the specific language governing 12 | // permissions and limitations under the License. 13 | 14 | /* 15 | For processing data sent to Firehose by Cloudwatch Logs subscription filters. 16 | 17 | Cloudwatch Logs sends to Firehose records that look like this: 18 | 19 | { 20 | "messageType": "DATA_MESSAGE", 21 | "owner": "123456789012", 22 | "logGroup": "log_group_name", 23 | "logStream": "log_stream_name", 24 | "subscriptionFilters": [ 25 | "subscription_filter_name" 26 | ], 27 | "logEvents": [ 28 | { 29 | "id": "01234567890123456789012345678901234567890123456789012345", 30 | "timestamp": 1510109208016, 31 | "message": "log message 1" 32 | }, 33 | { 34 | "id": "01234567890123456789012345678901234567890123456789012345", 35 | "timestamp": 1510109208017, 36 | "message": "log message 2" 37 | } 38 | ... 39 | ] 40 | } 41 | 42 | The data is additionally compressed with GZIP. 43 | 44 | NOTE: It is suggested to test the cloudwatch logs processor lambda function in a pre-production environment to ensure 45 | the 6000000 limit meets your requirements. If your data contains a sizable number of records that are classified as 46 | Dropped/ProcessingFailed, then it is suggested to lower the 6000000 limit within the function to a smaller value 47 | (eg: 5000000) in order to confine to the 6MB (6291456 bytes) payload limit imposed by lambda. You can find Lambda 48 | quotas at https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html 49 | 50 | The code below will: 51 | 52 | 1) Gunzip the data 53 | 2) Parse the json 54 | 3) Set the result to ProcessingFailed for any record whose messageType is not DATA_MESSAGE, thus redirecting them to the 55 | processing error output. Such records do not contain any log events. You can modify the code to set the result to 56 | Dropped instead to get rid of these records completely. 57 | 4) For records whose messageType is DATA_MESSAGE, extract the individual log events from the logEvents field, and pass 58 | each one to the transformLogEvent method. You can modify the transformLogEvent method to perform custom 59 | transformations on the log events. 60 | 5) Concatenate the result from (4) together and set the result as the data of the record returned to Firehose. Note that 61 | this step will not add any delimiters. Delimiters should be appended by the logic within the transformLogEvent 62 | method. 63 | 6) Any individual record exceeding 6,000,000 bytes in size after decompression and encoding is marked as 64 | ProcessingFailed within the function. The original compressed record will be backed up to the S3 bucket 65 | configured on the Firehose. 66 | 7) Any additional records which exceed 6MB will be re-ingested back into Firehose. 67 | 8) The retry count for intermittent failures during re-ingestion is set 20 attempts. If you wish to retry fewer number 68 | of times for intermittent failures you can lower this value. 69 | 70 | */ 71 | const zlib = require('zlib'); 72 | const {Kinesis} = require('@aws-sdk/client-kinesis'); 73 | const {Firehose} = require('@aws-sdk/client-firehose'); 74 | 75 | 76 | /** 77 | * logEvent has this format: 78 | * 79 | * { 80 | * "id": "01234567890123456789012345678901234567890123456789012345", 81 | * "timestamp": 1510109208016, 82 | * "message": "log message 1" 83 | * } 84 | * 85 | * The default implementation below just extracts the message and appends a newline to it. 86 | * 87 | * The result must be returned in a Promise. 88 | */ 89 | 90 | function transformLogEvent(logEvent) { 91 | let logEventItem = {}; 92 | logEventItem.timestamp = new Date(1 * logEvent.timestamp); 93 | logEventItem.eventid = logEvent.id; 94 | logEventItem.message = JSON.parse(logEvent.message); 95 | return Promise.resolve(`${JSON.stringify(logEventItem)}\n`); 96 | } 97 | 98 | function putRecordsToFirehoseStream(streamName, records, client, resolve, reject, attemptsMade, maxAttempts) { 99 | client.putRecordBatch({ 100 | DeliveryStreamName: streamName, 101 | Records: records, 102 | }, (err, data) => { 103 | const codes = []; 104 | let failed = []; 105 | let errMsg = err; 106 | 107 | if (err) { 108 | failed = records; 109 | } else { 110 | for (let i = 0; i < data.RequestResponses.length; i++) { 111 | const code = data.RequestResponses[i].ErrorCode; 112 | if (code) { 113 | codes.push(code); 114 | failed.push(records[i]); 115 | } 116 | } 117 | errMsg = `Individual error codes: ${codes}`; 118 | } 119 | 120 | if (failed.length > 0) { 121 | if (attemptsMade + 1 < maxAttempts) { 122 | console.log('Some records failed while calling PutRecordBatch, retrying. %s', errMsg); 123 | putRecordsToFirehoseStream(streamName, failed, client, resolve, reject, attemptsMade + 1, maxAttempts); 124 | } else { 125 | reject(`Could not put records after ${maxAttempts} attempts. ${errMsg}`); 126 | } 127 | } else { 128 | resolve(''); 129 | } 130 | }); 131 | } 132 | 133 | function putRecordsToKinesisStream(streamName, records, client, resolve, reject, attemptsMade, maxAttempts) { 134 | client.putRecords({ 135 | StreamName: streamName, 136 | Records: records, 137 | }, (err, data) => { 138 | const codes = []; 139 | let failed = []; 140 | let errMsg = err; 141 | 142 | if (err) { 143 | failed = records; 144 | } else { 145 | for (let i = 0; i < data.Records.length; i++) { 146 | const code = data.Records[i].ErrorCode; 147 | if (code) { 148 | codes.push(code); 149 | failed.push(records[i]); 150 | } 151 | } 152 | errMsg = `Individual error codes: ${codes}`; 153 | } 154 | 155 | if (failed.length > 0) { 156 | if (attemptsMade + 1 < maxAttempts) { 157 | console.log('Some records failed while calling PutRecords, retrying. %s', errMsg); 158 | putRecordsToKinesisStream(streamName, failed, client, resolve, reject, attemptsMade + 1, maxAttempts); 159 | } else { 160 | reject(`Could not put records after ${maxAttempts} attempts. ${errMsg}`); 161 | } 162 | } else { 163 | resolve(''); 164 | } 165 | }); 166 | } 167 | 168 | function createReingestionRecord(isSas, originalRecord) { 169 | if (isSas) { 170 | return { 171 | Data: Buffer.from(originalRecord.data, 'base64'), 172 | PartitionKey: originalRecord.kinesisRecordMetadata.partitionKey, 173 | }; 174 | } else { 175 | return { 176 | Data: Buffer.from(originalRecord.data, 'base64'), 177 | }; 178 | } 179 | } 180 | 181 | 182 | function getReingestionRecord(isSas, reIngestionRecord) { 183 | if (isSas) { 184 | return { 185 | Data: reIngestionRecord.Data, 186 | PartitionKey: reIngestionRecord.PartitionKey, 187 | }; 188 | } else { 189 | return { 190 | Data: reIngestionRecord.Data, 191 | }; 192 | } 193 | } 194 | 195 | exports.handler = (event, context, callback) => { 196 | Promise.all(event.records.map(r => { 197 | const buffer = Buffer.from(r.data, 'base64'); 198 | 199 | let decompressed; 200 | try { 201 | decompressed = zlib.gunzipSync(buffer); 202 | } catch (e) { 203 | return Promise.resolve({ 204 | recordId: r.recordId, 205 | result: 'ProcessingFailed', 206 | }); 207 | } 208 | 209 | const data = JSON.parse(decompressed); 210 | // CONTROL_MESSAGE are sent by CWL to check if the subscription is reachable. 211 | // They do not contain actual data. 212 | if (data.messageType === 'CONTROL_MESSAGE') { 213 | return Promise.resolve({ 214 | recordId: r.recordId, 215 | result: 'Dropped', 216 | }); 217 | } else if (data.messageType === 'DATA_MESSAGE') { 218 | const promises = data.logEvents.map(transformLogEvent); 219 | return Promise.all(promises) 220 | .then(transformed => { 221 | const payload = transformed.reduce((a, v) => a + v, ''); 222 | const encoded = Buffer.from(payload).toString('base64'); 223 | if (encoded.length <= 6000000) { 224 | return { 225 | recordId: r.recordId, 226 | result: 'Ok', 227 | data: encoded, 228 | }; 229 | } else { 230 | return { 231 | recordId: r.recordId, 232 | result: 'ProcessingFailed', 233 | }; 234 | } 235 | }); 236 | } else { 237 | return Promise.resolve({ 238 | recordId: r.recordId, 239 | result: 'ProcessingFailed', 240 | }); 241 | } 242 | })).then(recs => { 243 | const isSas = Object.prototype.hasOwnProperty.call(event, 'sourceKinesisStreamArn'); 244 | const streamARN = isSas ? event.sourceKinesisStreamArn : event.deliveryStreamArn; 245 | const region = streamARN.split(':')[3]; 246 | const streamName = streamARN.split('/')[1]; 247 | const result = {records: recs}; 248 | let recordsToReingest = []; 249 | const putRecordBatches = []; 250 | let totalRecordsToBeReingested = 0; 251 | const inputDataByRecId = {}; 252 | event.records.forEach(r => inputDataByRecId[r.recordId] = createReingestionRecord(isSas, r)); 253 | 254 | let projectedSize = recs.filter(rec => rec.result === 'Ok') 255 | .map(r => r.recordId.length + r.data.length) 256 | .reduce((a, b) => a + b, 0); 257 | // 6000000 instead of 6291456 to leave ample headroom for the stuff we didn't account for 258 | 259 | for (let idx = 0; idx < event.records.length && projectedSize > 6000000; idx++) { 260 | const rec = result.records[idx]; 261 | if (rec.result === 'Ok') { 262 | totalRecordsToBeReingested++; 263 | recordsToReingest.push(getReingestionRecord(isSas, inputDataByRecId[rec.recordId])); 264 | projectedSize -= rec.data.length; 265 | delete rec.data; 266 | result.records[idx].result = 'Dropped'; 267 | 268 | // split out the record batches into multiple groups, 500 records at max per group 269 | if (recordsToReingest.length === 500) { 270 | putRecordBatches.push(recordsToReingest); 271 | recordsToReingest = []; 272 | } 273 | } 274 | } 275 | 276 | if (recordsToReingest.length > 0) { 277 | // add the last batch 278 | putRecordBatches.push(recordsToReingest); 279 | } 280 | 281 | if (putRecordBatches.length > 0) { 282 | new Promise((resolve, reject) => { 283 | let recordsReingestedSoFar = 0; 284 | for (let idx = 0; idx < putRecordBatches.length; idx++) { 285 | const recordBatch = putRecordBatches[idx]; 286 | if (isSas) { 287 | const kinesisClient = new Kinesis({region: region}); 288 | putRecordsToKinesisStream(streamName, recordBatch, kinesisClient, resolve, reject, 0, 20); 289 | } else { 290 | const firehoseClient = new Firehose({region: region}); 291 | putRecordsToFirehoseStream(streamName, recordBatch, firehoseClient, resolve, reject, 0, 20); 292 | } 293 | recordsReingestedSoFar += recordBatch.length; 294 | console.log('Reingested %s/%s records out of %s in to %s stream', recordsReingestedSoFar, totalRecordsToBeReingested, event.records.length, streamName); 295 | } 296 | }).then( 297 | () => { 298 | console.log('Reingested all %s records out of %s in to %s stream', totalRecordsToBeReingested, event.records.length, streamName); 299 | callback(null, result); 300 | }, 301 | failed => { 302 | console.log('Failed to reingest records. %s', failed); 303 | callback(failed, null); 304 | }); 305 | } else { 306 | console.log('No records needed to be reingested.'); 307 | callback(null, result); 308 | } 309 | }).catch(ex => { 310 | console.log('Error: ', ex); 311 | callback(ex, null); 312 | }); 313 | }; 314 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/lib/CommonUtility.js: -------------------------------------------------------------------------------- 1 | const zlib = require('zlib'); 2 | 3 | const parseFileTimestampUTC = (fileTimestampString) => { 4 | //the input format is: 20220425T14:17_UTC 5 | const year = fileTimestampString.substring(0, 4); 6 | const month = fileTimestampString.substring(4, 6); 7 | const day = fileTimestampString.substring(6, 8); 8 | 9 | const hour = fileTimestampString.substring(9, 11); 10 | const minute = fileTimestampString.substring(12, 14); 11 | const second = "00"; 12 | 13 | const isoDateString = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`; 14 | const parsedDate = new Date(Date.parse(isoDateString)); 15 | return parsedDate; 16 | } 17 | 18 | const timestampMillisToISO = (timestamp) => { 19 | if (!timestamp) return null; 20 | 21 | const d = new Date(0); 22 | d.setUTCMilliseconds(timestamp); 23 | return d.toISOString(); 24 | } 25 | 26 | const isInteger = (str) => { 27 | if (typeof str != "string") return false; 28 | return !isNaN(str) && !isNaN(parseInt(str)); 29 | } 30 | 31 | const deflateStringifyObject = (inputObject = {}) => { 32 | const inputObjectJSON = JSON.stringify(inputObject); 33 | const deflatedInputObject = zlib.gzipSync(inputObjectJSON).toString('base64'); 34 | return deflatedInputObject; 35 | } 36 | 37 | const inflateParseObject = (inputString) => { 38 | const buffer = Buffer.from(inputString, 'base64'); 39 | const inflatedInputString = zlib.gunzipSync(buffer).toString('utf8'); 40 | const parsedObject = JSON.parse(inflatedInputString); 41 | return parsedObject; 42 | } 43 | 44 | module.exports = { 45 | parseFileTimestampUTC, 46 | timestampMillisToISO, 47 | isInteger, 48 | deflateStringifyObject, 49 | inflateParseObject, 50 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/lib/EFScoringUtil.js: -------------------------------------------------------------------------------- 1 | const {QuestionTypes} = require('../constants/EvaluationFormsConstants'); 2 | 3 | const extractEvaluationFormDetails = (evaluationFormObject) => { 4 | const evaluationId = evaluationFormObject.evaluationId; 5 | const contactId = evaluationFormObject.metadata?.contactId; 6 | const instanceId = evaluationFormObject.metadata?.instanceId; 7 | const agentId = evaluationFormObject.metadata?.agentId; 8 | const evaluationDefinitionTitle = evaluationFormObject.metadata?.evaluationDefinitionTitle; 9 | const evaluator = evaluationFormObject.metadata?.evaluator; 10 | 11 | const evaluationStartTimestamp = evaluationFormObject.metadata?.evaluationStartTimestamp; 12 | const evaluationSubmitTimestamp = evaluationFormObject.metadata?.evaluationSubmitTimestamp; 13 | 14 | const evaluationQuestionAnswers = extractEvaluationQuestionsAnswers(evaluationFormObject.sections, evaluationFormObject.questions); 15 | const evaluationSectionsScores = extractEvaluationSectionScores(evaluationFormObject.sections); 16 | const evaluationFormTotalScorePercentage = parseFloat(evaluationFormObject.metadata?.score?.percentage) || null; 17 | 18 | return { 19 | evaluationId, 20 | contactId, 21 | instanceId, 22 | agentId, 23 | evaluationDefinitionTitle, 24 | evaluator, 25 | evaluationStartTimestamp, 26 | evaluationSubmitTimestamp, 27 | evaluationQuestionAnswers, 28 | evaluationSectionsScores, 29 | evaluationFormTotalScorePercentage, 30 | } 31 | } 32 | 33 | const extractEvaluationQuestionsAnswers = (evaluationFormSections = [], evaluationFormQuestions = []) => { 34 | 35 | let questionsAnswers = []; 36 | 37 | 38 | for (const questionObject of evaluationFormQuestions) { 39 | 40 | const sectionObject = getSectionByRefId(evaluationFormSections, questionObject.sectionRefId); 41 | const parentSectionObject = getParentSectionByRefId(evaluationFormSections, questionObject.sectionRefId); 42 | 43 | const questionRefId = questionObject.questionRefId; 44 | 45 | const sectionRefId = questionObject.sectionRefId; 46 | const sectionTitle = sectionObject.sectionTitle; 47 | 48 | const parentSectionRefId = parentSectionObject?.sectionRefId ?? null; 49 | const parentSectionTitle = parentSectionObject?.sectionTitle ?? null; 50 | 51 | const fullSectionTitle = getFullSectionTitle(sectionTitle, parentSectionTitle); 52 | 53 | const questionType = questionObject.questionType; 54 | const questionText = questionObject.questionText; 55 | 56 | const questionAnswer = getQuestionAnswer(questionObject); 57 | 58 | questionsAnswers.push({ 59 | questionRefId, 60 | sectionRefId, 61 | sectionTitle, 62 | parentSectionRefId, 63 | parentSectionTitle, 64 | fullSectionTitle, 65 | questionType, 66 | questionText, 67 | ...questionAnswer, 68 | }); 69 | 70 | } 71 | 72 | return questionsAnswers; 73 | } 74 | 75 | const extractEvaluationSectionScores = (evaluationFormSections = []) => { 76 | let sectionsScores = []; 77 | for (const sectionObject of evaluationFormSections) { 78 | const sectionRefId = sectionObject.sectionRefId; 79 | const sectionTitle = sectionObject.sectionTitle; 80 | const sectionScorePercentage = parseFloat(sectionObject.score.percentage) || null; 81 | sectionsScores.push({ 82 | sectionRefId, 83 | sectionTitle, 84 | sectionScorePercentage, 85 | }); 86 | } 87 | 88 | return sectionsScores; 89 | } 90 | 91 | const getSectionByRefId = (evaluationFormSections, sectionRefId) => { 92 | const sectionObject = evaluationFormSections.find(evaluationFormSection => evaluationFormSection.sectionRefId === sectionRefId); 93 | return sectionObject; 94 | } 95 | 96 | const getParentSectionByRefId = (evaluationFormSections, sectionRefId) => { 97 | const sectionObject = getSectionByRefId(evaluationFormSections, sectionRefId); 98 | if (!Object.prototype.hasOwnProperty.call(sectionObject, 'parentSectionRefId')) return null; 99 | const parentSectionObject = getSectionByRefId(evaluationFormSections, sectionObject.parentSectionRefId); 100 | return parentSectionObject; 101 | } 102 | 103 | const getFullSectionTitle = (sectionTitle, parentSectionTitle) => { 104 | if (parentSectionTitle) return `${parentSectionTitle} -> ${sectionTitle}`; 105 | return sectionTitle; 106 | } 107 | 108 | const getQuestionAnswer = (questionObject) => { 109 | switch (questionObject.questionType) { 110 | case QuestionTypes.TEXT: 111 | return getQuestionAnswerText(questionObject); 112 | case QuestionTypes.SINGLESELECT: 113 | return getQuestionAnswerSingleSelect(questionObject); 114 | case QuestionTypes.NUMERIC: 115 | return getQuestionAnswerNumeric(questionObject); 116 | default: 117 | console.warn(`EvaluationFormsService -> getQuestionAnswer -> questionType: ${questionObject.questionType} is not supported. Skipping...`) 118 | return null; 119 | } 120 | } 121 | 122 | const getQuestionAnswerText = (questionObject) => { 123 | let questionAnswerValue = questionObject?.answer?.value ?? null; 124 | if (questionAnswerValue === 'undefined') questionAnswerValue = null; 125 | 126 | let questionAnswerScorePercentage = null; //No scoring for (free) TEXT based answers 127 | 128 | return { 129 | questionAnswerValue, 130 | questionAnswerScorePercentage, 131 | } 132 | } 133 | 134 | const getQuestionAnswerNumeric = (questionObject) => { 135 | let questionAnswerValue = questionObject?.answer?.value ?? null; 136 | if (questionAnswerValue === 'undefined') questionAnswerValue = null; 137 | 138 | let questionAnswerScorePercentage = parseFloat(questionObject?.score?.percentage) || null; 139 | 140 | return { 141 | questionAnswerValue, 142 | questionAnswerScorePercentage, 143 | } 144 | } 145 | 146 | const getQuestionAnswerSingleSelect = (questionObject) => { 147 | const singleSelectSelectedAnswer = getSingleSelectSelectedAnswer(questionObject.answer); 148 | 149 | const questionAnswerValue = singleSelectSelectedAnswer?.valueText ?? null; 150 | const questionAnswerValueRefId = singleSelectSelectedAnswer?.valueRefId ?? null; 151 | 152 | const questionAnswerScorePercentage = parseFloat(questionObject?.score?.percentage) || null; 153 | 154 | return { 155 | questionAnswerValue, 156 | questionAnswerValueRefId, 157 | questionAnswerScorePercentage, 158 | } 159 | } 160 | 161 | const getSingleSelectSelectedAnswer = (questionObjectAnswer) => { 162 | const singleSelectSelectedAnswer = questionObjectAnswer?.values?.find(questionObjectAnswerValue => questionObjectAnswerValue.selected); 163 | if (!singleSelectSelectedAnswer) return null; 164 | return singleSelectSelectedAnswer; 165 | } 166 | 167 | module.exports = { 168 | extractEvaluationFormDetails, 169 | } 170 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amazon-connect-data-analytics-sample-lambdas", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "keywords": [], 7 | "author": "", 8 | "license": "ISC", 9 | "devDependencies": { 10 | "@aws-sdk/client-athena": "^3.297.0", 11 | "@aws-sdk/client-firehose": "^3.297.0", 12 | "@aws-sdk/client-kinesis": "^3.297.0", 13 | "@aws-sdk/client-s3": "^3.297.0", 14 | "@aws-sdk/client-sqs": "^3.298.0", 15 | "eslint": "^8.13.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/AthenaService.js: -------------------------------------------------------------------------------- 1 | const { 2 | AthenaClient, 3 | StartQueryExecutionCommand, 4 | GetQueryExecutionCommand, 5 | GetQueryResultsCommand 6 | } = require("@aws-sdk/client-athena"); 7 | const region = process.env.AWS_REGION || 'us-east-1'; 8 | const athenaClient = new AthenaClient({region}); 9 | 10 | const createPartition = async (table_name, s3_bucket, s3_prefix, partition, outputLocation) => { 11 | 12 | const input = { 13 | QueryString: `ALTER TABLE ${table_name} 14 | ADD IF NOT EXISTS PARTITION ( ${partition.split('/')[0]}, ${partition.split('/')[1]}, ${partition.split('/')[2]} ) location 's3://${s3_bucket}/${s3_prefix}/${partition}';`, 15 | ResultConfiguration: { 16 | OutputLocation: outputLocation 17 | } 18 | }; 19 | const command = new StartQueryExecutionCommand(input); 20 | 21 | try { 22 | const result = await athenaClient.send(command); 23 | return result; 24 | } catch (error) { 25 | console.error('AthenaService.createPartition: ', error); 26 | throw error; 27 | } 28 | } 29 | 30 | const getQueryExecutionStatus = async (queryExecutionId) => { 31 | 32 | const input = { 33 | QueryExecutionId: queryExecutionId, 34 | }; 35 | const command = new GetQueryExecutionCommand(input); 36 | 37 | try { 38 | const result = await athenaClient.send(command); 39 | return { 40 | queryExecutionId: result?.QueryExecution?.QueryExecutionId, 41 | status: result?.QueryExecution?.Status?.State, 42 | } 43 | } catch (error) { 44 | console.error('AthenaService.getQueryExecutionStatus: ', error); 45 | throw error; 46 | } 47 | } 48 | 49 | const getQueryResults = async (queryExecutionId, maxResults = 5) => { 50 | 51 | const input = { 52 | QueryExecutionId: queryExecutionId, 53 | MaxResults: maxResults 54 | }; 55 | const command = new GetQueryResultsCommand(input); 56 | 57 | try { 58 | const result = await athenaClient.send(command); 59 | return result; 60 | } catch (error) { 61 | console.error('AthenaService.getQueryResults: ', error); 62 | throw error; 63 | } 64 | } 65 | 66 | module.exports = { 67 | createPartition, 68 | getQueryExecutionStatus, 69 | getQueryResults, 70 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/ContactLensService.js: -------------------------------------------------------------------------------- 1 | const SQSService = require('../services/SQSService'); 2 | const S3Service = require('../services/S3Service'); 3 | const {parseFileTimestampUTC, inflateParseObject} = require('../lib/CommonUtility'); 4 | const FirehoseService = require('./FirehoseService'); 5 | 6 | const clOutputFileLoaderQueueURL = process.env.CLOutputFileLoaderQueueURL; 7 | const clRecordWriterQueueURL = process.env.CLRecordWriterQueueURL; 8 | const clKinesisFirehoseName = process.env.CLKinesisFirehoseName; 9 | 10 | const clEventProcessor = async (event) => { 11 | 12 | const s3Bucket = event?.detail?.bucket?.name; 13 | if (!s3Bucket) return 's3Bucket not found in the payload'; 14 | 15 | const s3ObjectKey = event?.detail?.object?.key; 16 | if (!s3Bucket) return 's3ObjectKey not found in the payload'; 17 | 18 | console.info(`ContactLensService -> clEventProcessor -> Checking if ${s3ObjectKey} match Original analyzed transcript file pattern`); 19 | if (!isS3ObjectKeyValid(s3ObjectKey)) return 's3ObjectKey does not match Original analyzed transcript file pattern'; 20 | 21 | const clOutputFileDetails = { 22 | s3Bucket, 23 | s3ObjectKey, 24 | } 25 | 26 | console.info(`ContactLensService -> clEventProcessor -> Sending to ${clOutputFileLoaderQueueURL} queue: `, clOutputFileDetails); 27 | const clEventProcessorResult = await SQSService.sendObject(clOutputFileLoaderQueueURL, clOutputFileDetails).catch(error => { 28 | console.error(`ContactLensService -> clEventProcessor -> SQSService.sendMessage failed:`, error); 29 | throw error; 30 | }); 31 | 32 | return clEventProcessorResult; 33 | } 34 | 35 | const isS3ObjectKeyValid = (s3ObjectKey) => { 36 | 37 | //We need to match Original analyzed transcript file: Analysis/Voice/2020/02/04/contactID_analysis_2020-02-04T21:14:16Z.json 38 | //We don't want Redacted analyzed transcript file: /connect-instance- bucket/Analysis/Voice/Redacted/2020/02/04/contactID_analysis_redacted_2020-02-04T21:14:16Z.json 39 | //We don't want Redacted audio file: /connect-instance- bucket/Analysis/Voice/Redacted/2020/02/04/contactID_call_recording_redacted_2020-02-04T21:14:16Z.wav 40 | 41 | const contactLensOutputFileRegex = /^Analysis\/Voice\/\d{4}\/\d{2}\/\d{2}\/[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}_analysis_\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z.json/ 42 | return s3ObjectKey.match(contactLensOutputFileRegex); 43 | } 44 | 45 | const clOutputFileLoader = async (event) => { 46 | 47 | let processCLOutputFilePromises = []; 48 | for (const record of event.Records) { 49 | const inboundSQSMessageId = record.messageId; 50 | processCLOutputFilePromises.push(processCLOutputFile(record.body, inboundSQSMessageId)); 51 | } 52 | 53 | const processCLOutputFileResults = await Promise.allSettled(processCLOutputFilePromises); 54 | 55 | const processCLOutputFileErrors = processCLOutputFileResults.filter((processCLOutputFileResult) => processCLOutputFileResult.value?.error); 56 | 57 | if (processCLOutputFileErrors.length > 0) { 58 | console.error(`ContactLensService -> clOutputFileLoader -> processCLOutputFileErrors: ${processCLOutputFileErrors.length}`); 59 | 60 | const batchItemFailures = processCLOutputFileErrors.map((processCLOutputFileError) => { 61 | return {itemIdentifier: processCLOutputFileError.value?.inboundSQSMessageId} 62 | }); 63 | return {batchItemFailures}; 64 | } 65 | return {success: true}; 66 | } 67 | 68 | const processCLOutputFile = async (recordBody, inboundSQSMessageId) => { 69 | try { 70 | const clOutputFileDetails = inflateParseObject(recordBody); 71 | console.debug('ContactLensService -> processCLOutputFile -> clOutputFileDetails:', clOutputFileDetails); 72 | 73 | const contactLensObject = await S3Service.getObjectFromJSONFile(clOutputFileDetails.s3Bucket, clOutputFileDetails.s3ObjectKey).catch(error => { 74 | console.error(`ContactLensService -> processCLOutputFile -> S3Service.getObjectFromJSONFile failed:`, error); 75 | throw error; 76 | }); 77 | if (!contactLensObject) return 'contactLensObject not parsed'; 78 | console.debug(`ContactLensService -> processCLOutputFile -> contactLensObject: `, JSON.stringify(contactLensObject)); 79 | 80 | const contactLensDetails = extractContactLensDetails(contactLensObject); 81 | 82 | console.info(`ContactLensService -> processCLOutputFile -> Sending to ${clRecordWriterQueueURL} queue: `, contactLensDetails); 83 | const processCLOutputFileResult = await SQSService.sendObject(clRecordWriterQueueURL, contactLensDetails).catch(error => { 84 | console.error(`ContactLensService -> processCLOutputFile -> SQSService.sendMessage failed:`, error); 85 | throw error; 86 | }); 87 | 88 | return { 89 | inboundSQSMessageId, 90 | outboundSQSMessageId: processCLOutputFileResult.MessageId 91 | } 92 | } catch (error) { 93 | console.error(`ContactLensService -> processCLOutputFile failed:`, error); 94 | return ({error, inboundSQSMessageId}); 95 | } 96 | } 97 | 98 | const extractContactLensDetails = (contactLensObject) => { 99 | 100 | const contactId = contactLensObject.CustomerMetadata?.ContactId; 101 | const instanceId = contactLensObject.CustomerMetadata?.InstanceId; 102 | const recordingTimestamp = extractRecordingTimestamp(contactLensObject.CustomerMetadata.InputS3Uri, contactId).toISOString(); 103 | 104 | const channel = contactLensObject.Channel; 105 | const languageCode = contactLensObject.LanguageCode; 106 | const matchedCategories = contactLensObject.Categories?.MatchedCategories; 107 | const totalConversationDuration = Math.floor(contactLensObject.ConversationCharacteristics?.TotalConversationDurationMillis / 1000); 108 | 109 | const overallSentimentAgent = contactLensObject.ConversationCharacteristics?.Sentiment?.OverallSentiment?.AGENT; 110 | const overallSentimentCustomer = contactLensObject.ConversationCharacteristics?.Sentiment?.OverallSentiment?.CUSTOMER; 111 | 112 | const interruptionsTotalCount = contactLensObject.ConversationCharacteristics?.Interruptions?.TotalCount; 113 | const nonTalkTimeTotal = Math.floor(contactLensObject.ConversationCharacteristics?.NonTalkTime?.TotalTimeMillis / 1000); 114 | 115 | const averageWordsPerMinuteAgent = contactLensObject.ConversationCharacteristics?.TalkSpeed?.DetailsByParticipant?.AGENT?.AverageWordsPerMinute; 116 | const averageWordsPerMinuteCustomer = contactLensObject.ConversationCharacteristics?.TalkSpeed?.DetailsByParticipant?.CUSTOMER?.AverageWordsPerMinute; 117 | 118 | const talkTimeTotal = Math.floor(contactLensObject.ConversationCharacteristics?.TalkTime?.TotalTimeMillis / 1000); 119 | const talkTimeAgent = Math.floor(contactLensObject.ConversationCharacteristics?.TalkTime?.DetailsByParticipant?.AGENT?.TotalTimeMillis / 1000); 120 | const talkTimeCustomer = Math.floor(contactLensObject.ConversationCharacteristics?.TalkTime?.DetailsByParticipant?.CUSTOMER?.TotalTimeMillis / 1000); 121 | 122 | const callSummary = extractCallSummary(contactLensObject.Transcript); 123 | 124 | return { 125 | contactId, 126 | instanceId, 127 | recordingTimestamp, 128 | channel, 129 | languageCode, 130 | matchedCategories, 131 | totalConversationDuration, 132 | overallSentimentAgent, 133 | overallSentimentCustomer, 134 | interruptionsTotalCount, 135 | nonTalkTimeTotal, 136 | averageWordsPerMinuteAgent, 137 | averageWordsPerMinuteCustomer, 138 | talkTimeTotal, 139 | talkTimeAgent, 140 | talkTimeCustomer, 141 | callSummary, 142 | } 143 | } 144 | 145 | const extractRecordingTimestamp = (inputS3Uri, contactId) => { 146 | const fileName = /[^/]*$/.exec(inputS3Uri)[0]?.replace(/\.[^/.]+$/, ''); 147 | const timestampString = fileName.match(`(?<=${contactId}_).+`)?.[0]; 148 | const recordingTimestamp = parseFileTimestampUTC(timestampString); 149 | return recordingTimestamp; 150 | } 151 | 152 | const extractCallSummary = (contactLensObjectTranscript) => { 153 | 154 | let issuesDetectedCount = 0; 155 | let actionItemsDetectedCount = 0; 156 | let outcomesDetectedCount = 0; 157 | 158 | contactLensObjectTranscript.forEach(transcriptItem => { 159 | issuesDetectedCount += transcriptItem.IssuesDetected?.length ?? 0; 160 | actionItemsDetectedCount += transcriptItem.ActionItemsDetected?.length ?? 0; 161 | outcomesDetectedCount += transcriptItem.OutcomesDetected?.length ?? 0; 162 | }); 163 | 164 | return { 165 | issuesDetectedCount, 166 | actionItemsDetectedCount, 167 | outcomesDetectedCount, 168 | } 169 | } 170 | 171 | const clRecordWriter = async (event) => { 172 | 173 | let writeCLRecordPromises = []; 174 | for (const record of event.Records) { 175 | const inboundSQSMessageId = record.messageId; 176 | writeCLRecordPromises.push(writeCLRecord(record.body, inboundSQSMessageId)); 177 | } 178 | 179 | const writeCLRecordResults = await Promise.allSettled(writeCLRecordPromises); 180 | 181 | const writeCLRecordErrors = writeCLRecordResults.filter((writeCLRecordResult) => writeCLRecordResult.value?.error); 182 | 183 | if (writeCLRecordErrors.length > 0) { 184 | console.error(`ContactLensService -> clRecordWriter -> writeCLRecordErrors: ${writeCLRecordErrors.length}`); 185 | 186 | const batchItemFailures = writeCLRecordErrors.map((writeCLRecordError) => { 187 | return {itemIdentifier: writeCLRecordError.value?.inboundSQSMessageId} 188 | }); 189 | return {batchItemFailures}; 190 | } 191 | return {success: true}; 192 | } 193 | 194 | const writeCLRecord = async (recordBody, inboundSQSMessageId) => { 195 | 196 | try { 197 | const contactLensDetails = inflateParseObject(recordBody); 198 | console.debug('ContactLensService -> writeCLRecord -> contactLensDetails:', contactLensDetails); 199 | 200 | const putJSONToFirehoseResult = await FirehoseService.putJSONToFirehose(clKinesisFirehoseName, contactLensDetails).catch(error => { 201 | console.error(`ContactLensService -> writeCLRecord -> FirehoseService.putJSONToFirehose failed:`, error); 202 | throw error; 203 | }); 204 | 205 | return { 206 | inboundSQSMessageId, 207 | firehoseRecordId: putJSONToFirehoseResult.RecordId, 208 | } 209 | } catch (error) { 210 | console.error(`ContactLensService -> writeCLRecord failed:`, error); 211 | return ({error, inboundSQSMessageId}); 212 | } 213 | } 214 | 215 | module.exports = { 216 | clEventProcessor, 217 | clOutputFileLoader, 218 | clRecordWriter, 219 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/EvaluationFormsService.js: -------------------------------------------------------------------------------- 1 | const SQSService = require('../services/SQSService'); 2 | const S3Service = require('../services/S3Service'); 3 | const FirehoseService = require('./FirehoseService'); 4 | 5 | const {extractEvaluationFormDetails} = require('../lib/EFScoringUtil'); 6 | const {inflateParseObject} = require('../lib/CommonUtility'); 7 | 8 | const efOutputFileLoaderQueueURL = process.env.EFOutputFileLoaderQueueURL; 9 | const efRecordWriterQueueURL = process.env.EFRecordWriterQueueURL; 10 | const efKinesisFirehoseName = process.env.EFKinesisFirehoseName; 11 | 12 | const efEventProcessor = async (event) => { 13 | 14 | const s3Bucket = event?.detail?.bucket?.name; 15 | if (!s3Bucket) return 's3Bucket not found in the payload'; 16 | 17 | const s3ObjectKey = event?.detail?.object?.key; 18 | if (!s3Bucket) return 's3ObjectKey not found in the payload'; 19 | 20 | console.info(`EvaluationFormsService -> efEventProcessor -> Checking if ${s3ObjectKey} match Evaluation Forms output file pattern`); 21 | if (!isS3ObjectKeyValid(s3ObjectKey)) return 's3ObjectKey does not match Evaluation Forms output file pattern'; 22 | 23 | const efOutputFileDetails = { 24 | s3Bucket, 25 | s3ObjectKey, 26 | } 27 | 28 | console.info(`EvaluationFormsService -> efEventProcessor -> Sending to ${efOutputFileLoaderQueueURL} queue: `, efOutputFileDetails); 29 | const efEventProcessorResult = await SQSService.sendObject(efOutputFileLoaderQueueURL, efOutputFileDetails).catch(error => { 30 | console.error(`EvaluationFormsService -> efEventProcessor -> SQSService.sendMessage failed:`, error); 31 | throw error; 32 | }); 33 | 34 | return efEventProcessorResult; 35 | } 36 | 37 | const isS3ObjectKeyValid = (s3ObjectKey) => { 38 | 39 | //We need to match evaluation form file: 17:40:38.601Z-9fbed44b-8cd8-4c09-983f-d4b407698419-v3_1.json 40 | //We don't want deleted evaluation form file: 12:43:43.490Z-1fb0fa2c-ebbc-48d1-8654-ba88adf3d631-v3_1_DELETED.json 41 | 42 | const evaluationFormsOutputFileRegex = /^connect\/[a-zA-Z0-9-]+\/[a-zA-Z0-9-]+\/\d{4}\/\d{2}\/\d{2}\/\d{2}:\d{2}:\d{2}\.\d{3}Z-[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}-v3_1\.json$/; 43 | return s3ObjectKey.match(evaluationFormsOutputFileRegex); 44 | } 45 | 46 | const efOutputFileLoader = async (event) => { 47 | 48 | let processEFOutputFilePromises = []; 49 | for (const record of event.Records) { 50 | const inboundSQSMessageId = record.messageId; 51 | processEFOutputFilePromises.push(processEFOutputFile(record.body, inboundSQSMessageId)); 52 | } 53 | 54 | const processEFOutputFileResults = await Promise.allSettled(processEFOutputFilePromises); 55 | 56 | const processEFOutputFileErrors = processEFOutputFileResults.filter((processEFOutputFileResult) => processEFOutputFileResult.value?.error); 57 | 58 | if (processEFOutputFileErrors.length > 0) { 59 | console.error(`EvaluationFormsService -> efOutputFileLoader -> processEFOutputFileErrors: ${processEFOutputFileErrors.length}`); 60 | 61 | const batchItemFailures = processEFOutputFileErrors.map((processEFOutputFileError) => { 62 | return {itemIdentifier: processEFOutputFileError.value?.inboundSQSMessageId} 63 | }); 64 | return {batchItemFailures}; 65 | } 66 | return {success: true}; 67 | } 68 | 69 | const processEFOutputFile = async (recordBody, inboundSQSMessageId) => { 70 | try { 71 | 72 | const efOutputFileDetails = inflateParseObject(recordBody); 73 | console.debug('EvaluationFormsService -> processEFOutputFile -> efOutputFileDetails:', efOutputFileDetails); 74 | 75 | const evaluationFormObject = await S3Service.getObjectFromJSONFile(efOutputFileDetails.s3Bucket, efOutputFileDetails.s3ObjectKey).catch(error => { 76 | console.error(`EvaluationFormsService -> processEFOutputFile -> S3Service.getObjectFromJSONFile failed:`, error); 77 | throw error; 78 | }); 79 | if (!evaluationFormObject) return 'evaluationFormObject not parsed'; 80 | console.debug(`EvaluationFormsService -> processEFOutputFile -> evaluationFormObject: `, JSON.stringify(evaluationFormObject)); 81 | 82 | const evaluationFormDetails = extractEvaluationFormDetails(evaluationFormObject); 83 | 84 | console.info(`EvaluationFormsService -> processEFOutputFile -> Sending to ${efRecordWriterQueueURL} queue: `, evaluationFormDetails); 85 | const processEFOutputFileResult = await SQSService.sendObject(efRecordWriterQueueURL, evaluationFormDetails).catch(error => { 86 | console.error(`EvaluationFormsService -> processEFOutputFile -> SQSService.sendMessage failed:`, error); 87 | throw error; 88 | }); 89 | 90 | return { 91 | inboundSQSMessageId, 92 | outboundSQSMessageId: processEFOutputFileResult.MessageId 93 | } 94 | } catch (error) { 95 | console.error(`EvaluationFormsService -> processEFOutputFile failed:`, error); 96 | return ({error, inboundSQSMessageId}); 97 | } 98 | } 99 | 100 | const efRecordWriter = async (event) => { 101 | 102 | let writeEFRecordPromises = []; 103 | for (const record of event.Records) { 104 | const inboundSQSMessageId = record.messageId; 105 | writeEFRecordPromises.push(writeEFRecord(record.body, inboundSQSMessageId)); 106 | } 107 | 108 | const writeEFRecordResults = await Promise.allSettled(writeEFRecordPromises); 109 | 110 | const writeEFRecordErrors = writeEFRecordResults.filter((writeEFRecordResult) => writeEFRecordResult.value?.error); 111 | 112 | if (writeEFRecordErrors.length > 0) { 113 | console.error(`EvaluationFormsService -> efRecordWriter -> writeEFRecordErrors: ${writeEFRecordErrors.length}`); 114 | 115 | const batchItemFailures = writeEFRecordErrors.map((writeEFRecordError) => { 116 | return {itemIdentifier: writeEFRecordError.value?.inboundSQSMessageId} 117 | }); 118 | return {batchItemFailures}; 119 | } 120 | return {success: true}; 121 | } 122 | 123 | const writeEFRecord = async (recordBody, inboundSQSMessageId) => { 124 | 125 | try { 126 | const evaluationFormDetails = inflateParseObject(recordBody); 127 | console.debug('EvaluationFormsService -> writeEFRecord -> evaluationFormDetails:', evaluationFormDetails); 128 | 129 | const putJSONToFirehoseResult = await FirehoseService.putJSONToFirehose(efKinesisFirehoseName, evaluationFormDetails).catch(error => { 130 | console.error(`EvaluationFormsService -> writeEFRecord -> FirehoseService.putJSONToFirehose failed:`, error); 131 | throw error; 132 | }); 133 | 134 | return { 135 | inboundSQSMessageId, 136 | firehoseRecordId: putJSONToFirehoseResult.RecordId, 137 | } 138 | } catch (error) { 139 | console.error(`EvaluationFormsService -> writeEFRecord failed:`, error); 140 | return ({error, inboundSQSMessageId}); 141 | } 142 | } 143 | 144 | module.exports = { 145 | efEventProcessor, 146 | efOutputFileLoader, 147 | efRecordWriter, 148 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/FirehoseService.js: -------------------------------------------------------------------------------- 1 | const {FirehoseClient, PutRecordCommand} = require("@aws-sdk/client-firehose"); 2 | const region = process.env.AWS_REGION || 'us-east-1'; 3 | const firehoseClient = new FirehoseClient({region}); 4 | 5 | const putJSONToFirehose = async (deliveryStreamName, objectData) => { 6 | 7 | const objectDataString = `${JSON.stringify(objectData)}\n` 8 | const result = await putRecord(deliveryStreamName, objectDataString); 9 | 10 | return result; 11 | } 12 | 13 | const putRecord = async (deliveryStreamName, objectDataString) => { 14 | 15 | const input = { 16 | DeliveryStreamName: deliveryStreamName, Record: { 17 | Data: Buffer.from(objectDataString), 18 | }, 19 | } 20 | const command = new PutRecordCommand(input); 21 | 22 | try { 23 | const result = await firehoseClient.send(command); 24 | return result; 25 | } catch (error) { 26 | console.error('FirehoseService.putRecord: ', error); 27 | throw error; 28 | } 29 | } 30 | 31 | module.exports = { 32 | putJSONToFirehose, 33 | } 34 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/PartitioningService.js: -------------------------------------------------------------------------------- 1 | const S3Service = require("../services/S3Service"); 2 | const AthenaService = require("../services/AthenaService"); 3 | 4 | const athenaResultsS3bucketName = process.env.AthenaResultsS3bucketName; 5 | 6 | const startPartitioning = async (s3_bucket, s3_prefix, table_name, overridePartitionLoad) => { 7 | 8 | const currentTime = new Date(); 9 | currentTime.setDate(currentTime.getDate() + 1); 10 | 11 | const partition_year = currentTime.getFullYear(); 12 | const partition_month = ("0" + (currentTime.getMonth() + 1)).slice(-2); 13 | const partition_day = ("0" + currentTime.getDate()).slice(-2); 14 | 15 | let partition = 'year=' + partition_year + '/month=' + partition_month + '/day=' + partition_day + '/'; 16 | console.info(`Date-based partition: ${partition}`); 17 | 18 | if (overridePartitionLoad && overridePartitionLoad.length > 0) { 19 | partition = overridePartitionLoad.endsWith('/') ? overridePartitionLoad : `${overridePartitionLoad}/`; 20 | console.info(`Found override partition, hence using partition: ${partition}`); 21 | } 22 | 23 | const partitionExists = await S3Service.checkObjectExists(s3_bucket, `${s3_prefix}/${partition}`).catch(error => { 24 | console.error('S3Service.checkObjectExists: ', error); 25 | throw error; 26 | }); 27 | 28 | if (!partitionExists) { 29 | console.info(`Partition ${partition} doesn't exist in S3, creating...`); 30 | await S3Service.uploadObject(s3_bucket, `${s3_prefix}/${partition}`); 31 | } else { 32 | console.info(`Partition ${partition} already exists in S3`); 33 | } 34 | 35 | console.info(`Creating Athena partition: ${partition}`); 36 | const createPartitionResult = await AthenaService.createPartition(table_name, s3_bucket, s3_prefix, partition, `s3://${athenaResultsS3bucketName}/athena-partitioning/`).catch(error => { 37 | console.error('AthenaService.createPartition: ', error); 38 | throw error; 39 | }); 40 | 41 | console.info(`AthenaService.createPartition -> QueryExecutionId: ${createPartitionResult.QueryExecutionId}`) 42 | 43 | return { 44 | queryExecutionId: createPartitionResult.QueryExecutionId, 45 | waitTime: 1 46 | }; 47 | } 48 | 49 | const pollPartitioningStatus = async (queryExecutionId, waitTime) => { 50 | 51 | const queryExecutionStatus = await AthenaService.getQueryExecutionStatus(queryExecutionId).catch(error => { 52 | console.error('AthenaService.getQueryExecutionStatus: ', error); 53 | throw error; 54 | }); 55 | 56 | return { 57 | queryExecutionId: queryExecutionStatus?.queryExecutionId, 58 | status: queryExecutionStatus?.status, 59 | waitTime: waitTime * 2, 60 | } 61 | } 62 | 63 | const getPartitioningResults = async (queryExecutionId) => { 64 | 65 | const queryResults = await AthenaService.getQueryResults(queryExecutionId).catch(error => { 66 | console.error('AthenaService.getQueryResults: ', error); 67 | throw error; 68 | }); 69 | 70 | return queryResults; 71 | } 72 | 73 | const partitioningIterator = (index, step, count, iteratorWaitInit) => { 74 | 75 | const newIndex = index + step; 76 | 77 | const result = { 78 | index: newIndex, 79 | step, 80 | count, 81 | iteratorWaitInit, 82 | iteratorWaitCurrent: iteratorWaitInit * newIndex, 83 | continue: index < count 84 | } 85 | 86 | return result; 87 | } 88 | 89 | module.exports = { 90 | startPartitioning, 91 | pollPartitioningStatus, 92 | getPartitioningResults, 93 | partitioningIterator, 94 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/S3Service.js: -------------------------------------------------------------------------------- 1 | const {S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand} = require("@aws-sdk/client-s3"); 2 | const region = process.env.AWS_REGION || 'us-east-1'; 3 | const s3Client = new S3Client({region}); 4 | 5 | const checkObjectExists = async (bucketName, objectKey) => { 6 | 7 | const input = { 8 | Bucket: bucketName, 9 | Delimiter: '/', 10 | MaxKeys: 1, 11 | Prefix: objectKey, 12 | } 13 | const command = new ListObjectsV2Command(input); 14 | const result = await s3Client.send(command); 15 | 16 | return result?.KeyCount > 0; 17 | } 18 | 19 | const uploadObject = async (bucketName, objectKey, objectBody = '') => { 20 | 21 | const input = { 22 | Bucket: bucketName, 23 | Key: objectKey, 24 | Body: objectBody, 25 | } 26 | const command = new PutObjectCommand(input); 27 | const result = await s3Client.send(command); 28 | 29 | return result; 30 | } 31 | 32 | const getObjectFromJSONFile = async (s3Bucket, s3Key) => { 33 | 34 | console.debug(`S3Service -> getObjectFromJSONFile -> s3Bucket: ${s3Bucket} | s3Key: ${s3Key}`); 35 | 36 | const input = { 37 | Bucket: s3Bucket, 38 | Key: s3Key, 39 | }; 40 | const command = new GetObjectCommand(input); 41 | 42 | try { 43 | const response = await s3Client.send(command); 44 | const bodyString = await response.Body.transformToString(); 45 | const bodyJSON = JSON.parse(bodyString); 46 | 47 | return bodyJSON; 48 | } catch (error) { 49 | console.error(`S3Service -> getObjectFromJSONFile: `, error); 50 | throw error; 51 | } 52 | } 53 | 54 | 55 | module.exports = { 56 | checkObjectExists, 57 | uploadObject, 58 | getObjectFromJSONFile, 59 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/SQSService.js: -------------------------------------------------------------------------------- 1 | const {SQSClient, SendMessageCommand} = require("@aws-sdk/client-sqs"); 2 | const {deflateStringifyObject} = require('../lib/CommonUtility'); 3 | const region = process.env.AWS_REGION || 'us-east-1'; 4 | const sqsClient = new SQSClient({region}); 5 | 6 | const sendMessage = async (queueURL, messageBody) => { 7 | 8 | const input = { 9 | QueueUrl: queueURL, 10 | MessageBody: Buffer.from(JSON.stringify(messageBody)).toString('base64'), 11 | } 12 | const command = new SendMessageCommand(input); 13 | 14 | try { 15 | const result = await sqsClient.send(command); 16 | return result; 17 | } catch (error) { 18 | console.error('SQSService -> sendMessage: ', error); 19 | throw error; 20 | } 21 | } 22 | 23 | const sendObject = async (queueURL, inputObject) => { 24 | 25 | const messageBody = deflateStringifyObject(inputObject); 26 | const input = { 27 | QueueUrl: queueURL, 28 | MessageBody: messageBody, 29 | } 30 | const command = new SendMessageCommand(input); 31 | 32 | try { 33 | const result = await sqsClient.send(command); 34 | return result; 35 | } catch (error) { 36 | console.error('SQSService -> sendObject: ', error); 37 | throw error; 38 | } 39 | } 40 | 41 | module.exports = { 42 | sendMessage, 43 | sendObject, 44 | } 45 | -------------------------------------------------------------------------------- /cdk-stacks/lib/agent-events/ae-stack.ts: -------------------------------------------------------------------------------- 1 | import {NestedStack, NestedStackProps, RemovalPolicy} from 'aws-cdk-lib'; 2 | import {Construct} from 'constructs'; 3 | import * as kinesis from 'aws-cdk-lib/aws-kinesis'; 4 | import * as firehose from 'aws-cdk-lib/aws-kinesisfirehose'; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | import * as glue from "aws-cdk-lib/aws-glue"; 7 | import * as s3 from 'aws-cdk-lib/aws-s3'; 8 | import * as events from 'aws-cdk-lib/aws-events'; 9 | import * as eventTargets from 'aws-cdk-lib/aws-events-targets'; 10 | import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; 11 | import {NagSuppressions} from "cdk-nag"; 12 | 13 | export interface AEStackProps extends NestedStackProps { 14 | readonly SSMParams: any; 15 | readonly cdkAppName: string; 16 | readonly aeS3bucketName: string; 17 | readonly aeS3bucketAccessLogsName: string; 18 | readonly athenaPartitioningStateMachine: sfn.IStateMachine; 19 | } 20 | 21 | //Agent Events (AE) Stack 22 | export class AEStack extends NestedStack { 23 | 24 | constructor(scope: Construct, id: string, props: AEStackProps) { 25 | super(scope, id, props); 26 | 27 | //Amazon S3 bucket to store access logs for AES3Bucket 28 | const aeS3bucketAccessLogs = new s3.Bucket(this, 'AES3bucketAccessLogs', { 29 | bucketName: props.aeS3bucketAccessLogsName, 30 | removalPolicy: RemovalPolicy.RETAIN, 31 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 32 | objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, 33 | enforceSSL: true, 34 | serverAccessLogsPrefix: 'logs', 35 | }); 36 | 37 | //Amazon S3 bucket to store AEs 38 | const aeS3bucket = new s3.Bucket(this, 'AES3bucket', { 39 | bucketName: props.aeS3bucketName, 40 | removalPolicy: RemovalPolicy.RETAIN, 41 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 42 | serverAccessLogsBucket: aeS3bucketAccessLogs, 43 | enforceSSL: true, 44 | serverAccessLogsPrefix: 'logs', 45 | }); 46 | 47 | //Amazon Connect AE Kinesis Stream 48 | const aeKinesisStream = new kinesis.Stream(this, 'AEKinesisStream', { 49 | streamName: `${props.cdkAppName}-AEKinesisStream`, 50 | shardCount: 1, 51 | }); 52 | 53 | //Amazon Kinesis Firehose role that provides access to the source Kinesis data stream 54 | const aeKinesisFirehoseStreamSourceRole = new iam.Role(this, 'AEKinesisFirehoseStreamSourceRole', { 55 | assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com', { 56 | conditions: {'StringEquals': {"sts:ExternalId": this.account}} 57 | }), 58 | }); 59 | aeKinesisStream.grantRead(aeKinesisFirehoseStreamSourceRole); 60 | 61 | //Amazon Kinesis Firehose Role that provides access to the destination Amazon S3 bucket 62 | const aeKinesisFirehoseS3DestinationRole = new iam.Role(this, 'AEKinesisFirehoseS3DestinationRole', { 63 | assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com', { 64 | conditions: {'StringEquals': {"sts:ExternalId": this.account}} 65 | }), 66 | }); 67 | aeS3bucket.grantReadWrite(aeKinesisFirehoseS3DestinationRole); 68 | NagSuppressions.addResourceSuppressions(aeKinesisFirehoseS3DestinationRole, [ 69 | { 70 | id: 'AwsSolutions-IAM5', 71 | reason: 'It is justified because this is the intended behavior, for Kinesis to read and write to the S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific Kinesis Firehose and specific S3 bucket' 72 | } 73 | ], true); 74 | 75 | //Amazon Kinesis Firehose 76 | const aeKinesisFirehose = new firehose.CfnDeliveryStream(this, 'AEKinesisFirehose', { 77 | deliveryStreamName: `${props.cdkAppName}-AEKinesisFirehose`, 78 | deliveryStreamType: 'KinesisStreamAsSource', 79 | kinesisStreamSourceConfiguration: { 80 | kinesisStreamArn: aeKinesisStream.streamArn, 81 | roleArn: aeKinesisFirehoseStreamSourceRole.roleArn, 82 | }, 83 | extendedS3DestinationConfiguration: { 84 | bucketArn: aeS3bucket.bucketArn, 85 | roleArn: aeKinesisFirehoseS3DestinationRole.roleArn, 86 | prefix: 'fhbase/!{partitionKeyFromQuery:PartitionDateTimePrefix}/', 87 | errorOutputPrefix: 'fherroroutputbase-error/!{firehose:random-string}/!{firehose:error-output-type}/!{timestamp:yyy/MM/dd}/', 88 | bufferingHints: { 89 | intervalInSeconds: 60, 90 | sizeInMBs: 128, 91 | }, 92 | dynamicPartitioningConfiguration: { 93 | enabled: true, 94 | }, 95 | processingConfiguration: { 96 | enabled: true, 97 | processors: [ 98 | { 99 | type: "MetadataExtraction", 100 | parameters: [ 101 | { 102 | parameterName: 'JsonParsingEngine', 103 | parameterValue: 'JQ-1.6', 104 | }, 105 | { 106 | parameterName: 'MetadataExtractionQuery', 107 | parameterValue: '{PartitionDateTimePrefix: (.EventTimestamp[0:19] + "Z")| fromdateiso8601| strftime("year=%Y/month=%m/day=%d")}' 108 | }, 109 | ] 110 | }, 111 | { 112 | type: 'AppendDelimiterToRecord', 113 | parameters: [ 114 | { 115 | parameterName: 'Delimiter', 116 | parameterValue: '\\n', 117 | } 118 | ] 119 | } 120 | ] 121 | } 122 | } 123 | }); 124 | aeKinesisFirehose.node.addDependency(aeKinesisFirehoseStreamSourceRole); 125 | aeKinesisFirehose.node.addDependency(aeKinesisFirehoseS3DestinationRole); 126 | NagSuppressions.addResourceSuppressions(aeKinesisFirehose, [ 127 | { 128 | id: 'AwsSolutions-KDF1', 129 | reason: 'Firehose does not support encryption when being populated from a Kinesis stream. Under the current architecture, it is not possible. All the traffic is travelling within the same region and account, so it should be safe. Please reference https://docs.aws.amazon.com/firehose/latest/dev/encryption.html#sse-with-data-stream-as-source' 130 | }, 131 | ]); 132 | 133 | //Create AWS Glue Table 134 | const aeGlueTable = new glue.CfnTable(this, 'AEGlueTable', { 135 | catalogId: this.account, 136 | databaseName: props.SSMParams.awsGlueDatabaseName.toLowerCase(), 137 | tableInput: { 138 | name: 'connect_ae', 139 | tableType: 'EXTERNAL_TABLE', 140 | description: 'AWS Glue Table for Amazon Connect AEs', 141 | parameters: { 142 | "classification": "json" 143 | }, 144 | partitionKeys: [ 145 | { 146 | name: 'year', 147 | type: 'int' 148 | }, 149 | { 150 | name: 'month', 151 | type: 'int' 152 | }, 153 | { 154 | name: 'day', 155 | type: 'int' 156 | } 157 | ], 158 | storageDescriptor: { 159 | location: `s3://${aeS3bucket.bucketName}/fhbase`, 160 | inputFormat: 'org.apache.hadoop.mapred.TextInputFormat', 161 | outputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat', 162 | serdeInfo: { 163 | serializationLibrary: "org.openx.data.jsonserde.JsonSerDe", 164 | parameters: { 165 | "serialization.format": "1", 166 | } 167 | }, 168 | compressed: false, 169 | columns: [ 170 | { 171 | name: "awsaccountid", 172 | type: "string" 173 | }, 174 | { 175 | name: "agentarn", 176 | type: "string" 177 | }, 178 | { 179 | name: "currentagentsnapshot", 180 | type: "struct,configuration:struct>,defaultoutboundqueue:struct,name:string>,inboundqueues:array,name:string>>,name:string>,username:string>,contacts:array,queuetimestamp:string,state:string,statestarttimestamp:string>>,nextagentstatus:string>" 181 | }, 182 | { 183 | name: "eventid", 184 | type: "string" 185 | }, 186 | { 187 | name: "eventtimestamp", 188 | type: "string" 189 | }, 190 | { 191 | name: "eventtype", 192 | type: "string" 193 | }, 194 | { 195 | name: "instancearn", 196 | type: "string" 197 | }, 198 | { 199 | name: "previousagentsnapshot", 200 | type: "struct,configuration:struct>,defaultoutboundqueue:struct,name:string>,inboundqueues:array,name:string>>,name:string>,username:string>,contacts:array,queuetimestamp:string,state:string,statestarttimestamp:string>>,nextagentstatus:string>" 201 | }, 202 | { 203 | name: "version", 204 | type: "string" 205 | }, 206 | ], 207 | } 208 | } 209 | }); 210 | 211 | //Cloudwatch Scheduled Rules 212 | const aePartitioningSchedule = new events.Rule(this, 'AePartitioningSchedule', { 213 | ruleName: `${props.cdkAppName}-AePartitioningSchedule`, 214 | description: 'Executes AE partitioning job (Step Functions) on a daily basis', 215 | schedule: events.Schedule.expression('cron(45 23 ? * * *)'), 216 | enabled: props.SSMParams.aePartitioningScheduleEnabled, 217 | }); 218 | aePartitioningSchedule.addTarget(new eventTargets.SfnStateMachine(props.athenaPartitioningStateMachine, { 219 | input: events.RuleTargetInput.fromObject({ 220 | s3_bucket: aeS3bucket.bucketName, 221 | s3_prefix: 'fhbase', 222 | table_name: `${props.SSMParams.awsGlueDatabaseName.toLowerCase()}.connect_ae`, 223 | overridePartitionLoad: false, 224 | }) 225 | })); 226 | } 227 | 228 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/cdk-backend-stack.ts: -------------------------------------------------------------------------------- 1 | import {Stack, StackProps} from 'aws-cdk-lib'; 2 | import {Construct} from 'constructs'; 3 | import {StringParameter} from 'aws-cdk-lib/aws-ssm'; 4 | import * as glue from "aws-cdk-lib/aws-glue"; 5 | 6 | import {loadSSMParams} from '../lib/infrastructure/ssm-params-util'; 7 | import {CTRStack} from './contact-trace-records/ctr-stack'; 8 | import {AEStack} from './agent-events/ae-stack'; 9 | import {CFLStack} from './contact-flow-logs/cfl-stack'; 10 | import {PartitioningStack} from './partitioning/partitioning-stack'; 11 | import {CLStack} from './contact-lens/cl-stack'; 12 | import {EFStack} from './evaluation-forms/ef-stack'; 13 | import {EFReportingStack} from './evaluation-forms-reporting/ef-reporting-stack'; 14 | 15 | const configParams = require('../config.params.json'); 16 | 17 | export class CdkBackendStack extends Stack { 18 | constructor(scope: Construct, id: string, props?: StackProps) { 19 | super(scope, id, props); 20 | 21 | //store physical stack name to SSM 22 | const outputHierarchy = `${configParams.hierarchy}outputParameters`; 23 | const cdkBackendStackName = new StringParameter(this, 'CdkBackendStackName', { 24 | parameterName: `${outputHierarchy}/CdkBackendStackName`, 25 | stringValue: this.stackName 26 | }); 27 | 28 | const ssmParams = loadSSMParams(this); 29 | 30 | //Define S3 bucket names 31 | const athenaResultsS3bucketName = `${configParams.CdkAppName}-ar-${this.account}-${this.region}`.toLowerCase(); 32 | const ctrS3bucketName = `${configParams.CdkAppName}-ctr-${this.account}-${this.region}`.toLowerCase(); 33 | const aeS3bucketName = `${configParams.CdkAppName}-ae-${this.account}-${this.region}`.toLowerCase(); 34 | const cflS3bucketName = `${configParams.CdkAppName}-cfl-${this.account}-${this.region}`.toLowerCase(); 35 | const clS3bucketName = `${configParams.CdkAppName}-cl-${this.account}-${this.region}`.toLowerCase(); 36 | const efS3bucketName = `${configParams.CdkAppName}-ef-${this.account}-${this.region}`.toLowerCase(); 37 | 38 | //Define S3 bucket names for server access logs 39 | const athenaResultsS3bucketAccessLogsName = `${configParams.CdkAppName}-ar-al-${this.account}-${this.region}`.toLowerCase(); 40 | const ctrS3bucketAccessLogsName = `${configParams.CdkAppName}-ctr-al-${this.account}-${this.region}`.toLowerCase(); 41 | const aeS3bucketAccessLogsName = `${configParams.CdkAppName}-ae-al-${this.account}-${this.region}`.toLowerCase(); 42 | const cflS3bucketAccessLogsName = `${configParams.CdkAppName}-cfl-al-${this.account}-${this.region}`.toLowerCase(); 43 | const clS3bucketAccessLogsName = `${configParams.CdkAppName}-cl-al-${this.account}-${this.region}`.toLowerCase(); 44 | const efS3bucketAccessLogsName = `${configParams.CdkAppName}-ef-al-${this.account}-${this.region}`.toLowerCase(); 45 | 46 | //Create Glue Database 47 | const amazonConnectDataAnalyticsDB = new glue.CfnDatabase(this, 'AmazonConnectDataAnalyticsDB', { 48 | catalogId: this.account, 49 | databaseInput: { 50 | name: ssmParams.awsGlueDatabaseName.toLowerCase(), 51 | description: 'AWS Glue Database to hold tables for Amazon Connect Data Analytics' 52 | }, 53 | }); 54 | 55 | //Create Partitioning Stack 56 | const partitioningStack = new PartitioningStack(this, 'PartitioningStack', { 57 | SSMParams: ssmParams, 58 | cdkAppName: configParams['CdkAppName'], 59 | athenaResultsS3bucketName, 60 | athenaResultsS3bucketAccessLogsName, 61 | ctrS3bucketName, 62 | aeS3bucketName, 63 | cflS3bucketName, 64 | clS3bucketName, 65 | efS3bucketName, 66 | }); 67 | 68 | if (ssmParams.ctrStackEnabled) {//Create Contact Trace Records (CTR) Stack 69 | const ctrStack = new CTRStack(this, 'CtrStack', { 70 | SSMParams: ssmParams, 71 | cdkAppName: configParams['CdkAppName'], 72 | ctrS3bucketName, 73 | ctrS3bucketAccessLogsName, 74 | athenaPartitioningStateMachine: partitioningStack.athenaPartitioningStateMachine, 75 | }); 76 | } 77 | 78 | if (ssmParams.aeStackEnabled) {//Create Agent Events (AE) Stack 79 | const aeStack = new AEStack(this, 'AEStack', { 80 | SSMParams: ssmParams, 81 | cdkAppName: configParams['CdkAppName'], 82 | aeS3bucketName, 83 | aeS3bucketAccessLogsName, 84 | athenaPartitioningStateMachine: partitioningStack.athenaPartitioningStateMachine, 85 | }); 86 | } 87 | 88 | if (ssmParams.cflStackEnabled) {//Create Contact Flow Logs (CFL) Stack 89 | const cflStack = new CFLStack(this, 'CflStack', { 90 | SSMParams: ssmParams, 91 | cdkAppName: configParams['CdkAppName'], 92 | cflS3bucketName, 93 | cflS3bucketAccessLogsName, 94 | athenaPartitioningStateMachine: partitioningStack.athenaPartitioningStateMachine, 95 | }); 96 | } 97 | 98 | if (ssmParams.clStackEnabled) { //Create Contact Lens (CL) Stack 99 | const clStack = new CLStack(this, 'CLStack', { 100 | SSMParams: ssmParams, 101 | cdkAppName: configParams['CdkAppName'], 102 | clS3bucketName, 103 | clS3bucketAccessLogsName, 104 | athenaPartitioningStateMachine: partitioningStack.athenaPartitioningStateMachine, 105 | }); 106 | } 107 | 108 | if (ssmParams.efStackEnabled) { //Create Evaluation Forms (EF) Stack 109 | const efStack = new EFStack(this, 'EFStack', { 110 | SSMParams: ssmParams, 111 | cdkAppName: configParams['CdkAppName'], 112 | efS3bucketName, 113 | efS3bucketAccessLogsName, 114 | athenaPartitioningStateMachine: partitioningStack.athenaPartitioningStateMachine, 115 | }); 116 | } 117 | 118 | if (ssmParams.efReportingStackEnabled) { //Create Evaluation Forms (EF) Reporting Stack 119 | const efReportingStack = new EFReportingStack(this, 'EFReportingStack', { 120 | SSMParams: ssmParams, 121 | }); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /cdk-stacks/lib/contact-flow-logs/cfl-stack.ts: -------------------------------------------------------------------------------- 1 | import {Duration, NestedStack, NestedStackProps, RemovalPolicy} from 'aws-cdk-lib'; 2 | import {Construct} from 'constructs'; 3 | import * as logs from 'aws-cdk-lib/aws-logs'; 4 | import * as firehose from 'aws-cdk-lib/aws-kinesisfirehose'; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | import * as nodeLambda from "aws-cdk-lib/aws-lambda-nodejs"; 7 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 8 | import * as glue from "aws-cdk-lib/aws-glue"; 9 | import * as s3 from 'aws-cdk-lib/aws-s3'; 10 | import * as events from 'aws-cdk-lib/aws-events'; 11 | import * as eventTargets from 'aws-cdk-lib/aws-events-targets'; 12 | import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; 13 | import {NagSuppressions} from "cdk-nag"; 14 | 15 | export interface CFLStackProps extends NestedStackProps { 16 | readonly SSMParams: any; 17 | readonly cdkAppName: string; 18 | readonly cflS3bucketName: string; 19 | readonly cflS3bucketAccessLogsName: string; 20 | readonly athenaPartitioningStateMachine: sfn.IStateMachine; 21 | } 22 | 23 | //Contact Flow Logs (CFL) Stack 24 | export class CFLStack extends NestedStack { 25 | 26 | constructor(scope: Construct, id: string, props: CFLStackProps) { 27 | super(scope, id, props); 28 | 29 | //Amazon S3 bucket to store access logs for CFL 30 | const cflS3bucketAccessLogs = new s3.Bucket(this, 'CflS3bucketAccessLogs', { 31 | bucketName: props.cflS3bucketAccessLogsName, 32 | removalPolicy: RemovalPolicy.RETAIN, 33 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 34 | objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, 35 | enforceSSL: true, 36 | serverAccessLogsPrefix: 'logs', 37 | }); 38 | 39 | //Amazon S3 bucket to store CFLs 40 | const cflS3bucket = new s3.Bucket(this, 'CflS3bucket', { 41 | bucketName: props.cflS3bucketName, 42 | removalPolicy: RemovalPolicy.RETAIN, 43 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 44 | serverAccessLogsBucket: cflS3bucketAccessLogs, 45 | enforceSSL: true, 46 | serverAccessLogsPrefix: 'logs', 47 | }); 48 | 49 | //Amazon Kinesis Firehose Role that provides access to the destination Amazon S3 bucket 50 | const cflKinesisFirehoseS3DestinationRole = new iam.Role(this, 'CflKinesisFirehoseS3DestinationRole', { 51 | assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com', { 52 | conditions: {'StringEquals': {'sts:ExternalId': this.account}} 53 | }), 54 | }); 55 | cflS3bucket.grantReadWrite(cflKinesisFirehoseS3DestinationRole); 56 | NagSuppressions.addResourceSuppressions(cflKinesisFirehoseS3DestinationRole, [ 57 | { 58 | id: 'AwsSolutions-IAM5', 59 | reason: 'It is justified because this is the intended behavior, for Kinesis to read and write to the S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific Kinesis Firehose and specific S3 bucket' 60 | } 61 | ], true); 62 | 63 | //AWS Lambda - kinesis-firehose-cloudwatch-logs-processor 64 | const kinesisFirehoseCloudwatchLogsProcessorLambda = new nodeLambda.NodejsFunction(this, 'KinesisFirehoseCloudwatchLogsProcessorLambda', { 65 | functionName: `${props.cdkAppName}-FirehoseCloudwatchLogsProcessor`, 66 | runtime: lambda.Runtime.NODEJS_18_X, 67 | entry: 'lambdas/handlers/RecordProcessors/kinesisFirehoseCloudwatchLogsProcessor.js', 68 | timeout: Duration.minutes(3), 69 | }); 70 | NagSuppressions.addResourceSuppressions(kinesisFirehoseCloudwatchLogsProcessorLambda, [ 71 | { 72 | id: 'AwsSolutions-IAM4', 73 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 74 | } 75 | ], true); 76 | kinesisFirehoseCloudwatchLogsProcessorLambda.grantInvoke(cflKinesisFirehoseS3DestinationRole); 77 | 78 | //Amazon Kinesis Firehose 79 | const cflKinesisFirehose = new firehose.CfnDeliveryStream(this, 'CflKinesisFirehose', { 80 | deliveryStreamName: `${props.cdkAppName}-CflKinesisFirehose`, 81 | deliveryStreamType: 'DirectPut', 82 | deliveryStreamEncryptionConfigurationInput: { 83 | keyType: 'AWS_OWNED_CMK', 84 | }, 85 | extendedS3DestinationConfiguration: { 86 | bucketArn: cflS3bucket.bucketArn, 87 | roleArn: cflKinesisFirehoseS3DestinationRole.roleArn, 88 | prefix: 'fhbase/year=!{timestamp:YYYY}/month=!{timestamp:MM}/day=!{timestamp:dd}/', 89 | errorOutputPrefix: 'fherroroutputbase-error/!{firehose:random-string}/!{firehose:error-output-type}/!{timestamp:yyy/MM/dd}/', 90 | bufferingHints: { 91 | intervalInSeconds: 60, 92 | sizeInMBs: 128, 93 | }, 94 | processingConfiguration: { 95 | enabled: true, 96 | processors: [ 97 | { 98 | type: 'Lambda', 99 | parameters: [ 100 | { 101 | parameterName: 'LambdaArn', 102 | parameterValue: kinesisFirehoseCloudwatchLogsProcessorLambda.functionArn, 103 | } 104 | ] 105 | }, 106 | ] 107 | } 108 | } 109 | }); 110 | 111 | cflKinesisFirehose.node.addDependency(cflKinesisFirehoseS3DestinationRole); 112 | 113 | //Allow KinesisFirehoseCloudwatchLogsProcessorLambda access to CflKinesisFirehose 114 | kinesisFirehoseCloudwatchLogsProcessorLambda.role?.attachInlinePolicy(new iam.Policy(this, 'KinesisFirehoseAccess', { 115 | statements: [ 116 | new iam.PolicyStatement({ 117 | effect: iam.Effect.ALLOW, 118 | actions: ['firehose:PutRecordBatch'], 119 | resources: [`arn:aws:firehose:${this.region}:${this.account}:deliverystream/${cflKinesisFirehose.deliveryStreamName}`] 120 | }) 121 | ] 122 | })); 123 | 124 | //Amazon Connect Contact Flow Logs - Amazon CloudWatch log group 125 | const cflCloudWatchLogGroup = logs.LogGroup.fromLogGroupName(this, 'CflCloudWatchLogGroup', props.SSMParams.connectContactFlowLogsCloudWatchLogGroup); 126 | 127 | //Amazon CloudWatch Logs Role that provides access to the destination Amazon Kinesis Firehose 128 | const cflCloudWatchLogsKinesisFirehoseDestinationRole = new iam.Role(this, 'CflCloudWatchLogsKinesisFirehoseDestinationRole', { 129 | assumedBy: new iam.ServicePrincipal('logs.amazonaws.com', { 130 | conditions: {'StringEquals': {'sts:ExternalId': this.account}} 131 | }), 132 | }); 133 | 134 | cflCloudWatchLogsKinesisFirehoseDestinationRole.addToPolicy(new iam.PolicyStatement({ 135 | effect: iam.Effect.ALLOW, 136 | actions: ['firehose:PutRecord', 'firehose:PutRecordBatch'], 137 | resources: [`arn:aws:firehose:${this.region}:${this.account}:deliverystream/${cflKinesisFirehose.deliveryStreamName}`], 138 | })); 139 | 140 | //Amazon CloudWatch Logs subscription 141 | const cflSubscriptionFilter = new logs.CfnSubscriptionFilter(this, 'CflSubscriptionFilter', { 142 | destinationArn: cflKinesisFirehose.attrArn, 143 | logGroupName: cflCloudWatchLogGroup.logGroupName, 144 | filterPattern: '', 145 | roleArn: cflCloudWatchLogsKinesisFirehoseDestinationRole.roleArn, 146 | }); 147 | cflSubscriptionFilter.node.addDependency(cflCloudWatchLogsKinesisFirehoseDestinationRole); 148 | 149 | //Create AWS Glue Table 150 | const cflGlueTable = new glue.CfnTable(this, 'CflGlueTable', { 151 | catalogId: this.account, 152 | databaseName: props.SSMParams.awsGlueDatabaseName.toLowerCase(), 153 | tableInput: { 154 | name: 'connect_cfl', 155 | tableType: 'EXTERNAL_TABLE', 156 | description: 'AWS Glue Table for Amazon Connect CFLs', 157 | parameters: { 158 | "classification": "json" 159 | }, 160 | partitionKeys: [ 161 | { 162 | name: 'year', 163 | type: 'int' 164 | }, 165 | { 166 | name: 'month', 167 | type: 'int' 168 | }, 169 | { 170 | name: 'day', 171 | type: 'int' 172 | } 173 | ], 174 | storageDescriptor: { 175 | location: `s3://${cflS3bucket.bucketName}/fhbase`, 176 | inputFormat: 'org.apache.hadoop.mapred.TextInputFormat', 177 | outputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat', 178 | serdeInfo: { 179 | serializationLibrary: "org.openx.data.jsonserde.JsonSerDe", 180 | parameters: { 181 | "serialization.format": "1", 182 | } 183 | }, 184 | compressed: false, 185 | columns: [ 186 | { 187 | name: "timestamp", 188 | type: "string" 189 | }, 190 | { 191 | name: "eventid", 192 | type: "string" 193 | }, 194 | { 195 | name: "message", 196 | type: "string" 197 | }, 198 | ], 199 | } 200 | } 201 | }); 202 | 203 | //Cloudwatch Scheduled Rules 204 | const cflPartitioningSchedule = new events.Rule(this, 'CflPartitioningSchedule', { 205 | ruleName: `${props.cdkAppName}-CflPartitioningSchedule`, 206 | description: 'Executes CFL partitioning job (Step Functions) on a daily basis', 207 | schedule: events.Schedule.expression('cron(45 23 ? * * *)'), 208 | enabled: props.SSMParams.cflPartitioningScheduleEnabled, 209 | }); 210 | cflPartitioningSchedule.addTarget(new eventTargets.SfnStateMachine(props.athenaPartitioningStateMachine, { 211 | input: events.RuleTargetInput.fromObject({ 212 | s3_bucket: cflS3bucket.bucketName, 213 | s3_prefix: 'fhbase', 214 | table_name: `${props.SSMParams.awsGlueDatabaseName.toLowerCase()}.connect_cfl`, 215 | overridePartitionLoad: false, 216 | }) 217 | })); 218 | } 219 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/contact-lens/cl-stack.ts: -------------------------------------------------------------------------------- 1 | import {Duration, NestedStack, NestedStackProps, RemovalPolicy} from 'aws-cdk-lib'; 2 | import {Construct} from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as eventTargets from 'aws-cdk-lib/aws-events-targets'; 5 | import * as nodeLambda from "aws-cdk-lib/aws-lambda-nodejs"; 6 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 7 | import * as sqs from 'aws-cdk-lib/aws-sqs'; 8 | import {SqsEventSource} from 'aws-cdk-lib/aws-lambda-event-sources'; 9 | import * as s3 from 'aws-cdk-lib/aws-s3'; 10 | import * as iam from 'aws-cdk-lib/aws-iam'; 11 | import * as firehose from 'aws-cdk-lib/aws-kinesisfirehose'; 12 | import * as glue from "aws-cdk-lib/aws-glue"; 13 | import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; 14 | import {NagSuppressions} from "cdk-nag"; 15 | 16 | export interface CLStackProps extends NestedStackProps { 17 | readonly SSMParams: any; 18 | readonly cdkAppName: string; 19 | readonly clS3bucketName: string; 20 | readonly clS3bucketAccessLogsName: string; 21 | readonly athenaPartitioningStateMachine: sfn.IStateMachine; 22 | } 23 | 24 | //Contact Lens (CL) Stack 25 | export class CLStack extends NestedStack { 26 | 27 | constructor(scope: Construct, id: string, props: CLStackProps) { 28 | super(scope, id, props); 29 | 30 | //Amazon S3 bucket to store access logs for CL 31 | const clS3bucketAccessLogs = new s3.Bucket(this, 'CLS3bucketAccessLogs', { 32 | bucketName: props.clS3bucketAccessLogsName, 33 | removalPolicy: RemovalPolicy.RETAIN, 34 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 35 | objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, 36 | enforceSSL: true, 37 | serverAccessLogsPrefix: 'logs', 38 | }); 39 | 40 | //Amazon S3 bucket to store CLs 41 | const clS3bucket = new s3.Bucket(this, 'CLS3bucket', { 42 | bucketName: props.clS3bucketName, 43 | removalPolicy: RemovalPolicy.RETAIN, 44 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 45 | serverAccessLogsBucket: clS3bucketAccessLogs, 46 | enforceSSL: true, 47 | serverAccessLogsPrefix: 'logs', 48 | }); 49 | 50 | //Amazon Kinesis Firehose Role that provides access to the destination Amazon S3 bucket 51 | const clKinesisFirehoseS3DestinationRole = new iam.Role(this, 'CLKinesisFirehoseS3DestinationRole', { 52 | assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com', { 53 | conditions: {'StringEquals': {'sts:ExternalId': this.account}} 54 | }), 55 | }); 56 | clS3bucket.grantReadWrite(clKinesisFirehoseS3DestinationRole); 57 | NagSuppressions.addResourceSuppressions(clKinesisFirehoseS3DestinationRole, [ 58 | { 59 | id: 'AwsSolutions-IAM5', 60 | reason: 'It is justified because this is the intended behavior, for Kinesis to read and write to the S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific Kinesis Firehose and specific S3 bucket' 61 | } 62 | ], true); 63 | 64 | //Amazon Kinesis Firehose 65 | const clKinesisFirehose = new firehose.CfnDeliveryStream(this, 'CLKinesisFirehose', { 66 | deliveryStreamName: `${props.cdkAppName}-CLKinesisFirehose`, 67 | deliveryStreamType: 'DirectPut', 68 | deliveryStreamEncryptionConfigurationInput: { 69 | keyType: 'AWS_OWNED_CMK', 70 | }, 71 | extendedS3DestinationConfiguration: { 72 | bucketArn: clS3bucket.bucketArn, 73 | roleArn: clKinesisFirehoseS3DestinationRole.roleArn, 74 | prefix: 'fhbase/!{partitionKeyFromQuery:PartitionDateTimePrefix}/', 75 | errorOutputPrefix: 'fherroroutputbase-error/!{firehose:random-string}/!{firehose:error-output-type}/!{timestamp:yyy/MM/dd}/', 76 | bufferingHints: { 77 | intervalInSeconds: 60, 78 | sizeInMBs: 128, 79 | }, 80 | dynamicPartitioningConfiguration: { 81 | enabled: true, 82 | }, 83 | processingConfiguration: { 84 | enabled: true, 85 | processors: [ 86 | { 87 | type: "MetadataExtraction", 88 | parameters: [ 89 | { 90 | parameterName: 'JsonParsingEngine', 91 | parameterValue: 'JQ-1.6', 92 | }, 93 | { 94 | parameterName: 'MetadataExtractionQuery', 95 | parameterValue: '{PartitionDateTimePrefix: (.recordingTimestamp[0:19] + "Z")| fromdateiso8601| strftime("year=%Y/month=%m/day=%d")}' 96 | }, 97 | ] 98 | }, 99 | ] 100 | } 101 | }, 102 | }); 103 | clKinesisFirehose.node.addDependency(clKinesisFirehoseS3DestinationRole); 104 | 105 | //Create AWS Glue Table 106 | const clGlueTable = new glue.CfnTable(this, 'CLGlueTable', { 107 | catalogId: this.account, 108 | databaseName: props.SSMParams.awsGlueDatabaseName.toLowerCase(), 109 | tableInput: { 110 | name: 'connect_cl', 111 | tableType: 'EXTERNAL_TABLE', 112 | description: 'AWS Glue Table for Amazon Connect CLs', 113 | parameters: { 114 | "classification": "json" 115 | }, 116 | partitionKeys: [ 117 | { 118 | name: 'year', 119 | type: 'int' 120 | }, 121 | { 122 | name: 'month', 123 | type: 'int' 124 | }, 125 | { 126 | name: 'day', 127 | type: 'int' 128 | } 129 | ], 130 | storageDescriptor: { 131 | location: `s3://${clS3bucket.bucketName}/fhbase`, 132 | inputFormat: 'org.apache.hadoop.mapred.TextInputFormat', 133 | outputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat', 134 | serdeInfo: { 135 | serializationLibrary: "org.openx.data.jsonserde.JsonSerDe", 136 | parameters: { 137 | "serialization.format": "1", 138 | } 139 | }, 140 | compressed: false, 141 | columns: [ 142 | { 143 | name: "contactid", 144 | type: "string", 145 | }, 146 | { 147 | name: "instanceid", 148 | type: "string", 149 | }, 150 | { 151 | name: "recordingtimestamp", 152 | type: "string", 153 | }, 154 | { 155 | name: "channel", 156 | type: "string", 157 | }, 158 | { 159 | name: "languagecode", 160 | type: "string", 161 | }, 162 | { 163 | name: "matchedcategories", 164 | type: "array", 165 | }, 166 | { 167 | name: "totalconversationduration", 168 | type: "int", 169 | }, 170 | { 171 | name: "overallsentimentagent", 172 | type: "double", 173 | }, 174 | { 175 | name: "overallsentimentcustomer", 176 | type: "double", 177 | }, 178 | { 179 | name: "interruptionstotalcount", 180 | type: "int", 181 | }, 182 | { 183 | name: "nontalktimetotal", 184 | type: "int", 185 | }, 186 | { 187 | name: "averagewordsperminuteagent", 188 | type: "int", 189 | }, 190 | { 191 | name: "averagewordsperminutecustomer", 192 | type: "int", 193 | }, 194 | { 195 | name: "talktimetotal", 196 | type: "int", 197 | }, 198 | { 199 | name: "talktimeagent", 200 | type: "int", 201 | }, 202 | { 203 | name: "talktimecustomer", 204 | type: "int", 205 | }, 206 | { 207 | name: "callsummary", 208 | type: "struct", 209 | }, 210 | ] 211 | } 212 | } 213 | }); 214 | 215 | //Connect Recording S3Bucket Event Notification Rule 216 | const connectCLS3BucketNotificationRule = new events.Rule(this, 'ConnectCLS3BucketNotificationRule', { 217 | ruleName: `${props.cdkAppName}-ConnectCLS3NotificationRule`, 218 | description: 'Triggers clEventProcessorLambda Lambda function on Amazon Connect Contact Lens output file created', 219 | eventPattern: { 220 | source: ['aws.s3'], 221 | detailType: ['Object Created'], 222 | detail: { 223 | bucket: {name: [props.SSMParams.connectContactLensS3BucketName]}, 224 | object: {key: [{prefix: `Analysis/Voice/`}]}, 225 | reason: ['PutObject'], 226 | } 227 | } 228 | }); 229 | 230 | //clEventProcessor Lambda 231 | const clEventProcessorLambda = new nodeLambda.NodejsFunction(this, 'CLEventProcessorLambda', { 232 | functionName: `${props.cdkAppName}-CLEventProcessorLambda`, 233 | runtime: lambda.Runtime.NODEJS_18_X, 234 | entry: 'lambdas/handlers/ContactLens/clEventProcessor.js', 235 | timeout: Duration.seconds(20), 236 | }); 237 | NagSuppressions.addResourceSuppressions(clEventProcessorLambda, [ 238 | { 239 | id: 'AwsSolutions-IAM4', 240 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 241 | } 242 | ], true); 243 | connectCLS3BucketNotificationRule.addTarget(new eventTargets.LambdaFunction(clEventProcessorLambda)); 244 | 245 | //Dead letter queue 246 | const clDLQ = new sqs.Queue(this, 'CLDLQ', { 247 | queueName: `${props.cdkAppName}-CLDLQ`, 248 | retentionPeriod: Duration.days(14), 249 | visibilityTimeout: Duration.seconds(300), 250 | enforceSSL: true, 251 | }); 252 | 253 | NagSuppressions.addResourceSuppressions(clDLQ, [ 254 | { 255 | id: 'AwsSolutions-SQS3', 256 | reason: 'This is the dead letter queue.' 257 | }, 258 | ]); 259 | 260 | //clOutputFileLoader Queue 261 | const clOutputFileLoaderQueue = new sqs.Queue(this, 'CLOutputFileLoaderQueue', { 262 | queueName: `${props.cdkAppName}-CLOutputFileLoaderQueue`, 263 | enforceSSL: true, 264 | deadLetterQueue: { 265 | maxReceiveCount: 3, 266 | queue: clDLQ, 267 | } 268 | }); 269 | clOutputFileLoaderQueue.grantSendMessages(clEventProcessorLambda); 270 | clEventProcessorLambda.addEnvironment('CLOutputFileLoaderQueueURL', clOutputFileLoaderQueue.queueUrl); 271 | 272 | //clOutputFileLoader Lambda 273 | const clOutputFileLoaderLambda = new nodeLambda.NodejsFunction(this, 'CLOutputFileLoaderLambda', { 274 | functionName: `${props.cdkAppName}-CLOutputFileLoaderLambda`, 275 | runtime: lambda.Runtime.NODEJS_18_X, 276 | entry: 'lambdas/handlers/ContactLens/clOutputFileLoader.js', 277 | timeout: Duration.seconds(20), 278 | }); 279 | NagSuppressions.addResourceSuppressions(clOutputFileLoaderLambda, [ 280 | { 281 | id: 'AwsSolutions-IAM4', 282 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 283 | } 284 | ], true); 285 | clOutputFileLoaderQueue.grantConsumeMessages(clOutputFileLoaderLambda); 286 | clOutputFileLoaderLambda.addEventSource(new SqsEventSource(clOutputFileLoaderQueue, { 287 | batchSize: 10, //default 10 288 | reportBatchItemFailures: true, 289 | })); 290 | const clOutputFileLoaderS3AccessInlinePolicy = new iam.Policy(this, 'CLOutputFileLoader-S3Access', { 291 | statements: [ 292 | new iam.PolicyStatement({ 293 | effect: iam.Effect.ALLOW, 294 | actions: ['s3:GetObject'], 295 | resources: [`arn:aws:s3:::${props.SSMParams.connectContactLensS3BucketName}/Analysis/Voice/*`] 296 | }), 297 | ] 298 | }); 299 | clOutputFileLoaderLambda.role?.attachInlinePolicy(clOutputFileLoaderS3AccessInlinePolicy); 300 | NagSuppressions.addResourceSuppressions(clOutputFileLoaderS3AccessInlinePolicy, [ 301 | { 302 | id: 'AwsSolutions-IAM5', 303 | reason: 'The S3 action needs permissions to the specific S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific S3 bucket and all its contents', 304 | }, 305 | ], true); 306 | 307 | //clRecordWriter Queue 308 | const clRecordWriterQueue = new sqs.Queue(this, 'CLRecordWriterQueue', { 309 | queueName: `${props.cdkAppName}-CLRecordWriterQueue`, 310 | enforceSSL: true, 311 | deadLetterQueue: { 312 | maxReceiveCount: 3, 313 | queue: clDLQ, 314 | } 315 | }); 316 | clOutputFileLoaderLambda.addEnvironment('CLRecordWriterQueueURL', clRecordWriterQueue.queueUrl); 317 | clRecordWriterQueue.grantSendMessages(clOutputFileLoaderLambda); 318 | 319 | //clRecordWriter Lambda 320 | const clRecordWriterLambda = new nodeLambda.NodejsFunction(this, 'CLRecordWriterLambda', { 321 | functionName: `${props.cdkAppName}-CLRecordWriterLambda`, 322 | runtime: lambda.Runtime.NODEJS_18_X, 323 | entry: 'lambdas/handlers/ContactLens/clRecordWriter.js', 324 | timeout: Duration.seconds(20), 325 | environment: { 326 | CLKinesisFirehoseName: `${clKinesisFirehose.deliveryStreamName}`, 327 | } 328 | }); 329 | NagSuppressions.addResourceSuppressions(clRecordWriterLambda, [ 330 | { 331 | id: 'AwsSolutions-IAM4', 332 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 333 | } 334 | ], true); 335 | clRecordWriterQueue.grantConsumeMessages(clRecordWriterLambda); 336 | clRecordWriterLambda.addEventSource(new SqsEventSource(clRecordWriterQueue, { 337 | batchSize: 10, //default 10 338 | reportBatchItemFailures: true, 339 | })); 340 | 341 | //Allow clRecordWriterLambda access to ClKinesisFirehose 342 | clRecordWriterLambda.role?.attachInlinePolicy(new iam.Policy(this, 'CLRecordWriterLambda-KinesisFirehoseAccess', { 343 | statements: [ 344 | new iam.PolicyStatement({ 345 | effect: iam.Effect.ALLOW, 346 | actions: ['firehose:PutRecord', 'firehose:PutRecordBatch'], 347 | resources: [`arn:aws:firehose:${this.region}:${this.account}:deliverystream/${clKinesisFirehose.deliveryStreamName}`] 348 | }) 349 | ] 350 | })); 351 | 352 | //Cloudwatch Scheduled Rules 353 | const clPartitioningSchedule = new events.Rule(this, 'CLPartitioningSchedule', { 354 | ruleName: `${props.cdkAppName}-CLPartitioningSchedule`, 355 | description: 'Executes CL partitioning job (Step Functions) on a daily basis', 356 | schedule: events.Schedule.expression('cron(45 23 ? * * *)'), 357 | enabled: props.SSMParams.clPartitioningScheduleEnabled, 358 | }); 359 | clPartitioningSchedule.addTarget(new eventTargets.SfnStateMachine(props.athenaPartitioningStateMachine, { 360 | input: events.RuleTargetInput.fromObject({ 361 | s3_bucket: clS3bucket.bucketName, 362 | s3_prefix: 'fhbase', 363 | table_name: `${props.SSMParams.awsGlueDatabaseName.toLowerCase()}.connect_cl`, 364 | overridePartitionLoad: false, 365 | }) 366 | })); 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /cdk-stacks/lib/contact-trace-records/ctr-stack.ts: -------------------------------------------------------------------------------- 1 | import {NestedStack, NestedStackProps, RemovalPolicy} from 'aws-cdk-lib'; 2 | import {Construct} from 'constructs'; 3 | import * as kinesis from 'aws-cdk-lib/aws-kinesis'; 4 | import * as firehose from 'aws-cdk-lib/aws-kinesisfirehose'; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | import * as glue from "aws-cdk-lib/aws-glue"; 7 | import * as s3 from 'aws-cdk-lib/aws-s3'; 8 | import * as events from 'aws-cdk-lib/aws-events'; 9 | import * as eventTargets from 'aws-cdk-lib/aws-events-targets'; 10 | import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; 11 | import {NagSuppressions} from 'cdk-nag' 12 | 13 | 14 | export interface CTRStackProps extends NestedStackProps { 15 | readonly SSMParams: any; 16 | readonly cdkAppName: string; 17 | readonly ctrS3bucketName: string; 18 | readonly ctrS3bucketAccessLogsName: string; 19 | readonly athenaPartitioningStateMachine: sfn.IStateMachine; 20 | } 21 | 22 | //Contact Trace Records (CTR) Stack 23 | export class CTRStack extends NestedStack { 24 | 25 | constructor(scope: Construct, id: string, props: CTRStackProps) { 26 | super(scope, id, props); 27 | 28 | //Amazon S3 bucket to store access logs for CtrS3bucket 29 | const ctrS3bucketAccessLogs = new s3.Bucket(this, 'CtrS3bucketAccessLogs', { 30 | bucketName: props.ctrS3bucketAccessLogsName, 31 | removalPolicy: RemovalPolicy.RETAIN, 32 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 33 | objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, 34 | enforceSSL: true, 35 | serverAccessLogsPrefix: 'logs', 36 | }); 37 | 38 | //Amazon S3 bucket to store CTRs 39 | const ctrS3bucket = new s3.Bucket(this, 'CtrS3bucket', { 40 | bucketName: props.ctrS3bucketName, 41 | removalPolicy: RemovalPolicy.RETAIN, 42 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 43 | serverAccessLogsBucket: ctrS3bucketAccessLogs, 44 | enforceSSL: true, 45 | serverAccessLogsPrefix: 'logs', 46 | }); 47 | 48 | //Amazon Connect CTR Kinesis Stream 49 | const ctrKinesisStream = new kinesis.Stream(this, 'CTRKinesisStream', { 50 | streamName: `${props.cdkAppName}-CTRKinesisStream`, 51 | shardCount: 1, 52 | }); 53 | 54 | //Amazon Kinesis Firehose Role that provides access to the source Kinesis data stream 55 | const ctrKinesisFirehoseStreamSourceRole = new iam.Role(this, 'CtrKinesisFirehoseStreamSourceRole', { 56 | assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com', { 57 | conditions: {'StringEquals': {"sts:ExternalId": this.account}} 58 | }), 59 | }); 60 | ctrKinesisStream.grantRead(ctrKinesisFirehoseStreamSourceRole); 61 | 62 | //Amazon Kinesis Firehose Role that provides access to the destination Amazon S3 bucket 63 | const ctrKinesisFirehoseS3DestinationRole = new iam.Role(this, 'CtrKinesisFirehoseS3DestinationRole', { 64 | assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com', { 65 | conditions: {'StringEquals': {"sts:ExternalId": this.account}} 66 | }), 67 | }); 68 | ctrS3bucket.grantReadWrite(ctrKinesisFirehoseS3DestinationRole); 69 | NagSuppressions.addResourceSuppressions(ctrKinesisFirehoseS3DestinationRole, [ 70 | { 71 | id: 'AwsSolutions-IAM5', 72 | reason: 'It is justified because this is the intended behavior, for Kinesis to read and write to the S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific Kinesis Firehose and specific S3 bucket' 73 | } 74 | ], true); 75 | 76 | //Amazon Kinesis Firehose 77 | const ctrKinesisFirehose = new firehose.CfnDeliveryStream(this, 'CtrKinesisFirehose', { 78 | deliveryStreamName: `${props.cdkAppName}-CtrKinesisFirehose`, 79 | deliveryStreamType: 'KinesisStreamAsSource', 80 | kinesisStreamSourceConfiguration: { 81 | kinesisStreamArn: ctrKinesisStream.streamArn, 82 | roleArn: ctrKinesisFirehoseStreamSourceRole.roleArn, 83 | }, 84 | extendedS3DestinationConfiguration: { 85 | bucketArn: ctrS3bucket.bucketArn, 86 | roleArn: ctrKinesisFirehoseS3DestinationRole.roleArn, 87 | prefix: 'fhbase/!{partitionKeyFromQuery:PartitionDateTimePrefix}/', 88 | errorOutputPrefix: 'fherroroutputbase-error/!{firehose:random-string}/!{firehose:error-output-type}/!{timestamp:yyy/MM/dd}/', 89 | bufferingHints: { 90 | intervalInSeconds: 60, 91 | sizeInMBs: 128, 92 | }, 93 | dynamicPartitioningConfiguration: { 94 | enabled: true, 95 | }, 96 | processingConfiguration: { 97 | enabled: true, 98 | processors: [ 99 | { 100 | type: "MetadataExtraction", 101 | parameters: [ 102 | { 103 | parameterName: 'JsonParsingEngine', 104 | parameterValue: 'JQ-1.6', 105 | }, 106 | { 107 | parameterName: 'MetadataExtractionQuery', 108 | parameterValue: '{PartitionDateTimePrefix: .InitiationTimestamp| fromdateiso8601| strftime("year=%Y/month=%m/day=%d")}' 109 | }, 110 | ] 111 | }, 112 | { 113 | type: 'AppendDelimiterToRecord', 114 | parameters: [ 115 | { 116 | parameterName: 'Delimiter', 117 | parameterValue: '\\n', 118 | } 119 | ] 120 | } 121 | ] 122 | } 123 | } 124 | }); 125 | ctrKinesisFirehose.node.addDependency(ctrKinesisFirehoseStreamSourceRole); 126 | ctrKinesisFirehose.node.addDependency(ctrKinesisFirehoseS3DestinationRole); 127 | NagSuppressions.addResourceSuppressions(ctrKinesisFirehose, [ 128 | { 129 | id: 'AwsSolutions-KDF1', 130 | reason: 'Firehose does not support encryption when being populated from a Kinesis stream. Under the current architecture, it is not possible. All the traffic is travelling within the same region and account, so it should be safe.' 131 | }, 132 | ]); 133 | 134 | //Create AWS Glue Table 135 | const ctrGlueTable = new glue.CfnTable(this, 'CtrGlueTable', { 136 | catalogId: this.account, 137 | databaseName: props.SSMParams.awsGlueDatabaseName.toLowerCase(), 138 | tableInput: { 139 | name: 'connect_ctr', 140 | tableType: 'EXTERNAL_TABLE', 141 | description: 'AWS Glue Table for Amazon Connect CTRs', 142 | parameters: { 143 | "classification": "json" 144 | }, 145 | partitionKeys: [ 146 | { 147 | name: 'year', 148 | type: 'int' 149 | }, 150 | { 151 | name: 'month', 152 | type: 'int' 153 | }, 154 | { 155 | name: 'day', 156 | type: 'int' 157 | } 158 | ], 159 | storageDescriptor: { 160 | location: `s3://${ctrS3bucket.bucketName}/fhbase`, 161 | inputFormat: 'org.apache.hadoop.mapred.TextInputFormat', 162 | outputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat', 163 | serdeInfo: { 164 | serializationLibrary: "org.openx.data.jsonserde.JsonSerDe", 165 | parameters: { 166 | "serialization.format": "1", 167 | } 168 | }, 169 | compressed: false, 170 | columns: [ 171 | { 172 | name: "awsaccountid", 173 | type: "string", 174 | }, 175 | { 176 | name: "awscontacttracerecordformatversion", 177 | type: "string", 178 | }, 179 | { 180 | name: "agent", 181 | type: "struct,username:string>" 182 | }, 183 | { 184 | name: "agentconnectionattempts", 185 | type: "int" 186 | }, 187 | { 188 | name: "answeringmachinedetectionstatus", 189 | type: "string" 190 | }, 191 | { 192 | name: "attributes", 193 | type: "string" 194 | }, 195 | { 196 | name: "campaign", 197 | type: "struct" 198 | }, 199 | { 200 | name: "channel", 201 | type: "string", 202 | }, 203 | { 204 | name: "connectedtosystemtimestamp", 205 | type: "string" 206 | }, 207 | { 208 | name: "contactdetails", 209 | type: "string" 210 | }, 211 | { 212 | name: "contactid", 213 | type: "string" 214 | }, 215 | { 216 | name: "customerendpoint", 217 | type: "struct" 218 | }, 219 | { 220 | name: "disconnectreason", 221 | type: "string" 222 | }, 223 | { 224 | name: "disconnecttimestamp", 225 | type: "string" 226 | }, 227 | { 228 | name: "initialcontactid", 229 | type: "string" 230 | }, 231 | { 232 | name: "initiationmethod", 233 | type: "string" 234 | }, 235 | { 236 | name: "initiationtimestamp", 237 | type: "string" 238 | }, 239 | { 240 | name: "instancearn", 241 | type: "string" 242 | }, 243 | { 244 | name: "lastupdatetimestamp", 245 | type: "string" 246 | }, 247 | { 248 | name: "mediastreams", 249 | type: "array>" 250 | }, 251 | { 252 | name: "nextcontactid", 253 | type: "string" 254 | }, 255 | { 256 | name: "previouscontactid", 257 | type: "string" 258 | }, 259 | { 260 | name: "queue", 261 | type: "struct" 262 | }, 263 | { 264 | name: "recording", 265 | type: "struct" 266 | }, 267 | { 268 | name: "recordings", 269 | type: "array>" 270 | }, 271 | { 272 | name: "references", 273 | type: "array" 274 | }, 275 | { 276 | name: "scheduledtimestamp", 277 | type: "string" 278 | }, 279 | { 280 | name: "systemendpoint", 281 | type: "struct" 282 | }, 283 | { 284 | name: "transfercompletedtimestamp", 285 | type: "string" 286 | }, 287 | { 288 | name: "transferredtoendpoint", 289 | type: "struct" 290 | }, 291 | { 292 | name: "voiceidresult", 293 | type: "string" 294 | }, 295 | 296 | ], 297 | } 298 | } 299 | }); 300 | 301 | //Cloudwatch Scheduled Rules 302 | const ctrPartitioningSchedule = new events.Rule(this, 'CtrPartitioningSchedule', { 303 | ruleName: `${props.cdkAppName}-CtrPartitioningSchedule`, 304 | description: 'Executes CTR partitioning job (Step Functions) on a daily basis', 305 | schedule: events.Schedule.expression('cron(45 23 ? * * *)'), 306 | enabled: props.SSMParams.ctrPartitioningScheduleEnabled, 307 | }); 308 | ctrPartitioningSchedule.addTarget(new eventTargets.SfnStateMachine(props.athenaPartitioningStateMachine, { 309 | input: events.RuleTargetInput.fromObject({ 310 | s3_bucket: ctrS3bucket.bucketName, 311 | s3_prefix: 'fhbase', 312 | table_name: `${props.SSMParams.awsGlueDatabaseName.toLowerCase()}.connect_ctr`, 313 | overridePartitionLoad: false, 314 | }) 315 | })); 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /cdk-stacks/lib/evaluation-forms/ef-stack.ts: -------------------------------------------------------------------------------- 1 | import {Duration, NestedStack, NestedStackProps, RemovalPolicy} from 'aws-cdk-lib'; 2 | import {Construct} from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as eventTargets from 'aws-cdk-lib/aws-events-targets'; 5 | import * as nodeLambda from "aws-cdk-lib/aws-lambda-nodejs"; 6 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 7 | import * as sqs from 'aws-cdk-lib/aws-sqs'; 8 | import {SqsEventSource} from 'aws-cdk-lib/aws-lambda-event-sources'; 9 | import * as s3 from 'aws-cdk-lib/aws-s3'; 10 | import * as iam from 'aws-cdk-lib/aws-iam'; 11 | import * as firehose from 'aws-cdk-lib/aws-kinesisfirehose'; 12 | import * as glue from "aws-cdk-lib/aws-glue"; 13 | import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; 14 | import {NagSuppressions} from "cdk-nag"; 15 | 16 | export interface EFStackProps extends NestedStackProps { 17 | readonly SSMParams: any; 18 | readonly cdkAppName: string; 19 | readonly efS3bucketName: string; 20 | readonly efS3bucketAccessLogsName: string; 21 | readonly athenaPartitioningStateMachine: sfn.IStateMachine; 22 | } 23 | 24 | //Evaluation Forms (EF) Stack 25 | export class EFStack extends NestedStack { 26 | constructor(scope: Construct, id: string, props: EFStackProps) { 27 | super(scope, id, props); 28 | 29 | //Amazon S3 bucket to store access logs for EF 30 | const efS3bucketAccessLogs = new s3.Bucket(this, 'EFS3bucketAccessLogs', { 31 | bucketName: props.efS3bucketAccessLogsName, 32 | removalPolicy: RemovalPolicy.RETAIN, 33 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 34 | objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, 35 | enforceSSL: true, 36 | serverAccessLogsPrefix: 'logs', 37 | }); 38 | 39 | //Amazon S3 bucket to store EFs 40 | const efS3bucket = new s3.Bucket(this, 'EFS3bucket', { 41 | bucketName: props.efS3bucketName, 42 | removalPolicy: RemovalPolicy.RETAIN, 43 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 44 | serverAccessLogsBucket: efS3bucketAccessLogs, 45 | enforceSSL: true, 46 | serverAccessLogsPrefix: 'logs', 47 | }); 48 | 49 | //Amazon Kinesis Firehose Role that provides access to the destination Amazon S3 bucket 50 | const efKinesisFirehoseS3DestinationRole = new iam.Role(this, 'EFKinesisFirehoseS3DestinationRole', { 51 | assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com', { 52 | conditions: {'StringEquals': {'sts:ExternalId': this.account}} 53 | }), 54 | }); 55 | efS3bucket.grantReadWrite(efKinesisFirehoseS3DestinationRole); 56 | NagSuppressions.addResourceSuppressions(efKinesisFirehoseS3DestinationRole, [ 57 | { 58 | id: 'AwsSolutions-IAM5', 59 | reason: 'The S3 action needs permissions to the specific S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific S3 bucket and all its contents', 60 | }, 61 | ], true); 62 | 63 | //Amazon Kinesis Firehose 64 | const efKinesisFirehose = new firehose.CfnDeliveryStream(this, 'EFKinesisFirehose', { 65 | deliveryStreamName: `${props.cdkAppName}-EFKinesisFirehose`, 66 | deliveryStreamType: 'DirectPut', 67 | deliveryStreamEncryptionConfigurationInput: { 68 | keyType: 'AWS_OWNED_CMK', 69 | }, 70 | extendedS3DestinationConfiguration: { 71 | bucketArn: efS3bucket.bucketArn, 72 | roleArn: efKinesisFirehoseS3DestinationRole.roleArn, 73 | prefix: 'fhbase/!{partitionKeyFromQuery:PartitionDateTimePrefix}/', 74 | errorOutputPrefix: 'fherroroutputbase-error/!{firehose:random-string}/!{firehose:error-output-type}/!{timestamp:yyy/MM/dd}/', 75 | bufferingHints: { 76 | intervalInSeconds: 60, 77 | sizeInMBs: 128, 78 | }, 79 | dynamicPartitioningConfiguration: { 80 | enabled: true, 81 | }, 82 | processingConfiguration: { 83 | enabled: true, 84 | processors: [ 85 | { 86 | type: "MetadataExtraction", 87 | parameters: [ 88 | { 89 | parameterName: 'JsonParsingEngine', 90 | parameterValue: 'JQ-1.6', 91 | }, 92 | { 93 | parameterName: 'MetadataExtractionQuery', 94 | parameterValue: '{PartitionDateTimePrefix: (.evaluationSubmitTimestamp[0:19] + "Z")| fromdateiso8601| strftime("year=%Y/month=%m/day=%d")}' 95 | }, 96 | ] 97 | }, 98 | ] 99 | } 100 | }, 101 | }); 102 | efKinesisFirehose.node.addDependency(efKinesisFirehoseS3DestinationRole); 103 | 104 | //Create AWS Glue Table 105 | const efGlueTable = new glue.CfnTable(this, 'EFGlueTable', { 106 | catalogId: this.account, 107 | databaseName: props.SSMParams.awsGlueDatabaseName.toLowerCase(), 108 | tableInput: { 109 | name: 'connect_ef', 110 | tableType: 'EXTERNAL_TABLE', 111 | description: 'AWS Glue Table for Amazon Connect EFs', 112 | parameters: { 113 | "classification": "json" 114 | }, 115 | partitionKeys: [ 116 | { 117 | name: 'year', 118 | type: 'int' 119 | }, 120 | { 121 | name: 'month', 122 | type: 'int' 123 | }, 124 | { 125 | name: 'day', 126 | type: 'int' 127 | } 128 | ], 129 | storageDescriptor: { 130 | location: `s3://${efS3bucket.bucketName}/fhbase`, 131 | inputFormat: 'org.apache.hadoop.mapred.TextInputFormat', 132 | outputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat', 133 | serdeInfo: { 134 | serializationLibrary: "org.openx.data.jsonserde.JsonSerDe", 135 | parameters: { 136 | "serialization.format": "1", 137 | } 138 | }, 139 | compressed: false, 140 | columns: [ 141 | { 142 | name: "evaluationid", 143 | type: "string" 144 | }, 145 | { 146 | name: "contactid", 147 | type: "string" 148 | }, 149 | { 150 | name: "instanceid", 151 | type: "string" 152 | }, 153 | { 154 | name: "agentid", 155 | type: "string" 156 | }, 157 | { 158 | name: "evaluationdefinitiontitle", 159 | type: "string" 160 | }, 161 | { 162 | name: "evaluator", 163 | type: "string" 164 | }, 165 | { 166 | name: "evaluationstarttimestamp", 167 | type: "string" 168 | }, 169 | { 170 | name: "evaluationsubmittimestamp", 171 | type: "string" 172 | }, 173 | { 174 | name: "evaluationquestionanswers", 175 | type: "array>" 176 | }, 177 | { 178 | name: "evaluationsectionsscores", 179 | type: "array>" 180 | }, 181 | { 182 | name: "evaluationformtotalscorepercentage", 183 | type: "float" 184 | } 185 | ] 186 | } 187 | } 188 | }); 189 | 190 | //Connect Recording S3Bucket Event Notification Rule 191 | 192 | //Parse out bucket name from the bucket path 193 | const connectEvaluationFormsS3BucketName = props.SSMParams.connectEvaluationFormsS3Location.slice(0, props.SSMParams.connectEvaluationFormsS3Location.indexOf("/")); 194 | //parse out bucket prefix from the bucket path 195 | const connectEvaluationFormsS3BucketPrefix = props.SSMParams.connectEvaluationFormsS3Location.slice(props.SSMParams.connectEvaluationFormsS3Location.indexOf("/") + 1, props.SSMParams.connectEvaluationFormsS3Location.length) + "/" 196 | 197 | const connectEFS3BucketNotificationRule = new events.Rule(this, 'ConnectEFS3BucketNotificationRule', { 198 | ruleName: `${props.cdkAppName}-ConnectEFS3NotificationRule`, 199 | description: 'Triggers efEventProcessorLambda Lambda function on Amazon Connect Evaluation Forms output file created', 200 | eventPattern: { 201 | source: ['aws.s3'], 202 | detailType: ['Object Created'], 203 | detail: { 204 | bucket: {name: [connectEvaluationFormsS3BucketName]}, 205 | object: {key: [{prefix: connectEvaluationFormsS3BucketPrefix}]}, 206 | reason: ['PutObject'], 207 | } 208 | } 209 | }); 210 | 211 | //efEventProcessor Lambda 212 | const efEventProcessorLambda = new nodeLambda.NodejsFunction(this, 'EFEventProcessorLambda', { 213 | functionName: `${props.cdkAppName}-EFEventProcessorLambda`, 214 | runtime: lambda.Runtime.NODEJS_18_X, 215 | entry: 'lambdas/handlers/EvaluationForms/efEventProcessor.js', 216 | timeout: Duration.seconds(20), 217 | }); 218 | NagSuppressions.addResourceSuppressions(efEventProcessorLambda, [ 219 | { 220 | id: 'AwsSolutions-IAM4', 221 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 222 | } 223 | ], true); 224 | connectEFS3BucketNotificationRule.addTarget(new eventTargets.LambdaFunction(efEventProcessorLambda)); 225 | 226 | //Dead letter queue 227 | const efDLQ = new sqs.Queue(this, 'EFDLQ', { 228 | queueName: `${props.cdkAppName}-EFDLQ`, 229 | retentionPeriod: Duration.days(14), 230 | visibilityTimeout: Duration.seconds(300), 231 | enforceSSL: true, 232 | }); 233 | 234 | NagSuppressions.addResourceSuppressions(efDLQ, [ 235 | { 236 | id: 'AwsSolutions-SQS3', 237 | reason: 'This is the dead letter queue.' 238 | }, 239 | ]); 240 | 241 | //efOutputFileLoader Queue 242 | const efOutputFileLoaderQueue = new sqs.Queue(this, 'EFOutputFileLoaderQueue', { 243 | queueName: `${props.cdkAppName}-EFOutputFileLoaderQueue`, 244 | enforceSSL: true, 245 | deadLetterQueue: { 246 | maxReceiveCount: 3, 247 | queue: efDLQ, 248 | } 249 | }); 250 | efOutputFileLoaderQueue.grantSendMessages(efEventProcessorLambda); 251 | efEventProcessorLambda.addEnvironment('EFOutputFileLoaderQueueURL', efOutputFileLoaderQueue.queueUrl); 252 | 253 | //efOutputFileLoader Lambda 254 | const efOutputFileLoaderLambda = new nodeLambda.NodejsFunction(this, 'EFOutputFileLoaderLambda', { 255 | functionName: `${props.cdkAppName}-EFOutputFileLoaderLambda`, 256 | runtime: lambda.Runtime.NODEJS_18_X, 257 | entry: 'lambdas/handlers/EvaluationForms/efOutputFileLoader.js', 258 | timeout: Duration.seconds(20), 259 | }); 260 | NagSuppressions.addResourceSuppressions(efOutputFileLoaderLambda, [ 261 | { 262 | id: 'AwsSolutions-IAM4', 263 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 264 | } 265 | ], true); 266 | efOutputFileLoaderQueue.grantConsumeMessages(efOutputFileLoaderLambda); 267 | efOutputFileLoaderLambda.addEventSource(new SqsEventSource(efOutputFileLoaderQueue, { 268 | batchSize: 10, //default 10 269 | reportBatchItemFailures: true, 270 | })); 271 | 272 | const efOutputFileLoaderInlinePolicy = new iam.Policy(this, 'EFOutputFileLoader-S3Access', { 273 | statements: [ 274 | new iam.PolicyStatement({ 275 | effect: iam.Effect.ALLOW, 276 | actions: ['s3:GetObject', 's3:ListBucket'], 277 | resources: [`arn:aws:s3:::${connectEvaluationFormsS3BucketName}/*`] 278 | }), 279 | ] 280 | }); 281 | efOutputFileLoaderLambda.role?.attachInlinePolicy(efOutputFileLoaderInlinePolicy); 282 | NagSuppressions.addResourceSuppressions(efOutputFileLoaderInlinePolicy, [ 283 | { 284 | id: 'AwsSolutions-IAM5', 285 | reason: 'The S3 action needs permissions to the specific S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific S3 bucket and all its contents', 286 | }, 287 | ], true); 288 | 289 | //efRecordWriter Queue 290 | const efRecordWriterQueue = new sqs.Queue(this, 'EFRecordWriterQueue', { 291 | queueName: `${props.cdkAppName}-EFRecordWriterQueue`, 292 | enforceSSL: true, 293 | deadLetterQueue: { 294 | maxReceiveCount: 3, 295 | queue: efDLQ, 296 | } 297 | }); 298 | efOutputFileLoaderLambda.addEnvironment('EFRecordWriterQueueURL', efRecordWriterQueue.queueUrl); 299 | efRecordWriterQueue.grantSendMessages(efOutputFileLoaderLambda); 300 | 301 | //efRecordWriter Lambda 302 | const efRecordWriterLambda = new nodeLambda.NodejsFunction(this, 'EFRecordWriterLambda', { 303 | functionName: `${props.cdkAppName}-EFRecordWriterLambda`, 304 | runtime: lambda.Runtime.NODEJS_18_X, 305 | entry: 'lambdas/handlers/EvaluationForms/efRecordWriter.js', 306 | timeout: Duration.seconds(20), 307 | environment: { 308 | EFKinesisFirehoseName: `${efKinesisFirehose.deliveryStreamName}`, 309 | } 310 | }); 311 | NagSuppressions.addResourceSuppressions(efRecordWriterLambda, [ 312 | { 313 | id: 'AwsSolutions-IAM4', 314 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 315 | } 316 | ], true); 317 | efRecordWriterQueue.grantConsumeMessages(efRecordWriterLambda); 318 | efRecordWriterLambda.addEventSource(new SqsEventSource(efRecordWriterQueue, { 319 | batchSize: 10, //default 10 320 | reportBatchItemFailures: true, 321 | })); 322 | 323 | //Allow efRecordWriterLambda access to EfKinesisFirehose 324 | efRecordWriterLambda.role?.attachInlinePolicy(new iam.Policy(this, 'EFRecordWriterLambda-KinesisFirehoseAccess', { 325 | statements: [ 326 | new iam.PolicyStatement({ 327 | effect: iam.Effect.ALLOW, 328 | actions: ['firehose:PutRecord', 'firehose:PutRecordBatch'], 329 | resources: [`arn:aws:firehose:${this.region}:${this.account}:deliverystream/${efKinesisFirehose.deliveryStreamName}`] 330 | }) 331 | ] 332 | })); 333 | 334 | //Cloudwatch Scheduled Rules 335 | const efPartitioningSchedule = new events.Rule(this, 'EFPartitioningSchedule', { 336 | ruleName: `${props.cdkAppName}-EFPartitioningSchedule`, 337 | description: 'Executes EF partitioning job (Step Functions) on a daily basis', 338 | schedule: events.Schedule.expression('cron(45 23 ? * * *)'), 339 | enabled: props.SSMParams.efPartitioningScheduleEnabled, 340 | }); 341 | efPartitioningSchedule.addTarget(new eventTargets.SfnStateMachine(props.athenaPartitioningStateMachine, { 342 | input: events.RuleTargetInput.fromObject({ 343 | s3_bucket: efS3bucket.bucketName, 344 | s3_prefix: 'fhbase', 345 | table_name: `${props.SSMParams.awsGlueDatabaseName.toLowerCase()}.connect_ef`, 346 | overridePartitionLoad: false, 347 | }) 348 | })); 349 | } 350 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/infrastructure/ssm-params-util.ts: -------------------------------------------------------------------------------- 1 | import {Construct} from 'constructs'; 2 | import {StringParameter} from 'aws-cdk-lib/aws-ssm'; 3 | 4 | const configParams = require('../../config.params.json'); 5 | 6 | export const loadSSMParams = (scope: Construct) => { 7 | const params: any = {} 8 | const SSM_NOT_DEFINED = 'not-defined'; 9 | for (const param of configParams.parameters) { 10 | if (param.boolean) { 11 | params[param.name] = (StringParameter.valueFromLookup(scope, `${configParams.hierarchy}${param.name}`).toLowerCase() === "true"); 12 | } else { 13 | params[param.name] = StringParameter.valueFromLookup(scope, `${configParams.hierarchy}${param.name}`); 14 | } 15 | } 16 | return {...params, SSM_NOT_DEFINED} 17 | } 18 | 19 | export const fixDummyValueString = (value: string): string => { 20 | if (value.includes('dummy-value-for-')) return value.replace(/\//g, '-'); 21 | else return value; 22 | } 23 | -------------------------------------------------------------------------------- /cdk-stacks/lib/partitioning/partitioning-stack.ts: -------------------------------------------------------------------------------- 1 | import {Duration, NestedStack, NestedStackProps, RemovalPolicy} from 'aws-cdk-lib'; 2 | import {Construct} from 'constructs'; 3 | import * as nodeLambda from "aws-cdk-lib/aws-lambda-nodejs"; 4 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 5 | import * as s3 from 'aws-cdk-lib/aws-s3'; 6 | import * as iam from 'aws-cdk-lib/aws-iam'; 7 | import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; 8 | import * as sfn_tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; 9 | import * as logs from 'aws-cdk-lib/aws-logs'; 10 | import {NagSuppressions} from "cdk-nag"; 11 | 12 | 13 | export interface PartitioningStackProps extends NestedStackProps { 14 | readonly SSMParams: any; 15 | readonly cdkAppName: string; 16 | readonly athenaResultsS3bucketName: string; 17 | readonly athenaResultsS3bucketAccessLogsName: string; 18 | readonly ctrS3bucketName: string; 19 | readonly aeS3bucketName: string; 20 | readonly cflS3bucketName: string; 21 | readonly clS3bucketName: string; 22 | readonly efS3bucketName: string; 23 | } 24 | 25 | export class PartitioningStack extends NestedStack { 26 | 27 | public readonly athenaPartitioningStateMachine: sfn.IStateMachine; 28 | 29 | constructor(scope: Construct, id: string, props: PartitioningStackProps) { 30 | super(scope, id, props); 31 | 32 | //Amazon S3 bucket to store access logs for Athena Results 33 | const athenaResultsS3bucketAccessLogs = new s3.Bucket(this, 'AthenaResultsS3bucketAccessLogs', { 34 | bucketName: props.athenaResultsS3bucketAccessLogsName, 35 | removalPolicy: RemovalPolicy.RETAIN, 36 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 37 | objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, 38 | enforceSSL: true, 39 | serverAccessLogsPrefix: 'logs', 40 | }); 41 | NagSuppressions.addResourceSuppressions(athenaResultsS3bucketAccessLogs, [ 42 | { 43 | id: 'AwsSolutions-S1', 44 | reason: 'This is the access log bucket.' 45 | }, 46 | ]); 47 | 48 | //Amazon S3 bucket to store Athena Results 49 | const athenaResultsS3bucket = new s3.Bucket(this, 'AthenaResultsS3bucket', { 50 | bucketName: `${props.cdkAppName}-ar-${this.account}-${this.region}`.toLowerCase(), 51 | removalPolicy: RemovalPolicy.RETAIN, 52 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 53 | serverAccessLogsBucket: athenaResultsS3bucketAccessLogs, 54 | enforceSSL: true, 55 | serverAccessLogsPrefix: 'logs', 56 | }); 57 | 58 | const startPartitioningLambda = new nodeLambda.NodejsFunction(this, 'StartPartitioningLambda', { 59 | functionName: `${props.cdkAppName}-StartPartitioningLambda`, 60 | runtime: lambda.Runtime.NODEJS_18_X, 61 | entry: 'lambdas/handlers/Partitioning/startPartitioning.js', 62 | timeout: Duration.seconds(180), 63 | environment: { 64 | AthenaResultsS3bucketName: athenaResultsS3bucket.bucketName, 65 | } 66 | }); 67 | NagSuppressions.addResourceSuppressions(startPartitioningLambda, [ 68 | { 69 | id: 'AwsSolutions-IAM4', 70 | reason: 'This is using the default AWS Lambda role, and is limited enough for our use case.', 71 | } 72 | ], true); 73 | startPartitioningLambda.role?.attachInlinePolicy(new iam.Policy(this, 'StartPartitioning-AthenaAccess', { 74 | statements: [ 75 | new iam.PolicyStatement({ 76 | effect: iam.Effect.ALLOW, 77 | actions: ['athena:StartQueryExecution'], 78 | resources: [`arn:aws:athena:${this.region}:${this.account}:workgroup/primary`] 79 | }), 80 | ] 81 | })); 82 | 83 | const startPartitioningGlueAccessInlinePolicy = new iam.Policy(this, 'StartPartitioning-GlueAccess', { 84 | statements: [ 85 | new iam.PolicyStatement({ 86 | effect: iam.Effect.ALLOW, 87 | actions: ['glue:GetTable', 'glue:UpdateTable', 'glue:GetPartitions', 'glue:CreatePartition', 'glue:UpdatePartition', 'glue:BatchCreatePartition'], 88 | resources: ["*"] 89 | }), 90 | ] 91 | }); 92 | startPartitioningLambda.role?.attachInlinePolicy(startPartitioningGlueAccessInlinePolicy); 93 | NagSuppressions.addResourceSuppressions(startPartitioningGlueAccessInlinePolicy, [ 94 | { 95 | id: 'AwsSolutions-IAM5', 96 | reason: 'The Glue actions need permissions to all of the tables. We mitigate it by allowing permission to the specific catalog and database.', 97 | }, 98 | ], true); 99 | 100 | const startPartitioningS3AccessInlinePolicy = new iam.Policy(this, 'StartPartitioning-S3Access', { 101 | statements: [ 102 | new iam.PolicyStatement({ 103 | effect: iam.Effect.ALLOW, 104 | actions: ['s3:GetBucketLocation'], 105 | resources: [athenaResultsS3bucket.bucketArn] 106 | }), 107 | new iam.PolicyStatement({ 108 | effect: iam.Effect.ALLOW, 109 | actions: ['s3:PutObject'], 110 | resources: [ 111 | `arn:aws:s3:::${props.athenaResultsS3bucketName}/*`, 112 | `arn:aws:s3:::${props.ctrS3bucketName}/*`, 113 | `arn:aws:s3:::${props.aeS3bucketName}/*`, 114 | `arn:aws:s3:::${props.cflS3bucketName}/*`, 115 | `arn:aws:s3:::${props.clS3bucketName}/*`, 116 | `arn:aws:s3:::${props.efS3bucketName}/*`, 117 | ] 118 | }), 119 | new iam.PolicyStatement({ 120 | effect: iam.Effect.ALLOW, 121 | actions: ['s3:ListBucket'], 122 | resources: [ 123 | `arn:aws:s3:::${props.ctrS3bucketName}`, 124 | `arn:aws:s3:::${props.aeS3bucketName}`, 125 | `arn:aws:s3:::${props.cflS3bucketName}`, 126 | `arn:aws:s3:::${props.clS3bucketName}`, 127 | `arn:aws:s3:::${props.efS3bucketName}`, 128 | ] 129 | }), 130 | ] 131 | }); 132 | startPartitioningLambda.role?.attachInlinePolicy(startPartitioningS3AccessInlinePolicy); 133 | NagSuppressions.addResourceSuppressions(startPartitioningS3AccessInlinePolicy, [ 134 | { 135 | id: 'AwsSolutions-IAM5', 136 | reason: 'The S3 action needs permissions to the specific S3 bucket and all its contents. We mitigate it by allowing read write permission to the specific S3 bucket and all its contents', 137 | }, 138 | ], true); 139 | 140 | const pollPartitioningStatusLambda = new nodeLambda.NodejsFunction(this, 'PollPartitioningStatusLambda', { 141 | functionName: `${props.cdkAppName}-PollPartitioningStatusLambda`, 142 | runtime: lambda.Runtime.NODEJS_18_X, 143 | entry: 'lambdas/handlers/Partitioning/pollPartitioningStatus.js', 144 | timeout: Duration.seconds(180), 145 | }); 146 | pollPartitioningStatusLambda.role?.attachInlinePolicy(new iam.Policy(this, 'PollPartitioningStatus-AthenaAccess', { 147 | statements: [ 148 | new iam.PolicyStatement({ 149 | effect: iam.Effect.ALLOW, 150 | actions: ['athena:GetQueryExecution'], 151 | resources: [`arn:aws:athena:${this.region}:${this.account}:workgroup/primary`] 152 | }), 153 | ] 154 | })); 155 | NagSuppressions.addResourceSuppressions(pollPartitioningStatusLambda, [ 156 | { 157 | id: 'AwsSolutions-IAM4', 158 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 159 | } 160 | ], true) 161 | 162 | const getPartitioningResultsLambda = new nodeLambda.NodejsFunction(this, 'GetPartitioningResultsLambda', { 163 | functionName: `${props.cdkAppName}-GetPartitioningResultsLambda`, 164 | runtime: lambda.Runtime.NODEJS_18_X, 165 | entry: 'lambdas/handlers/Partitioning/getPartitioningResults.js', 166 | timeout: Duration.seconds(180), 167 | }); 168 | getPartitioningResultsLambda.role?.attachInlinePolicy(new iam.Policy(this, 'GetPartitioningResults-AthenaAccess', { 169 | statements: [ 170 | new iam.PolicyStatement({ 171 | effect: iam.Effect.ALLOW, 172 | actions: ['athena:GetQueryResults'], 173 | resources: [`arn:aws:athena:${this.region}:${this.account}:workgroup/primary`] 174 | }), 175 | ] 176 | })); 177 | const getPartitionResultsS3AccessInlinePolicy = new iam.Policy(this, 'GetPartitioningResults-S3Access', { 178 | statements: [ 179 | new iam.PolicyStatement({ 180 | effect: iam.Effect.ALLOW, 181 | actions: ['s3:GetObject'], 182 | resources: [`${athenaResultsS3bucket.bucketArn}/*`] 183 | }), 184 | ] 185 | }); 186 | getPartitioningResultsLambda.role?.attachInlinePolicy(getPartitionResultsS3AccessInlinePolicy); 187 | NagSuppressions.addResourceSuppressions(getPartitioningResultsLambda, [ 188 | { 189 | id: 'AwsSolutions-IAM4', 190 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.', 191 | } 192 | ], true); 193 | NagSuppressions.addResourceSuppressions(getPartitionResultsS3AccessInlinePolicy, [ 194 | { 195 | id: 'AwsSolutions-IAM5', 196 | reason: 'This is the intended use case, for the Lambda to access all object in the Athena results bucket.', 197 | } 198 | ], true); 199 | 200 | const partitioningIteratorLambda = new nodeLambda.NodejsFunction(this, 'PartitioningIteratorLambda', { 201 | functionName: `${props.cdkAppName}-PartitioningIteratorLambda`, 202 | runtime: lambda.Runtime.NODEJS_18_X, 203 | entry: 'lambdas/handlers/Partitioning/partitioningIterator.js', 204 | timeout: Duration.seconds(180), 205 | }); 206 | NagSuppressions.addResourceSuppressions(partitioningIteratorLambda, [ 207 | { 208 | id: 'AwsSolutions-IAM4', 209 | reason: 'This is using the default AWS Lambda role, and is limited enough for our use case.', 210 | } 211 | ], true); 212 | 213 | //AWS Step Functions - State Machine for Partitioning 214 | 215 | const pollPartitioningStatusTask = new sfn_tasks.LambdaInvoke(this, 'PollPartitioningStatusTask', { 216 | lambdaFunction: pollPartitioningStatusLambda, 217 | payloadResponseOnly: true, 218 | retryOnServiceExceptions: true, 219 | resultPath: '$.result', 220 | }); 221 | 222 | const partitioningWaitNext = new sfn.Wait(this, 'PartitioningWaitNext', { 223 | time: sfn.WaitTime.secondsPath('$.result.waitTime') 224 | }); 225 | 226 | const startPartitioningTask = new sfn_tasks.LambdaInvoke(this, 'StartPartitioningTask', { 227 | lambdaFunction: startPartitioningLambda, 228 | payloadResponseOnly: true, 229 | retryOnServiceExceptions: true, 230 | resultPath: '$.result', 231 | }); 232 | 233 | const partitioningIteratorWait = new sfn.Wait(this, 'PartitioningIteratorWait', { 234 | time: sfn.WaitTime.secondsPath('$.iterator.iteratorWaitCurrent') 235 | }); 236 | 237 | const partitioningIteratorTask = new sfn_tasks.LambdaInvoke(this, 'PartitioningIteratorTask', { 238 | lambdaFunction: partitioningIteratorLambda, 239 | payloadResponseOnly: true, 240 | retryOnServiceExceptions: true, 241 | resultPath: '$.iterator', 242 | }); 243 | 244 | const getPartitioningResultTask = new sfn_tasks.LambdaInvoke(this, 'GetPartitioningResultTask', { 245 | lambdaFunction: getPartitioningResultsLambda, 246 | payloadResponseOnly: true, 247 | retryOnServiceExceptions: true, 248 | }); 249 | 250 | const partitioningConfigureCount = new sfn.Pass(this, 'PartitioningConfigureCount', { 251 | result: { 252 | value: { 253 | count: 5, 254 | index: -1, 255 | step: 1, 256 | iteratorWaitInit: 10, 257 | iteratorWaitCurrent: 0 258 | } 259 | }, 260 | resultPath: '$.iterator' 261 | }); 262 | 263 | const partitioningQueryFailed = new sfn.Fail(this, 'PartitioningQueryFailed', { 264 | cause: 'Athena query execution failed', 265 | error: 'Athena query execution failed' 266 | }); 267 | 268 | //Define log group for state machine 269 | const athenaPartitioningLogGroup = new logs.LogGroup(this, 'AthenaPartitioningLogGroup'); 270 | 271 | const athenaPartitioningStateMachine = new sfn.StateMachine(this, 'AthenaPartitioningStateMachine', { 272 | stateMachineName: `${props.cdkAppName}-AthenaPartitioningStateMachine`, 273 | definition: sfn.Chain.start( 274 | partitioningConfigureCount 275 | .next(partitioningIteratorTask) 276 | .next( 277 | new sfn.Choice(this, 'PartitioningIsCountReached') 278 | .when(sfn.Condition.booleanEquals('$.iterator.continue', true), partitioningIteratorWait.next( 279 | startPartitioningTask.next( 280 | partitioningWaitNext.next( 281 | pollPartitioningStatusTask.next( 282 | new sfn.Choice(this, 'PartitioningCheckComplete') 283 | .when(sfn.Condition.stringEquals('$.result.status', 'FAILED'), partitioningIteratorTask) 284 | .when(sfn.Condition.stringEquals('$.result.status', 'SUCCEEDED'), getPartitioningResultTask) 285 | .otherwise(partitioningWaitNext) 286 | ) 287 | ) 288 | ) 289 | )) 290 | .otherwise(partitioningQueryFailed) 291 | ) 292 | ), 293 | logs: { 294 | destination: athenaPartitioningLogGroup, 295 | level: sfn.LogLevel.ALL 296 | }, 297 | tracingEnabled: true, 298 | }); 299 | NagSuppressions.addResourceSuppressions(athenaPartitioningStateMachine, [ 300 | { 301 | id: 'AwsSolutions-IAM5', 302 | reason: 'This is the default State Machine Execution Policy which just grants the state machine start execution permissions.', 303 | }, 304 | ], true); 305 | this.athenaPartitioningStateMachine = athenaPartitioningStateMachine; 306 | } 307 | } -------------------------------------------------------------------------------- /cdk-stacks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-stacks", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk-stacks": "bin/cdk-stacks.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk", 12 | "configure": "node configure.js -il", 13 | "configure:test": "node configure.js -ilt", 14 | "install:cdk-stacks": "npm install", 15 | "install:lambdas": "cd lambdas && npm install", 16 | "install:all": "npm run install:cdk-stacks && npm run install:lambdas", 17 | "cdk:remove:context": "rm -f cdk.context.json", 18 | "cdk:synth": "npm run cdk:remove:context && cdk synth", 19 | "cdk:deploy": "npm run cdk:remove:context && cdk deploy --all --no-rollback", 20 | "cdk:deploy:gitbash": "npm run cdk:remove:context && winpty cdk.cmd deploy --all --disable-rollback" 21 | }, 22 | "devDependencies": { 23 | "@types/jest": "^26.0.10", 24 | "@types/node": "10.17.27", 25 | "aws-cdk": "2.88.0", 26 | "esbuild": "^0.14.36", 27 | "jest": "^26.4.2", 28 | "ts-jest": "^26.2.0", 29 | "ts-node": "^9.0.0", 30 | "typescript": "~3.9.7" 31 | }, 32 | "dependencies": { 33 | "@aws-sdk/client-ssm": "^3.58.0", 34 | "aws-cdk-lib": "2.88.0", 35 | "cdk-nag": "^2.22.28", 36 | "constructs": "^10.0.0", 37 | "source-map-support": "^0.5.16" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cdk-stacks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2018" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "typeRoots": [ 23 | "./node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "cdk.out" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /contact-flows/SimpleInboundFlow: -------------------------------------------------------------------------------- 1 | {"Version":"2019-10-30","StartAction":"7c80f727-565f-4ea6-bc4e-b164e6e4d642","Metadata":{"entryPointPosition":{"x":20,"y":20},"ActionMetadata":{"94097827-7b36-48dd-ac95-9139a06c8701":{"position":{"x":773.6,"y":956}},"1a039430-1ede-4531-81a4-fd6a217d943a":{"position":{"x":1077.6,"y":-2.4},"dynamicParams":[]},"40a7f1d0-e112-4ed9-9612-bd162a12cd04":{"position":{"x":1076.8,"y":188.8},"dynamicParams":[]},"f7b70d86-3990-4d08-a732-f82c4bfc8927":{"position":{"x":1068.8,"y":377.6},"dynamicParams":[]},"2965d0c9-ebe7-4f80-9911-baad0a6d5f9b":{"position":{"x":1049.6,"y":570.4},"dynamicParams":[]},"60e61d5f-c405-4d67-930e-2e43d6586697":{"position":{"x":1644,"y":416}},"c24f3a99-0bf7-4702-b4f4-8ac87bbaaf01":{"position":{"x":765.6,"y":781.6}},"208f92b4-feeb-4394-b69d-086e98bbff89":{"position":{"x":1389.6,"y":304},"parameters":{"QueueId":{"displayName":"BasicQueue"}},"queue":{"text":"BasicQueue"}},"ff1dd4a2-0e23-4b3e-81ef-5561379a36a2":{"position":{"x":677.6,"y":24},"conditionMetadata":[{"id":"55104930-54a4-4baf-b9a1-bbdc0b9c4415","value":"1"},{"id":"04e45bbf-0232-446b-9486-69808143e6cf","value":"2"},{"id":"21f5d70a-a7e9-4785-97d1-a29c8399f99c","value":"3"}]},"7c80f727-565f-4ea6-bc4e-b164e6e4d642":{"position":{"x":172.8,"y":26.4}},"d06e0b16-e773-416c-a584-40eb16213113":{"position":{"x":439.2,"y":64.8}},"d14390ef-077a-4b75-9f6f-e07f945bae29":{"position":{"x":299.2,"y":313.6}}},"name":"SimpleInboundFlow","description":"SimpleInboundFlow","type":"contactFlow","status":"saved","hash":{}},"Actions":[{"Parameters":{},"Identifier":"94097827-7b36-48dd-ac95-9139a06c8701","Type":"DisconnectParticipant","Transitions":{}},{"Parameters":{"Attributes":{"CallReason":"RecentPurchase"},"TargetContact":"Current"},"Identifier":"1a039430-1ede-4531-81a4-fd6a217d943a","Type":"UpdateContactAttributes","Transitions":{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","Errors":[{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","ErrorType":"NoMatchingError"}]}},{"Parameters":{"Attributes":{"CallReason":"RepeatOrder"},"TargetContact":"Current"},"Identifier":"40a7f1d0-e112-4ed9-9612-bd162a12cd04","Type":"UpdateContactAttributes","Transitions":{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","Errors":[{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","ErrorType":"NoMatchingError"}]}},{"Parameters":{"Attributes":{"CallReason":"NewOrder"},"TargetContact":"Current"},"Identifier":"f7b70d86-3990-4d08-a732-f82c4bfc8927","Type":"UpdateContactAttributes","Transitions":{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","Errors":[{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","ErrorType":"NoMatchingError"}]}},{"Parameters":{"Attributes":{"CallReason":"NoSelection"},"TargetContact":"Current"},"Identifier":"2965d0c9-ebe7-4f80-9911-baad0a6d5f9b","Type":"UpdateContactAttributes","Transitions":{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","Errors":[{"NextAction":"208f92b4-feeb-4394-b69d-086e98bbff89","ErrorType":"NoMatchingError"}]}},{"Parameters":{},"Identifier":"60e61d5f-c405-4d67-930e-2e43d6586697","Type":"TransferContactToQueue","Transitions":{"NextAction":"c24f3a99-0bf7-4702-b4f4-8ac87bbaaf01","Errors":[{"NextAction":"c24f3a99-0bf7-4702-b4f4-8ac87bbaaf01","ErrorType":"QueueAtCapacity"},{"NextAction":"c24f3a99-0bf7-4702-b4f4-8ac87bbaaf01","ErrorType":"NoMatchingError"}]}},{"Parameters":{"Text":"We are experiencing technical difficulties , please try again later."},"Identifier":"c24f3a99-0bf7-4702-b4f4-8ac87bbaaf01","Type":"MessageParticipant","Transitions":{"NextAction":"94097827-7b36-48dd-ac95-9139a06c8701"}},{"Parameters":{"QueueId":"arn:aws:connect:us-west-2:976576190876:instance/4e3a1ac7-02cc-46f6-80ef-ca0f80affd4f/queue/1f56e765-4d77-4d45-ba47-88d974497393"},"Identifier":"208f92b4-feeb-4394-b69d-086e98bbff89","Type":"UpdateContactTargetQueue","Transitions":{"NextAction":"60e61d5f-c405-4d67-930e-2e43d6586697","Errors":[{"NextAction":"c24f3a99-0bf7-4702-b4f4-8ac87bbaaf01","ErrorType":"NoMatchingError"}]}},{"Parameters":{"Text":"If you are calling about your recent purchase, please press 1. If you would like to repeat the previous order, please press 2. To place a new order, please press 3.","StoreInput":"False","InputTimeLimitSeconds":"5"},"Identifier":"ff1dd4a2-0e23-4b3e-81ef-5561379a36a2","Type":"GetParticipantInput","Transitions":{"NextAction":"2965d0c9-ebe7-4f80-9911-baad0a6d5f9b","Conditions":[{"NextAction":"1a039430-1ede-4531-81a4-fd6a217d943a","Condition":{"Operator":"Equals","Operands":["1"]}},{"NextAction":"40a7f1d0-e112-4ed9-9612-bd162a12cd04","Condition":{"Operator":"Equals","Operands":["2"]}},{"NextAction":"f7b70d86-3990-4d08-a732-f82c4bfc8927","Condition":{"Operator":"Equals","Operands":["3"]}}],"Errors":[{"NextAction":"2965d0c9-ebe7-4f80-9911-baad0a6d5f9b","ErrorType":"InputTimeLimitExceeded"},{"NextAction":"2965d0c9-ebe7-4f80-9911-baad0a6d5f9b","ErrorType":"NoMatchingCondition"},{"NextAction":"c24f3a99-0bf7-4702-b4f4-8ac87bbaaf01","ErrorType":"NoMatchingError"}]}},{"Parameters":{"FlowLoggingBehavior":"Enabled"},"Identifier":"7c80f727-565f-4ea6-bc4e-b164e6e4d642","Type":"UpdateFlowLoggingBehavior","Transitions":{"NextAction":"d14390ef-077a-4b75-9f6f-e07f945bae29"}},{"Parameters":{"Text":"Welcome to Amazon Connect Contact Centre."},"Identifier":"d06e0b16-e773-416c-a584-40eb16213113","Type":"MessageParticipant","Transitions":{"NextAction":"ff1dd4a2-0e23-4b3e-81ef-5561379a36a2"}},{"Parameters":{"RecordingBehavior":{"RecordedParticipants":["Agent","Customer"]},"AnalyticsBehavior":{"Enabled":"True","AnalyticsLanguage":"en-US","AnalyticsRedactionBehavior":"Disabled","AnalyticsRedactionResults":"RedactedAndOriginal","ChannelConfiguration":{"Chat":{"AnalyticsModes":["ContactLens"]},"Voice":{"AnalyticsModes":["RealTime"]}}}},"Identifier":"d14390ef-077a-4b75-9f6f-e07f945bae29","Type":"UpdateContactRecordingBehavior","Transitions":{"NextAction":"d06e0b16-e773-416c-a584-40eb16213113"}}]} -------------------------------------------------------------------------------- /diagrams/AmazonConnectDataAnalyticsSample-Architecture-AE.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/diagrams/AmazonConnectDataAnalyticsSample-Architecture-AE.jpg -------------------------------------------------------------------------------- /diagrams/AmazonConnectDataAnalyticsSample-Architecture-CFL.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/diagrams/AmazonConnectDataAnalyticsSample-Architecture-CFL.jpg -------------------------------------------------------------------------------- /diagrams/AmazonConnectDataAnalyticsSample-Architecture-CL.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/diagrams/AmazonConnectDataAnalyticsSample-Architecture-CL.jpg -------------------------------------------------------------------------------- /diagrams/AmazonConnectDataAnalyticsSample-Architecture-CTR.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/diagrams/AmazonConnectDataAnalyticsSample-Architecture-CTR.jpg -------------------------------------------------------------------------------- /diagrams/AmazonConnectDataAnalyticsSample-Architecture-EF.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/diagrams/AmazonConnectDataAnalyticsSample-Architecture-EF.jpg -------------------------------------------------------------------------------- /diagrams/AmazonConnectDataAnalyticsSample-Architecture-QuickSight-Athena-Glue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/diagrams/AmazonConnectDataAnalyticsSample-Architecture-QuickSight-Athena-Glue.jpg -------------------------------------------------------------------------------- /diagrams/AmazonConnectDataAnalyticsSample-Architecture.drawio: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /diagrams/PartitioningStepFunctionsStateMachine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/diagrams/PartitioningStepFunctionsStateMachine.png -------------------------------------------------------------------------------- /images/AmazonConnectDataAnalyticsSample-SampleEvaluationForm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/AmazonConnectDataAnalyticsSample-SampleEvaluationForm.png -------------------------------------------------------------------------------- /images/ContactFlowAllBlocks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowAllBlocks.png -------------------------------------------------------------------------------- /images/ContactFlowCallReasonNewOrder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowCallReasonNewOrder.png -------------------------------------------------------------------------------- /images/ContactFlowCallReasonNoSelection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowCallReasonNoSelection.png -------------------------------------------------------------------------------- /images/ContactFlowCallReasonRecentPurchase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowCallReasonRecentPurchase.png -------------------------------------------------------------------------------- /images/ContactFlowCallReasonRepeatOrder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowCallReasonRepeatOrder.png -------------------------------------------------------------------------------- /images/ContactFlowEnableLogging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowEnableLogging.png -------------------------------------------------------------------------------- /images/ContactFlowMainMenuOptions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowMainMenuOptions.png -------------------------------------------------------------------------------- /images/ContactFlowMainMenuOptionsOutputs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowMainMenuOptionsOutputs.png -------------------------------------------------------------------------------- /images/ContactFlowMainMenuPrompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowMainMenuPrompt.png -------------------------------------------------------------------------------- /images/ContactFlowMainMenuTechnicalDifficulties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowMainMenuTechnicalDifficulties.png -------------------------------------------------------------------------------- /images/ContactFlowSetBasicQueue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowSetBasicQueue.png -------------------------------------------------------------------------------- /images/ContactFlowSetBasicQueueTransferToQueue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowSetBasicQueueTransferToQueue.png -------------------------------------------------------------------------------- /images/ContactFlowSetContactAttributesToSetBasicQueue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowSetContactAttributesToSetBasicQueue.png -------------------------------------------------------------------------------- /images/ContactFlowTechnicalDifficultiesPrompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowTechnicalDifficultiesPrompt.png -------------------------------------------------------------------------------- /images/ContactFlowWelcomePrompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ContactFlowWelcomePrompt.png -------------------------------------------------------------------------------- /images/DisconnectionReason-date-range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/DisconnectionReason-date-range.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSource.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSource.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceAEAgentStatus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceAEAgentStatus.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceAEAgentStatusDirectlyQuery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceAEAgentStatusDirectlyQuery.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceAEAgentStatusQueryName.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceAEAgentStatusQueryName.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceAEAgentStatusVisualizeTable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceAEAgentStatusVisualizeTable.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDay.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDayDirectlyQuery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDayDirectlyQuery.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDayQueryName.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDayQueryName.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDayValidate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDayValidate.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualize.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReason.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReason.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReasonPieChart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReasonPieChart.png -------------------------------------------------------------------------------- /images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReasonTable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-AthenaSourceCTRCallsCurrentDayVisualizeCallReasonTable.png -------------------------------------------------------------------------------- /images/QuickSight-EnableAthenaAccess.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-EnableAthenaAccess.png -------------------------------------------------------------------------------- /images/QuickSight-EnableS3Access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-EnableS3Access.png -------------------------------------------------------------------------------- /images/QuickSight-GoToAmazonQuickSight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-GoToAmazonQuickSight.png -------------------------------------------------------------------------------- /images/QuickSight-ManageData.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-ManageData.png -------------------------------------------------------------------------------- /images/QuickSight-NewDataSet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-NewDataSet.png -------------------------------------------------------------------------------- /images/QuickSight-SetRegion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-SetRegion.png -------------------------------------------------------------------------------- /images/QuickSight-Share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-Share.png -------------------------------------------------------------------------------- /images/QuickSight-ShareAnalysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-ShareAnalysis.png -------------------------------------------------------------------------------- /images/QuickSight-ShareAnalysisDashboardName.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-ShareAnalysisDashboardName.png -------------------------------------------------------------------------------- /images/QuickSight-Signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-Signup.png -------------------------------------------------------------------------------- /images/QuickSight-StandardVsEnterprise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/QuickSight-StandardVsEnterprise.png -------------------------------------------------------------------------------- /images/ResultSetAE-AgentTrace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetAE-AgentTrace.png -------------------------------------------------------------------------------- /images/ResultSetAE-AgentsCurrentStatus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetAE-AgentsCurrentStatus.png -------------------------------------------------------------------------------- /images/ResultSetAE-AgentsCurrentStatusNumberOfContacts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetAE-AgentsCurrentStatusNumberOfContacts.png -------------------------------------------------------------------------------- /images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactState.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactState.png -------------------------------------------------------------------------------- /images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactId.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactId.png -------------------------------------------------------------------------------- /images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactIdCallReason.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactIdCallReason.png -------------------------------------------------------------------------------- /images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactIdCallReasonEnded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetAE-AgentsCurrentStatusNumberOfContactsContactStateQueueContactIdCallReasonEnded.png -------------------------------------------------------------------------------- /images/ResultSetCFL-CallReasons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCFL-CallReasons.png -------------------------------------------------------------------------------- /images/ResultSetCFL-DTMFInputs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCFL-DTMFInputs.png -------------------------------------------------------------------------------- /images/ResultSetCFL-VisitedNodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCFL-VisitedNodes.png -------------------------------------------------------------------------------- /images/ResultSetCL-AvgOverallSentiment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCL-AvgOverallSentiment.png -------------------------------------------------------------------------------- /images/ResultSetCL-AvgOverallSentimentQueue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCL-AvgOverallSentimentQueue.png -------------------------------------------------------------------------------- /images/ResultSetCL-MatchedCategoryCustomerLoyaltyRisk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCL-MatchedCategoryCustomerLoyaltyRisk.png -------------------------------------------------------------------------------- /images/ResultSetCTR-CallReasonAnsweredTimeInQueueAgentHandlingTime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCTR-CallReasonAnsweredTimeInQueueAgentHandlingTime.png -------------------------------------------------------------------------------- /images/ResultSetCTR-CallReasonAvgAbandonTimeByDate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCTR-CallReasonAvgAbandonTimeByDate.png -------------------------------------------------------------------------------- /images/ResultSetCTR-CallReasonAvgAbandonTimeThresholdByDate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCTR-CallReasonAvgAbandonTimeThresholdByDate.png -------------------------------------------------------------------------------- /images/ResultSetCTR-CallReasonByDate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCTR-CallReasonByDate.png -------------------------------------------------------------------------------- /images/ResultSetCTR-CurrentDate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCTR-CurrentDate.png -------------------------------------------------------------------------------- /images/ResultSetCTR-CurrentDateCallReason.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetCTR-CurrentDateCallReason.png -------------------------------------------------------------------------------- /images/ResultSetEF-CurrentDate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetEF-CurrentDate.png -------------------------------------------------------------------------------- /images/ResultSetEF-CurrentDateSpecificForm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetEF-CurrentDateSpecificForm.png -------------------------------------------------------------------------------- /images/ResultSetEF-CurrentDateSpecificFormAvgSectionsScores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetEF-CurrentDateSpecificFormAvgSectionsScores.png -------------------------------------------------------------------------------- /images/ResultSetEF-CurrentDateSpecificFormSectionsScores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetEF-CurrentDateSpecificFormSectionsScores.png -------------------------------------------------------------------------------- /images/ResultSetEF-CurrentDateSpecificFormTotalScores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetEF-CurrentDateSpecificFormTotalScores.png -------------------------------------------------------------------------------- /images/ResultSetEF-CurrentDateSpecificFormTotalScoresAvg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-data-analytics-sample/dd709532152e0140985e16d3a32fb9708d3acc27/images/ResultSetEF-CurrentDateSpecificFormTotalScoresAvg.png --------------------------------------------------------------------------------