├── .eslintrc.js ├── .flake8 ├── .gitignore ├── .mypy.ini ├── .pylintrc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── THIRD-PARTY-LICENSES ├── VERSION ├── package-lock.json ├── package.json ├── prettier.config.js ├── pyproject.toml ├── requirements ├── requirements-build.txt └── requirements-dev.txt ├── samconfig.toml ├── src ├── lambda_functions │ ├── banker_bot │ │ ├── __init__.py │ │ ├── lambda_function.py │ │ └── requirements.txt │ ├── bot_tester │ │ ├── __init__.py │ │ ├── audio │ │ │ └── hello.pcm │ │ ├── bot_conversations.py │ │ ├── lambda_function.py │ │ ├── requirements.txt │ │ └── run_conversation_test.py │ ├── cw_custom_widget_nodejs │ │ ├── index.js │ │ ├── lib │ │ │ ├── index.js │ │ │ └── runQuery.js │ │ ├── package-lock.json │ │ ├── package.json │ │ └── widgets │ │ │ ├── conversationPath.js │ │ │ ├── heatmap.js │ │ │ ├── index.js │ │ │ └── missedUtterance.js │ ├── cw_custom_widget_python │ │ ├── __init__.py │ │ ├── lambda_function.py │ │ ├── lib │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── cw_logs.py │ │ │ └── logger.py │ │ ├── requirements.txt │ │ └── widgets │ │ │ ├── __init__.py │ │ │ ├── session_attributes.py │ │ │ └── slots.py │ ├── cw_metric_filter_cr │ │ ├── __init__.py │ │ ├── lambda_function.py │ │ └── requirements.txt │ └── resource_name_cfn_cr │ │ ├── __init__.py │ │ ├── lambda_function.py │ │ └── requirements.txt └── lambda_layers │ ├── cw_custom_widget_nodejs │ ├── package-lock.json │ └── package.json │ ├── cw_custom_widget_python │ └── requirements.txt │ └── shared_cfn_cr_python │ └── requirements.txt ├── template.yaml └── tests └── events ├── bot_tester └── default.json ├── cw_custom_widget_nodejs ├── conv-path.json ├── default.json ├── heatmap-intent-hr.json └── missed-utterance.json ├── cw_custom_widget_python └── default.json ├── cw_metric_filter_cr ├── create.json └── delete.json ├── default-env-vars.json └── resource_name_cfn_cr └── create.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 12, 12 | }, 13 | rules: { 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # extend-ignore = E203, W503 3 | max-line-length = 100 4 | exclude = 5 | test_* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 245 | .aws-sam 246 | node_modules 247 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore-patterns=^test_.* 3 | 4 | [MESSAGES CONTROL] 5 | #disable = C0330, C0326 6 | 7 | [FORMAT] 8 | # Maximum number of characters on a single line. 9 | max-line-length=100 10 | 11 | [SIMILARITIES] 12 | # Minimum lines number of a similarity. 13 | min-similarity-lines=9 14 | 15 | [DESIGN] 16 | # Maximum number of arguments for function / method 17 | max-args=7 18 | 19 | # Maximum number of locals for function / method body 20 | max-locals=16 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug SAM Python Lambda debugpy attach", 9 | "type": "python", 10 | "request": "attach", 11 | "port": 5678, 12 | "host": "localhost", 13 | "pathMappings": [ 14 | { 15 | "localRoot": "${workspaceFolder}/${relativeFileDirname}", 16 | "remoteRoot": "/var/task" 17 | } 18 | ], 19 | }, 20 | { 21 | "type": "node", 22 | "request": "attach", 23 | "name": "Debug SAM Node Lambda attach", 24 | "address": "localhost", 25 | "port": 5858, 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}/${relativeFileDirname}", 29 | "remoteRoot": "/var/task" 30 | } 31 | ], 32 | "protocol": "inspector", 33 | "stopOnEntry": false 34 | }, 35 | ] 36 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.trimTrailingWhitespace": true, 3 | 4 | "editor.rulers": [ 5 | 80, 6 | 100 7 | ], 8 | 9 | "python.pythonPath": "${workspaceFolder}/out/venv/bin/python3.8", 10 | "python.linting.pylintEnabled": true, 11 | 12 | "[javascript]": { 13 | "editor.tabSize": 2, 14 | "editor.insertSpaces": true, 15 | "editor.detectIndentation":false, 16 | } 17 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 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 | ## [Unreleased] 8 | 9 | ## [0.3.1] - 2021-09-15 10 | ### Fixed 11 | - Fixed typo in ReadyForFulfillment state 12 | - Improved error handling for empty data in custom widgets 13 | 14 | ## [0.3.0] - 2021-09-10 15 | ### Added 16 | - Custom Widget to display top N slots and session attribute values 17 | ### Changed 18 | - Bot tester now sets session attributes and sends audio utterances 19 | 20 | ## [0.2.0] - 2021-09-03 21 | ### Fixed 22 | - Changed metric namespace to include bot locale 23 | - Added bot and locale ID filters to CloudWatch Insight rules/queries 24 | - Fixed handling of ReadyForFullfilment state and empty data in conversation 25 | path widget 26 | ### Removed 27 | - Removed session count value from Activity section due to exceptions if the 28 | selected time range is larger than 24 hours 29 | 30 | ## [0.1.0] - 2021-08-26 31 | ### Added 32 | - Initial release 33 | 34 | [Unreleased]: https://github.com/aws-samples/aws-lex-v2-bot-analytics/compare/v0.3.1...develop 35 | [0.3.1]: https://github.com/aws-samples/aws-lex-v2-bot-analytics/releases/tag/v0.3.1 36 | [0.3.0]: https://github.com/aws-samples/aws-lex-v2-bot-analytics/releases/tag/v0.3.0 37 | [0.2.0]: https://github.com/aws-samples/aws-lex-v2-bot-analytics/releases/tag/v0.2.0 38 | [0.1.0]: https://github.com/aws-samples/aws-lex-v2-bot-analytics/releases/tag/v0.1.0 -------------------------------------------------------------------------------- /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 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += --warn-undefined-variables 2 | SHELL := bash 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .DEFAULT_GOAL := all 5 | .DELETE_ON_ERROR: 6 | .SUFFIXES: 7 | 8 | all: build 9 | .PHONY: all 10 | 11 | # This maps to the sam cli --config-env option. You can overrided it using 12 | # by exporting the CONFIG_ENV variable in your shell. It defaults to: "default" 13 | export CONFIG_ENV ?= default 14 | 15 | ifndef CONFIG_ENV 16 | $(error [ERROR] - CONFIG_ENV environmental variable\ 17 | to map to the sam config-env option is not set) 18 | endif 19 | 20 | all: build 21 | .PHONY: all 22 | 23 | TEMPLATE_FILE ?= template.yaml 24 | SAMCONFIG_FILE ?= samconfig.toml 25 | SAM_BUILD_DIR ?= .aws-sam 26 | SRC_DIR := src 27 | 28 | OUT_DIR ?= out 29 | $(OUT_DIR): 30 | @echo '[INFO] creating build output dir: [$(@)]' 31 | mkdir -p '$(@)' 32 | 33 | ########################################################################## 34 | # Install 35 | # 36 | # Install build dependencies. Should only be needed before first run or 37 | # if updating build dependencies 38 | ########################################################################## 39 | 40 | PYTHON_VERSION ?= 3.8 41 | # python virtual environment directory 42 | VIRTUALENV_DIR ?= $(OUT_DIR)/venv 43 | VENV_CFG := $(VIRTUALENV_DIR)/pyvenv.cfg 44 | $(VENV_CFG): | $(OUT_DIR) 45 | echo "[INFO] Creating python virtual env under directory: [$(VIRTUALENV_DIR)]" 46 | python$(PYTHON_VERSION) -m venv '$(VIRTUALENV_DIR)' 47 | install-python-venv: $(VENV_CFG) 48 | .PHONY: install-python-venv 49 | 50 | VIRTUALENV_BIN_DIR ?= $(VIRTUALENV_DIR)/bin 51 | PYTHON_REQUIREMENTS_DIR ?= requirements 52 | PYTHON_BUILD_REQUIREMENTS := $(PYTHON_REQUIREMENTS_DIR)/requirements-build.txt 53 | PYTHON_DEV_REQUIREMENTS := $(PYTHON_REQUIREMENTS_DIR)/requirements-dev.txt 54 | 55 | PYTHON_SRC_REQUIREMENTS := $(PYTHON_DEV_REQUIREMENTS) $(PYTHON_BUILD_REQUIREMENTS) 56 | PYTHON_TARGET_REQUIREMENTS := $(patsubst \ 57 | $(PYTHON_REQUIREMENTS_DIR)/%, \ 58 | $(OUT_DIR)/%, \ 59 | $(PYTHON_SRC_REQUIREMENTS) \ 60 | ) 61 | 62 | $(PYTHON_TARGET_REQUIREMENTS): $(OUT_DIR)/%: $(PYTHON_REQUIREMENTS_DIR)/% | $(OUT_DIR) $(VENV_CFG) 63 | @echo "[INFO] Installing python dependencies file: [$(^)]" 64 | @source '$(VIRTUALENV_BIN_DIR)/activate' && \ 65 | pip install -U -r $(^) | tee $(@) 66 | install-python-requirements: $(PYTHON_TARGET_REQUIREMENTS) 67 | .PHONY: install-python-requirements 68 | 69 | PACKAGE_JSON := package.json 70 | $(OUT_DIR)/$(PACKAGE_JSON): $(PACKAGE_JSON) | $(OUT_DIR) 71 | @echo "[INFO] Installing node dependencies file: [$(^)]" 72 | npm install | tee '$(@)' 73 | 74 | install-node-dependencies: $(OUT_DIR)/$(PACKAGE_JSON) 75 | .PHONY: install-node-dependencies 76 | 77 | install: install-python-venv install-python-requirements install-node-dependencies 78 | .PHONY: install 79 | 80 | # prepend python virtual env bin directory to path 81 | VIRTUALENV_BIN_DIR ?= "$(VIRTUALENV_DIR)/bin" 82 | export PATH := "$(VIRTUALENV_BIN_DIR):$(PATH)" 83 | export VIRTUALENV_DIR 84 | 85 | ########################################################################## 86 | # build 87 | ########################################################################## 88 | SAM_CMD ?= $(VIRTUALENV_BIN_DIR)/sam 89 | $(SAM_CMD): $(PYTHON_TARGET_REQUIREMENTS) 90 | 91 | LAMBDA_FUNCTIONS_DIR := $(SRC_DIR)/lambda_functions 92 | LAMBDA_FUNCTIONS := $(wildcard $(LAMBDA_FUNCTIONS_DIR)/*) 93 | LAMBDA_FUNCTIONS_PYTHON_SRC_FILES := $(wildcard \ 94 | $(LAMBDA_FUNCTIONS_DIR)/**/*.py \ 95 | $(LAMBDA_FUNCTIONS_DIR)/**/**/*.py \ 96 | $(LAMBDA_FUNCTIONS_DIR)/**/requirements.txt \ 97 | ) 98 | # python Lambda function dir should have an __init__.py file 99 | LAMBDA_FUNCTIONS_PYTHON_SRC_DIRS := $(patsubst \ 100 | $(LAMBDA_FUNCTIONS_DIR)/%/__init__.py, \ 101 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 102 | $(wildcard $(LAMBDA_FUNCTIONS_DIR)/**/__init__.py) \ 103 | ) 104 | 105 | LAMBDA_FUNCTIONS_JS_SRC_FILES := $(wildcard \ 106 | $(LAMBDA_FUNCTIONS_DIR)/**/*.js \ 107 | $(LAMBDA_FUNCTIONS_DIR)/**/**/*.js \ 108 | $(LAMBDA_FUNCTIONS_DIR)/**/package.json \ 109 | ) 110 | # JavaScrip Lambda function dir should have a package.json file 111 | LAMBDA_FUNCTIONS_JS_SRC_DIRS := $(patsubst \ 112 | $(LAMBDA_FUNCTIONS_DIR)/%/package.json, \ 113 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 114 | $(wildcard $(LAMBDA_FUNCTIONS_DIR)/**/package.json) \ 115 | ) 116 | 117 | LAMBDA_LAYERS_DIR := $(SRC_DIR)/lambda_layers 118 | LAMBDA_LAYERS := $(wildcard $(LAMBDA_LAYERS_DIR)/*) 119 | LAMBDA_LAYERS_SRC_FILES := $(wildcard \ 120 | $(LAMBDA_LAYERS_DIR)/**/*.py \ 121 | $(LAMBDA_LAYERS_DIR)/**/**/*.py \ 122 | $(LAMBDA_LAYERS_DIR)/**/requirements.txt \ 123 | $(LAMBDA_LAYERS_DIR)/**/Makefile \ 124 | $(LAMBDA_LAYERS_DIR)/**/*.js \ 125 | $(LAMBDA_LAYERS_DIR)/**/**/*.js \ 126 | $(LAMBDA_LAYERS_DIR)/**/package.json \ 127 | ) 128 | STATE_MACHINES_DIR := $(SRC_DIR)/state_machines 129 | STATE_MACHINES := $(wildcard $(STATE_MACHINES_DIR)/*) 130 | STATE_MACHINES_SRC_FILES := $(wildcard \ 131 | $(STATE_MACHINES_DIR)/**/*.asl.json \ 132 | ) 133 | BUILD_SOURCES := $(TEMPLATE_FILE) \ 134 | $(LAMBDA_LAYERS_SRC_FILES) \ 135 | $(LAMBDA_FUNCTIONS_PYTHON_SRC_FILES) \ 136 | $(LAMBDA_FUNCTIONS_JS_SRC_FILES) \ 137 | $(STATE_MACHINES_SRC_FILES) \ 138 | $(SAMCONFIG_FILE) \ 139 | 140 | SAM_BUILD_TOML_FILE := $(SAM_BUILD_DIR)/build.toml 141 | $(SAM_BUILD_TOML_FILE): $(BUILD_SOURCES) | $(SAM_CMD) 142 | @echo '[INFO] sam building config env: [$(CONFIG_ENV)]' 143 | '$(SAM_CMD)' build \ 144 | --config-env '$(CONFIG_ENV)' \ 145 | --template-file '$(TEMPLATE_FILE)' 146 | 147 | SAM_BUILD_TEMPLATE_FILE := $(SAM_BUILD_DIR)/build/template.yaml 148 | $(SAM_BUILD_TEMPLATE_FILE): $(SAM_BUILD_TOML_FILE) 149 | 150 | build: $(SAM_BUILD_TEMPLATE_FILE) 151 | .PHONY: build 152 | 153 | ########################################################################## 154 | # package 155 | ########################################################################## 156 | PACKAGE_OUT_FILE := $(OUT_DIR)/template-packaged-$(CONFIG_ENV).yaml 157 | $(PACKAGE_OUT_FILE): $(TEMPLATE_FILE) $(SAM_BUILD_TEMPLATE_FILE) | $(OUT_DIR) $(SAM_CMD) 158 | @echo '[INFO] sam packaging config env: [$(CONFIG_ENV)]' 159 | '$(SAM_CMD)' package \ 160 | --config-env '$(CONFIG_ENV)' \ 161 | --output-template-file '$(@)' 162 | 163 | package: $(PACKAGE_OUT_FILE) 164 | .PHONY: package 165 | 166 | ########################################################################## 167 | # publish 168 | ########################################################################## 169 | PUBLISH_OUT_FILE := $(OUT_DIR)/sam-publish-$(CONFIG_ENV).txt 170 | $(PUBLISH_OUT_FILE): $(PACKAGE_OUT_FILE) | $(OUT_DIR) $(SAM_CMD) 171 | @echo '[INFO] sam publishing config env: [$(CONFIG_ENV)]' 172 | '$(SAM_CMD)' publish \ 173 | --debug \ 174 | --config-env '$(CONFIG_ENV)' \ 175 | --template '$(PACKAGE_OUT_FILE)' \ 176 | | tee '$(@)' 177 | 178 | publish: $(PUBLISH_OUT_FILE) 179 | .PHONY: publish 180 | 181 | ########################################################################## 182 | # release packaged template and artifacts to an S3 bucket 183 | # RELEASE_S3_BUCKET=my-release-bucket make release 184 | # 185 | # RELEASE_S3_BUCKET=mybucket RELEASE_S3_PREFIX=release RELEASE_VERSION=0.1.0 make release 186 | # RELEASE_S3_PREFIX defaults to 'release' 187 | # RELEASE_VERSION defaults to content of VERSION file 188 | ########################################################################## 189 | VERSION_FILE := VERSION 190 | RELEASE_S3_PREFIX ?= release 191 | RELEASE_VERSION ?= $(shell cat $(VERSION_FILE)) 192 | PACKAGE_RELEASE_FILE_NAME := template-packaged-$(RELEASE_S3_BUCKET)-$(RELEASE_S3_PREFIX)-$(RELEASE_VERSION).yaml 193 | PACKAGE_RELEASE_OUT_FILE := $(OUT_DIR)/$(PACKAGE_RELEASE_FILE_NAME) 194 | $(PACKAGE_RELEASE_OUT_FILE): $(TEMPLATE_FILE) $(SAM_BUILD_TEMPLATE_FILE) | $(OUT_DIR) $(SAM_CMD) 195 | @[ -z '$(RELEASE_S3_BUCKET)' ] && \ 196 | echo '[ERROR] need to set env var: RELEASE_S3_BUCKET' && \ 197 | exit 1 || true 198 | @echo '[INFO] sam packaging for release $(RELEASE_VERSION)' 199 | '$(SAM_CMD)' package \ 200 | --s3-bucket '$(RELEASE_S3_BUCKET)' \ 201 | --s3-prefix '$(RELEASE_S3_PREFIX)/$(RELEASE_VERSION)' \ 202 | --output-template-file '$(@)' \ 203 | 204 | RELEASE_UPLOAD_OUT_FILE := $(OUT_DIR)/release-upload-$(PACKAGE_RELEASE_FILE_NAME).txt 205 | RELEASE_S3_URL := s3://$(RELEASE_S3_BUCKET)/$(RELEASE_S3_PREFIX)/$(RELEASE_VERSION)/lex-analytics-$(RELEASE_VERSION).yaml 206 | $(RELEASE_UPLOAD_OUT_FILE): $(PACKAGE_RELEASE_OUT_FILE) | $(OUT_DIR) $(SAM_CMD) 207 | @echo '[INFO] uploading $(PACKAGE_RELEASE_OUT_FILE) to $(RELEASE_S3_URL)' 208 | aws s3 cp '$(PACKAGE_RELEASE_OUT_FILE)' '$(RELEASE_S3_URL)' \ 209 | | tee '$(@)' 210 | @echo '[INFO] CloudFormation template URL: https://$(RELEASE_S3_BUCKET).s3.amazonaws.com/$(RELEASE_S3_PREFIX)/$(RELEASE_VERSION)/lex-analytics-$(RELEASE_VERSION).yaml' 211 | 212 | release: $(RELEASE_UPLOAD_OUT_FILE) 213 | .PHONY: releaase 214 | 215 | ########################################################################## 216 | # deploy 217 | ########################################################################## 218 | DEPLOY_OUT_FILE := $(OUT_DIR)/sam-deploy-$(CONFIG_ENV).txt 219 | $(DEPLOY_OUT_FILE): $(SAM_BUILD_TOML_FILE) | $(OUT_DIR) $(SAM_CMD) 220 | @rm -f '$(DELETE_STACK_OUT_FILE)' 221 | @echo '[INFO] sam deploying config env: [$(CONFIG_ENV)]' 222 | '$(SAM_CMD)' deploy --config-env '$(CONFIG_ENV)' | tee '$(@)' 223 | 224 | deploy: $(DEPLOY_OUT_FILE) 225 | .PHONY: deploy 226 | 227 | ########################################################################## 228 | # delete stack 229 | ########################################################################## 230 | DELETE_STACK_OUT_FILE := $(OUT_DIR)/sam-delete-$(CONFIG_ENV).txt 231 | $(DELETE_STACK_OUT_FILE): $(SAMCONFIG_FILE) | $(OUT_DIR) 232 | @echo "[INFO] deleting stack for config env: [$(CONFIG_ENV)]" 233 | '$(SAM_CMD)' delete --config-env '$(CONFIG_ENV)' | tee '$(@)' 234 | @rm -f '$(DEPLOY_OUT_FILE)' 235 | 236 | delete-stack: $(DELETE_STACK_OUT_FILE) 237 | .PHONY: delete-stack 238 | 239 | ########################################################################## 240 | # tests 241 | ########################################################################## 242 | 243 | #### 244 | # local invoke 245 | #### 246 | TESTS_DIR := tests 247 | EVENTS_DIR := $(TESTS_DIR)/events 248 | 249 | # build dynamic targets for sam local invoke 250 | # each lambda function should have a corresponding invoke-local-% target 251 | SAM_INVOKE_TARGETS := $(patsubst \ 252 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 253 | local-invoke-%, \ 254 | $(LAMBDA_FUNCTIONS) \ 255 | ) 256 | .PHONY: $(SAM_INVOKE_TARGETS) 257 | $(SAM_INVOKE_TARGETS): build 258 | 259 | export LOCAL_INVOKE_DEBUG_ARGS ?= \ 260 | 261 | ifdef DEBUGGER_PY 262 | DEBUG_PORT ?= 5678 263 | export LOCAL_INVOKE_DEBUG_ARGS := --debug-port $(DEBUG_PORT) \ 264 | --debug-args '-m debugpy --listen 0.0.0.0:$(DEBUG_PORT) --wait-for-client' 265 | endif 266 | 267 | ifdef DEBUGGER_JS 268 | DEBUG_PORT ?= 5858 269 | export LOCAL_INVOKE_DEBUG_ARGS := --debug-port $(DEBUG_PORT) 270 | endif 271 | 272 | # Invoke the default event associated with the lambda function 273 | # for each lambda function, there should be a corresponding 274 | # .json file under the tests/events/ directory 275 | # where matches the directory name under 276 | # src/lambda_functions. For example: 277 | # 278 | # make local-invoke- 279 | # 280 | # You may override the event file by setting the EVENT_FILE environmental 281 | # variable: 282 | # EVENT_FILE=myevent.json make local-invoke- 283 | # 284 | # The Lambda functions are invoked using environment variables from the file 285 | # under tests/events/-env-vars.json. This passes the --env-vars 286 | # parameter to `sam local invoke`. See: 287 | # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-invoke.html#serverless-sam-cli-using-invoke-environment-file 288 | # You can override the file by setting the ENV_VARS_FILE environmental variable: 289 | # 290 | # EVENT_VARS_FILE=my-env-vars.json make local-invoke- 291 | # 292 | # It parses out the logical resource name from the build.toml file 293 | # For example, to invoke the src/lambda_functions/my_function use: 294 | # make local-invoke-my_function 295 | # 296 | # To debug inside a python Lambda function put debugpy in the function 297 | # requirements.txt under the funtion directory. 298 | # Set the DEBUGGER_PY environmental variable when calling local invoke. 299 | # Setup a VS Code launch task to attach to the debugger: 300 | # { 301 | # "name": "Debug SAM Python Lambda debugpy attach", 302 | # "type": "python", 303 | # "request": "attach", 304 | # "port": 5678, 305 | # "host": "localhost", 306 | # "pathMappings": [ 307 | # { 308 | # "localRoot": "${workspaceFolder}/${relativeFileDirname}", 309 | # "remoteRoot": "/var/task" 310 | # } 311 | # ], 312 | # } 313 | # 314 | # To debug the incoming_process function use: 315 | # DEBUGGER_PY=true make local-invoke-my_python_function 316 | # 317 | # To debug inside a Node JS Lambda function 318 | # Set the DEBUGGER_JS environmental variable when calling local invoke. 319 | # Setup a VS Code launch task to attach to the debugger: 320 | # { 321 | # "name": "Debug SAM Node JS Lambda attach", 322 | # "type": "node", 323 | # "request": "attach", 324 | # "port": 5858, 325 | # "host": "localhost", 326 | # "pathMappings": [ 327 | # { 328 | # "localRoot": "${workspaceFolder}/${relativeFileDirname}", 329 | # "remoteRoot": "/var/task" 330 | # } 331 | # ], 332 | # "protocol": "inspector", 333 | # "stopOnEntry": false 334 | # } 335 | # 336 | # To debug the incoming_process function use: 337 | # DEBUGGER_JS=true make local-invoke-my_node_function 338 | $(SAM_INVOKE_TARGETS): local-invoke-%: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) $(SAM_CMD) 339 | @FUNCTION_LOGICAL_ID=$$( \ 340 | '$(VIRTUALENV_BIN_DIR)/python' -c 'import toml; \ 341 | f_defs = ( \ 342 | toml.load("$(SAM_BUILD_TOML_FILE)") \ 343 | .get("function_build_definitions") \ 344 | ); \ 345 | print( \ 346 | [f_defs[f]["functions"][0] \ 347 | for f in f_defs \ 348 | if f_defs[f]["codeuri"].endswith("/$(*)")] \ 349 | [0] \ 350 | );' \ 351 | ) || { \ 352 | echo -n "[ERROR] failed to parse sam build toml file. "; >&2 \ 353 | echo -n "Check that you have sourced the python virtual env and "; >&2 \ 354 | echo -n "run the command: "; >&2 \ 355 | echo "[pip install -r $(PYTHON_REQUIREMENTS_DIR)/requirements-dev.txt]"; >&2 \ 356 | exit 1; \ 357 | } && \ 358 | EVENT_FILE="$${EVENT_FILE:-$(EVENTS_DIR)/$(*)/$(CONFIG_ENV).json}" && \ 359 | ENV_VARS_FILE="$${ENV_VARS_FILE:-$(EVENTS_DIR)/$(CONFIG_ENV)-env-vars.json}" && \ 360 | echo "[INFO] invoking target: [$(@)] function: [$${FUNCTION_LOGICAL_ID}] with event file: [$${EVENT_FILE}]" && \ 361 | '$(SAM_CMD)' local invoke \ 362 | --config-env '$(CONFIG_ENV)' \ 363 | --event "$$EVENT_FILE" \ 364 | --env-vars "$$ENV_VARS_FILE" \ 365 | $(LOCAL_INVOKE_DEBUG_ARGS) \ 366 | "$$FUNCTION_LOGICAL_ID" | \ 367 | tee '$(OUT_DIR)/$(@).txt' 368 | @echo 369 | @tail '$(OUT_DIR)/$(@).txt' | grep -q -E '^{ *"errorMessage" *:.*"errorType" *:' && { \ 370 | echo "[ERROR] Lambda local invoke returned an error" >&2;\ 371 | exit 1; \ 372 | } || true 373 | 374 | test-local-invoke-default: $(SAM_INVOKE_TARGETS) 375 | .PHONY: test-local-invoke-default 376 | 377 | test: test-local-invoke-default 378 | .PHONY: test 379 | 380 | ########################################################################## 381 | # lint 382 | ########################################################################## 383 | 384 | ### 385 | # cfn-lint 386 | ### 387 | CFN_LINT_OUT_FILE := $(OUT_DIR)/lint-cfn-lint.txt 388 | $(CFN_LINT_OUT_FILE): $(TEMPLATE_FILE) | $(OUT_DIR) 389 | @echo '[INFO] running cfn-lint on template: [$(^)]' 390 | $(VIRTUALENV_BIN_DIR)/cfn-lint '$(^)' | tee '$(@)' 391 | 392 | lint-cfn-lint: $(CFN_LINT_OUT_FILE) 393 | .PHONY: lint-cfn-lint 394 | 395 | ### 396 | # yamllint 397 | ### 398 | YAMLLINT_OUT_FILE := $(OUT_DIR)/lint-yamllint.txt 399 | $(YAMLLINT_OUT_FILE): $(TEMPLATE_FILE) | $(OUT_DIR) 400 | @echo '[INFO] running yamllint on template: [$(^)]' 401 | $(VIRTUALENV_BIN_DIR)/yamllint '$(^)' | tee '$(@)' 402 | 403 | lint-yamllint: $(YAMLLINT_OUT_FILE) 404 | .PHONY: lint-yamllint 405 | 406 | ### 407 | # validate 408 | ### 409 | VALIDATE_OUT_FILE := $(OUT_DIR)/lint-validate.txt 410 | $(VALIDATE_OUT_FILE): $(TEMPLATE_FILE) | $(OUT_DIR) 411 | @echo '[INFO] running sam validate on config env: [$(CONFIG_ENV)]' 412 | $(VIRTUALENV_BIN_DIR)/sam validate --config-env '$(CONFIG_ENV)' | tee '$(@)' 413 | 414 | lint-validate: $(VALIDATE_OUT_FILE) 415 | .PHONY: lint-validate 416 | 417 | ### 418 | # cfnnag 419 | ### 420 | CFN_NAG_OUT_FILE := $(OUT_DIR)/lint-cfnnag.txt 421 | $(CFN_NAG_OUT_FILE): $(TEMPLATE_FILE) | $(OUT_DIR) 422 | @echo '[INFO] running cfn_nag on template: [$(^)]' 423 | docker run -i --rm stelligent/cfn_nag /dev/stdin < '$(^)' | tee '$(@)' 424 | 425 | lint-cfn_nag: $(CFN_NAG_OUT_FILE) 426 | .PHONY: lint-cfn_nag 427 | 428 | # XXX disable lint-cfn_nag due to issues with Lex V2 Custom Resource properties 429 | # lint-cfn: lint-cfn-lint lint-yamllint lint-validate lint-cfn_nag 430 | lint-cfn: lint-cfn-lint lint-yamllint lint-validate 431 | .PHONY: lint-cfn 432 | 433 | ### 434 | # pylint 435 | ### 436 | 437 | # TODO add Lamda Layers 438 | PYTHON_LINTER_MAX_LINE_LENGTH ?= 100 439 | LAMBDA_PYLINT_TARGETS := $(patsubst \ 440 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 441 | $(OUT_DIR)/lint-pylint-%.txt, \ 442 | $(LAMBDA_FUNCTIONS_PYTHON_SRC_DIRS) \ 443 | ) 444 | $(LAMBDA_PYLINT_TARGETS): $(LAMBDA_FUNCTIONS_PYTHON_SRC_FILES) 445 | $(LAMBDA_PYLINT_TARGETS): $(OUT_DIR)/lint-pylint-%.txt: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) 446 | @echo '[INFO] running pylint on dir: [$(<)]' 447 | $(VIRTUALENV_BIN_DIR)/pylint \ 448 | --max-line-length='$(PYTHON_LINTER_MAX_LINE_LENGTH)' \ 449 | '$(<)' | \ 450 | tee '$(@)' 451 | 452 | lint-pylint: $(LAMBDA_PYLINT_TARGETS) 453 | .PHONY: lint-pylint 454 | 455 | ### 456 | # flake8 457 | ### 458 | LAMBDA_FLAKE8_TARGETS := $(patsubst \ 459 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 460 | $(OUT_DIR)/lint-flake8-%.txt, \ 461 | $(LAMBDA_FUNCTIONS_PYTHON_SRC_DIRS) \ 462 | ) 463 | $(LAMBDA_FLAKE8_TARGETS): $(LAMBDA_FUNCTIONS_PYTHON_SRC_FILES) 464 | $(LAMBDA_FLAKE8_TARGETS): $(OUT_DIR)/lint-flake8-%.txt: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) 465 | @echo '[INFO] running flake8 on dir: [$(<)]' 466 | $(VIRTUALENV_BIN_DIR)/flake8 \ 467 | --max-line-length='$(PYTHON_LINTER_MAX_LINE_LENGTH)' \ 468 | $(<) | \ 469 | tee '$(@)' 470 | 471 | lint-flake8: $(LAMBDA_FLAKE8_TARGETS) 472 | .PHONY: lint-flake8 473 | 474 | ### 475 | # mypy 476 | ### 477 | LAMBDA_MYPY_TARGETS := $(patsubst \ 478 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 479 | $(OUT_DIR)/lint-mypy-%.txt, \ 480 | $(LAMBDA_FUNCTIONS_PYTHON_SRC_DIRS) \ 481 | ) 482 | $(LAMBDA_MYPY_TARGETS): $(LAMBDA_FUNCTIONS_PYTHON_SRC_FILES) 483 | $(LAMBDA_MYPY_TARGETS): $(OUT_DIR)/lint-mypy-%.txt: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) 484 | @echo '[INFO] running mypy on dir: [$(<)]' 485 | $(VIRTUALENV_BIN_DIR)/mypy \ 486 | $(<) | \ 487 | tee '$(@)' 488 | 489 | lint-mypy: $(LAMBDA_MYPY_TARGETS) 490 | .PHONY: lint-mypy 491 | 492 | ### 493 | # black 494 | ### 495 | LAMBDA_BLACK_TARGETS := $(patsubst \ 496 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 497 | $(OUT_DIR)/lint-black-%.txt, \ 498 | $(LAMBDA_FUNCTIONS_PYTHON_SRC_DIRS) \ 499 | ) 500 | $(LAMBDA_BLACK_TARGETS): $(LAMBDA_FUNCTIONS_PYTHON_SRC_FILES) 501 | $(LAMBDA_BLACK_TARGETS): $(OUT_DIR)/lint-black-%.txt: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) 502 | @echo '[INFO] running black on dir: [$(<)]' 503 | $(VIRTUALENV_BIN_DIR)/black \ 504 | --check \ 505 | --diff \ 506 | --line-length='$(PYTHON_LINTER_MAX_LINE_LENGTH)' \ 507 | $(<) | \ 508 | tee '$(@)' 509 | 510 | lint-black: $(LAMBDA_BLACK_TARGETS) 511 | .PHONY: lint-black 512 | 513 | ### 514 | # bandit 515 | ### 516 | LAMBDA_BANDIT_TARGETS := $(patsubst \ 517 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 518 | $(OUT_DIR)/lint-bandit-%.txt, \ 519 | $(LAMBDA_FUNCTIONS_PYTHON_SRC_DIRS) \ 520 | ) 521 | $(LAMBDA_BANDIT_TARGETS): $(LAMBDA_FUNCTIONS_PYTHON_SRC_FILES) 522 | $(LAMBDA_BANDIT_TARGETS): $(OUT_DIR)/lint-bandit-%.txt: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) 523 | @echo '[INFO] running bandit on dir: [$(<)]' 524 | $(VIRTUALENV_BIN_DIR)/bandit \ 525 | --recursive \ 526 | $(<) | \ 527 | tee '$(@)' 528 | 529 | lint-bandit: $(LAMBDA_BANDIT_TARGETS) 530 | .PHONY: lint-bandit 531 | 532 | lint-python: lint-pylint lint-flake8 lint-mypy lint-black lint-bandit 533 | .PHONY: lint-python 534 | 535 | ### 536 | # eslint 537 | ### 538 | LAMBDA_ESLINT_TARGETS := $(patsubst \ 539 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 540 | $(OUT_DIR)/lint-eslint-%.txt, \ 541 | $(LAMBDA_FUNCTIONS_JS_SRC_DIRS) \ 542 | ) 543 | $(LAMBDA_ESLINT_TARGETS): $(LAMBDA_FUNCTIONS_JS_SRC_FILES) 544 | $(LAMBDA_ESLINT_TARGETS): $(OUT_DIR)/lint-eslint-%.txt: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) 545 | @echo '[INFO] running eslint on dir: [$(<)]' 546 | npx eslint \ 547 | $(<) | \ 548 | tee '$(@)' 549 | 550 | lint-eslint: $(LAMBDA_ESLINT_TARGETS) 551 | .PHONY: lint-eslint 552 | 553 | ### 554 | # prettier 555 | ### 556 | LAMBDA_PRETTIER_TARGETS := $(patsubst \ 557 | $(LAMBDA_FUNCTIONS_DIR)/%, \ 558 | $(OUT_DIR)/lint-prettier-%.txt, \ 559 | $(LAMBDA_FUNCTIONS_JS_SRC_DIRS) \ 560 | ) 561 | $(LAMBDA_PRETTIER_TARGETS): $(LAMBDA_FUNCTIONS_JS_SRC_FILES) 562 | $(LAMBDA_PRETTIER_TARGETS): $(OUT_DIR)/lint-prettier-%.txt: $(LAMBDA_FUNCTIONS_DIR)/% | $(OUT_DIR) 563 | @echo '[INFO] running prettier on dir: [$(<)]' 564 | npx prettier --check \ 565 | $(<) | \ 566 | tee '$(@)' 567 | 568 | lint-prettier: $(LAMBDA_PRETTIER_TARGETS) 569 | .PHONY: lint-prettier 570 | 571 | lint-javascript: lint-eslint lint-prettier 572 | .PHONY: lint-javascript 573 | 574 | ### 575 | # State Machine Lint 576 | ### 577 | STATELINT_DIR ?= $(OUT_DIR)/statelint 578 | STATELINT ?= $(STATELINT_DIR)/bin/statelint 579 | $(STATELINT): | $(OUT_DIR) 580 | @echo "[INFO] installing statelint" 581 | -gem install statelint --install-dir '$(STATELINT_DIR)' 582 | 583 | STATELINT_TARGETS := $(patsubst \ 584 | $(STATE_MACHINES_DIR)/%, \ 585 | $(OUT_DIR)/lint-statelint-%.txt, \ 586 | $(STATE_MACHINES) \ 587 | ) 588 | 589 | $(STATELINT_TARGETS): $(STATE_MACHINES_SRC_FILES) 590 | $(STATELINT_TARGETS): $(OUT_DIR)/lint-statelint-%.txt: $(STATE_MACHINES_DIR)/% | $(OUT_DIR) 591 | @echo "[INFO] Running statelint on file: [$(<)]" 592 | -@GEM_HOME='$(STATELINT_DIR)' '$(STATELINT)' '$(<)/state_machine.asl.json' | \ 593 | tee '$(@)' 594 | 595 | lint-state-machines: $(STATELINT_TARGETS) 596 | .PHONY: lint-state-machines 597 | 598 | ### 599 | # all linters 600 | ### 601 | lint: lint-cfn lint-python lint-javascript lint-state-machines 602 | .PHONY: lint 603 | 604 | ########################################################################## 605 | # XXX TODO add help 606 | ########################################################################## 607 | help: 608 | .PHONY: help 609 | 610 | ########################################################################## 611 | # clean 612 | ########################################################################## 613 | clean-out-dir: 614 | -[ -d '$(OUT_DIR)' ] && rm -rf '$(OUT_DIR)/'* 615 | .PHONY: clean-out-dir 616 | 617 | clean-sam-dir: 618 | -[ -d '$(SAM_BUILD_DIR)' ] && rm -rf '$(SAM_BUILD_DIR)/'* 619 | .PHONY: clean-sam-dir 620 | 621 | # TODO clean docker container images 622 | 623 | clean: clean-out-dir clean-sam-dir 624 | .PHONY: clean 625 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon Lex V2 Analytics 2 | 3 | > An Amazon Lex V2 Analytics Dashboard Solution 4 | 5 | ## Solution Description 6 | 7 | The Amazon Lex V2 Analytics Dashboard Solution helps you to monitor and 8 | visualize the performance and operational metrics of your 9 | [Lex V2 chatbot](https://docs.aws.amazon.com/lexv2/latest/dg/what-is.html). 10 | It provides a dashboard that you can use to continuously analyze and improve the 11 | experience of end-users interacting with your chatbot. 12 | 13 | This solution implements metrics and visualizations that help you identify 14 | chatbot performance, trends and engagement insights. This is done by extracting 15 | operational data from your Lex V2 chatbot 16 | [conversation logs](https://docs.aws.amazon.com/lexv2/latest/dg/monitoring-logs.html). 17 | The solution presents a unified view of how users are interacting with your 18 | chatbot in an 19 | [Amazon CloudWatch dashboard](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Dashboards.html). 20 | 21 | Features include: 22 | 23 | - A common view of valuable chatbot insights such as: 24 | - User and session activity (e.g. sentiment analysis, top-n sessions, 25 | text/speech modality) 26 | - Conversation statistics and aggregations (e.g. average of session duration, 27 | messages per session, session heatmaps) 28 | - Conversation flow, trends and history (e.g. intent path chart, intent per 29 | hour heatmaps) 30 | - Utterance history and performance (e.g. missed utterances, top-n utterances) 31 | - Rich visualizations and widgets such as metrics charts, top-n lists, heatmaps, 32 | form-based utterance management 33 | - Serverless architecture using pay-per-use managed services that scale 34 | transparently 35 | - Metrics that can be used outside of the dashboard for alarming and monitoring 36 | 37 | ### Architecture 38 | 39 | The solution architecture leverages the following AWS Services and features: 40 | 41 | - **CloudWatch Logs** to store your chatbot conversation logs 42 | - **CloudWatch Metric Filters** to create custom metrics from conversation logs 43 | - **CloudWatch Log Insights** to query the conversation logs and to create powerful 44 | aggregations from the log data 45 | - **CloudWatch Contributor Insights** to identify top contributors and 46 | outliers in higly variable data such as sessions and utterances 47 | - **CloudWatch Dashboard** to put together a set of charts and visualizations 48 | representing the metrics and data insights from your chatbot conversations 49 | - **CloudWatch Custom Widgets** to create custom visualizations like heatmaps 50 | and conversation flows using Lambda functions 51 | 52 | ## Quick Start 53 | 54 | This solution can be easily installed in your AWS accounts by launching it from 55 | the [AWS Serverless Repository](https://aws.amazon.com/serverless/serverlessrepo/). 56 | 57 | ### Deploy Using SAR 58 | 59 | Click the following AWS Console link to create a dashboard for your Lex V2: 60 | 61 | [https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:777566285978:applications/lexv2-analytics](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:777566285978:applications/lexv2-analytics) 62 | 63 | Once you click on the link, it will take you to the *create application page* in 64 | the AWS Lambda console (this is a Serverless solution!). In this page, scroll 65 | down to the **Application Settings** section to enter the parameters for your 66 | dashboard. See the following sections for an overview on how to set the 67 | parameters. 68 | 69 | ### Parameters 70 | 71 | #### Existing Lex Bot 72 | 73 | If you have an existing Lex V2 bot that already has 74 | [conversation logs](https://docs.aws.amazon.com/lexv2/latest/dg/monitoring-logs.html) 75 | enabled, you would need to configure the following parameters of the 76 | under the **Application Settings** section: 77 | 78 | - **BotId**: The ID of an existing Lex V2 Bot that is going to 79 | be used with this dashboard 80 | - **BotLocaleId**: The Bot locale ID associated to the Bot Id with this 81 | dashboard. Defaults to `en_US`. Each dashboard creates metrics for a specific 82 | locale ID of a Lex bot. 83 | - **LexConversationLogGroupName**: Name of an existing CloudWatch Log Group 84 | containing the Lex Conversation Logs. The Bot ID and Locale in the parameters 85 | above must be configured to use this Log Group for its conversation logs 86 | 87 | **NOTE:** The *Application name* parameter (CloudFormation stack name) must 88 | be unique per AWS account and region. 89 | 90 | #### Sample Bot 91 | 92 | Alternatively, if you just want to test drive the dashboard, this solution can 93 | deploy a fully functional sample bot. The sample bot comes with a Lambda 94 | function that is invoked every two minutes to generate conversation traffic. 95 | This allows you to have data to see in the dashboard. If you want to deploy the 96 | dashboard with the sample bot instead of using an existing bot, set the 97 | **ShouldDeploySampleBots** parameter to `true`. This is a quick an easy way 98 | to kick the tires! 99 | 100 | #### Other Parameters 101 | 102 | Set the **ShouldAddWriteWidgets** to `true` (defaults to `false`) if you want 103 | your dashboard to have more than read-only visualizations. Setting this 104 | parameter to `true` adds a widget that allows to add missed utterances to an 105 | intent in your bot. 106 | 107 | **NOTE:** Setting the **ShouldAddWriteWidgets** parameter to `true` will enable 108 | users that are allowed to access your dashboard to also make changes to your 109 | chatbot. Only set this parameter to true if you intend to provide the dashboard 110 | users with more than just read-only access. This is useful when you restrict 111 | access to the dashboard to only allow users who are also permitted to add 112 | utterances to the intents configured in your bot. 113 | 114 | The **LogLevel** parameter can be used to set the logging level of the Lambda 115 | functions. The **LogRetentionInDays** controls the CloudWatch Logs retention 116 | (in days) for the Bot Conversation Logs. This is only used when the stack 117 | creates a Log Group for you if the *LexConversationLogGroupName* parameter is 118 | left empty. 119 | 120 | ### Deploy 121 | 122 | Once you have set the desired values in the **Application parameters** section, 123 | scroll down to the bottom of the page and select the checkbox to acknowledge 124 | that the application creates custom IAM roles and nested applications. Click on 125 | the **Deploy** button to create the dashboard. 126 | 127 | After you click the **Deploy** button, it will take you to the application 128 | overview page. From there, you can click on the **Deployments** tab to watch 129 | the deployment status. Click on the **View stack events** button to go 130 | to the AWS CloudFormation console to see the deployment details. The stack may 131 | take around 5 minutes to create. Wait until the stack status is 132 | **CREATE_COMPLETE**. 133 | 134 | ### Go to the Dashboard 135 | 136 | Once the stack creation has successfully completed, you can look for a direct 137 | link to your dashboard under the **Outputs** tab of the stack in the AWS 138 | CloudFormation console (**DashboardConsoleLink** output variable). 139 | 140 | Alternatively, you can browse to the 141 | [Dashboard section of the CloudWatch Console](https://console.aws.amazon.com/cloudwatch/home?#dashboards) 142 | to find your newly created dashboard. The dashboard name contains the 143 | stack name and bot information (name, ID, locale). 144 | 145 | **NOTE:** You may need to wait a few minutes for data to be reflected in the 146 | dashboard. 147 | 148 | ## Update Using SAR 149 | 150 | After you've deployed the dashboard from SAR, you may need to update it. 151 | For example, you may need to change an application setting, or you may want 152 | to update the application to the latest version that was published. 153 | 154 | You can use the same 155 | [link](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:777566285978:applications/lexv2-analytics) 156 | used to deploy the stack to perform updates. Use the same procedure as 157 | deploying the application, and provide the same **Application name** that you 158 | originally used to deploy it. 159 | 160 | *NOTE:* SAR prepends `serverlessrepro-` to your stack name. However, to deploy a 161 | new version of your application, you should provide the original application 162 | name without the `serverlessrepo-` prefix. 163 | 164 | See the [SAR Updating Applications](https://docs.aws.amazon.com/serverlessrepo/latest/devguide/serverlessrepo-how-to-consume-new-version.html) 165 | documentation for details. 166 | 167 | ## Deploy Using SAM 168 | 169 | In addition to deploying the project using SAR as shown in the 170 | [Quick Start](#quick-start) section, you can use the 171 | [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) 172 | to build and deploy the solution. This is a more advanced option that allows 173 | you to deploy from source code. With this approach, you can make modifications 174 | to the code and deploy the solution to your account by running a couple of SAM 175 | CLI commands. 176 | 177 | The SAM CLI is an extension of the AWS CLI that adds functionality for building 178 | and testing Lambda applications. It uses Docker to run your functions in an 179 | Amazon Linux environment that matches Lambda. 180 | 181 | ### Requirements 182 | 183 | To use the SAM CLI, you need the following tools: 184 | 185 | - SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) 186 | - [Python 3 installed](https://www.python.org/downloads/) 187 | - Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) 188 | 189 | To build and deploy this project for the first time, run the following two 190 | commands in your shell from the base directory of the code repository: 191 | 192 | ```bash 193 | sam build --use-container 194 | sam deploy --guided 195 | ``` 196 | 197 | The first command will build the source of this project. The second command 198 | will package and deploy your application to your AWS account, with a series of 199 | prompts. 200 | 201 | These prompts allow you to customize your stack name and set up the AWS region. 202 | It also allows you to input the CloudFormation parameters including parameters 203 | to to deploy a sample bot or link the dashboard to one of your existing 204 | chatbots. 205 | 206 | Here is an example of the parameter prompts: 207 | 208 | ```shell 209 | Stack Name [lex-analytics]: 210 | AWS Region [us-east-1]: 211 | Parameter ShouldDeploySampleBots [False]: 212 | Parameter LogLevel [DEBUG]: 213 | Parameter LogRetentionInDays [90]: 214 | Parameter BotId []: 215 | Parameter BotLocaleId [en_US]: 216 | Parameter LexConversationLogGroupName []: 217 | Parameter ShouldAddWriteWidgets [False] 218 | #Shows you resources changes to be deployed and require a 'Y' to initiate deploy 219 | Confirm changes before deploy [Y/n]: n 220 | #SAM needs permission to be able to create roles to connect to the resources in your template 221 | Allow SAM CLI IAM role creation [Y/n]: n 222 | Capabilities [['CAPABILITY_IAM', 'CAPABILITY_AUTO_EXPAND']]: 223 | Save arguments to configuration file [Y/n]: 224 | ``` 225 | 226 | You can see the description of the parameters in the **Parameters** section 227 | in the [template.yaml](template.yaml) file and an overview in the 228 | [parameters](#parameters) section. 229 | 230 | Here are more details about the general SAM parameters: 231 | 232 | - **Stack Name**: The name of the stack to deploy to CloudFormation. This 233 | should be unique to your account and region, and a good starting point would be 234 | something matching your project name. 235 | - **AWS Region**: The AWS region you want to deploy your app to. 236 | - **Confirm changes before deploy**: If set to yes, any change sets will be 237 | shown to you before execution for manual review. If set to no, the AWS SAM 238 | CLI will automatically deploy application changes. 239 | - **Allow SAM CLI IAM role creation**: Many AWS SAM templates, including this 240 | example, create AWS IAM roles required for the AWS Lambda function(s) 241 | included to access AWS services. By default, these are scoped down to minimum 242 | required permissions. To deploy an AWS CloudFormation stack which creates or 243 | modifies IAM roles, the `CAPABILITY_IAM` value for `capabilities` must be 244 | provided. If permission isn't provided through this prompt, to deploy this 245 | example you must explicitly pass `--capabilities CAPABILITY_IAM` to the 246 | `sam deploy` command. 247 | - **Save arguments to samconfig.toml**: If set to yes, your choices will be 248 | saved to a configuration file inside the project, so that in the future you 249 | can just re-run `sam deploy` without parameters to deploy changes to your 250 | application. 251 | 252 | ## Caveats / Known Issues 253 | 254 | - Pie and bar charts in the dashboard may show not show accurate data for the 255 | selected time range. We are currently investigating this issue 256 | - Metrics using CloudWatch Contributor Insights are limited to a 24 hour window 257 | within the selected time range. The CloudWatch Contributor Insights feature 258 | allows a maximum time range for the report is 24 hours, but you can choose a 259 | 24-hour window that occurred up to 15 days ago. 260 | - The Sentiment Analysis metrics will be empty if your bot does not have 261 | Sentiment Analysis enabled. For details, see the Lex V2 262 | [Sentiment Analysis](https://docs.aws.amazon.com/lex/latest/dg/sentiment-analysis.html) 263 | documentation. 264 | 265 | ## Development 266 | 267 | This project uses the SAM CLI to build and deploy the solution. See the 268 | [Deploy Using SAM](#deploy-using-sam) for an overview of using the SAM CLI. 269 | 270 | ### Development Environment Setup 271 | 272 | This project is developed and tested on 273 | [Amazon Linux 2](https://aws.amazon.com/amazon-linux-2/) 274 | using [AWS Cloud9](https://aws.amazon.com/cloud9/) and the following tools: 275 | 276 | - Bash 4.2 277 | - Python 3.8 278 | - Python build and development requirements are listed in the 279 | [requirements/requirements-build.txt](requirements/requirements-build.txt) and 280 | [requirements/requirements-dev.txt](requirements/requirements-dev.txt) files 281 | - AWS SAM CLI ~= 1.29.0 282 | - Docker >= 20 283 | - GNU make >= 3.82 284 | - Node.js >= 14.17.3 285 | - Node.js development dependencies are in the [package.json](package.json) file 286 | 287 | The [samconfig.toml](samconfig.toml) file can be used to configure the SAM 288 | environment. For more details see the 289 | [SAM cli config documentation](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html) 290 | 291 | ### Makefile 292 | 293 | This project contains a [Makefile](Makefile) that can be optionally used to 294 | run tasks such as building and deploying the project. The `Makefile` is used 295 | by the `make` command. It defines the dependencies between tasks such as 296 | automatically only building the project based on changes before you deploy. 297 | 298 | You can set the `CONFIG_ENV` environmental file to have the `Makefile` 299 | use build and deployment configurations from the `samconfig.toml` file. 300 | 301 | Here are some examples of tasks handled using the `make` command: 302 | 303 | 1. Install the required build tools and creates a python virtual environment: 304 | 305 | ```bash 306 | make install 307 | ``` 308 | 309 | 2. Build the project: 310 | 311 | ```bash 312 | make build 313 | ``` 314 | 315 | 3. Deploy the stack: 316 | 317 | Before deploying for the first time, you may need to configure your deployment 318 | settings using: 319 | 320 | ```bash 321 | sam deploy --guided 322 | ``` 323 | 324 | Alternatively, you can edit the [samconfig.toml](samconfig.toml) file to 325 | configure your deployment values. 326 | 327 | After that initial setup, you can deploy using: 328 | 329 | ```bash 330 | make deploy 331 | ``` 332 | 333 | You can also have multiple configurations in your `samconfig.toml` file and 334 | have the `Makefile` use a specific config by setting the `CONFIG_ENV` 335 | environmental variable: 336 | 337 | ```bash 338 | # uses the `[myconfig.deploy.parameters]` config entry in samconfig.toml 339 | CONFIG_ENV=myconfig make deploy 340 | ``` 341 | 342 | 4. Run linters on the source code: 343 | 344 | ```bash 345 | make lint 346 | ``` 347 | 348 | 5. Publish to SAR: 349 | 350 | ```bash 351 | make publish 352 | ``` 353 | 354 | 6. Publish a release of built artifacts to an S3 bucket: 355 | 356 | ```bash 357 | RELEASE_S3_BUCKET=my-release-s3-bucket make release 358 | ``` 359 | 360 | 7. Delete the stack: 361 | 362 | ```bash 363 | make delete-stack 364 | ``` 365 | 366 | #### Makefile SAM Local Invoke 367 | 368 | To invoke local functions with an event file: 369 | 370 | ```bash 371 | EVENT_FILE=tests/events/cw_metric_filter_cr/create.json make local-invoke-cw_metric_filter_cr 372 | ``` 373 | 374 | #### Makefile SAM Local Invoke Debug Lambda Functions 375 | 376 | To interactively debug Python Lambda functions inside the SAM container put 377 | `debugpy` as a dependency in the `requirements.txt` file under the function 378 | directory. 379 | 380 | To debug using Visual Studio Code, create a launch task to attach to the 381 | debugger (example found in the [launch.json](.vscode/launch.json) file under the 382 | .vscode directory): 383 | 384 | ```json 385 | { 386 | "name": "Debug SAM Lambda debugpy attach", 387 | "type": "python", 388 | "request": "attach", 389 | "port": 5678, 390 | "host": "localhost", 391 | "pathMappings": [ 392 | { 393 | "localRoot": "${workspaceFolder}/${relativeFileDirname}", 394 | "remoteRoot": "/var/task" 395 | } 396 | ], 397 | }, 398 | { 399 | "type": "node", 400 | "request": "attach", 401 | "name": "Debug SAM Node Lambda attach", 402 | "address": "localhost", 403 | "port": 5858, 404 | "pathMappings": [ 405 | { 406 | "localRoot": "${workspaceFolder}/${relativeFileDirname}", 407 | "remoteRoot": "/var/task" 408 | } 409 | ], 410 | "protocol": "inspector", 411 | "stopOnEntry": false 412 | }, 413 | ``` 414 | 415 | Set the `DEBUGGER_PY` environmental variable to debug Python Lambda functions. 416 | Similarly, set the `DEBUGGER_JS` environmental variable to debug Node.js Lambda 417 | functions. For example, to debug the `cw_metric_filter_cr` function, run the 418 | following command (requires debugpy in the function requirements.txt folder): 419 | 420 | ```shell 421 | DEBUGGER_PY=true EVENT_FILE=tests/events/cw_metric_filter_cr/create.json make local-invoke-cw_metric_filter_cr 422 | ``` 423 | 424 | ### Resources 425 | 426 | See the 427 | [AWS SAM developer guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) 428 | for an introduction to SAM specification, the SAM CLI, and serverless 429 | application concepts. 430 | 431 | ## Cleanup 432 | 433 | To delete this application, you can use the SAM CLI: 434 | 435 | ```bash 436 | sam delete 437 | ``` 438 | 439 | Or [delete the stack using the AWS CloudFormation Console](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-console-delete-stack.html) 440 | 441 | ## Security 442 | 443 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 444 | 445 | ## License 446 | 447 | This library is licensed under the MIT-0 License. See the LICENSE file. 448 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES: -------------------------------------------------------------------------------- 1 | The aws-lex-v2-bot-analytics project includes the following third-party software/licensing: 2 | 3 | ** D3: Data-Driven Documents - https://github.com/d3/d3 4 | Copyright 2010-2021 Mike Bostock 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose 7 | with or without fee is hereby granted, provided that the above copyright notice 8 | and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 11 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 12 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 13 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 14 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 15 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 16 | THIS SOFTWARE. 17 | 18 | ---------------- 19 | 20 | ** d3-sankey - https://github.com/d3/d3-sankey 21 | Copyright 2015, Mike Bostock 22 | All rights reserved. 23 | 24 | Redistribution and use in source and binary forms, with or without modification, 25 | are permitted provided that the following conditions are met: 26 | 27 | * Redistributions of source code must retain the above copyright notice, this 28 | list of conditions and the following disclaimer. 29 | 30 | * Redistributions in binary form must reproduce the above copyright notice, 31 | this list of conditions and the following disclaimer in the documentation 32 | and/or other materials provided with the distribution. 33 | 34 | * Neither the name of the author nor the names of contributors may be used to 35 | endorse or promote products derived from this software without specific prior 36 | written permission. 37 | 38 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 39 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 40 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 41 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 42 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 43 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 44 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 45 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 46 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 47 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 48 | 49 | ---------------- 50 | 51 | ** d3-sankey-circular - https://github.com/tomshanley/d3-sankey-circular 52 | MIT License 53 | 54 | Copyright (c) 2017 Tom Shanley 55 | 56 | Permission is hereby granted, free of charge, to any person obtaining a copy 57 | of this software and associated documentation files (the "Software"), to deal 58 | in the Software without restriction, including without limitation the rights 59 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 60 | copies of the Software, and to permit persons to whom the Software is 61 | furnished to do so, subject to the following conditions: 62 | 63 | The above copyright notice and this permission notice shall be included in all 64 | copies or substantial portions of the Software. 65 | 66 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 67 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 68 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 69 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 70 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 71 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 72 | SOFTWARE. 73 | 74 | ---------------- 75 | 76 | ** jsdom - https://github.com/jsdom/jsdom 77 | Copyright (c) 2010 Elijah Insua 78 | 79 | Permission is hereby granted, free of charge, to any person 80 | obtaining a copy of this software and associated documentation 81 | files (the "Software"), to deal in the Software without 82 | restriction, including without limitation the rights to use, 83 | copy, modify, merge, publish, distribute, sublicense, and/or sell 84 | copies of the Software, and to permit persons to whom the 85 | Software is furnished to do so, subject to the following 86 | conditions: 87 | 88 | The above copyright notice and this permission notice shall be 89 | included in all copies or substantial portions of the Software. 90 | 91 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 92 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 93 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 94 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 95 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 96 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 97 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 98 | OTHER DEALINGS IN THE SOFTWARE. 99 | 100 | ---------------- 101 | 102 | ** Faker - https://github.com/joke2k/faker 103 | Copyright (c) 2012 Daniele Faraglia 104 | 105 | Permission is hereby granted, free of charge, to any person obtaining a copy 106 | of this software and associated documentation files (the "Software"), to deal 107 | in the Software without restriction, including without limitation the rights 108 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 109 | copies of the Software, and to permit persons to whom the Software is 110 | furnished to do so, subject to the following conditions: 111 | 112 | The above copyright notice and this permission notice shall be included in 113 | all copies or substantial portions of the Software. 114 | 115 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 116 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 117 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 118 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 119 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 120 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 121 | THE SOFTWARE. 122 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.3.1 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lex-analytics", 3 | "version": "0.0.1", 4 | "description": "Lex Analytics", 5 | "devDependencies": { 6 | "eslint": "^7.31.0", 7 | "eslint-config-airbnb": "^18.2.1", 8 | "eslint-config-airbnb-base": "^14.2.1", 9 | "eslint-plugin-import": "^2.23.4", 10 | "prettier": "^2.3.2" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | endOfLine: 'lf', 3 | printWidth: 100, 4 | quoteProps: 'consistent', 5 | singleQuote: true, 6 | trailingComma: 'es5', 7 | }; -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | -------------------------------------------------------------------------------- /requirements/requirements-build.txt: -------------------------------------------------------------------------------- 1 | aws-sam-cli~=1.29.0 -------------------------------------------------------------------------------- /requirements/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # development dependencies 2 | pip~=21.2.4 3 | black~=21.7b0 4 | cfn-lint~=0.53.1 5 | flake8~=3.9.2 6 | pylint~=2.9.6 7 | yamllint~=1.26.2 8 | toml>=0.10.2 9 | mypy~=0.910 10 | bandit~=1.7.0 11 | 12 | # add lambda dependencies here to make them available in the virtual environment 13 | boto3==1.18.24 14 | crhelper==2.0.10 15 | pandas~=1.3.2 -------------------------------------------------------------------------------- /samconfig.toml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html 2 | # https://aws.amazon.com/blogs/compute/optimizing-serverless-development-with-samconfig/ 3 | 4 | version=0.1 5 | [default.build.parameters] 6 | use_container = true 7 | parallel = true 8 | cached = true 9 | skip_pull_image = true 10 | 11 | [default.deploy.parameters] 12 | stack_name = "lex-analytics" 13 | s3_bucket = "lex-analytics-artifacts-531380608753-us-east-1" 14 | s3_prefix = "sam-artifacts/lex-analytics" 15 | region = "us-east-1" 16 | fail_on_empty_changeset = false 17 | confirm_changeset = true 18 | capabilities = "CAPABILITY_IAM CAPABILITY_AUTO_EXPAND" 19 | parameter_overrides = [ 20 | "ShouldDeploySampleBots=true", 21 | "ShouldAddWriteWidgets=true", 22 | ] 23 | 24 | [default.package.parameters] 25 | s3_bucket = "lex-analytics-artifacts-531380608753-us-east-1" 26 | s3_prefix = "sam-artifacts/lex-analytics" 27 | 28 | [default.publish.parameters] 29 | region = "us-east-1" 30 | 31 | [publish-account.build.parameters] 32 | use_container = true 33 | 34 | [publish-account.package.parameters] 35 | s3_bucket = "lex-analytics-artifacts-777566285978-us-east-1" 36 | s3_prefix = "sam-artifacts/lex-analytics" 37 | 38 | [publish-account.publish.parameters] 39 | region = "us-east-1" 40 | -------------------------------------------------------------------------------- /src/lambda_functions/banker_bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/banker_bot/__init__.py -------------------------------------------------------------------------------- /src/lambda_functions/banker_bot/lambda_function.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | # https://amazonlex.workshop.aws/banker-bot/create-first-lambda.en.files/BankingBotEnglish.py 5 | 6 | # Disable linters 7 | # pylint: disable-all 8 | # flake8: noqa 9 | # fmt: off 10 | 11 | import json 12 | import random 13 | import decimal 14 | 15 | def random_num(): 16 | return(decimal.Decimal(random.randrange(1000, 50000))/100) # nosec 17 | 18 | def get_slots(intent_request): 19 | return intent_request['sessionState']['intent']['slots'] 20 | 21 | def get_slot(intent_request, slotName): 22 | slots = get_slots(intent_request) 23 | if slots is not None and slotName in slots and slots[slotName] is not None: 24 | return slots[slotName]['value']['interpretedValue'] 25 | else: 26 | return None 27 | 28 | def get_session_attributes(intent_request): 29 | sessionState = intent_request['sessionState'] 30 | if 'sessionAttributes' in sessionState: 31 | return sessionState['sessionAttributes'] 32 | 33 | return {} 34 | 35 | def elicit_intent(intent_request, session_attributes, message): 36 | return { 37 | 'sessionState': { 38 | 'dialogAction': { 39 | 'type': 'ElicitIntent' 40 | }, 41 | 'sessionAttributes': session_attributes 42 | }, 43 | 'messages': [ message ] if message != None else None, 44 | 'requestAttributes': intent_request['requestAttributes'] if 'requestAttributes' in intent_request else None 45 | } 46 | 47 | 48 | def close(intent_request, session_attributes, fulfillment_state, message): 49 | intent_request['sessionState']['intent']['state'] = fulfillment_state 50 | return { 51 | 'sessionState': { 52 | 'sessionAttributes': session_attributes, 53 | 'dialogAction': { 54 | 'type': 'Close' 55 | }, 56 | 'intent': intent_request['sessionState']['intent'] 57 | }, 58 | 'messages': [message], 59 | 'sessionId': intent_request['sessionId'], 60 | 'requestAttributes': intent_request['requestAttributes'] if 'requestAttributes' in intent_request else None 61 | } 62 | 63 | def CheckBalance(intent_request): 64 | session_attributes = get_session_attributes(intent_request) 65 | slots = get_slots(intent_request) 66 | account = get_slot(intent_request, 'accountType') 67 | #The account balance in this case is a random number 68 | #Here is where you could query a system to get this information 69 | balance = str(random_num()) 70 | text = "Thank you. The balance on your "+account+" account is $"+balance+" dollars." 71 | message = { 72 | 'contentType': 'PlainText', 73 | 'content': text 74 | } 75 | fulfillment_state = "Fulfilled" 76 | return close(intent_request, session_attributes, fulfillment_state, message) 77 | 78 | def FollowupCheckBalance(intent_request): 79 | session_attributes = get_session_attributes(intent_request) 80 | slots = get_slots(intent_request) 81 | account = get_slot(intent_request, 'accountType') 82 | #The account balance in this case is a random number 83 | #Here is where you could query a system to get this information 84 | balance = str(random_num()) 85 | text = "Thank you. The balance on your "+account+" account is $"+balance+" dollars." 86 | message = { 87 | 'contentType': 'PlainText', 88 | 'content': text 89 | } 90 | fulfillment_state = "Fulfilled" 91 | return close(intent_request, session_attributes, fulfillment_state, message) 92 | 93 | 94 | def dispatch(intent_request): 95 | intent_name = intent_request['sessionState']['intent']['name'] 96 | response = None 97 | # Dispatch to your bot's intent handlers 98 | if intent_name == 'CheckBalance': 99 | return CheckBalance(intent_request) 100 | elif intent_name == 'FollowupCheckBalance': 101 | return FollowupCheckBalance(intent_request) 102 | 103 | raise Exception('Intent with name ' + intent_name + ' not supported') 104 | 105 | def lambda_handler(event, context): 106 | response = dispatch(event) 107 | return response 108 | -------------------------------------------------------------------------------- /src/lambda_functions/banker_bot/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/banker_bot/requirements.txt -------------------------------------------------------------------------------- /src/lambda_functions/bot_tester/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/bot_tester/__init__.py -------------------------------------------------------------------------------- /src/lambda_functions/bot_tester/audio/hello.pcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/bot_tester/audio/hello.pcm -------------------------------------------------------------------------------- /src/lambda_functions/bot_tester/bot_conversations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.8 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """Bot Conversation Definitions""" 5 | import random 6 | from faker import Faker # pylint: disable=import-error 7 | 8 | with open("audio/hello.pcm", "rb") as f: 9 | HELLO_AUDIO = f.read() 10 | 11 | AUDIO_REQUEST_CONTENT_TYPE = ( 12 | "audio/lpcm; sample-rate=8000; sample-size-bits=16; channel-count=1; is-big-endian=false" 13 | ) 14 | 15 | 16 | class BankerBot: 17 | """Banker Bot Conversation""" 18 | 19 | # pylint: disable=too-few-public-methods 20 | DEFINITIONS = { 21 | "en_US": { 22 | "account_types": {"savings", "checking", "credit card", "visa", "mastercard", "amex"}, 23 | "check_balance_utterances": { 24 | "check balance", 25 | "what's the balance in my account", 26 | "I want to know my balance", 27 | }, 28 | "confirmations": {"yes", "no"}, 29 | "invalid_account_types": {"I don't know", "tax", "my account"}, 30 | "transfer_funds_utterances": { 31 | "i want to make a transfer", 32 | "transfer", 33 | "transfer funds", 34 | }, 35 | "welcome_utterances": {"hi", "hello", "help", "I need help", "can you please help"}, 36 | "fallback_utterances": {"what is this", "exit", "i am lost"}, 37 | } 38 | } 39 | 40 | def __init__(self, locale="en_US"): 41 | self._locale = locale 42 | self._faker = Faker(locale=locale) 43 | 44 | def get_bot_conversations(self): 45 | """Get a Banker Bot Conversation""" 46 | user_name = self._faker.user_name() 47 | return [ 48 | # welcome 49 | [ 50 | { 51 | "operation": "recognize_text", 52 | "args": { 53 | "text": self._faker.random_element( 54 | elements=self.DEFINITIONS[self._locale]["welcome_utterances"] 55 | ), 56 | "requestAttributes": {"state": "init"}, 57 | "sessionState": {"sessionAttributes": {"username": user_name}}, 58 | }, 59 | } 60 | ], 61 | # hello speech 62 | [ 63 | { 64 | "operation": "recognize_utterance", 65 | "args": { 66 | "inputStream": HELLO_AUDIO, 67 | "requestContentType": AUDIO_REQUEST_CONTENT_TYPE, 68 | "responseContentType": "text/plain;charset=utf-8", 69 | }, 70 | } 71 | ], 72 | # fallback utterance 73 | [ 74 | { 75 | "operation": "recognize_text", 76 | "args": { 77 | "text": self._faker.random_element( 78 | elements=self.DEFINITIONS[self._locale]["fallback_utterances"] 79 | ), 80 | "sessionState": {"sessionAttributes": {"username": user_name}}, 81 | }, 82 | } 83 | ], 84 | # check balance 85 | [ 86 | { 87 | "operation": "recognize_text", 88 | "args": { 89 | "text": self._faker.random_element( 90 | elements=self.DEFINITIONS[self._locale]["check_balance_utterances"] 91 | ), 92 | "sessionState": { 93 | "sessionAttributes": {"appState": "CheckBalance", "username": user_name} 94 | }, 95 | }, 96 | }, 97 | { 98 | "operation": "recognize_text", 99 | "args": { 100 | "text": self._faker.random_element( 101 | elements=self.DEFINITIONS[self._locale]["account_types"] 102 | ), 103 | "sessionState": { 104 | "sessionAttributes": { 105 | "appState": "CheckBalance:Account", 106 | "username": user_name, 107 | } 108 | }, 109 | }, 110 | }, 111 | { 112 | "operation": "recognize_text", 113 | "args": { 114 | "text": self._faker.date_of_birth().isoformat(), 115 | "sessionState": { 116 | "sessionAttributes": { 117 | "appState": "CheckBalance:Account:DoB", 118 | "username": user_name, 119 | } 120 | }, 121 | }, 122 | }, 123 | ], 124 | # check balance invalid account slot value 125 | [ 126 | { 127 | "operation": "recognize_text", 128 | "args": { 129 | "text": self._faker.random_element( 130 | elements=self.DEFINITIONS[self._locale]["check_balance_utterances"] 131 | ), 132 | "sessionState": { 133 | "sessionAttributes": {"appState": "CheckBalance", "username": user_name} 134 | }, 135 | }, 136 | }, 137 | *[ 138 | { 139 | "operation": "recognize_text", 140 | "args": { 141 | "text": t, 142 | "sessionState": { 143 | "sessionAttributes": { 144 | "appState": "CheckBalance:Account", 145 | "username": user_name, 146 | } 147 | }, 148 | }, 149 | } 150 | for t in self.DEFINITIONS[self._locale]["invalid_account_types"] 151 | ], 152 | ], 153 | # transfer funds 154 | [ 155 | { 156 | "operation": "recognize_text", 157 | "args": { 158 | "text": self._faker.random_element( 159 | elements=self.DEFINITIONS[self._locale]["transfer_funds_utterances"] 160 | ), 161 | "sessionState": { 162 | "sessionAttributes": { 163 | "appState": "TransferFunds", 164 | "username": user_name, 165 | } 166 | }, 167 | }, 168 | }, 169 | { 170 | "operation": "recognize_text", 171 | "args": { 172 | "text": self._faker.random_element( 173 | elements=self.DEFINITIONS[self._locale]["account_types"] 174 | ), 175 | "sessionState": { 176 | "sessionAttributes": { 177 | "appState": "TransferFunds:SrcAccount", 178 | "username": user_name, 179 | } 180 | }, 181 | }, 182 | }, 183 | { 184 | "operation": "recognize_text", 185 | "args": { 186 | "text": self._faker.random_element( 187 | elements=self.DEFINITIONS[self._locale]["account_types"] 188 | ), 189 | "sessionState": { 190 | "sessionAttributes": { 191 | "appState": "TransferFunds:DstAccount", 192 | "username": user_name, 193 | } 194 | }, 195 | }, 196 | }, 197 | { 198 | "operation": "recognize_text", 199 | "args": { 200 | "text": self._faker.pricetag(), 201 | "sessionState": { 202 | "sessionAttributes": { 203 | "appState": "Transfer:Amount", 204 | "username": user_name, 205 | } 206 | }, 207 | }, 208 | }, 209 | { 210 | "operation": "recognize_text", 211 | "args": { 212 | "text": self._faker.random_element( 213 | elements=self.DEFINITIONS[self._locale]["confirmations"] 214 | ), 215 | "sessionState": { 216 | "sessionAttributes": { 217 | "appState": "TransferFunds:DstAccount", 218 | "username": user_name, 219 | } 220 | }, 221 | }, 222 | }, 223 | ], 224 | ] 225 | 226 | 227 | def get_bot_conversation(bot_logical_name="BankerBot", locale="en_US"): 228 | """Get Bot Conversation Definition""" 229 | bot = None 230 | if bot_logical_name == "BankerBot": 231 | bot = BankerBot(locale=locale) 232 | 233 | if not bot: 234 | raise ValueError(f"unknown bot logical name: {bot_logical_name}") 235 | 236 | conversations = bot.get_bot_conversations() 237 | conversation = random.choice(conversations) # nosec 238 | 239 | return conversation 240 | -------------------------------------------------------------------------------- /src/lambda_functions/bot_tester/lambda_function.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.8 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """Lex Bot Runner Lambda Function""" 5 | import json 6 | import logging 7 | import random 8 | from os import environ, getenv 9 | 10 | from bot_conversations import get_bot_conversation # pylint: disable=import-error 11 | from run_conversation_test import run_conversation_test # pylint: disable=import-error 12 | 13 | 14 | LOGGER = logging.getLogger(__name__) 15 | LOG_LEVEL = getenv("LOG_LEVEL", "DEBUG") 16 | LOGGER.setLevel(LOG_LEVEL) 17 | 18 | BOTS_CONFIG_JSON = environ["BOTS_CONFIG_JSON"] 19 | BOTS_CONFIG = json.loads(BOTS_CONFIG_JSON) 20 | BOT_NAMES = list(BOTS_CONFIG.keys()) 21 | 22 | 23 | def handler(event, context): 24 | """Lambda Handler""" 25 | # pylint: disable=unused-argument 26 | LOGGER.info("bots config: %s", BOTS_CONFIG) 27 | bot_logical_name = random.choice(BOT_NAMES) # nosec 28 | locale_ids = BOTS_CONFIG[bot_logical_name]["localeIds"].split(",") 29 | locale_id = random.choice(locale_ids) # nosec 30 | bot_args = { 31 | "botId": BOTS_CONFIG[bot_logical_name]["botId"], 32 | "botAliasId": BOTS_CONFIG[bot_logical_name]["botAliasId"], 33 | "localeId": locale_id, 34 | } 35 | LOGGER.info("bot: %s", bot_args) 36 | session_id = context.aws_request_id 37 | 38 | conversation = get_bot_conversation(bot_logical_name=bot_logical_name, locale=locale_id) 39 | LOGGER.debug("conversation: %s", conversation) 40 | responses = run_conversation_test( 41 | bot_args=bot_args, 42 | conversation=conversation, 43 | session_id=session_id, 44 | ) 45 | LOGGER.debug("responses: %s", responses) 46 | -------------------------------------------------------------------------------- /src/lambda_functions/bot_tester/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.18.2 2 | faker==8.10.1 -------------------------------------------------------------------------------- /src/lambda_functions/bot_tester/run_conversation_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.8 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """Lex Bot Conversation Runner""" 5 | import boto3 6 | from botocore.config import Config as BotoCoreConfig 7 | 8 | CLIENT_CONFIG = BotoCoreConfig( 9 | retries={"mode": "adaptive", "max_attempts": 5}, 10 | ) 11 | CLIENT = boto3.client("lexv2-runtime", config=CLIENT_CONFIG) 12 | 13 | 14 | def run_conversation_test(bot_args, conversation, session_id="test"): 15 | """Runs Lex Conversation Test""" 16 | responses = [] 17 | for interaction in conversation: 18 | api_function = getattr(CLIENT, interaction["operation"]) 19 | response = api_function(**bot_args, sessionId=session_id, **interaction["args"]) 20 | responses.append(response) 21 | 22 | return responses 23 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/index.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | const { CloudWatchLogsClient } = require('@aws-sdk/client-cloudwatch-logs'); // eslint-disable-line import/no-unresolved 4 | const { LexModelsV2Client } = require('@aws-sdk/client-lex-models-v2'); // eslint-disable-line import/no-unresolved 5 | const { runQuery } = require('./lib'); 6 | const { 7 | displayConversationPath, 8 | displayHeatmapSessionHourOfDay, 9 | displayHeatmapIntentPerHour, 10 | displayMissedUtterance, 11 | } = require('./widgets'); 12 | 13 | const DOCS = ` 14 | ## Lex Analytics Widgets 15 | Renders Lex Analytics Custom Widgets 16 | 17 | ### Widget parameters 18 | Param | Description 19 | ---|--- 20 | **widgetType** | heatmapSessionHourOfDay\\|heatmapIntentPerHour\\|conversationPath 21 | **logGroups** | The log groups (comma-separated) to run query against 22 | **query** | The query used to get the data to generate the widget content 23 | 24 | ### Example parameters 25 | \`\`\` yaml 26 | widgetType: heatmapIntentPerHour 27 | logGroups: ${process.env.AWS_LAMBDA_LOG_GROUP_NAME} 28 | query: 'fields @timestamp, @message | sort @timestamp desc | limit 20' 29 | \`\`\` 30 | `; 31 | 32 | const AWS_SDK_MAX_RETRIES = Number(process.env.AWS_SDK_MAX_RETRIES) || 16; 33 | const LOGS_CLIENT = new CloudWatchLogsClient({ 34 | region: process.env.AWS_REGION, 35 | maxAttempts: AWS_SDK_MAX_RETRIES, 36 | }); 37 | const LEX_MODELS_CLIENT = new LexModelsV2Client({ 38 | region: process.env.AWS_REGION, 39 | maxAttempts: AWS_SDK_MAX_RETRIES, 40 | }); 41 | 42 | exports.handler = async (event, context) => { 43 | if (event.describe) { 44 | return DOCS; 45 | } 46 | console.debug(JSON.stringify(event)); // eslint-disable-line no-console 47 | 48 | const { widgetContext } = event; 49 | const form = widgetContext.forms.all; 50 | const logGroups = form.logGroups || event.logGroups || widgetContext.params.logGroups || ''; 51 | const query = form.query || event.query || widgetContext.params.query || ''; 52 | const widgetType = form.widgetType || event.widgetType || widgetContext.params.widgetType || ''; 53 | const timeRange = widgetContext.timeRange.zoom || widgetContext.timeRange; 54 | const shouldRunQuery = event.shouldRunQuery ?? true; 55 | 56 | let queryResults; 57 | if (shouldRunQuery) { 58 | if (!query || query.trim() === '') { 59 | return '
Required parameter "query" is empty or undefined
'; 60 | } 61 | try { 62 | queryResults = await runQuery(LOGS_CLIENT, logGroups, query, timeRange.start, timeRange.end); 63 | } catch (e) { 64 | console.error('exception: ', e); // eslint-disable-line no-console 65 | return '
Exception running query. Please see Lambda logs.
'; 66 | } 67 | } 68 | 69 | if (!widgetType) { 70 | return '
Required parameter "widgetType" is not defined
'; 71 | } 72 | 73 | switch (widgetType) { 74 | case 'missedUtterance': 75 | return displayMissedUtterance({ 76 | lexModelsClient: LEX_MODELS_CLIENT, 77 | widgetContext, 78 | context, 79 | queryResults, 80 | event, 81 | }); 82 | case 'heatmapSessionHourOfDay': 83 | return displayHeatmapSessionHourOfDay({ widgetContext, queryResults }); 84 | case 'heatmapIntentPerHour': 85 | return displayHeatmapIntentPerHour({ widgetContext, queryResults }); 86 | case 'conversationPath': 87 | return displayConversationPath({ widgetContext, queryResults }); 88 | default: 89 | return `
unknown widgetType: ${widgetType}
`; 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/lib/index.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | const { runQuery } = require('./runQuery'); 4 | 5 | module.exports = { 6 | runQuery, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/lib/runQuery.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | const { StartQueryCommand, GetQueryResultsCommand } = require('@aws-sdk/client-cloudwatch-logs'); // eslint-disable-line import/no-unresolved 4 | 5 | const CHECK_QUERY_STATUS_DELAY_MS = 250; 6 | 7 | const sleep = async (delay) => new Promise((resolve) => setTimeout(resolve, delay)); 8 | 9 | const runQuery = async (logsClient, logGroups, queryString, startTime, endTime) => { 10 | const startQueryCommand = new StartQueryCommand({ 11 | logGroupNames: logGroups.replace(/\s/g, '').split(','), 12 | queryString, 13 | startTime, 14 | endTime, 15 | }); 16 | 17 | // TODO add cache 18 | const startQuery = await logsClient.send(startQueryCommand); 19 | const { queryId } = startQuery; 20 | 21 | // eslint-disable-next-line no-constant-condition 22 | while (true) { 23 | /* eslint-disable no-await-in-loop */ 24 | const getQueryResultsCommand = new GetQueryResultsCommand({ queryId }); 25 | const queryResults = await logsClient.send(getQueryResultsCommand); 26 | if (queryResults.status !== 'Complete') { 27 | await sleep(CHECK_QUERY_STATUS_DELAY_MS); // Sleep before calling again 28 | } else { 29 | return queryResults.results; 30 | } 31 | } 32 | }; 33 | 34 | module.exports = { 35 | runQuery, 36 | }; 37 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cw_custom_widget", 3 | "version": "0.0.1", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "version": "0.0.1" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cw_custom_widget", 3 | "version": "0.0.1", 4 | "description": "Lex Analytics CloudWatch Custom Widget" 5 | } 6 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/widgets/conversationPath.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | // This module is a prototype of conversation path 5 | // TODO add a lengend to reflect link color direction black => forward), red => circular 6 | const d3 = require('d3'); // eslint-disable-line import/no-unresolved 7 | const { sankeyCircular: d3SankeyCircular, sankeyJustify } = require('d3-sankey-circular'); // eslint-disable-line import/no-unresolved 8 | const jsdom = require('jsdom'); // eslint-disable-line import/no-unresolved 9 | 10 | const { JSDOM } = jsdom; 11 | 12 | const START_NODE_NAME = 'START'; 13 | 14 | function displaySankey({ data, widgetContext } = {}) { 15 | // based on: 16 | // https://bl.ocks.org/tomshanley/6f3fcf68c0dbc401548733dd0c64e3c3 17 | const { document } = new JSDOM().window; 18 | 19 | // set the dimensions and margins 20 | const margin = { 21 | top: 20, 22 | right: 20, 23 | bottom: 20, 24 | left: 20, 25 | }; 26 | const width = widgetContext.width - margin.left - margin.right; 27 | const height = widgetContext.height - margin.top - margin.bottom; 28 | 29 | // append the svg object to the body of the page 30 | const svg = d3 31 | .select(document.body) 32 | .append('svg') 33 | .attr('width', width + margin.left + margin.right) 34 | .attr('height', height + margin.top + margin.bottom) 35 | .append('g') 36 | .attr('transform', `translate(${margin.left}, ${margin.top})`); 37 | 38 | const sankey = d3SankeyCircular() 39 | .nodeId((d) => d.name) 40 | .nodeWidth(30) 41 | .nodePaddingRatio(0.8) 42 | .nodeAlign(sankeyJustify) 43 | .circularLinkGap(20) 44 | .extent([ 45 | [5, 5], 46 | [width - 5, height - 5], 47 | ]); 48 | 49 | const { nodes, links } = sankey(data); 50 | 51 | const color = d3.scaleSequential(d3.interpolateCool).domain([0, width]); 52 | 53 | function getTitle(d) { 54 | const totalValue = d3.sum( 55 | nodes.filter((node) => node.name !== START_NODE_NAME), 56 | (i) => i.value // eslint-disable-line comma-dangle 57 | ); 58 | const nodePercent = d3.format('.0%')(d.value / totalValue); 59 | if (d.name === START_NODE_NAME) { 60 | return `${d.name}`; 61 | } 62 | return `${d.name}\n${d.value} (${nodePercent})`; 63 | } 64 | 65 | svg 66 | .append('g') 67 | .selectAll('rect') 68 | .data(nodes) 69 | .join('rect') 70 | .attr('x', (d) => d.x0) 71 | .attr('y', (d) => d.y0) 72 | .attr('height', (d) => Math.max(20, d.y1 - d.y0)) 73 | .attr('width', (d) => Math.max(20, d.x1 - d.x0)) 74 | .style('fill', (d) => color(d.x0)) 75 | .style('opacity', 0.5) 76 | .append('title') 77 | .text((d) => getTitle(d)); 78 | 79 | svg 80 | .append('g') 81 | .attr('font-family', 'sans-serif') 82 | .attr('font-size', 14) 83 | .attr('fill', '#202630') 84 | .selectAll('text') 85 | .data(nodes) 86 | .join('text') 87 | .attr('x', (d) => (d.x0 < width / 2 ? d.x0 : d.x1)) 88 | .attr('y', (d) => (d.y1 < width / 2 ? d.y1 + 12 : d.y0 - 12)) 89 | .attr('dy', '0.35em') 90 | .attr('text-anchor', (d) => (d.x0 < width / 2 ? 'start' : 'end')) 91 | .text((d) => `${d.name}`) 92 | .append('title') 93 | .text((d) => getTitle(d)); 94 | 95 | svg 96 | .append('defs') 97 | .append('marker') 98 | .attr('id', 'arrow') 99 | .attr('viewBox', [0, -5, 10, 10]) 100 | .attr('refX', 8) 101 | .attr('refY', 0) 102 | .attr('markerWidth', 2) 103 | .attr('markerHeight', 2) 104 | .attr('orient', 'auto') 105 | .append('path') 106 | .attr('d', 'M0,-5L10,0L0,5') 107 | .attr('fill-opacity', 0.05) 108 | .attr('stroke-opacity', 0.65) 109 | .attr('stroke', 'black'); 110 | 111 | svg 112 | .append('g') 113 | .attr('class', 'links') 114 | .attr('fill', 'none') 115 | .attr('stroke-opacity', 0.2) 116 | .selectAll('path') 117 | .data(links) 118 | .enter() 119 | .append('path') 120 | .attr('d', (d) => d.path) 121 | .attr('marker-end', 'url(#arrow)') 122 | .style('stroke-width', (d) => Math.max(4, d.width / 10)) 123 | .style('stroke', (link) => (link.circular ? 'red' : 'black')) 124 | .append('title') 125 | .text((d) => `${d.source.name} → ${d.target.name}`); 126 | 127 | return document.body.innerHTML; 128 | } 129 | 130 | function displayConversationPath({ widgetContext, queryResults }) { 131 | // flatten CloudWatch Insights results into objects 132 | const results = queryResults.map((x) => x.reduce((a, c) => ({ [c.field]: c.value, ...a }), {})); 133 | if (!results?.length) { 134 | return '
No data found
'; 135 | } 136 | const nodes = [ 137 | ...new Set(results.map((x) => x['@intentName'])), 138 | // add artificial start intent to have a single entry point 139 | START_NODE_NAME, 140 | ].map((i) => ({ name: i, category: i })); 141 | 142 | // group results by session id 143 | const resultsBySessionId = results.reduce((a, e) => { 144 | const sessionId = e['@sessionId']; 145 | const entries = a[sessionId] || []; 146 | return { 147 | ...a, 148 | ...{ [sessionId]: [...entries, e] }, 149 | }; 150 | }, {}); 151 | 152 | const intentsPerSessionId = Object.keys(resultsBySessionId) 153 | // add a 'START' intent to each session to introduce a common initial point 154 | .map((i) => [{ '@intentName': START_NODE_NAME }, ...resultsBySessionId[i]]); 155 | 156 | // rollup sum of unique paths 157 | const linksRollup = intentsPerSessionId 158 | .map( 159 | // create pairs of intents to build source and destination links 160 | (result) => 161 | result // eslint-disable-line implicit-arrow-linebreak 162 | .map((_, index, array) => array.slice(index, index + 2)) 163 | // remove same source and destination to avoid circular links 164 | .filter((i) => i.length === 2 && i[0]['@intentName'] !== i[1]['@intentName']) 165 | // map to objects with source and destination attributes 166 | .map((i) => ({ source: i[0]['@intentName'], target: i[1]['@intentName'], count: 1 })) // eslint-disable-line comma-dangle 167 | ) 168 | // flatten into a single array 169 | .reduce((a, e) => [...a, ...e], []) 170 | // create an object with aggregated sums of times a source and destination 171 | // pair path has been traveled 172 | .reduce((a, e) => { 173 | const key = `${e.source}:${e.target}`; 174 | return { ...a, ...{ [key]: a[key] ? a[key] + 1 : 1 } }; 175 | }, {}); 176 | 177 | const links = Object.keys(linksRollup).map((e) => { 178 | const [source, target] = e.split(':'); 179 | return { source, target, value: linksRollup[e] }; 180 | }); 181 | 182 | const data = { nodes, links, units: 'count' }; 183 | return displaySankey({ data, widgetContext }); 184 | } 185 | 186 | module.exports = { 187 | displayConversationPath, 188 | }; 189 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/widgets/heatmap.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | const d3 = require('d3'); // eslint-disable-line import/no-unresolved 4 | const jsdom = require('jsdom'); // eslint-disable-line import/no-unresolved 5 | 6 | const { JSDOM } = jsdom; 7 | 8 | // eslint-disable-next-line object-curly-newline 9 | function displayHeatMap({ data, widgetContext, groups, vars } = {}) { 10 | // create a new JSDOM instance for d3-selection to use 11 | const { document } = new JSDOM().window; 12 | 13 | // set the dimensions and margins of the graph 14 | const margin = { 15 | top: 30, 16 | right: 30, 17 | bottom: 120, 18 | left: 30, 19 | }; 20 | const width = widgetContext.width - margin.left - margin.right; 21 | const height = widgetContext.height - margin.top - margin.bottom; 22 | 23 | // append the svg object to the body of the page 24 | const svg = d3 25 | .select(document.body) 26 | .append('svg') 27 | .attr('width', width + margin.left + margin.right) 28 | .attr('height', height + margin.top + margin.bottom) 29 | .append('g') 30 | .attr('transform', `translate(${margin.left}, ${margin.top})`); 31 | 32 | // Labels of row and columns 33 | 34 | // Build X scales and axis: 35 | const scaleX = d3.scaleBand().range([0, width]).domain(groups).padding(0.005); 36 | svg 37 | .append('g') 38 | .classed('axis-x', true) 39 | .attr('transform', `translate(0, ${height})`) 40 | .call(d3.axisBottom(scaleX)) 41 | .selectAll('text') 42 | .style('text-anchor', 'end') 43 | .attr('dx', '-.8em') 44 | .attr('dy', '.15em') 45 | .attr('transform', 'rotate(-65)'); 46 | 47 | // Build Y scales and axis: 48 | const scaleY = d3.scaleBand().range([height, 0]).domain(vars).padding(0.01); 49 | svg.append('g').classed('axis-y', true).call(d3.axisLeft(scaleY)); 50 | 51 | // Build color scale 52 | const values = data.map((d) => d.value); 53 | const setColorScalar = d3 54 | .scaleLinear() 55 | .range(['#fff', '#A3320B']) 56 | .domain([d3.min(values), d3.max(values)]); 57 | 58 | const dataGroup = svg 59 | .selectAll() 60 | .data(data, (d) => `${d.group}:${d.variable}`) 61 | .enter() 62 | .append('g') 63 | .classed('datagroup', true); 64 | 65 | dataGroup 66 | .append('rect') 67 | .attr('x', (d) => scaleX(d.group)) 68 | .attr('y', (d) => scaleY(d.variable)) 69 | .attr('width', scaleX.bandwidth()) 70 | .attr('height', scaleY.bandwidth()) 71 | .style('fill', (d) => setColorScalar(d.value)); 72 | 73 | dataGroup 74 | .append('text') 75 | .text((d) => d.value) 76 | .attr('x', (d) => scaleX(d.group) + 14) 77 | .attr('y', (d) => scaleY(d.variable) + 14) 78 | .classed('text-value', true); 79 | const CSS = ` 80 | 84 | `; 85 | 86 | return CSS + document.body.innerHTML; 87 | } 88 | 89 | function displayHeatmapSessionHourOfDay({ widgetContext, queryResults }) { 90 | const { 91 | timeRange: { start, end }, 92 | } = widgetContext; 93 | // at least four hours 94 | if ((end - start) / 1000 / 60 / 60 < 4) { 95 | return '
Time range should be greater than 4 hours
'; 96 | } 97 | const data = queryResults 98 | .map((x) => x.reduce((a, c) => ({ [c.field]: c.value, ...a }), {})) 99 | .map((x) => { 100 | const d = new Date(x['@t']); 101 | return { 102 | // TODO handle locales 103 | group: d.toLocaleString('en-US', { weekday: 'short' }), 104 | variable: `0${d.getHours()}`.slice(-2), 105 | value: Number(x['@count']), 106 | }; 107 | }); 108 | 109 | // TODO locale 110 | const groups = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 111 | const vars = ['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22']; 112 | 113 | return displayHeatMap({ 114 | data, 115 | widgetContext, 116 | groups, 117 | vars, 118 | }); 119 | } 120 | 121 | function displayHeatmapIntentPerHour({ widgetContext, queryResults }) { 122 | const data = queryResults 123 | .map((x) => x.reduce((a, c) => ({ [c.field]: c.value, ...a }), {})) 124 | .map((x) => { 125 | const d = new Date(x['@t']); 126 | return { 127 | group: x['@intent'], 128 | variable: `0${d.getHours()}`.slice(-2), 129 | value: Number(x['@count']), 130 | }; 131 | }); 132 | 133 | const groups = Array.from(new Set(data.map((x) => x.group))).sort(); 134 | const vars = Array.from(new Set(data.map((x) => x.variable))).sort(); 135 | 136 | return displayHeatMap({ 137 | data, 138 | widgetContext, 139 | groups, 140 | vars, 141 | }); 142 | } 143 | 144 | module.exports = { 145 | displayHeatmapSessionHourOfDay, 146 | displayHeatmapIntentPerHour, 147 | }; 148 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/widgets/index.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | const { displayHeatmapSessionHourOfDay, displayHeatmapIntentPerHour } = require('./heatmap'); 4 | const { displayConversationPath } = require('./conversationPath'); 5 | const { displayMissedUtterance } = require('./missedUtterance'); 6 | 7 | module.exports = { 8 | displayConversationPath, 9 | displayHeatmapSessionHourOfDay, 10 | displayHeatmapIntentPerHour, 11 | displayMissedUtterance, 12 | }; 13 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_nodejs/widgets/missedUtterance.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | const { 4 | DescribeIntentCommand, 5 | ListIntentsCommand, 6 | UpdateIntentCommand, 7 | } = require('@aws-sdk/client-lex-models-v2'); // eslint-disable-line import/no-unresolved 8 | 9 | const CSS = ''; 10 | const RESULTS_MISSED_UTTERANCE_KEY = '@missed_utterance'; 11 | const RESULTS_COUNT_KEY = '@count'; 12 | const DRAFT_VERSION = 'DRAFT'; 13 | const FALLBACK_INTENT_ID = 'FALLBCKINT'; 14 | 15 | const displayListMissedUtterance = async ({ 16 | lexModelsClient, 17 | widgetContext, 18 | queryResults, 19 | context, 20 | }) => { 21 | const { 22 | params: { botId, botLocaleId }, 23 | } = widgetContext; 24 | // flatten CloudWatch Insights results into objects 25 | const results = queryResults.map((x) => x.reduce((a, c) => ({ [c.field]: c.value, ...a }), {})); 26 | 27 | // TODO use paginator 28 | const listIntentsCommand = new ListIntentsCommand({ 29 | botId, 30 | localeId: botLocaleId, 31 | botVersion: DRAFT_VERSION, 32 | }); 33 | const listIntentsResponse = await lexModelsClient.send(listIntentsCommand); 34 | const intentSummaries = listIntentsResponse.intentSummaries || []; 35 | 36 | const sampleUtteranceResponsePromises = intentSummaries.map(async (x) => { 37 | const describeIntentCommand = new DescribeIntentCommand({ 38 | botId, 39 | localeId: botLocaleId, 40 | botVersion: DRAFT_VERSION, 41 | intentId: x.intentId, 42 | }); 43 | const describeIntentResponse = await lexModelsClient.send(describeIntentCommand); 44 | const { sampleUtterances: responseUtterances = [] } = describeIntentResponse; 45 | return responseUtterances; 46 | }); 47 | 48 | const sampleUtteranceResponses = await Promise.all(sampleUtteranceResponsePromises); 49 | const sampleUtterances = sampleUtteranceResponses 50 | .map((x) => x.map((y) => y.utterance)) 51 | .reduce((a, x) => [...a, ...x], []); 52 | 53 | const missedUtterances = results.filter( 54 | // eslint-disable-next-line comma-dangle 55 | (x) => !sampleUtterances.includes(x[RESULTS_MISSED_UTTERANCE_KEY]) 56 | ); 57 | 58 | let html = `

59 |

Quickly add a missed utterance to your bot

60 |

Select an intent next to a missed utterance and click on the 'Add' button next to it 61 | to add the utterance to the ${DRAFT_VERSION} version of botId: ${botId} and 62 | locale: ${botLocaleId}

63 |

NOTE: Only missed utterances that are not already in an existing intent in 64 | the ${DRAFT_VERSION} version are listed

65 |

This is meant for testing of your bot using the ${DRAFT_VERSION} version. You are going to 66 | need to build your bot after adding the utterance before testing

67 | `; 68 | 69 | if (missedUtterances && missedUtterances.length > 0) { 70 | html += ` 71 | 72 | 73 | 74 | 75 | 76 |
Missed UtteranceCountIntentAdd
`; 77 | 78 | const intentOptions = intentSummaries 79 | .filter((i) => i.intentId !== FALLBACK_INTENT_ID) 80 | .sort((i, j) => i.intentName.localeCompare(j.intentName)) 81 | .map((i) => ``) 82 | .join('\n'); 83 | 84 | missedUtterances.forEach((missedUtterance, index) => { 85 | const intentDropDown = ` 86 | 87 | `; 88 | 89 | html += ` 90 |
91 | 92 | 93 | 94 | 95 | 96 | 111 | 112 |
${missedUtterance[RESULTS_MISSED_UTTERANCE_KEY]}${missedUtterance[RESULTS_COUNT_KEY]}${intentDropDown} 97 | Add 98 | 99 | { 100 | "shouldRunQuery": false, 101 | "action": { 102 | "operation": "addUtterance", 103 | "arguments": { 104 | "utterance": "${missedUtterance[RESULTS_MISSED_UTTERANCE_KEY]}", 105 | "formIndex": ${index} 106 | } 107 | } 108 | } 109 | 110 |
113 | `; 114 | }); 115 | } else { 116 | html += `

WARNING: Could not find missed utterances that are not already in 117 | an existing intent. See the values below:

`; 118 | html += `

Missed Utterance Query Result:

${JSON.stringify(results, null, 2)}
`; 119 | html += `

Configured utterances in bot:

120 |
${JSON.stringify(sampleUtterances, null, 2)}
`; 121 | } 122 | 123 | return CSS + html; 124 | }; 125 | 126 | const displayAddMissedUtterance = async ({ 127 | lexModelsClient, 128 | widgetContext, 129 | context, 130 | action, 131 | form, 132 | }) => { 133 | const { 134 | params: { botId, botLocaleId }, 135 | } = widgetContext; 136 | 137 | const { utterance, formIndex } = action.arguments; 138 | const intentId = form[`intentId-${formIndex}`]; 139 | 140 | try { 141 | const describeIntentCommand = new DescribeIntentCommand({ 142 | botId, 143 | localeId: botLocaleId, 144 | botVersion: DRAFT_VERSION, 145 | intentId, 146 | }); 147 | const describeIntentResponse = await lexModelsClient.send(describeIntentCommand); 148 | const { sampleUtterances: responseUtterances, intentName } = describeIntentResponse; 149 | 150 | const sampleUtterances = [...responseUtterances, { utterance }]; 151 | 152 | const updateIntentCommand = new UpdateIntentCommand({ 153 | ...describeIntentResponse, 154 | sampleUtterances, 155 | }); 156 | await lexModelsClient.send(updateIntentCommand); 157 | 158 | return ` 159 |

160 |

Successfully added utterance: "${utterance}" to intent: 161 | ${intentName} of botID: ${botId} 162 | in locale: ${botLocaleId}. These changes were done to the 163 | ${DRAFT_VERSION} version of the bot.

164 | 165 |

NOTE: You should build your bot before your test

166 | 167 |

Go back to the list of missed utterances:

168 | List Missed Utterances 169 | 170 | `; 171 | } catch (e) { 172 | console.error('Exception adding utterance to intent: ', e); // eslint-disable-line no-console 173 | return `
There was an error adding the utterance to the intent.
174 |       Please see the Lambda logs
`; 175 | } 176 | }; 177 | 178 | const displayMissedUtterance = async ({ 179 | lexModelsClient, 180 | widgetContext, 181 | queryResults, 182 | context, 183 | event, 184 | }) => { 185 | const form = widgetContext.forms.all; 186 | const action = form.action || event.action || widgetContext.params.action 187 | || { operation: 'listMissedUtterance' }; // prettier-ignore 188 | const { operation } = action; 189 | switch (operation) { 190 | case 'addUtterance': 191 | return displayAddMissedUtterance({ 192 | lexModelsClient, 193 | widgetContext, 194 | context, 195 | action, 196 | form, 197 | }); 198 | case 'listMissedUtterance': 199 | default: 200 | return displayListMissedUtterance({ 201 | lexModelsClient, 202 | widgetContext, 203 | queryResults, 204 | context, 205 | }); 206 | } 207 | }; 208 | 209 | module.exports = { 210 | displayMissedUtterance, 211 | }; 212 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | """CloudWatch Metric Filter Dimension CloudFormation Custom Resource""" 4 | from . import lambda_function 5 | from . import lib 6 | 7 | __all__ = ["lambda_function", "lib"] 8 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/lambda_function.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.9 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """ 5 | Lex Analytics CloudWatch Dashboard Custom Widget Lambda Handler 6 | """ 7 | 8 | # pylint: disable=import-error 9 | from lib.client import get_client 10 | from lib.logger import get_logger 11 | from lib.cw_logs import get_query_results_as_df 12 | from widgets.slots import render_slots_top_n_widget 13 | from widgets.session_attributes import render_session_attributes_top_n_widget 14 | 15 | # pylint: enable=import-error 16 | 17 | LOGGER = get_logger(__name__) 18 | CLIENT = get_client("logs") 19 | 20 | 21 | def handler(event, _): 22 | """Lambda Handler""" 23 | LOGGER.debug(event) 24 | widget_context = event.get("widgetContext") 25 | 26 | time_range = widget_context.get("timeRange", {}).get("zoom") or widget_context.get("timeRange") 27 | start_time = time_range.get("start") // 1000 28 | end_time = time_range.get("end") // 1000 29 | 30 | log_group = event["logGroups"] 31 | query = event["query"] 32 | widget_type = event["widgetType"] 33 | 34 | try: 35 | input_df = get_query_results_as_df( 36 | log_group_names=[log_group], 37 | query=query, 38 | start_time=start_time, 39 | end_time=end_time, 40 | logs_client=CLIENT, 41 | logger=LOGGER, 42 | ) 43 | except Exception as exception: # pylint disable=broad-except 44 | LOGGER.error("exception running query: %s", exception) 45 | raise 46 | 47 | if input_df.empty: 48 | return "
No data found
" 49 | 50 | if widget_type == "slotsTopN": 51 | return render_slots_top_n_widget(event=event, input_df=input_df) 52 | if widget_type == "sessionAttributesTopN": 53 | return render_session_attributes_top_n_widget(event=event, input_df=input_df) 54 | 55 | raise RuntimeError(f"unknown widget type: {widget_type}") 56 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/cw_custom_widget_python/lib/__init__.py -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/lib/client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | # pylint: disable=global-statement 4 | """Boto3 Client""" 5 | from os import getenv 6 | import json 7 | import boto3 8 | from botocore.config import Config 9 | from .logger import get_logger 10 | 11 | 12 | LOGGER = get_logger(__name__) 13 | 14 | CLIENT_CONFIG = Config( 15 | retries={"mode": "adaptive", "max_attempts": 10}, 16 | **json.loads(getenv("AWS_SDK_USER_AGENT", "{}")), 17 | ) 18 | 19 | _HELPERS_SERVICE_CLIENTS = dict() 20 | 21 | 22 | def get_client(service_name, config=CLIENT_CONFIG): 23 | """Get Boto3 Client""" 24 | global _HELPERS_SERVICE_CLIENTS 25 | if service_name not in _HELPERS_SERVICE_CLIENTS: 26 | LOGGER.debug("Initializing global boto3 client for %s", service_name) 27 | _HELPERS_SERVICE_CLIENTS[service_name] = boto3.client(service_name, config=config) 28 | return _HELPERS_SERVICE_CLIENTS[service_name] 29 | 30 | 31 | def reset_client(): 32 | """Reset Boto3 Client""" 33 | global _HELPERS_SERVICE_CLIENTS 34 | _HELPERS_SERVICE_CLIENTS = dict() 35 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/lib/cw_logs.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | """CloudWatch Log Insights Query""" 5 | 6 | from time import sleep 7 | import pandas as pd 8 | 9 | 10 | _QUERY_WAIT_STATUS = ["Scheduled", "Running"] 11 | _QUERY_TARGET_STATUS = ["Complete"] 12 | _QUERY_MAX_TRIES = 60 13 | _DEFAULT_POLL_SLEEP_TIME_IN_SECS = 0.5 14 | 15 | 16 | def get_query_results_as_df( 17 | query, 18 | log_group_names, 19 | start_time, 20 | end_time, 21 | logger, 22 | logs_client, 23 | poll_sleep_time_in_secs=_DEFAULT_POLL_SLEEP_TIME_IN_SECS, 24 | max_tries=_QUERY_MAX_TRIES, 25 | ): 26 | """Get CloudWatch Log Insights Query Results as a Pandas Dataframe""" 27 | # pylint: disable=too-many-arguments 28 | args = { 29 | "logGroupNames": log_group_names, 30 | "startTime": start_time, 31 | "endTime": end_time, 32 | "queryString": query, 33 | } 34 | response = logs_client.start_query(**args) 35 | logger.debug(response) 36 | query_id = response["queryId"] 37 | 38 | tries = 0 39 | while True: 40 | response = logs_client.get_query_results(queryId=query_id) 41 | logger.debug(response) 42 | status = response["status"] 43 | if status not in _QUERY_WAIT_STATUS or tries >= max_tries: 44 | break 45 | sleep(poll_sleep_time_in_secs) 46 | tries = tries + 1 47 | if status not in _QUERY_TARGET_STATUS: 48 | logger.error("failed waiting for query - response: %s, tries: %s", response, tries) 49 | raise RuntimeError("Failed waiting for query") 50 | 51 | return pd.DataFrame(({i["field"]: i["value"] for i in r} for r in response["results"])) 52 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/lib/logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | """Logger""" 5 | import logging 6 | from os import getenv 7 | 8 | DEFAULT_LEVEL = "WARNING" 9 | 10 | 11 | def get_log_level(): 12 | """ 13 | Get the logging level from the LOG_LEVEL environment variable if it is valid. 14 | Otherwise set to WARNING 15 | :return: The logging level to use 16 | """ 17 | valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 18 | requested_level = getenv("LOG_LEVEL", DEFAULT_LEVEL) 19 | if requested_level and requested_level in valid_levels: 20 | return requested_level 21 | return DEFAULT_LEVEL 22 | 23 | 24 | def get_logger(name): 25 | """ 26 | Get a configured logger. 27 | Compatible with both the AWS Lambda runtime (root logger) and local execution 28 | :param name: The name of the logger (most often __name__ of the calling module) 29 | :return: The logger to use 30 | """ 31 | logger = None 32 | # first case: running as a lambda function or in pytest with conftest 33 | # second case: running a single test or locally under test 34 | if len(logging.getLogger().handlers) > 0: 35 | logger = logging.getLogger() 36 | logger.setLevel(get_log_level()) 37 | # overrides 38 | logging.getLogger("boto3").setLevel(logging.WARNING) 39 | logging.getLogger("botocore").setLevel(logging.WARNING) 40 | logging.getLogger("urllib3").setLevel(logging.WARNING) 41 | else: 42 | logging.basicConfig( 43 | format=( 44 | "%(asctime)s [%(levelname)s] " "%(filename)s:%(lineno)s:%(funcName)s(): %(message)s" 45 | ), 46 | level=get_log_level(), 47 | datefmt="%Y-%m-%d %H:%M:%S", 48 | ) # NOSONAR logger's config is safe here 49 | 50 | logger = logging.getLogger(name) 51 | return logger 52 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/cw_custom_widget_python/requirements.txt -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/cw_custom_widget_python/widgets/__init__.py -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/widgets/session_attributes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.9 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """Lex Session Attributes CloudWatch Custom Widgets""" 5 | 6 | import json 7 | 8 | import pandas as pd 9 | 10 | 11 | def render_session_attributes_top_n_widget(event, input_df): 12 | """Render Session Attributes Custom Widget""" 13 | # pylint: disable=too-many-locals 14 | session_attributes_to_exclude = event.get("sessionAttributesToExclude", []) 15 | top_n = event.get("topN", 10) 16 | 17 | # deserialize json from message fields 18 | message_series = input_df["@message"].apply(json.loads) 19 | # flatten dictionaries 20 | normalized_message_df = pd.DataFrame.from_records( 21 | pd.json_normalize(message_series, max_level=1) 22 | ) 23 | 24 | if "sessionState.sessionAttributes" not in normalized_message_df.columns: 25 | return "
No session attributes found
" 26 | 27 | session_attributes_series = normalized_message_df["sessionState.sessionAttributes"].dropna( 28 | how="all" 29 | ) 30 | 31 | if session_attributes_series.empty: 32 | return "
No session attribute values found
" 33 | 34 | # extract sessionState.sessionAttributes dictionaries and turn then into a dataframe 35 | session_attributes_df = pd.DataFrame.from_records(session_attributes_series).drop( 36 | session_attributes_to_exclude, 37 | axis="columns", 38 | errors="ignore", 39 | ) 40 | 41 | session_attributes_columns = session_attributes_df.columns 42 | session_attributes_topn_values = [] 43 | for column in session_attributes_columns: 44 | topn_df = ( 45 | session_attributes_df[column] 46 | .value_counts() 47 | .to_frame() 48 | .head(top_n) 49 | .reset_index() 50 | .rename({column: "count", "index": "value"}, axis="columns") 51 | ) 52 | session_attributes_topn_values.append( 53 | { 54 | "name": column, 55 | "topn_df": topn_df, 56 | } 57 | ) 58 | 59 | output = f"

Top {top_n} Session Attribute Values

" 60 | for entry in session_attributes_topn_values: 61 | if not entry["topn_df"].empty: 62 | output = output + f"

Session Attribute Key: {entry['name']}


" 63 | output = output + entry["topn_df"].to_html(index=False) 64 | 65 | return output 66 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_custom_widget_python/widgets/slots.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.9 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """Lex Slots CloudWatch Custom Widgets""" 5 | 6 | import json 7 | 8 | import pandas as pd 9 | 10 | _SLOT_NAME_SUFFIX = ".value.originalValue" 11 | 12 | 13 | def render_slots_top_n_widget(event, input_df): 14 | """Render Slots Custom Widget""" 15 | # pylint: disable=too-many-locals 16 | slots_to_exclude = event.get("slotsToExclude", []) 17 | intents_to_exclude = event.get("intentToExclude", []) 18 | top_n = event.get("topN", 10) 19 | 20 | # deserialize json from message fields 21 | message_series = input_df["@message"].apply(json.loads) 22 | # flatten dictionaries up to one level and create a dataframe from it 23 | # only one level to selectively flatten other fields 24 | normalized_message_df = pd.DataFrame.from_records( 25 | pd.json_normalize(message_series, max_level=1) 26 | ) 27 | if "sessionState.intent" not in normalized_message_df.columns: 28 | return "
No intent data found
" 29 | 30 | # extract sessionState.intent dictionaries and turn then into a dataframe 31 | intent_df = pd.DataFrame.from_records(normalized_message_df["sessionState.intent"]) 32 | 33 | # extract slots and normalize 34 | if "slots" not in intent_df.columns: 35 | return "
No slots data found
" 36 | 37 | slots_df = pd.json_normalize(intent_df["slots"]).dropna(how="all") 38 | 39 | # join slots with intent to get a flattened dataframe with slots and intents 40 | slots_intent_df = slots_df.join(intent_df) 41 | if slots_intent_df.empty: 42 | return "
No slot values found
" 43 | 44 | # get intent names in data without exclusion 45 | slots_intent_columns = slots_intent_df.columns 46 | intent_names = [i for i in slots_intent_df["name"].unique() if i not in intents_to_exclude] 47 | 48 | # get slot names in data without exclusion 49 | slots_to_exclude_column_names = [f"{s}{_SLOT_NAME_SUFFIX}" for s in slots_to_exclude] 50 | slot_names = [ 51 | c[: -len(_SLOT_NAME_SUFFIX)] 52 | for c in slots_intent_columns 53 | if c.endswith(_SLOT_NAME_SUFFIX) and c not in slots_to_exclude_column_names 54 | ] 55 | 56 | intent_slot_topn_values = [] 57 | for intent_name in intent_names: 58 | for slot_name in slot_names: 59 | slot_column_name = f"{slot_name}{_SLOT_NAME_SUFFIX}" 60 | 61 | intent_slot_values_df = slots_intent_df[ 62 | (slots_intent_df["name"] == intent_name) 63 | & slots_intent_df["state"].isin(["Fulfilled", "ReadyForFulfillment"]) 64 | ][[slot_column_name]].rename({slot_column_name: "value"}, axis="columns") 65 | 66 | intent_slot_topn_values_df = ( 67 | intent_slot_values_df.value_counts() 68 | .head(top_n) 69 | .to_frame() 70 | .rename({0: "count"}, axis="columns") 71 | .reset_index() 72 | ) 73 | 74 | intent_slot_topn_values.append( 75 | { 76 | "intent_name": intent_name, 77 | "slot_name": slot_name, 78 | "topn_df": intent_slot_topn_values_df, 79 | } 80 | ) 81 | 82 | output = f"

Top {top_n} Slot Values in Fulfilled Intents

" 83 | for entry in intent_slot_topn_values: 84 | if not entry["topn_df"].empty: 85 | output = ( 86 | output 87 | + f"

Intent: {entry['intent_name']}" 88 | + f" Slot: {entry['slot_name']}


" 89 | ) 90 | output = output + entry["topn_df"].to_html(index=False) 91 | 92 | return output 93 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_metric_filter_cr/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | """CloudWatch Metric Filter Dimension CloudFormation Custom Resource""" 4 | from . import lambda_function 5 | 6 | __all__ = ["lambda_function"] 7 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_metric_filter_cr/lambda_function.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.8 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """CloudWatch Metrics Filter Dimension CloudFormation Custom Resource 5 | 6 | CloudFormation does not currently (as of 07/2021) support configuring 7 | dimensions on Metrics Filters. This custom resource adds dimensions to an 8 | existing Metrics Filter. 9 | """ 10 | 11 | import logging 12 | from os import getenv 13 | 14 | import boto3 15 | from botocore.config import Config as BotoCoreConfig 16 | from crhelper import CfnResource 17 | 18 | 19 | LOGGER = logging.getLogger(__name__) 20 | LOG_LEVEL = getenv("LOG_LEVEL", "DEBUG") 21 | HELPER = CfnResource( 22 | json_logging=True, 23 | log_level=LOG_LEVEL, 24 | ) 25 | 26 | # global init code goes here so that it can pass failure in case 27 | # of an exception 28 | try: 29 | # boto3 client 30 | CLIENT_CONFIG = BotoCoreConfig( 31 | retries={"mode": "adaptive", "max_attempts": 5}, 32 | ) 33 | CLIENT = boto3.client("logs", config=CLIENT_CONFIG) 34 | except Exception as init_exception: # pylint: disable=broad-except 35 | HELPER.init_failure(init_exception) 36 | 37 | 38 | def get_metric_filter( 39 | log_group_name, 40 | filter_name_prefix, 41 | metric_name, 42 | metric_namespace, 43 | ): 44 | """Gets a metric filter matching the parameters""" 45 | paginator = CLIENT.get_paginator("describe_metric_filters") 46 | response_iterator = paginator.paginate( 47 | logGroupName=log_group_name, 48 | filterNamePrefix=filter_name_prefix, 49 | ) 50 | metric_filters_response = [ 51 | metric_filter 52 | for response in response_iterator 53 | for metric_filter in response.get("metricFilters", []) 54 | ] 55 | LOGGER.debug("metric filters response: %s", metric_filters_response) 56 | if not metric_filters_response: 57 | raise ValueError( 58 | "failed to find existing metric filter with " 59 | f"logGroupName: [{log_group_name}], " 60 | f"filterNamePrefix: [{filter_name_prefix}]" 61 | ) 62 | # Get the fist metric filter with a matching transformation with the same 63 | # metricNameSpace and metricName 64 | # NOTE: There is a chance that there are multiple metric filters since the 65 | # describe_metric_filters uses a name prefix 66 | for m_f in metric_filters_response: 67 | metric_filters = [ 68 | m_f 69 | for m_t in m_f["metricTransformations"] 70 | if m_t["metricName"] == metric_name and m_t["metricNamespace"] == metric_namespace 71 | ] 72 | if metric_filters: 73 | break 74 | 75 | if not metric_filters: 76 | raise ValueError( 77 | "failed to find existing metric filter with " 78 | f"logGroupName: [{log_group_name}], " 79 | f"filterNamePrefix: [{filter_name_prefix}], " 80 | f"metricName: [{metric_name}], " 81 | f"metricNamespace: [{metric_namespace}]" 82 | ) 83 | 84 | metric_filter_properties = [ 85 | "filterName", 86 | "filterPattern", 87 | "logGroupName", 88 | "metricTransformations", 89 | ] 90 | # only return the properties that are needed for the put_metric_filter call 91 | return {k: v for k, v in metric_filters[0].items() if k in metric_filter_properties} 92 | 93 | 94 | def put_metric_filter_dimension( 95 | filter_name, 96 | log_group_name, 97 | metric_transformation, 98 | request_type, 99 | ): 100 | """Put Metric Filter Dimension""" 101 | metric_namespace = metric_transformation["MetricNamespace"] 102 | metric_name = metric_transformation["MetricName"] 103 | is_create_update = request_type.lower() in ["create", "update"] 104 | dimensions = metric_transformation["Dimensions"] if is_create_update else {} 105 | 106 | metric_filter_match = get_metric_filter( 107 | filter_name_prefix=filter_name, 108 | log_group_name=log_group_name, 109 | metric_namespace=metric_namespace, 110 | metric_name=metric_name, 111 | ) 112 | 113 | metric_transformations = [] 114 | for m_t in metric_filter_match["metricTransformations"]: 115 | is_target_metric_transformation = ( 116 | m_t["metricName"] == metric_name and m_t["metricNamespace"] == metric_namespace 117 | ) 118 | 119 | # add dimension to the target metric transformation 120 | transformation = ( 121 | {**m_t, **{"dimensions": dimensions}} if is_target_metric_transformation else m_t 122 | ) 123 | 124 | # Remove dimensions from metric transformation when deleting the resource. 125 | # Setting to an empty dict seems to have a different effect 126 | if not is_create_update and is_target_metric_transformation: 127 | del transformation["dimensions"] 128 | 129 | metric_transformations.append(transformation) 130 | 131 | metrics_filter_args = { 132 | **metric_filter_match, 133 | **{"metricTransformations": metric_transformations}, 134 | } 135 | 136 | CLIENT.put_metric_filter(**metrics_filter_args) 137 | 138 | 139 | @HELPER.create 140 | @HELPER.update 141 | def create_or_update_metric_filter_dimension(event, _): 142 | """Create or Update Resource""" 143 | resource_type = event["ResourceType"] 144 | resource_properties = event["ResourceProperties"] 145 | 146 | if resource_type == "Custom::MetricFilterDimension": 147 | filter_name = resource_properties["FilterName"] 148 | log_group_name = resource_properties["LogGroupName"] 149 | 150 | metric_transformations = resource_properties["MetricTransformations"] 151 | for metric_transformation in metric_transformations: 152 | put_metric_filter_dimension( 153 | filter_name=filter_name, 154 | log_group_name=log_group_name, 155 | metric_transformation=metric_transformation, 156 | request_type=event["RequestType"], 157 | ) 158 | 159 | return 160 | 161 | raise ValueError(f"invalid resource type: {resource_type}") 162 | 163 | 164 | @HELPER.delete 165 | def delete_metric_filter_dimension(event, _): 166 | """Delete Resource""" 167 | # ignore all exceptions when deleting. TODO: target specific scenarios 168 | # when an exeption is generated when the resource does not exists or has 169 | # been deleted 170 | try: 171 | create_or_update_metric_filter_dimension(event, _) 172 | except Exception as exception: # pylint: disable=broad-except 173 | LOGGER.error("failed to delete exception: %s", exception) 174 | 175 | 176 | def handler(event, context): 177 | """Lambda Handler""" 178 | HELPER(event, context) 179 | -------------------------------------------------------------------------------- /src/lambda_functions/cw_metric_filter_cr/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/cw_metric_filter_cr/requirements.txt -------------------------------------------------------------------------------- /src/lambda_functions/resource_name_cfn_cr/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | """Resource Name CloudFormation Custom Resource""" 4 | from . import lambda_function 5 | 6 | __all__ = ["lambda_function"] 7 | -------------------------------------------------------------------------------- /src/lambda_functions/resource_name_cfn_cr/lambda_function.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.9 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | """Resource Name CloudFormation Custom Resource 5 | 6 | This is used generate a resource name prefix based on the parameters 7 | of the stack. 8 | 9 | It takes the following resource attributes: 10 | - BotId 11 | - BotLocaleId 12 | - StackName 13 | 14 | It retrieves the Bot Name using the DescribeBot API and returns a string like: 15 | --- 16 | 17 | This is used to be able to generate resource names related to the bot but 18 | still keep them human readable. It also works around the `serverlessrepo` 19 | prefix that is added by the Serverless Application Repository (SAR) 20 | """ 21 | 22 | import logging 23 | from os import getenv 24 | 25 | import boto3 26 | from botocore.config import Config as BotoCoreConfig 27 | from crhelper import CfnResource 28 | 29 | 30 | LOGGER = logging.getLogger(__name__) 31 | LOG_LEVEL = getenv("LOG_LEVEL", "DEBUG") 32 | HELPER = CfnResource( 33 | json_logging=True, 34 | log_level=LOG_LEVEL, 35 | ) 36 | 37 | SAR_STACK_PREFIX = "serverlessrepo-" 38 | 39 | # global init code goes here so that it can pass failure in case 40 | # of an exception 41 | try: 42 | # boto3 client 43 | CLIENT_CONFIG = BotoCoreConfig( 44 | retries={"mode": "adaptive", "max_attempts": 5}, 45 | ) 46 | CLIENT = boto3.client("lexv2-models", config=CLIENT_CONFIG) 47 | except Exception as init_exception: # pylint: disable=broad-except 48 | HELPER.init_failure(init_exception) 49 | 50 | 51 | @HELPER.create 52 | @HELPER.update 53 | def create_or_update_resource_name(event, _): 54 | """Create or Update Resource""" 55 | resource_type = event["ResourceType"] 56 | resource_properties = event["ResourceProperties"] 57 | 58 | if resource_type == "Custom::ResourceName": 59 | bot_id = resource_properties["BotId"] 60 | bot_locale_id = resource_properties["BotLocaleId"] 61 | stack_name = resource_properties["StackName"].removeprefix(SAR_STACK_PREFIX) 62 | 63 | try: 64 | response = CLIENT.describe_bot( 65 | botId=bot_id, 66 | ) 67 | bot_name = response["botName"] 68 | except Exception as exception: # pylint: disable=broad-except 69 | LOGGER.error("failed to call describe_bot - exception: %s", exception) 70 | raise 71 | 72 | return f"{bot_name}-{bot_id}-{bot_locale_id}-{stack_name}" 73 | 74 | raise ValueError(f"invalid resource type: {resource_type}") 75 | 76 | 77 | @HELPER.delete 78 | def delete_no_op(event, _): 79 | """Delete Resource""" 80 | LOGGER.info("delete event ignored: %s", event) 81 | 82 | 83 | def handler(event, context): 84 | """Lambda Handler""" 85 | HELPER(event, context) 86 | -------------------------------------------------------------------------------- /src/lambda_functions/resource_name_cfn_cr/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-lex-v2-bot-analytics/01a1156684091cf1708af3cb98f23a78c9b51a28/src/lambda_functions/resource_name_cfn_cr/requirements.txt -------------------------------------------------------------------------------- /src/lambda_layers/cw_custom_widget_nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lex_analytics_shared_nodejs", 3 | "description": "Lex Analytics Shared Lambda Layer", 4 | "version": "0.1.0", 5 | "dependencies": { 6 | "@aws-sdk/client-cloudwatch-logs": "^3.22.0", 7 | "@aws-sdk/client-lex-models-v2": "^3.25.0", 8 | "d3": "^7.6.1", 9 | "d3-sankey": "^0.12.3", 10 | "d3-sankey-circular": "^0.34.0", 11 | "jsdom": "^16.6.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lambda_layers/cw_custom_widget_python/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3~=1.18.24 2 | pandas~=1.3.2 -------------------------------------------------------------------------------- /src/lambda_layers/shared_cfn_cr_python/requirements.txt: -------------------------------------------------------------------------------- 1 | # use by CloudFormation Custom Resources 2 | crhelper==2.0.10 3 | boto3==1.18.2 -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: 2010-09-09 3 | Transform: AWS::Serverless-2016-10-31 4 | 5 | Description: |- 6 | Lex V2 Analytics (v0.3.1) 7 | Deploys a CloudWatch Dashboard for your Lex V2 bot 8 | 9 | Parameters: 10 | ShouldDeploySampleBots: 11 | Description: >- 12 | If set to true, deploys a sample bot for testing. In that case, the BotId 13 | and BotLocaleId parameters will be ignored. Set to false if you are 14 | passing the BotId and BotLocaleId of an existing bot. 15 | Type: String 16 | Default: false 17 | AllowedValues: 18 | - true 19 | - false 20 | 21 | LogLevel: 22 | Description: Lambda log level 23 | Type: String 24 | Default: DEBUG 25 | AllowedValues: 26 | - CRITICAL 27 | - ERROR 28 | - WARNING 29 | - INFO 30 | - DEBUG 31 | 32 | LogRetentionInDays: 33 | Description: >- 34 | CloudWatch Logs retention in days for the Bot Conversation Logs. This is 35 | only used when the stack creates a Log Group for you if the 36 | LexConversationLogGroupName parameter is left empty. 37 | Type: Number 38 | Default: 90 39 | AllowedValues: 40 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html#cfn-logs-loggroup-retentionindays 41 | [ 42 | 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 43 | 180, 365, 400, 545, 731, 1827, 3653, 44 | ] 45 | 46 | BotId: 47 | Description: >- 48 | Lex V2 Bot ID. This value is ignored if you set the ShouldDeploySampleBots 49 | parameter to true 50 | Type: String 51 | Default: '' 52 | AllowedPattern: '^([A-Z0-9]{10}|)$' 53 | 54 | BotLocaleId: 55 | Description: >- 56 | Locale Ids This value is ignored if you set the ShouldDeploySampleBots 57 | parameter to true 58 | Type: String 59 | Default: en_US 60 | AllowedPattern: '^([\w-]{4,}|)$' 61 | 62 | LexConversationLogGroupName: 63 | Description: >- 64 | Name of existing CloudWatch Log Group containing Lex Conversation Logs. 65 | If left empty, a Log Group will be created with a name based on the stack 66 | name. You can use this created log group (if parameter left empy) 67 | when you configure the conversation logs in your Lex bot. 68 | Type: String 69 | Default: '' 70 | AllowedPattern: '^([\.\-_/#A-Za-z0-9]+|)$' 71 | 72 | ShouldAddWriteWidgets: 73 | Description: >- 74 | If set to true, the stack add widgets with write capabilities to the 75 | dashboard. Set to false if you only want read-only widgets that display 76 | visualizations. Setting to true will add widgets that allow to make 77 | changes to your bot. 78 | Type: String 79 | Default: false 80 | AllowedValues: 81 | - true 82 | - false 83 | 84 | Conditions: 85 | ShouldDeploySampleBots: !Equals [!Ref ShouldDeploySampleBots, true] 86 | ShouldCreateLogGroup: !Equals [!Ref LexConversationLogGroupName, ''] 87 | ShouldAddWriteWidgets: !Equals [!Ref ShouldAddWriteWidgets, true] 88 | 89 | Mappings: 90 | Config: 91 | # Lex V2 CloudFormation Custom Resource 92 | CfnCr: 93 | Arn: 94 | arn:aws:serverlessrepo:us-east-1:777566285978:applications/lex-v2-cfn-cr 95 | Version: 0.3.0 96 | 97 | Metadata: 98 | AWS::ServerlessRepo::Application: 99 | Name: lexv2-analytics 100 | Description: Amazon Lex V2 Analytics Dashboard 101 | Author: AWS Lex SA Team 102 | ReadmeUrl: README.md 103 | SpdxLicenseId: MIT-0 104 | LicenseUrl: LICENSE 105 | Labels: 106 | - Lex 107 | - V2 108 | - Dashboard 109 | - CloudWatch 110 | HomePageUrl: https://github.com/aws-samples/aws-lex-v2-bot-analytics 111 | SemanticVersion: 0.3.1 112 | SourceCodeUrl: https://github.com/aws-samples/aws-lex-v2-bot-analytics 113 | 114 | Resources: 115 | ########################################################################## 116 | # IAM 117 | ########################################################################## 118 | CwMfdCfnCrIamRole: 119 | Type: AWS::IAM::Role 120 | Properties: 121 | AssumeRolePolicyDocument: 122 | Version: 2012-10-17 123 | Statement: 124 | - Effect: Allow 125 | Principal: 126 | Service: lambda.amazonaws.com 127 | Action: 128 | - sts:AssumeRole 129 | Description: 130 | !Sub "Used by CloudWatch Metric Filter Dimension Lambda \ 131 | CloudFormation Custom Resource in: ${AWS::StackName}" 132 | ManagedPolicyArns: 133 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 134 | Policies: 135 | - PolicyName: Cwl 136 | PolicyDocument: 137 | Version: 2012-10-17 138 | Statement: 139 | - Effect: Allow 140 | Action: 141 | - logs:DescribeMetricFilters 142 | - logs:PutMetricFilter 143 | Resource: "*" 144 | 145 | CwCustomWidgetFunctionIamRole: 146 | Type: AWS::IAM::Role 147 | Properties: 148 | AssumeRolePolicyDocument: 149 | Version: 2012-10-17 150 | Statement: 151 | - Effect: Allow 152 | Principal: 153 | Service: lambda.amazonaws.com 154 | Action: 155 | - sts:AssumeRole 156 | Description: 157 | !Sub "Used by CloudWatch Custom Widget Lambda in: ${AWS::StackName}" 158 | ManagedPolicyArns: 159 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 160 | Policies: 161 | - PolicyName: CwlInsightsQuery 162 | PolicyDocument: 163 | Version: 2012-10-17 164 | Statement: 165 | - Effect: Allow 166 | Action: 167 | - logs:StartQuery 168 | Resource: 169 | !Sub 170 | - "arn:${AWS::Partition}:logs:${AWS::Region}:\ 171 | ${AWS::AccountId}:log-group:${LogGroupName}:log-stream:*" 172 | - LogGroupName: 173 | !If 174 | - ShouldCreateLogGroup 175 | - !Ref LexBotConversationLogs 176 | - !Ref LexConversationLogGroupName 177 | - Effect: Allow 178 | Action: 179 | - logs:GetQueryResults 180 | Resource: "*" 181 | - !If 182 | - ShouldAddWriteWidgets 183 | - PolicyName: LexModels 184 | PolicyDocument: 185 | Version: 2012-10-17 186 | Statement: 187 | - Effect: Allow 188 | Action: 189 | - lex:DescribeIntent 190 | - lex:ListIntents 191 | - lex:UpdateIntent 192 | Resource: 193 | !Sub 194 | - "arn:${AWS::Partition}:lex:${AWS::Region}:\ 195 | ${AWS::AccountId}:bot/${BotId}" 196 | - BotId: 197 | !If 198 | - ShouldDeploySampleBots 199 | - !Ref BankerBotLexBot 200 | - !Ref BotId 201 | - !Ref AWS::NoValue 202 | 203 | LexBotPolicy: 204 | Condition: ShouldDeploySampleBots 205 | Type: AWS::IAM::Policy 206 | Properties: 207 | PolicyName: 208 | Fn::Sub: ${AWS::StackName}-CloudWatchLogs 209 | PolicyDocument: 210 | Version: 2012-10-17 211 | Statement: 212 | - Effect: Allow 213 | Action: 214 | - logs:CreateLogStream 215 | - logs:PutLogEvents 216 | Resource: 217 | !If 218 | - ShouldCreateLogGroup 219 | - !GetAtt LexBotConversationLogs.Arn 220 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:\ 221 | ${AWS::AccountId}:log-group:\ 222 | ${LexConversationLogGroupName}:*" 223 | Roles: 224 | - Fn::Select: 225 | - 2 226 | - Fn::Split: 227 | - / 228 | - Fn::Select: 229 | - 5 230 | - Fn::Split: 231 | - ':' 232 | - !GetAtt LexV2CfnCr.Outputs.LexServiceLinkedRole 233 | 234 | ########################################################################## 235 | # Lambda 236 | ########################################################################## 237 | CwMfdCfnCrFunction: 238 | Type: AWS::Serverless::Function 239 | Properties: 240 | CodeUri: ./src/lambda_functions/cw_metric_filter_cr 241 | Description: >- 242 | CloudWatch Metric Filter Dimension CloudFormation Customer Resource 243 | Handler: lambda_function.handler 244 | MemorySize: 128 245 | Role: !GetAtt CwMfdCfnCrIamRole.Arn 246 | Layers: 247 | - !Ref SharedCfnCrPythonLayer 248 | Runtime: python3.9 249 | Timeout: 30 250 | Environment: 251 | Variables: 252 | LOG_LEVEL: !Ref LogLevel 253 | 254 | ResourceNameCfnCrFunction: 255 | Type: AWS::Serverless::Function 256 | Properties: 257 | CodeUri: ./src/lambda_functions/resource_name_cfn_cr 258 | Description: 259 | !Sub "Lex Analytics Resource Name CloudFormation Customer Resource for \ 260 | ${AWS::StackName}" 261 | Handler: lambda_function.handler 262 | MemorySize: 128 263 | Policies: 264 | - Statement: 265 | - Effect: Allow 266 | Action: 267 | - lex:DescribeBot 268 | Resource: '*' 269 | Runtime: python3.9 270 | Layers: 271 | - !Ref SharedCfnCrPythonLayer 272 | Timeout: 30 273 | Environment: 274 | Variables: 275 | LOG_LEVEL: !Ref LogLevel 276 | 277 | CwCustomWidgetNodeJsLayer: 278 | Type: AWS::Serverless::LayerVersion 279 | Metadata: 280 | BuildMethod: nodejs14.x 281 | Properties: 282 | Description: 283 | !Sub "Lex Analytics CloudWatch Custom Widget Node.js layer for \ 284 | stack: ${AWS::StackName}" 285 | ContentUri: ./src/lambda_layers/cw_custom_widget_nodejs 286 | LayerName: !Sub "LexAnalyticsSharedNodeJs-${AWS::StackName}" 287 | CompatibleRuntimes: 288 | - nodejs14.x 289 | 290 | SharedCfnCrPythonLayer: 291 | Type: AWS::Serverless::LayerVersion 292 | Metadata: 293 | BuildMethod: python3.9 294 | Properties: 295 | Description: 296 | !Sub "Lex Analytics shared CloudFormation Custom Resource layer for 297 | stack: ${AWS::StackName}" 298 | ContentUri: ./src/lambda_layers/shared_cfn_cr_python 299 | LayerName: !Sub "SharedCfnCrPythonLayer-${AWS::StackName}" 300 | CompatibleRuntimes: 301 | - python3.9 302 | 303 | CwCustomWidgetPythonLayer: 304 | Type: AWS::Serverless::LayerVersion 305 | Metadata: 306 | BuildMethod: python3.9 307 | Properties: 308 | Description: 309 | !Sub "Lex Analytics CloudWatch Custom Widget Python layer for \ 310 | stack: ${AWS::StackName}" 311 | ContentUri: ./src/lambda_layers/cw_custom_widget_python 312 | LayerName: !Sub "CwCustomWidgetPythonLayer-${AWS::StackName}" 313 | CompatibleRuntimes: 314 | - python3.9 315 | 316 | CwCustomWidgetNodeJsFunction: 317 | Type: AWS::Serverless::Function 318 | Properties: 319 | CodeUri: ./src/lambda_functions/cw_custom_widget_nodejs 320 | Description: !Sub 321 | CloudWatch Custom Widget for ${AWS::StackName} 322 | Handler: index.handler 323 | Runtime: nodejs14.x 324 | Layers: 325 | - !Ref CwCustomWidgetNodeJsLayer 326 | MemorySize: 512 327 | Role: !GetAtt CwCustomWidgetFunctionIamRole.Arn 328 | Timeout: 300 329 | 330 | CwCustomWidgetPythonFunction: 331 | Type: AWS::Serverless::Function 332 | Properties: 333 | CodeUri: ./src/lambda_functions/cw_custom_widget_python 334 | Description: !Sub CloudWatch Custom Widget Python for ${AWS::StackName} 335 | Handler: lambda_function.handler 336 | MemorySize: 2048 337 | Layers: 338 | - !Ref CwCustomWidgetPythonLayer 339 | Runtime: python3.9 340 | Role: !GetAtt CwCustomWidgetFunctionIamRole.Arn 341 | Timeout: 300 342 | Environment: 343 | Variables: 344 | LOG_LEVEL: !Ref LogLevel 345 | 346 | ########################################################################## 347 | # Resource Name Custom Resource 348 | ########################################################################## 349 | ResourceName: 350 | Type: Custom::ResourceName 351 | Properties: 352 | ServiceToken: !GetAtt ResourceNameCfnCrFunction.Arn 353 | BotId: 354 | !If 355 | - ShouldDeploySampleBots 356 | - !Ref BankerBotLexBot 357 | - !Ref BotId 358 | BotLocaleId: 359 | !If 360 | - ShouldDeploySampleBots 361 | - Fn::Select: 362 | - 0 363 | - Fn::Split: 364 | - ',' 365 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 366 | - !Ref BotLocaleId 367 | StackName: !Ref AWS::StackName 368 | 369 | ########################################################################## 370 | # CloudWatch 371 | ########################################################################## 372 | LexBotConversationLogs: 373 | Condition: ShouldCreateLogGroup 374 | Type: AWS::Logs::LogGroup 375 | Properties: 376 | LogGroupName: !Sub "${AWS::StackName}-conversation-logs" 377 | RetentionInDays: !Ref LogRetentionInDays 378 | 379 | CountMessagesMetricFilter: 380 | Type: AWS::Logs::MetricFilter 381 | Properties: 382 | FilterPattern: 383 | !Sub 384 | - >- 385 | { 386 | ($.bot.id = "${BotId}") 387 | && ($.bot.localeId = "${BotLocaleId}") 388 | && ($.inputTranscript = *) 389 | && ($.sessionState.intent.name = *) 390 | } 391 | - BotId: 392 | !If 393 | - ShouldDeploySampleBots 394 | - !Ref BankerBotLexBot 395 | - !Ref BotId 396 | BotLocaleId: 397 | !If 398 | - ShouldDeploySampleBots 399 | - Fn::Select: 400 | - 0 401 | - Fn::Split: 402 | - ',' 403 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 404 | - !Ref BotLocaleId 405 | LogGroupName: 406 | !If 407 | - ShouldCreateLogGroup 408 | - !Ref LexBotConversationLogs 409 | - !Ref LexConversationLogGroupName 410 | MetricTransformations: 411 | - MetricName: CountMessages 412 | MetricValue: "1" 413 | MetricNamespace: 414 | !Sub 415 | - >- 416 | Lex/Activity/${BotId}/${BotLocaleId} 417 | - BotId: 418 | !If 419 | - ShouldDeploySampleBots 420 | - !Ref BankerBotLexBot 421 | - !Ref BotId 422 | BotLocaleId: 423 | !If 424 | - ShouldDeploySampleBots 425 | - Fn::Select: 426 | - 0 427 | - Fn::Split: 428 | - ',' 429 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 430 | - !Ref BotLocaleId 431 | 432 | CountMessagesMetricFilterDimension: 433 | Type: Custom::MetricFilterDimension 434 | Properties: 435 | ServiceToken: !GetAtt CwMfdCfnCrFunction.Arn 436 | FilterName: !Ref CountMessagesMetricFilter 437 | LogGroupName: 438 | !If 439 | - ShouldCreateLogGroup 440 | - !Ref LexBotConversationLogs 441 | - !Ref LexConversationLogGroupName 442 | MetricTransformations: 443 | - MetricName: CountMessages 444 | MetricNamespace: 445 | !Sub 446 | - >- 447 | Lex/Activity/${BotId}/${BotLocaleId} 448 | - BotId: 449 | !If 450 | - ShouldDeploySampleBots 451 | - !Ref BankerBotLexBot 452 | - !Ref BotId 453 | BotLocaleId: 454 | !If 455 | - ShouldDeploySampleBots 456 | - Fn::Select: 457 | - 0 458 | - Fn::Split: 459 | - ',' 460 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 461 | - !Ref BotLocaleId 462 | Dimensions: 463 | BotVersion: >- 464 | $.bot.version 465 | BotAliasName: >- 466 | $.bot.aliasName 467 | Intent: >- 468 | $.sessionState.intent.name 469 | 470 | MissedUtteranceMetricFilter: 471 | Type: AWS::Logs::MetricFilter 472 | Properties: 473 | FilterPattern: 474 | !Sub 475 | - >- 476 | { 477 | ($.bot.id = "${BotId}") 478 | && ($.bot.localeId = "${BotLocaleId}") 479 | && ($.sessionState.intent.name = *) 480 | && ($.missedUtterance IS TRUE) 481 | } 482 | - BotId: 483 | !If 484 | - ShouldDeploySampleBots 485 | - !Ref BankerBotLexBot 486 | - !Ref BotId 487 | BotLocaleId: 488 | !If 489 | - ShouldDeploySampleBots 490 | - Fn::Select: 491 | - 0 492 | - Fn::Split: 493 | - ',' 494 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 495 | - !Ref BotLocaleId 496 | LogGroupName: 497 | !If 498 | - ShouldCreateLogGroup 499 | - !Ref LexBotConversationLogs 500 | - !Ref LexConversationLogGroupName 501 | MetricTransformations: 502 | - MetricName: MissedUtterance 503 | MetricValue: "1" 504 | MetricNamespace: 505 | !Sub 506 | - >- 507 | Lex/Activity/${BotId}/${BotLocaleId} 508 | - BotId: 509 | !If 510 | - ShouldDeploySampleBots 511 | - !Ref BankerBotLexBot 512 | - !Ref BotId 513 | BotLocaleId: 514 | !If 515 | - ShouldDeploySampleBots 516 | - Fn::Select: 517 | - 0 518 | - Fn::Split: 519 | - ',' 520 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 521 | - !Ref BotLocaleId 522 | 523 | MissedUtteranceMetricFilterDimension: 524 | Type: Custom::MetricFilterDimension 525 | Properties: 526 | ServiceToken: !GetAtt CwMfdCfnCrFunction.Arn 527 | FilterName: !Ref MissedUtteranceMetricFilter 528 | LogGroupName: 529 | !If 530 | - ShouldCreateLogGroup 531 | - !Ref LexBotConversationLogs 532 | - !Ref LexConversationLogGroupName 533 | MetricTransformations: 534 | - MetricName: MissedUtterance 535 | MetricNamespace: 536 | !Sub 537 | - >- 538 | Lex/Activity/${BotId}/${BotLocaleId} 539 | - BotId: 540 | !If 541 | - ShouldDeploySampleBots 542 | - !Ref BankerBotLexBot 543 | - !Ref BotId 544 | BotLocaleId: 545 | !If 546 | - ShouldDeploySampleBots 547 | - Fn::Select: 548 | - 0 549 | - Fn::Split: 550 | - ',' 551 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 552 | - !Ref BotLocaleId 553 | Dimensions: 554 | BotVersion: >- 555 | $.bot.version 556 | BotAliasName: >- 557 | $.bot.aliasName 558 | Intent: >- 559 | $.sessionState.intent.name 560 | 561 | InputModeMetricFilter: 562 | Type: AWS::Logs::MetricFilter 563 | Properties: 564 | FilterPattern: 565 | !Sub 566 | - >- 567 | { 568 | ($.bot.id = "${BotId}") 569 | && ($.bot.localeId = "${BotLocaleId}") 570 | && ($.inputMode = *) 571 | } 572 | - BotId: 573 | !If 574 | - ShouldDeploySampleBots 575 | - !Ref BankerBotLexBot 576 | - !Ref BotId 577 | BotLocaleId: 578 | !If 579 | - ShouldDeploySampleBots 580 | - Fn::Select: 581 | - 0 582 | - Fn::Split: 583 | - ',' 584 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 585 | - !Ref BotLocaleId 586 | LogGroupName: 587 | !If 588 | - ShouldCreateLogGroup 589 | - !Ref LexBotConversationLogs 590 | - !Ref LexConversationLogGroupName 591 | MetricTransformations: 592 | - MetricName: inputModeMetric 593 | MetricValue: "1" 594 | MetricNamespace: 595 | !Sub 596 | - >- 597 | Lex/Activity/${BotId}/${BotLocaleId} 598 | - BotId: 599 | !If 600 | - ShouldDeploySampleBots 601 | - !Ref BankerBotLexBot 602 | - !Ref BotId 603 | BotLocaleId: 604 | !If 605 | - ShouldDeploySampleBots 606 | - Fn::Select: 607 | - 0 608 | - Fn::Split: 609 | - ',' 610 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 611 | - !Ref BotLocaleId 612 | 613 | InputModeMetricFilterDimension: 614 | Type: Custom::MetricFilterDimension 615 | Properties: 616 | ServiceToken: !GetAtt CwMfdCfnCrFunction.Arn 617 | FilterName: !Ref InputModeMetricFilter 618 | LogGroupName: 619 | !If 620 | - ShouldCreateLogGroup 621 | - !Ref LexBotConversationLogs 622 | - !Ref LexConversationLogGroupName 623 | MetricTransformations: 624 | - MetricName: inputModeMetric 625 | MetricNamespace: 626 | !Sub 627 | - >- 628 | Lex/Activity/${BotId}/${BotLocaleId} 629 | - BotId: 630 | !If 631 | - ShouldDeploySampleBots 632 | - !Ref BankerBotLexBot 633 | - !Ref BotId 634 | BotLocaleId: 635 | !If 636 | - ShouldDeploySampleBots 637 | - Fn::Select: 638 | - 0 639 | - Fn::Split: 640 | - ',' 641 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 642 | - !Ref BotLocaleId 643 | Dimensions: 644 | BotVersion: >- 645 | $.bot.version 646 | BotAliasName: >- 647 | $.bot.aliasName 648 | inputModeDimension: >- 649 | $.inputMode 650 | 651 | SessionIdContributorInsight: 652 | Type: AWS::CloudWatch::InsightRule 653 | Properties: 654 | RuleName: !Sub "${ResourceName}-SessionId" 655 | RuleState: ENABLED 656 | RuleBody: 657 | !Sub 658 | - >- 659 | { 660 | "AggregateOn": "Count", 661 | "Contribution": { 662 | "Filters": [ 663 | { "Match": "$.bot.id", "In": ["${BotId}"] }, 664 | { "Match": "$.bot.localeId", "In": ["${BotLocaleId}"] } 665 | ], 666 | "Keys": [ 667 | "$.sessionId" 668 | ] 669 | }, 670 | "LogFormat": "JSON", 671 | "LogGroupNames": [ 672 | "${LogGroupName}" 673 | ], 674 | "Schema": { 675 | "Name": "CloudWatchLogRule", 676 | "Version": 1 677 | } 678 | } 679 | - BotId: 680 | !If 681 | - ShouldDeploySampleBots 682 | - !Ref BankerBotLexBot 683 | - !Ref BotId 684 | BotLocaleId: 685 | !If 686 | - ShouldDeploySampleBots 687 | - Fn::Select: 688 | - 0 689 | - Fn::Split: 690 | - ',' 691 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 692 | - !Ref BotLocaleId 693 | LogGroupName: 694 | !If 695 | - ShouldCreateLogGroup 696 | - !Ref LexBotConversationLogs 697 | - !Ref LexConversationLogGroupName 698 | 699 | MessageContributorInsight: 700 | Type: AWS::CloudWatch::InsightRule 701 | Properties: 702 | RuleName: !Sub "${ResourceName}-Message" 703 | RuleState: ENABLED 704 | RuleBody: 705 | !Sub 706 | - >- 707 | { 708 | "AggregateOn": "Count", 709 | "Contribution": { 710 | "Filters": [ 711 | { "Match": "$.bot.id", "In": ["${BotId}"] }, 712 | { "Match": "$.bot.localeId", "In": ["${BotLocaleId}"] } 713 | ], 714 | "Keys": [ 715 | "$.inputTranscript" 716 | ] 717 | }, 718 | "LogFormat": "JSON", 719 | "LogGroupNames": [ 720 | "${LogGroupName}" 721 | ], 722 | "Schema": { 723 | "Name": "CloudWatchLogRule", 724 | "Version": 1 725 | } 726 | } 727 | - BotId: 728 | !If 729 | - ShouldDeploySampleBots 730 | - !Ref BankerBotLexBot 731 | - !Ref BotId 732 | BotLocaleId: 733 | !If 734 | - ShouldDeploySampleBots 735 | - Fn::Select: 736 | - 0 737 | - Fn::Split: 738 | - ',' 739 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 740 | - !Ref BotLocaleId 741 | LogGroupName: 742 | !If 743 | - ShouldCreateLogGroup 744 | - !Ref LexBotConversationLogs 745 | - !Ref LexConversationLogGroupName 746 | 747 | LexAnalyticsCWLDashboard: 748 | Type: AWS::CloudWatch::Dashboard 749 | Properties: 750 | DashboardName: !Sub "Lex-Analytics-${ResourceName}" 751 | DashboardBody: 752 | !Sub 753 | # yamllint disable rule:line-length 754 | - >- 755 | { 756 | "widgets": [ 757 | { 758 | "height": 1, 759 | "width": 18, 760 | "y": 0, 761 | "x": 0, 762 | "type": "text", 763 | "properties": { 764 | "markdown": "# Activity" 765 | } 766 | }, 767 | { 768 | "height": 3, 769 | "width": 18, 770 | "y": 0, 771 | "x": 0, 772 | "type": "metric", 773 | "properties": { 774 | "metrics": [ 775 | [ { "expression": "SUM(e1)", "label": "Messages", "id": "e2" } ], 776 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"MissedUtterance\"', 'Sum', 300)", "label": "MissedUtterance", "id": "e3" } ], 777 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"CountMessages\"', 'SampleCount', 300)", "label": "SearchCountMessages", "id": "e1", "visible": false } ] 778 | ], 779 | "region": "${AWS::Region}", 780 | "view": "singleValue", 781 | "stacked": false, 782 | "setPeriodToTimeRange": true, 783 | "stat": "Average", 784 | "period": 60, 785 | "title": "Overview" 786 | } 787 | }, 788 | { 789 | "height": 6, 790 | "width": 9, 791 | "y": 4, 792 | "x": 0, 793 | "type": "metric", 794 | "properties": { 795 | "metrics": [ 796 | [ { "expression": "INSIGHT_RULE_METRIC('${SessionIdContributorInsight.RuleName}', 'UniqueContributors')", "label": "SessionID", "id": "e0", "period": 300 } ] 797 | ], 798 | "region": "${AWS::Region}", 799 | "view": "timeSeries", 800 | "stacked": false, 801 | "stat": "Average", 802 | "yAxis": { 803 | "left": { 804 | "showUnits": false, 805 | "min": 0 806 | } 807 | }, 808 | "legend": { 809 | "position": "bottom" 810 | }, 811 | "title": "Sessions" 812 | } 813 | }, 814 | { 815 | "height": 6, 816 | "width": 9, 817 | "y": 4, 818 | "x": 9, 819 | "type": "metric", 820 | "properties": { 821 | "metrics": [ 822 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"CountMessages\"', 'Sum', 300)", "label": "", "id": "e1", "visible": false } ], 823 | [ { "expression": "SUM(e1)", "label": "SumupMessages", "id": "e2" } ] 824 | ], 825 | "view": "timeSeries", 826 | "yAxis": { 827 | "left": { 828 | "showUnits": false, 829 | "min": 0 830 | } 831 | }, 832 | "region": "${AWS::Region}", 833 | "stat": "Sum", 834 | "period": 300, 835 | "setPeriodToTimeRange": true, 836 | "title": "Messages" 837 | } 838 | }, 839 | { 840 | "height": 6, 841 | "width": 9, 842 | "y": 10, 843 | "x": 0, 844 | "type": "log", 845 | "properties": { 846 | "query": "SOURCE '${LogGroupName}' | FIELDS @message | FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}' | STATS COUNT(*) AS Count_ BY interpretations.0.sentimentResponse.sentiment AS Sentiment", 847 | "region": "${AWS::Region}", 848 | "title": "Sentiment Analysis", 849 | "setPeriodToTimeRange": true, 850 | "view": "bar" 851 | } 852 | }, 853 | { 854 | "height": 6, 855 | "width": 9, 856 | "y": 10, 857 | "x": 9, 858 | "type": "metric", 859 | "properties": { 860 | "metrics": [ 861 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"CountMessages\" ', 'Sum', 259200)", "label": "", "id": "e1" } ] 862 | ], 863 | "view": "bar", 864 | "stacked": false, 865 | "region": "${AWS::Region}", 866 | "setPeriodToTimeRange": true, 867 | "stat": "Sum", 868 | "period": 300, 869 | "title": "Top Intents" 870 | } 871 | }, 872 | { 873 | "height": 1, 874 | "width": 18, 875 | "y": 16, 876 | "x": 0, 877 | "type": "text", 878 | "properties": { 879 | "markdown": "# Demographic" 880 | } 881 | }, 882 | { 883 | "height": 6, 884 | "width": 18, 885 | "y": 17, 886 | "x": 0, 887 | "type": "metric", 888 | "properties": { 889 | "metrics": [ 890 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, inputModeDimension} MetricName=\"inputModeMetric\"', 'Sum', 259200)", "label": "", "id": "e1" } ] 891 | ], 892 | "view": "pie", 893 | "stacked": false, 894 | "region": "${AWS::Region}", 895 | "stat": "Sum", 896 | "period": 300, 897 | "setPeriodToTimeRange": true, 898 | "labels": { 899 | "visible": false 900 | }, 901 | "title": "Modality" 902 | } 903 | }, 904 | { 905 | "height": 1, 906 | "width": 18, 907 | "y": 23, 908 | "x": 0, 909 | "type": "text", 910 | "properties": { 911 | "markdown": "# Missed Utterances" 912 | } 913 | }, 914 | { 915 | "height": 6, 916 | "width": 9, 917 | "y": 24, 918 | "x": 0, 919 | "type": "metric", 920 | "properties": { 921 | "metrics": [ 922 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"MissedUtterance\"', 'Sum', 300)", "label": "Expression1", "id": "e1", "visible": false } ], 923 | [ { "expression": "SUM(e1)", "label": "Missed Utterances", "id": "e2", "period": 60 } ] 924 | ], 925 | "view": "timeSeries", 926 | "stacked": false, 927 | "region": "${AWS::Region}", 928 | "stat": "SampleCount", 929 | "period": 300, 930 | "yAxis": { 931 | "left": { 932 | "label": "", 933 | "showUnits": false, 934 | "min": 0 935 | }, 936 | "right": { 937 | "showUnits": true 938 | } 939 | }, 940 | "liveData": false, 941 | "legend": { 942 | "position": "bottom" 943 | }, 944 | "title": "Missed Utterances" 945 | } 946 | }, 947 | { 948 | "height": 6, 949 | "width": 9, 950 | "y": 24, 951 | "x": 9, 952 | "type": "metric", 953 | "properties": { 954 | "metrics": [ 955 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"CountMessages\"', 'SampleCount', 300)", "label": "SearchCountMessges", "id": "e1", "visible": false, "region": "${AWS::Region}" } ], 956 | [ { "expression": "SUM(e1)", "label": "CountMessages", "id": "e2", "stat": "SampleCount", "visible": false, "region": "${AWS::Region}" } ], 957 | [ { "expression": "100*(e4/e2)", "label": "PercentageOfIncomingMessages", "id": "e3", "yAxis": "left", "stat": "SampleCount", "region": "${AWS::Region}" } ], 958 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"MissedUtterance\"', 'Sum', 300)", "label": "SearchMissedUtterance", "id": "e4", "visible": false, "region": "${AWS::Region}" } ], 959 | [ { "expression": "SUM(e4)", "label": "MissedUtterance", "id": "e5", "visible": false, "region": "${AWS::Region}" } ] 960 | ], 961 | "view": "timeSeries", 962 | "region": "${AWS::Region}", 963 | "stat": "SampleCount", 964 | "period": 300, 965 | "yAxis": { 966 | "left": { 967 | "label": "", 968 | "min": 0, 969 | "max": 100, 970 | "showUnits": false 971 | }, 972 | "right": { 973 | "showUnits": true 974 | } 975 | }, 976 | "liveData": false, 977 | "legend": { 978 | "position": "hidden" 979 | }, 980 | "title": "% Incoming Messages" 981 | } 982 | }, 983 | { 984 | "height": 6, 985 | "width": 6, 986 | "y": 30, 987 | "x": 0, 988 | "type": "metric", 989 | "properties": { 990 | "metrics": [ 991 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"MissedUtterance\" ', 'Sum', 259200)" } ] 992 | ], 993 | "view": "bar", 994 | "stacked": false, 995 | "region": "${AWS::Region}", 996 | "setPeriodToTimeRange": true, 997 | "stat": "Sum", 998 | "period": 300, 999 | "title": "MissedUtterance for Intents " 1000 | } 1001 | }, 1002 | { 1003 | "height": 6, 1004 | "width": 6, 1005 | "y": 30, 1006 | "x": 6, 1007 | "type": "metric", 1008 | "properties": { 1009 | "view": "pie", 1010 | "stacked": false, 1011 | "metrics": [ 1012 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"MissedUtterance\" ', 'Sum', 259200)" } ] 1013 | ], 1014 | "region": "${AWS::Region}", 1015 | "setPeriodToTimeRange": true, 1016 | "title": "% MissedUtterance for Intents" 1017 | } 1018 | }, 1019 | { 1020 | "height": 6, 1021 | "width": 6, 1022 | "y": 30, 1023 | "x": 12, 1024 | "type": "log", 1025 | "properties": { 1026 | "query": "SOURCE '${LogGroupName}' | FIELDS @message | FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}' AND missedUtterance = 1 | DISPLAY inputTranscript", 1027 | "region": "${AWS::Region}", 1028 | "stacked": false, 1029 | "title": "Missed Utterances History", 1030 | "view": "table" 1031 | } 1032 | }, 1033 | { 1034 | "height": 1, 1035 | "width": 18, 1036 | "y": 37, 1037 | "x": 0, 1038 | "type": "text", 1039 | "properties": { 1040 | "markdown": "# Conversation" 1041 | } 1042 | }, 1043 | { 1044 | "height": 6, 1045 | "width": 18, 1046 | "y": 38, 1047 | "x": 0, 1048 | "type": "metric", 1049 | "properties": { 1050 | "metrics": [ 1051 | [ { "expression": "INSIGHT_RULE_METRIC('${SessionIdContributorInsight.RuleName}', 'UniqueContributors')", "label": "SessionID Sum", "id": "e0", "period": 300, "visible": false } ], 1052 | [ { "expression": "300/e0", "label": "Average Session Duration (in seconds)", "id": "e1" } ] 1053 | ], 1054 | "region": "${AWS::Region}", 1055 | "view": "timeSeries", 1056 | "stacked": false, 1057 | "stat": "Average", 1058 | "period": 300, 1059 | "title": "Average Session Duration (in seconds)", 1060 | "yAxis": { 1061 | "left": { 1062 | "showUnits": false, 1063 | "min": 0, 1064 | "label": "Seconds" 1065 | }, 1066 | "right": { 1067 | "showUnits": false 1068 | } 1069 | } 1070 | } 1071 | }, 1072 | { 1073 | "height": 6, 1074 | "width": 9, 1075 | "y": 44, 1076 | "x": 0, 1077 | "type": "metric", 1078 | "properties": { 1079 | "period": 300, 1080 | "region": "${AWS::Region}", 1081 | "stacked": false, 1082 | "timezone": "local", 1083 | "title": "Top 10 Sessions", 1084 | "view": "timeSeries", 1085 | "insightRule": { 1086 | "maxContributorCount": 10, 1087 | "orderBy": "Sum", 1088 | "ruleName": "${SessionIdContributorInsight.RuleName}" 1089 | }, 1090 | "legend": { 1091 | "position": "right" 1092 | } 1093 | } 1094 | }, 1095 | { 1096 | "height": 6, 1097 | "width": 9, 1098 | "y": 44, 1099 | "x": 9, 1100 | "type": "metric", 1101 | "properties": { 1102 | "metrics": [ 1103 | [ { "expression": "INSIGHT_RULE_METRIC('${SessionIdContributorInsight.RuleName}', 'UniqueContributors')", "label": "SessionID Sum", "id": "e0", "period": 300, "visible": false, "region": "${AWS::Region}" } ], 1104 | [ { "expression": "SEARCH('{Lex/Activity/${BotId}/${BotLocaleId}, BotAliasName, BotVersion, Intent} MetricName=\"CountMessages\"', 'Sum', 300)", "label": "SearchCountMessages", "id": "e1", "visible": false, "region": "${AWS::Region}" } ], 1105 | [ { "expression": "SUM(e1)", "label": "CountMessages", "id": "e2", "visible": false, "region": "${AWS::Region}" } ], 1106 | [ { "expression": "IF(e0 !=0, e2/e0, 0)", "label": "MessagesPerSessions", "id": "e3", "yAxis": "left", "region": "${AWS::Region}" } ] 1107 | ], 1108 | "region": "${AWS::Region}", 1109 | "view": "timeSeries", 1110 | "stacked": false, 1111 | "stat": "Average", 1112 | "period": 300, 1113 | "setPeriodToTimeRange": true, 1114 | "title": "Average Messages Per Sessions", 1115 | "legend": { 1116 | "position": "bottom" 1117 | }, 1118 | "yAxis": { 1119 | "left": { 1120 | "showUnits": false, 1121 | "min": 0 1122 | } 1123 | } 1124 | } 1125 | }, 1126 | { 1127 | "height": 1, 1128 | "width": 18, 1129 | "y": 50, 1130 | "x": 0, 1131 | "type": "text", 1132 | "properties": { 1133 | "markdown": "# Conversation History" 1134 | } 1135 | }, 1136 | { 1137 | "type": "metric", 1138 | "x": 0, 1139 | "y": 51, 1140 | "width": 18, 1141 | "height": 6, 1142 | "properties": { 1143 | "period": 60, 1144 | "region": "${AWS::Region}", 1145 | "stacked": false, 1146 | "timezone": "local", 1147 | "title": "Top 10 Messages", 1148 | "view": "timeSeries", 1149 | "insightRule": { 1150 | "maxContributorCount": 10, 1151 | "orderBy": "Sum", 1152 | "ruleName": "${MessageContributorInsight.RuleName}" 1153 | }, 1154 | "legend": { 1155 | "position": "right" 1156 | } 1157 | } 1158 | }, 1159 | { 1160 | "height": 6, 1161 | "width": 18, 1162 | "y": 51, 1163 | "x": 0, 1164 | "type": "log", 1165 | "properties": { 1166 | "query": "SOURCE '${LogGroupName}' | FIELDS @message | FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}' | DISPLAY sessionId, timestamp, inputTranscript", 1167 | "region": "${AWS::Region}", 1168 | "stacked": false, 1169 | "title": "Message History", 1170 | "view": "table" 1171 | } 1172 | }, 1173 | { 1174 | "type": "custom", 1175 | "width": 15, 1176 | "height": 15, 1177 | "properties": { 1178 | "endpoint": "${CwCustomWidgetNodeJsFunction.Arn}", 1179 | "params": { 1180 | "logGroups": "${LogGroupName}", 1181 | "botId": "${BotId}", 1182 | "botLocaleId": "${BotLocaleId}", 1183 | "widgetType": "heatmapSessionHourOfDay", 1184 | "query": "FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}' | STATS COUNT(sessionId) AS @count BY BIN(2h) AS @t" 1185 | }, 1186 | "updateOn": { 1187 | "refresh": true, 1188 | "timeRange": true 1189 | }, 1190 | "title": "Session count heatmap per 2 hour block each day of the week" 1191 | } 1192 | }, 1193 | { 1194 | "type": "custom", 1195 | "width": 15, 1196 | "height": 15, 1197 | "properties": { 1198 | "endpoint": "${CwCustomWidgetNodeJsFunction.Arn}", 1199 | "params": { 1200 | "logGroups": "${LogGroupName}", 1201 | "botId": "${BotId}", 1202 | "botLocaleId": "${BotLocaleId}", 1203 | "widgetType": "heatmapIntentPerHour", 1204 | "query": "FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}' | FIELDS sessionState.intent.name AS @intent | STATS COUNT(*) AS @count BY BIN(1h) AS @t, @intent" 1205 | }, 1206 | "updateOn": { 1207 | "refresh": true, 1208 | "timeRange": true 1209 | }, 1210 | "title": "Intent match count heatmap per hour of day" 1211 | } 1212 | }, 1213 | { 1214 | "type": "custom", 1215 | "width": 15, 1216 | "height": 15, 1217 | "properties": { 1218 | "endpoint": "${CwCustomWidgetNodeJsFunction.Arn}", 1219 | "params": { 1220 | "logGroups": "${LogGroupName}", 1221 | "botId": "${BotId}", 1222 | "botLocaleId": "${BotLocaleId}", 1223 | "widgetType": "conversationPath", 1224 | "query": "FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}' | FILTER sessionState.intent.state = 'Fulfilled' OR sessionState.intent.state = 'ReadyForFulfillment' | STATS COUNT(*) AS count BY sessionId AS @sessionId, sessionState.intent.name AS @intentName, BIN(5s) AS @period | SORT @period ASC" 1225 | }, 1226 | "updateOn": { 1227 | "refresh": true, 1228 | "timeRange": true 1229 | }, 1230 | "title": "Conversation path aggregated per session" 1231 | } 1232 | }, 1233 | { 1234 | "type": "custom", 1235 | "width": 15, 1236 | "height": 15, 1237 | "properties": { 1238 | "endpoint": "${CwCustomWidgetPythonFunction.Arn}", 1239 | "params": { 1240 | "logGroups": "${LogGroupName}", 1241 | "botId": "${BotId}", 1242 | "botLocaleId": "${BotLocaleId}", 1243 | "widgetType": "slotsTopN", 1244 | "topN": 10, 1245 | "slotsToExclude": [], 1246 | "intentsToExclude": [], 1247 | "query": "FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}'" 1248 | }, 1249 | "updateOn": { 1250 | "refresh": true, 1251 | "timeRange": true 1252 | }, 1253 | "title": "" 1254 | } 1255 | }, 1256 | { 1257 | "type": "custom", 1258 | "width": 15, 1259 | "height": 15, 1260 | "properties": { 1261 | "endpoint": "${CwCustomWidgetPythonFunction.Arn}", 1262 | "params": { 1263 | "logGroups": "${LogGroupName}", 1264 | "botId": "${BotId}", 1265 | "botLocaleId": "${BotLocaleId}", 1266 | "widgetType": "sessionAttributesTopN", 1267 | "topN": 10, 1268 | "sessionAttributesToExclude": [], 1269 | "query": "FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}'" 1270 | }, 1271 | "updateOn": { 1272 | "refresh": true, 1273 | "timeRange": true 1274 | }, 1275 | "title": "" 1276 | } 1277 | } 1278 | ${WriteWidgets} 1279 | ] 1280 | } 1281 | - BotId: 1282 | !If 1283 | - ShouldDeploySampleBots 1284 | - !Ref BankerBotLexBot 1285 | - !Ref BotId 1286 | BotLocaleId: 1287 | !If 1288 | - ShouldDeploySampleBots 1289 | - Fn::Select: 1290 | - 0 1291 | - Fn::Split: 1292 | - ',' 1293 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 1294 | - !Ref BotLocaleId 1295 | LogGroupName: 1296 | !If 1297 | - ShouldCreateLogGroup 1298 | - !Ref LexBotConversationLogs 1299 | - !Ref LexConversationLogGroupName 1300 | WriteWidgets: 1301 | !If 1302 | - ShouldAddWriteWidgets 1303 | - !Sub 1304 | - >- 1305 | ,{ 1306 | "type": "custom", 1307 | "width": 15, 1308 | "height": 15, 1309 | "properties": { 1310 | "endpoint": "${CwCustomWidgetNodeJsFunction.Arn}", 1311 | "params": { 1312 | "logGroups": "${LogGroupName}", 1313 | "botId": "${BotId}", 1314 | "botLocaleId": "${BotLocaleId}", 1315 | "widgetType": "missedUtterance", 1316 | "query": "FILTER bot.id = '${BotId}' AND bot.localeId = '${BotLocaleId}' | FILTER missedUtterance = 1 | STATS COUNT(*) AS @count BY inputTranscript AS @missed_utterance | SORT BY count DESC" 1317 | }, 1318 | "updateOn": { 1319 | "refresh": true, 1320 | "timeRange": true 1321 | }, 1322 | "title": "Add Missed Utterances" 1323 | } 1324 | } 1325 | # yamllint enable rule:line-length 1326 | # is there a way to not repeat these subs everywhere (?) 1327 | - BotId: 1328 | !If 1329 | - ShouldDeploySampleBots 1330 | - !Ref BankerBotLexBot 1331 | - !Ref BotId 1332 | BotLocaleId: 1333 | !If 1334 | - ShouldDeploySampleBots 1335 | - Fn::Select: 1336 | - 0 1337 | - Fn::Split: 1338 | - ',' 1339 | - !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 1340 | - !Ref BotLocaleId 1341 | LogGroupName: 1342 | !If 1343 | - ShouldCreateLogGroup 1344 | - !Ref LexBotConversationLogs 1345 | - !Ref LexConversationLogGroupName 1346 | - '' 1347 | 1348 | ########################################################################## 1349 | # Sample Bot 1350 | ########################################################################## 1351 | # this deploys the Custom Resource as a nested CloudFormation stack 1352 | LexV2CfnCr: 1353 | Condition: ShouldDeploySampleBots 1354 | Type: AWS::Serverless::Application 1355 | Properties: 1356 | Location: 1357 | ApplicationId: 1358 | !FindInMap 1359 | - Config 1360 | - CfnCr 1361 | - Arn 1362 | SemanticVersion: 1363 | !FindInMap 1364 | - Config 1365 | - CfnCr 1366 | - Version 1367 | Parameters: 1368 | # Custom Resource Lambda log level 1369 | LogLevel: DEBUG 1370 | 1371 | BankerBotLexBot: 1372 | Condition: ShouldDeploySampleBots 1373 | Type: Custom::LexBot 1374 | Properties: 1375 | ServiceToken: !GetAtt LexV2CfnCr.Outputs.LexV2CfnCrFunctionArn 1376 | botName: !Sub "${AWS::StackName}-BankerBot" 1377 | dataPrivacy: 1378 | childDirected: false 1379 | description: Example Banker bot to demonstrate Lex V2 capabilities 1380 | idleSessionTTLInSeconds: 300 1381 | roleArn: !GetAtt LexV2CfnCr.Outputs.LexServiceLinkedRole 1382 | CR.botLocales: 1383 | - localeId: en_US 1384 | nluIntentConfidenceThreshold: 0.40 1385 | voiceSettings: 1386 | voiceId: Ivy 1387 | CR.slotTypes: 1388 | - slotTypeName: accountType 1389 | valueSelectionSetting: 1390 | resolutionStrategy: TopResolution 1391 | slotTypeValues: 1392 | - sampleValue: 1393 | value: Checking 1394 | - sampleValue: 1395 | value: Savings 1396 | - sampleValue: 1397 | value: Credit 1398 | synonyms: 1399 | - value: credit card 1400 | - value: visa 1401 | - value: mastercard 1402 | - value: amex 1403 | - value: american express 1404 | CR.intents: 1405 | - intentName: FallbackIntent 1406 | description: Default fallback intent when no other intent matches 1407 | intentClosingSetting: 1408 | closingResponse: 1409 | messageGroups: 1410 | - message: 1411 | plainTextMessage: 1412 | value: >- 1413 | Sorry I am having trouble understanding. 1414 | Can you describe what you'd like to do in a few 1415 | words? I can help you find your account balance, 1416 | transfer funds and make a payment. 1417 | - intentName: Welcome 1418 | description: Welcome intent 1419 | sampleUtterances: 1420 | - utterance: Hi 1421 | - utterance: Hello 1422 | - utterance: I need help 1423 | - utterance: Can you help me? 1424 | intentClosingSetting: 1425 | closingResponse: 1426 | messageGroups: 1427 | - message: 1428 | plainTextMessage: 1429 | value: >- 1430 | Hi! I'm BB, the Banking Bot. How can I help you 1431 | today? 1432 | - intentName: CheckBalance 1433 | description: 1434 | Intent to check the balance in the specified account type 1435 | sampleUtterances: 1436 | - utterance: What’s the balance in my account ? 1437 | - utterance: Check my account balance 1438 | - utterance: What’s the balance in my {accountType} account ? 1439 | - utterance: How much do I have in {accountType} ? 1440 | - utterance: I want to check the balance 1441 | - utterance: Can you help me with account balance ? 1442 | - utterance: Balance in {accountType} 1443 | fulfillmentCodeHook: 1444 | enabled: true 1445 | outputContexts: 1446 | - name: contextCheckBalance 1447 | timeToLiveInSeconds: 90 1448 | turnsToLive: 5 1449 | CR.slots: 1450 | - slotName: accountType 1451 | CR.slotTypeName: accountType 1452 | valueElicitationSetting: 1453 | slotConstraint: Required 1454 | promptSpecification: 1455 | messageGroups: 1456 | - message: 1457 | plainTextMessage: 1458 | value: 1459 | For which account would you like your balance? 1460 | maxRetries: 2 1461 | - slotName: dateOfBirth 1462 | CR.slotTypeName: AMAZON.Date 1463 | valueElicitationSetting: 1464 | slotConstraint: Required 1465 | promptSpecification: 1466 | messageGroups: 1467 | - message: 1468 | plainTextMessage: 1469 | value: >- 1470 | For verification purposes, what is your date of 1471 | birth? 1472 | maxRetries: 2 1473 | - intentName: FollowupCheckBalance 1474 | description: >- 1475 | Intent to allow a follow-up balance check request without 1476 | authentication 1477 | sampleUtterances: 1478 | - utterance: How about my {accountType} account 1479 | - utterance: What about {accountType} 1480 | - utterance: And in {accountType} ? 1481 | fulfillmentCodeHook: 1482 | enabled: true 1483 | inputContexts: 1484 | - name: contextCheckBalance 1485 | CR.slots: 1486 | - slotName: accountType 1487 | CR.slotTypeName: accountType 1488 | valueElicitationSetting: 1489 | slotConstraint: Required 1490 | promptSpecification: 1491 | messageGroups: 1492 | - message: 1493 | plainTextMessage: 1494 | value: 1495 | For which account would you like your balance? 1496 | maxRetries: 2 1497 | - slotName: dateOfBirth 1498 | CR.slotTypeName: AMAZON.Date 1499 | valueElicitationSetting: 1500 | slotConstraint: Required 1501 | promptSpecification: 1502 | messageGroups: 1503 | - message: 1504 | plainTextMessage: 1505 | value: >- 1506 | For verification purposes, what is your date of 1507 | birth? 1508 | maxRetries: 2 1509 | defaultValueSpecification: 1510 | defaultValueList: 1511 | - defaultValue: '#contextCheckBalance.dateOfBirth' 1512 | - intentName: TransferFunds 1513 | description: Help user transfer funds between bank accounts 1514 | sampleUtterances: 1515 | - utterance: I want to transfer funds 1516 | - utterance: Can I make a transfer? 1517 | - utterance: I want to make a transfer 1518 | - utterance: >- 1519 | I'd like to transfer {transferAmount} from 1520 | {sourceAccountType} to {targetAccountType} 1521 | - utterance: >- 1522 | Can I transfer {transferAmount} to my {targetAccountType} 1523 | - utterance: Would you be able to help me with a transfer? 1524 | - utterance: Need to make a transfer 1525 | fulfillmentCodeHook: 1526 | enabled: false 1527 | intentConfirmationSetting: 1528 | declinationResponse: 1529 | messageGroups: 1530 | - message: 1531 | plainTextMessage: 1532 | value: The transfer has been cancelled 1533 | promptSpecification: 1534 | messageGroups: 1535 | - message: 1536 | plainTextMessage: 1537 | value: >- 1538 | Got it. So we are transferring {transferAmount} from 1539 | {sourceAccountType} to {targetAccountType}. 1540 | Can I go ahead with the transfer? 1541 | maxRetries: 2 1542 | intentClosingSetting: 1543 | closingResponse: 1544 | messageGroups: 1545 | - message: 1546 | plainTextMessage: 1547 | value: >- 1548 | The transfer is complete. {transferAmount} should 1549 | now be available in your {targetAccountType} 1550 | account. 1551 | CR.slots: 1552 | - slotName: sourceAccountType 1553 | CR.slotTypeName: accountType 1554 | valueElicitationSetting: 1555 | slotConstraint: Required 1556 | promptSpecification: 1557 | messageGroups: 1558 | - message: 1559 | plainTextMessage: 1560 | value: 1561 | Which account would you like to transfer from? 1562 | maxRetries: 2 1563 | - slotName: targetAccountType 1564 | CR.slotTypeName: accountType 1565 | valueElicitationSetting: 1566 | slotConstraint: Required 1567 | promptSpecification: 1568 | messageGroups: 1569 | - message: 1570 | plainTextMessage: 1571 | value: Which account are you transferring to? 1572 | maxRetries: 2 1573 | - slotName: transferAmount 1574 | CR.slotTypeName: AMAZON.Number 1575 | valueElicitationSetting: 1576 | slotConstraint: Required 1577 | promptSpecification: 1578 | messageGroups: 1579 | - message: 1580 | plainTextMessage: 1581 | value: How much money would you like to transfer? 1582 | maxRetries: 2 1583 | 1584 | BankerBotLexBotVersion: 1585 | Condition: ShouldDeploySampleBots 1586 | # Bot versions are deleted by the Bot on Stack Deletions 1587 | DeletionPolicy: Retain 1588 | # Version number changes between updates which cause a CloudFormation 1589 | # delete event since the version number is the physical resource ID. 1590 | # The following policies prevents deletion events 1591 | UpdateReplacePolicy: Retain 1592 | Type: Custom::LexBotVersion 1593 | Properties: 1594 | ServiceToken: !GetAtt LexV2CfnCr.Outputs.LexV2CfnCrFunctionArn 1595 | botId: !Ref BankerBotLexBot 1596 | # botVersionLocaleSpecification is derived from the bot locales 1597 | CR.botLocaleIds: !GetAtt BankerBotLexBot.botLocaleIds 1598 | CR.lastUpdatedDateTime: !GetAtt BankerBotLexBot.lastUpdatedDateTime 1599 | 1600 | BankerBotLexBotAlias: 1601 | Condition: ShouldDeploySampleBots 1602 | # Alias is deleted by the Bot on Stack Deletions 1603 | DeletionPolicy: Retain 1604 | UpdateReplacePolicy: Retain 1605 | Type: Custom::LexBotAlias 1606 | Properties: 1607 | ServiceToken: !GetAtt LexV2CfnCr.Outputs.LexV2CfnCrFunctionArn 1608 | botId: !Ref BankerBotLexBot 1609 | botAliasName: live 1610 | botVersion: !Ref BankerBotLexBotVersion 1611 | botAliasLocaleSettings: 1612 | en_US: 1613 | enabled: true 1614 | # Lambda Code Hook 1615 | codeHookSpecification: 1616 | lambdaCodeHook: 1617 | lambdaARN: !GetAtt BankerBotLexBotFunction.Arn 1618 | codeHookInterfaceVersion: "1.0" 1619 | conversationLogSettings: 1620 | # Text Conversation Logs to CloudWatch 1621 | textLogSettings: 1622 | - enabled: true 1623 | destination: 1624 | cloudWatch: 1625 | cloudWatchLogGroupArn: 1626 | !If 1627 | - ShouldCreateLogGroup 1628 | - !GetAtt LexBotConversationLogs.Arn 1629 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:\ 1630 | ${AWS::AccountId}:log-group:\ 1631 | ${LexConversationLogGroupName}:*" 1632 | logPrefix: 1633 | !Sub "aws/lex/${BankerBotLexBot}/${BankerBotLexBotVersion}/" 1634 | sentimentAnalysisSettings: 1635 | detectSentiment: true 1636 | 1637 | ########################################################################## 1638 | # Sampmle Bot Lambda Functions 1639 | ########################################################################## 1640 | BankerBotLexBotFunction: 1641 | Condition: ShouldDeploySampleBots 1642 | Type: AWS::Serverless::Function 1643 | Properties: 1644 | CodeUri: ./src/lambda_functions/banker_bot 1645 | Description: >- 1646 | Lex Banker Bot Fulfillment Function 1647 | Handler: lambda_function.lambda_handler 1648 | MemorySize: 128 1649 | Runtime: python3.8 1650 | Timeout: 3 1651 | 1652 | # Add resource policy to allow the Lex Bot Alias to invoke it 1653 | BankerBotLexBotFunctionPermission: 1654 | Condition: ShouldDeploySampleBots 1655 | Type: AWS::Lambda::Permission 1656 | Properties: 1657 | Action: lambda:InvokeFunction 1658 | FunctionName: !GetAtt BankerBotLexBotFunction.Arn 1659 | Principal: lexv2.amazonaws.com 1660 | SourceArn: 1661 | !Sub "arn:${AWS::Partition}:lex:${AWS::Region}:${AWS::AccountId}:\ 1662 | bot-alias/${BankerBotLexBot}/${BankerBotLexBotAlias}" 1663 | 1664 | BotTesterFunction: 1665 | Condition: ShouldDeploySampleBots 1666 | Type: AWS::Serverless::Function 1667 | Properties: 1668 | CodeUri: ./src/lambda_functions/bot_tester 1669 | Description: Bot Tester Function 1670 | Handler: .lambda_function.handler 1671 | MemorySize: 128 1672 | Runtime: python3.8 1673 | Timeout: 30 1674 | Policies: 1675 | - Statement: 1676 | - Sid: LexRuntime 1677 | Effect: Allow 1678 | Action: 1679 | - lex:RecognizeText 1680 | - lex:RecognizeUtterance 1681 | Resource: '*' 1682 | Events: 1683 | RunTestScheduledEvent: 1684 | Type: Schedule 1685 | Properties: 1686 | Schedule: rate(2 minutes) 1687 | Environment: 1688 | Variables: 1689 | BOTS_CONFIG_JSON: 1690 | !Sub 1691 | - 1692 | >- 1693 | { 1694 | "BankerBot": { 1695 | "botId": "${BankerBotLexBot}", 1696 | "botAliasId": "${BankerBotLexBotAlias}", 1697 | "localeIds": "${BankerBotLocaleIds}" 1698 | } 1699 | } 1700 | - BankerBotLexBot: !Ref BankerBotLexBot 1701 | BankerBotLexBotAlias: !Ref BankerBotLexBotAlias 1702 | BankerBotLocaleIds: 1703 | !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 1704 | 1705 | Outputs: 1706 | LexBotConversationLogs: 1707 | Description: CloudWatch Log Group Name for Lex Conversation Logs 1708 | Value: 1709 | !If 1710 | - ShouldCreateLogGroup 1711 | - !Ref LexBotConversationLogs 1712 | - !Ref LexConversationLogGroupName 1713 | 1714 | DashboardConsoleLink: 1715 | Description: Link to the CloudWatch Dashboard 1716 | Value: 1717 | !Sub "https://console.aws.amazon.com/cloudwatch/home?region=\ 1718 | ${AWS::Region}#dashboards:name=${LexAnalyticsCWLDashboard}" 1719 | 1720 | BankerBotLexBotId: 1721 | Condition: ShouldDeploySampleBots 1722 | Description: Banker Bot Lex Bot ID 1723 | Value: !Ref BankerBotLexBot 1724 | 1725 | BankerBotLexBotLocaleIds: 1726 | Condition: ShouldDeploySampleBots 1727 | Description: Banker Bot Lex Bot Locale IDs 1728 | Value: !Join [",", !GetAtt BankerBotLexBot.botLocaleIds] 1729 | 1730 | BankerBotLexBotLatestVersion: 1731 | Condition: ShouldDeploySampleBots 1732 | Description: Banker Bot Latest Lex Bot Version ID 1733 | Value: !Ref BankerBotLexBotVersion 1734 | 1735 | BankerBotLexBotAliasId: 1736 | Condition: ShouldDeploySampleBots 1737 | Description: Banker Bot Lex Bot Alias ID 1738 | Value: !Ref BankerBotLexBotAlias 1739 | -------------------------------------------------------------------------------- /tests/events/bot_tester/default.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/events/cw_custom_widget_nodejs/conv-path.json: -------------------------------------------------------------------------------- 1 | { 2 | "logGroups": "lex-analytics-oatoa-conversation-logs", 3 | "botId": "IJE04MZEZ3", 4 | "botLocaleId": "en_US", 5 | "widgetType": "conversationPath", 6 | "query": "filter sessionState.intent.state = 'Fulfilled' | stats count(*) as count by sessionId as @sessionId, sessionState.intent.name as @intentName, bin(5s) as @period | sort @period asc", 7 | "widgetContext": { 8 | "dashboardName": "lex-analytics-oatoa-Conversation-Analytics", 9 | "widgetId": "widget-90", 10 | "domain": "https://console.aws.amazon.com", 11 | "accountId": "531380608753", 12 | "locale": "en", 13 | "timezone": { 14 | "label": "Local", 15 | "offsetISO": "-04:00", 16 | "offsetInMinutes": 240 17 | }, 18 | "period": 300, 19 | "isAutoPeriod": true, 20 | "timeRange": { 21 | "mode": "absolute", 22 | "start": 1628164818767, 23 | "end": 1628179218767 24 | }, 25 | "theme": "light", 26 | "linkCharts": true, 27 | "title": "Session count heatmap per 2 hour block each day of the week", 28 | "params": { 29 | "logGroups": "lex-analytics-oatoa-conversation-logs", 30 | "botId": "IJE04MZEZ3", 31 | "botLocaleId": "en_US", 32 | "widgetType": "conversationPath", 33 | "query": "filter sessionState.intent.state = 'Fulfilled' | stats count(*) as count by sessionId as @sessionId, sessionState.intent.name as @intentName, bin(5s) as @period | sort @period asc" 34 | }, 35 | "forms": { 36 | "all": {} 37 | }, 38 | "width": 791, 39 | "height": 696 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/events/cw_custom_widget_nodejs/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "botId": "IJE04MZEZ3", 3 | "botLocaleId": "en_US", 4 | "widgetType": "heatmapIntentPerHour", 5 | "logGroups": "lex-analytics-oatoa-conversation-logs", 6 | "query": "fields concat(bot.name, ':', bot.localeId, ':', sessionState.intent.name) as @intent | stats count(*) as @count by bin(1h) as @t, @intent", 7 | "widgetContext": { 8 | "dashboardName": "lex-analytics-oatoa-Conversation-Analytics", 9 | "widgetId": "widget-4", 10 | "domain": "https://console.aws.amazon.com", 11 | "accountId": "531380608753", 12 | "locale": "en", 13 | "timezone": { 14 | "label": "Local", 15 | "offsetISO": "-04:00", 16 | "offsetInMinutes": 240 17 | }, 18 | "period": 300, 19 | "isAutoPeriod": true, 20 | "timeRange": { 21 | "mode": "relative", 22 | "start": 1627298099670, 23 | "end": 1627308899670, 24 | "relativeStart": 10800005 25 | }, 26 | "theme": "light", 27 | "linkCharts": true, 28 | "title": "Input Transcript", 29 | "params": { 30 | "logGroups": "lex-analytics-oatoa-conversation-logs", 31 | "botId": "IJE04MZEZ3", 32 | "botLocaleId": "en_US", 33 | "widgetType": "heatmapIntentPerHour" 34 | }, 35 | "forms": { 36 | "all": { 37 | "logGroups": "lex-analytics-oatoa-conversation-logs", 38 | "query": "fields concat(bot.name, ':', bot.localeId, ':', sessionState.intent.name) as @intent | stats count(*) as @count by bin(1h) as @t, @intent" 39 | } 40 | }, 41 | "width": 851, 42 | "height": 396 43 | } 44 | } -------------------------------------------------------------------------------- /tests/events/cw_custom_widget_nodejs/heatmap-intent-hr.json: -------------------------------------------------------------------------------- 1 | { 2 | "logGroups": "lex-analytics-oatoa-conversation-logs", 3 | "botId": "IJE04MZEZ3", 4 | "botLocaleId": "en_US", 5 | "widgetType": "heatmapIntentPerHour", 6 | "query": "fields sessionState.intent.name as @intent | stats count(*) as @count by bin(1h) as @t, @intent", 7 | "widgetContext": { 8 | "dashboardName": "lex-analytics-oatoa-Conversation-Analytics", 9 | "widgetId": "widget-103", 10 | "domain": "https://console.aws.amazon.com", 11 | "accountId": "531380608753", 12 | "locale": "en", 13 | "timezone": { 14 | "label": "Local", 15 | "offsetISO": "-04:00", 16 | "offsetInMinutes": 240 17 | }, 18 | "period": 300, 19 | "isAutoPeriod": true, 20 | "timeRange": { 21 | "mode": "absolute", 22 | "start": 1628164818903, 23 | "end": 1628179218903 24 | }, 25 | "theme": "light", 26 | "linkCharts": true, 27 | "title": "Intent match count heatmap per hour of day", 28 | "params": { 29 | "logGroups": "lex-analytics-oatoa-conversation-logs", 30 | "botId": "IJE04MZEZ3", 31 | "botLocaleId": "en_US", 32 | "widgetType": "heatmapIntentPerHour", 33 | "query": "fields sessionState.intent.name as @intent | stats count(*) as @count by bin(1h) as @t, @intent" 34 | }, 35 | "forms": { 36 | "all": {} 37 | }, 38 | "width": 791, 39 | "height": 696 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/events/cw_custom_widget_nodejs/missed-utterance.json: -------------------------------------------------------------------------------- 1 | { 2 | "logGroups": "lex-analytics-oatoa-conversation-logs", 3 | "botId": "IJE04MZEZ3", 4 | "botLocaleId": "en_US", 5 | "widgetType": "missedUtterance", 6 | "query": "filter missedUtterance = 1 | stats count(*) as @count by inputTranscript as @missed_utterance | sort @count desc", 7 | "widgetContext": { 8 | "dashboardName": "lex-analytics-oatoa-Conversation-Analytics", 9 | "widgetId": "widget-90", 10 | "domain": "https://console.aws.amazon.com", 11 | "accountId": "531380608753", 12 | "locale": "en", 13 | "timezone": { 14 | "label": "Local", 15 | "offsetISO": "-04:00", 16 | "offsetInMinutes": 240 17 | }, 18 | "period": 300, 19 | "isAutoPeriod": true, 20 | "timeRange": { 21 | "mode": "absolute", 22 | "start": 1628164818767, 23 | "end": 1628179218767 24 | }, 25 | "theme": "light", 26 | "linkCharts": true, 27 | "title": "Session count heatmap per 2 hour block each day of the week", 28 | "params": { 29 | "logGroups": "lex-analytics-oatoa-conversation-logs", 30 | "botId": "IJE04MZEZ3", 31 | "botLocaleId": "en_US", 32 | "widgetType": "missedUtterance", 33 | "query": "filter missedUtterance = 1 | stats count(*) as @count by inputTranscript as @missed_utterance | sort @count desc" 34 | }, 35 | "forms": { 36 | "all": {} 37 | }, 38 | "width": 791, 39 | "height": 696 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/events/cw_custom_widget_python/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "logGroups": "lex-analytics-v003-conversation-logs", 3 | "botId": "NKTYGIYOGB", 4 | "botLocaleId": "en_US", 5 | "widgetType": "slotsTopN", 6 | "query": "filter bot.id = 'NKTYGIYOGB' and bot.localeId = 'en_US'", 7 | "widgetContext": { 8 | "dashboardName": "lex-analytics-oatoa-Conversation-Analytics", 9 | "widgetId": "widget-90", 10 | "domain": "https://console.aws.amazon.com", 11 | "accountId": "531380608753", 12 | "locale": "en", 13 | "timezone": { 14 | "label": "Local", 15 | "offsetISO": "-04:00", 16 | "offsetInMinutes": 240 17 | }, 18 | "period": 300, 19 | "isAutoPeriod": true, 20 | "timeRange": { 21 | "mode": "absolute", 22 | "start": 1631221052701, 23 | "end": 1631224652701 24 | }, 25 | "theme": "light", 26 | "linkCharts": true, 27 | "title": "Test", 28 | "params": { 29 | "logGroups": "lex-analytics-v003-conversation-logs", 30 | "botId": "NKTYGIYOGB", 31 | "botLocaleId": "en_US", 32 | "widgetType": "slotsTopN", 33 | "query": "filter bot.id = 'NKTYGIYOGB' and bot.localeId = 'en_US'" 34 | }, 35 | "forms": { 36 | "all": {} 37 | }, 38 | "width": 791, 39 | "height": 696 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/events/cw_metric_filter_cr/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "RequestType": "Create", 3 | "ServiceToken": "arn:aws:lambda:us-east-1:012345678912:function:lex-analytics", 4 | "ResponseURL": "https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/", 5 | "StackId": "arn:aws:cloudformation:us-east-1:012345678912:stack/lex-analytics/abcdef", 6 | "RequestId": "abcdef", 7 | "LogicalResourceId": "CountMessagesMetricFilterDimension", 8 | "ResourceType": "Custom::MetricFilterDimension", 9 | "ResourceProperties": { 10 | "ServiceToken": "arn:aws:lambda:us-east-1:012345678912:function:lex-analytics", 11 | "MetricTransformations": [ 12 | { 13 | "MetricName": "Count_Messages", 14 | "MetricNamespace": "Activity", 15 | "Dimensions": { 16 | "Locale": "$.bot.localeId", 17 | "Intent": "$.sessionState.intent.name", 18 | "BotName": "$.bot.name" 19 | } 20 | } 21 | ], 22 | "LogGroupName": "lex-analytics-oatoa-conversation-logs", 23 | "FilterName": "lex-analytics-oatoa-CountMessagesMetricFilter-YESBZZEDQ4AS" 24 | } 25 | } -------------------------------------------------------------------------------- /tests/events/cw_metric_filter_cr/delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "RequestType": "Delete", 3 | "ServiceToken": "arn:aws:lambda:us-east-1:012345678912:function:lex-analytics", 4 | "ResponseURL": "https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/", 5 | "StackId": "arn:aws:cloudformation:us-east-1:012345678912:stack/lex-analytics/abcdef", 6 | "RequestId": "abcdef", 7 | "LogicalResourceId": "CountMessagesMetricFilterDimension", 8 | "PhysicalResourceId": "lex-analytics-CountMessagesMetricFilterDimension", 9 | "ResourceType": "Custom::MetricFilterDimension", 10 | "ResourceProperties": { 11 | "ServiceToken": "arn:aws:lambda:us-east-1:531380608753:function:lex-analytics", 12 | "MetricTransformations": [ 13 | { 14 | "MetricName": "Count_Messages", 15 | "MetricNamespace": "Activity", 16 | "Dimensions": { 17 | "LocaleId": "$.bot.localeId", 18 | "Intent": "$.sessionState.intent.name", 19 | "BotName": "$.bot.name" 20 | } 21 | } 22 | ], 23 | "LogGroupName": "lex-analytics-oatoa-conversation-logs", 24 | "FilterName": "lex-analytics-oatoa-CountMessagesMetricFilter-YESBZZEDQ4AS" 25 | } 26 | } -------------------------------------------------------------------------------- /tests/events/default-env-vars.json: -------------------------------------------------------------------------------- 1 | { 2 | "CwMfdCfnCrFunction": { 3 | "LOG_LEVEL": "DEBUG" 4 | }, 5 | "BotTesterFunction": { 6 | "LOG_LEVEL": "DEBUG", 7 | "BOTS_CONFIG_JSON": "{\"BankerBot\": {\"botId\": \"MYBOTID\", \"botAliasId\": \"MYBOTALIAS\", \"localeIds\": \"en_US\"}}" 8 | } 9 | } -------------------------------------------------------------------------------- /tests/events/resource_name_cfn_cr/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "RequestType": "Create", 3 | "ServiceToken": "arn:aws:lambda:us-east-1:012345678912:function:lex-analytics", 4 | "ResponseURL": "https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/", 5 | "StackId": "arn:aws:cloudformation:us-east-1:012345678912:stack/lex-analytics/abcdef", 6 | "RequestId": "abcdef", 7 | "LogicalResourceId": "ResourceName", 8 | "ResourceType": "Custom::ResourceName", 9 | "ResourceProperties": { 10 | "ServiceToken": "arn:aws:lambda:us-east-1:012345678912:function:lex-analytics", 11 | "BotId": "6TWUHNKPMI", 12 | "BotLocaleId": "en_US", 13 | "StackName": "serverlessrepo-mystack" 14 | } 15 | } --------------------------------------------------------------------------------