diff --git a/package-lock.json b/package-lock.json index 8e0ee2d7..02ca0dfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -757,6 +757,26 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudwatch-logs": { + "version": "3.1131.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1131.0.tgz", + "integrity": "sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-node": "^3.972.83", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.983.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.983.0.tgz", @@ -978,93 +998,32 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.982.0.tgz", - "integrity": "sha512-qJrIiivmvujdGqJ0ldSUvhN3k3N7GtPesoOI1BSt0fNXovVnMz4C/JmnkhZihU7hJhDvxJaBROLYTU+lpild4w==", + "node_modules/@aws-sdk/core": { + "version": "3.978.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", + "integrity": "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.6", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.982.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.4", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.22.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.12", - "@smithy/middleware-retry": "^4.4.29", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.28", - "@smithy/util-defaults-mode-node": "^4.2.31", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.982.0.tgz", - "integrity": "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==", + "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.6.tgz", - "integrity": "sha512-pz4ZOw3BLG0NdF25HoB9ymSYyPbMiIjwQJ2aROXRhAzt+b+EOxStfFv8s5iZyP6Kiw7aYhyWxj5G3NhmkoOTKw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/xml-builder": "^3.972.4", - "@smithy/core": "^3.22.0", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" } }, "node_modules/@aws-sdk/crc64-nvme": { @@ -1167,15 +1126,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.4.tgz", - "integrity": "sha512-/8dnc7+XNMmViEom2xsNdArQxQPSgy4Z/lm6qaFPTrMFesT1bV3PsBhb19n09nmxHdrtQskYmViddUIjUQElXg==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.71.tgz", + "integrity": "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1183,20 +1142,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.6.tgz", - "integrity": "sha512-5ERWqRljiZv44AIdvIRQ3k+EAV0Sq2WeJHvXuK7gL7bovSxOf8Al7MLH7Eh3rdovH4KHFnlIty7J71mzvQBl5Q==", + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.73.tgz", + "integrity": "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/types": "^3.973.1", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/util-stream": "^4.5.10", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1204,24 +1160,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.4.tgz", - "integrity": "sha512-eRUg+3HaUKuXWn/lEMirdiA5HOKmEl8hEHVuszIDt2MMBUKgVX5XNGmb3XmbgU17h6DZ+RtjbxQpjhz3SbTjZg==", + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.16.tgz", + "integrity": "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/credential-provider-env": "^3.972.4", - "@aws-sdk/credential-provider-http": "^3.972.6", - "@aws-sdk/credential-provider-login": "^3.972.4", - "@aws-sdk/credential-provider-process": "^3.972.4", - "@aws-sdk/credential-provider-sso": "^3.972.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.4", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-login": "^3.972.78", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1229,18 +1184,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.4.tgz", - "integrity": "sha512-nLGjXuvWWDlQAp505xIONI7Gam0vw2p7Qu3P6on/W2q7rjJXtYjtpHbcsaOjJ/pAju3eTvEQuSuRedcRHVQIAQ==", + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.78.tgz", + "integrity": "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1248,22 +1201,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.5.tgz", - "integrity": "sha512-VWXKgSISQCI2GKN3zakTNHSiZ0+mux7v6YHmmbLQp/o3fvYUQJmKGcLZZzg2GFA+tGGBStplra9VFNf/WwxpYg==", + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.83.tgz", + "integrity": "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.4", - "@aws-sdk/credential-provider-http": "^3.972.6", - "@aws-sdk/credential-provider-ini": "^3.972.4", - "@aws-sdk/credential-provider-process": "^3.972.4", - "@aws-sdk/credential-provider-sso": "^3.972.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.4", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-ini": "^3.973.16", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1271,16 +1223,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.4.tgz", - "integrity": "sha512-TCZpWUnBQN1YPk6grvd5x419OfXjHvhj5Oj44GYb84dOVChpg/+2VoEj+YVA4F4E/6huQPNnX7UYbTtxJqgihw==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.71.tgz", + "integrity": "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1288,18 +1239,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.4.tgz", - "integrity": "sha512-wzsGwv9mKlwJ3vHLyembBvGE/5nPUIwRR2I51B1cBV4Cb4ql9nIIfpmHzm050XYTY5fqTOKJQnhLj7zj89VG8g==", + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.15.tgz", + "integrity": "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.982.0", - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/token-providers": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/token-providers": "3.1129.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1307,17 +1257,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.4.tgz", - "integrity": "sha512-hIzw2XzrG8jzsUSEatehmpkd5rWzASg5IHUfA+m01k/RtvfAML7ZJVVohuKdhAYx+wV2AThLiQJVzqn7F0khrw==", + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.77.tgz", + "integrity": "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1603,64 +1552,33 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.982.0.tgz", - "integrity": "sha512-VVkaH27digrJfdVrT64rjkllvOp4oRiZuuJvrylLXAKl18ujToJR7AqpDldL/LS63RVne3QWIpkygIymxFtliQ==", + "version": "3.997.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", + "integrity": "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.6", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.982.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.4", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.22.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.12", - "@smithy/middleware-retry": "^4.4.29", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.28", - "@smithy/util-defaults-mode-node": "^4.2.31", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.982.0.tgz", - "integrity": "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==", + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1702,17 +1620,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.982.0.tgz", - "integrity": "sha512-v3M0KYp2TVHYHNBT7jHD9lLTWAdS9CaWJ2jboRKt0WAB65bA7iUEpR+k4VqKYtpQN4+8kKSc4w+K6kUNZkHKQw==", + "version": "3.1129.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1129.0.tgz", + "integrity": "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1720,12 +1637,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", - "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1810,13 +1727,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", - "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.4", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -5464,10 +5380,22 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@slack/types": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.22.0.tgz", + "integrity": "sha512-sZ9lIgJhPX2qft/tKWiklFlc0o1FWeI7QtciZJfW1+ErH1eGGHvOZ8e73sleTCFEFJp1q/R0WeS8Oa7AsiDprg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -5522,20 +5450,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.22.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.22.1.tgz", - "integrity": "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==", + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz", + "integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.9", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.11", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -5543,15 +5463,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", - "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -5634,15 +5552,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", - "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -5831,15 +5747,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.9.tgz", - "integrity": "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -5872,20 +5786,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", - "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-uri-escape": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/querystring-parser": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", @@ -5925,18 +5825,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", - "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -5962,9 +5857,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6155,18 +6050,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/util-utf8": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", @@ -6552,6 +6435,13 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "node_modules/@types/jsurl": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@types/jsurl/-/jsurl-0.1.2.tgz", + "integrity": "sha512-uZOjZDzB0x48XKmMvykALaLWZ1xF85BcoI/7K6f+oqNdYtnomXVfQJC7CdJER4GTlgli5zA+QwZc82JulVw4xg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -10607,24 +10497,6 @@ } ] }, - "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -12573,6 +12445,13 @@ "node": "*" } }, + "node_modules/jsurl": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/jsurl/-/jsurl-0.1.5.tgz", + "integrity": "sha512-HyrafsIRCa+VlJAsvuvxWUM0iMeZd6+2J4JngECGF0JTRi5moUFV/mLxgyYfDizGF4ofLLYRpvD/Kt1d9wlUUg==", + "dev": true, + "license": "MIT" + }, "node_modules/just-diff": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", @@ -17160,18 +17039,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -18556,19 +18423,32 @@ }, "packages/cdk-utilities": { "name": "@shiftcode/cdk-utilities", - "version": "1.0.0", + "version": "1.1.0-pr87.0", "license": "MIT", "dependencies": { "just-pascal-case": "^3.2.0" }, "devDependencies": { + "@aws-sdk/client-cloudwatch-logs": "^3.204.0", + "@shiftcode/branch-utilities": "^6.0.0", + "@shiftcode/iac-utilities": "^1.0.1", + "@shiftcode/lambda-utilities": "^1.0.0", + "@shiftcode/logger": "^5.0.0", + "@shiftcode/utilities": "^5.0.0", + "@slack/types": "^2.11.0", + "@types/aws-lambda": "^8.10.89", + "@types/jsurl": "^0.1.2", "aws-cdk-lib": "^2.3.0", - "constructs": "^10.0.0" + "constructs": "^10.0.0", + "esbuild": "^0.27.2", + "jsurl": "^0.1.5" }, "engines": { "node": ">=24.10.2" }, "peerDependencies": { + "@shiftcode/branch-utilities": "^6.0.0 || ^6.0.0-pr254", + "@shiftcode/iac-utilities": "^1.0.1 || ^1.0.1-pr254", "aws-cdk-lib": "^2.3.0", "constructs": "^10.0.0", "tslib": "^2.3.0" diff --git a/packages/cdk-utilities/eslint.config.js b/packages/cdk-utilities/eslint.config.js index 47256408..d0a037d8 100644 --- a/packages/cdk-utilities/eslint.config.js +++ b/packages/cdk-utilities/eslint.config.js @@ -10,4 +10,10 @@ export default defineScTsConfig( 'import/no-internal-modules': ['error', { allow: ['aws-cdk-lib/*'] }], }, }, + { + files: ['src/scripts/**/*.ts'], + rules: { + 'no-console': 'off', + }, + }, ) diff --git a/packages/cdk-utilities/package.json b/packages/cdk-utilities/package.json index 88dcd6dd..15fc2bc4 100644 --- a/packages/cdk-utilities/package.json +++ b/packages/cdk-utilities/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/cdk-utilities", - "version": "1.0.0", + "version": "1.1.0-pr87.0", "description": "Constructs for iac with aws-cdk-lib", "repository": "https://github.com/shiftcode/sc-commons-public", "license": "MIT", @@ -14,7 +14,7 @@ } }, "scripts": { - "build": "rm -rf ./dist && tsc", + "build": "rm -rf ./dist && tsc && node dist/scripts/esbuild.script.js", "format": "prettier --write .", "lint": "eslint --fix --cache .", "lint:ci": "eslint .", @@ -27,10 +27,23 @@ "just-pascal-case": "^3.2.0" }, "devDependencies": { + "@aws-sdk/client-cloudwatch-logs": "^3.204.0", + "@shiftcode/branch-utilities": "^6.0.0", + "@shiftcode/iac-utilities": "^1.0.1", + "@shiftcode/lambda-utilities": "^1.0.0", + "@shiftcode/logger": "^5.0.0", + "@shiftcode/utilities": "^5.0.0", + "@slack/types": "^2.11.0", + "@types/aws-lambda": "^8.10.89", + "@types/jsurl": "^0.1.2", "aws-cdk-lib": "^2.3.0", - "constructs": "^10.0.0" + "constructs": "^10.0.0", + "esbuild": "^0.27.2", + "jsurl": "^0.1.5" }, "peerDependencies": { + "@shiftcode/branch-utilities": "^6.0.0 || ^6.0.0-pr254", + "@shiftcode/iac-utilities": "^1.0.1 || ^1.0.1-pr254", "aws-cdk-lib": "^2.3.0", "constructs": "^10.0.0", "tslib": "^2.3.0" diff --git a/packages/cdk-utilities/src/index.ts b/packages/cdk-utilities/src/index.ts index da8a60f1..874e5ec0 100644 --- a/packages/cdk-utilities/src/index.ts +++ b/packages/cdk-utilities/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/alarming.construct.js' export * from './lib/basic-auth-cf-function.construct.js' export * from './lib/cloud-watch-api.construct.js' export * from './lib/cloud-watch-log-transport.construct.js' diff --git a/packages/cdk-utilities/src/lib/alarming.construct.ts b/packages/cdk-utilities/src/lib/alarming.construct.ts new file mode 100644 index 00000000..3b612bef --- /dev/null +++ b/packages/cdk-utilities/src/lib/alarming.construct.ts @@ -0,0 +1,300 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { BundleInfo, handlerToBundleInfo } from '@shiftcode/iac-utilities' +import { Duration, Stack } from 'aws-cdk-lib' +import { Alarm, ComparisonOperator, IMetric, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch' +import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions' +import { Secret } from 'aws-cdk-lib/aws-ecs' +import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam' +import { Code, Function as LambdaFunction, IFunction, Runtime } from 'aws-cdk-lib/aws-lambda' +import { ILogGroup } from 'aws-cdk-lib/aws-logs' +import { LambdaDestination } from 'aws-cdk-lib/aws-logs-destinations' +import { Topic } from 'aws-cdk-lib/aws-sns' +import { EmailSubscription, LambdaSubscription } from 'aws-cdk-lib/aws-sns-subscriptions' +import { StringParameter } from 'aws-cdk-lib/aws-ssm' +import { Construct } from 'constructs' + +import { LambdaFunctionName } from './lambda-function-name.enum.js' + +// the compiled location of this file, i.e. `dist/lib/` - also the root of the compiled lambda +// sources referenced by `LambdaFunctionName` (`dist/lib/functions/*.js`) +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const esbuildOutDir = join(__dirname, '../esbuild') + +/** + * Metadata embedded into an alarm's description. Consumed by the bundled + * publish-alarm-to-slack lambda to correlate an alarm with its log group. + */ +export interface AlarmMetadata { + readonly region: string + readonly description: string + readonly logGroupName?: string +} + +export interface AddMetricAlarmProps { + /** construct id, also used to derive the alarm name if `alarmName` is not given */ + id: string + metric: IMetric + /** @default `${stackName}-${id}` */ + alarmName?: string + /** @default `Alarm for ${alarmName}` */ + description?: string + /** @default 1 */ + threshold?: number + /** @default ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD */ + comparisonOperator?: ComparisonOperator + /** @default 1 */ + evaluationPeriods?: number + /** @default TreatMissingData.NOT_BREACHING */ + treatMissingData?: TreatMissingData + /** set if the alarmed resource has an associated log group, for correlation in notifications */ + logGroupName?: string +} + +export interface AddLogErrorSubscriptionProps { + id: string + logGroup: ILogGroup + /** CloudWatch Logs JSON filter pattern string, defaults to matching structured `level: "ERROR"` logs */ + filterPattern?: string + /** optional explicit CloudWatch Logs subscription filter name */ + filterName?: string + addPermissions?: boolean +} + +export interface SetupLambdaAlarmProps { + /** @default true */ + addErrorLogSubscription?: boolean + /** @default true */ + addThrottlesAlarm?: boolean +} + +export interface AlarmingConstructProps { + /** SSM (SecureString) parameter name holding the Slack incoming-webhook URL */ + slackWebhookEndpointSsmParamKey: string + /** notified by email if the bundled notification lambdas themselves start failing */ + fallbackAlarmEmail: string + /** @default `${stackName}-AlarmTopic` */ + alarmTopicName?: string +} + +/** + * Generic, drop-in CloudWatch alarming construct. Bundles its own Slack-notification lambdas + * (code ships as part of this library), so consumers only need to pass the props below - no + * lambda code/bundling of their own required. + * + * Provides: + * - a shared SNS topic that all alarms publish to + * - `PublishAlarmToSlack` lambda, subscribed to that topic, posting alarm state changes to Slack + * - `PublishErrorLogsToSlack` lambda, used as the destination for `addLogErrorSubscription`, + * posting structured ERROR log lines to Slack + * - `addMetricAlarm` / `addLogErrorSubscription` / `setupAlarmForLambda` to wire up alarms for + * any CloudWatch metric or log group (Lambda, Fargate, Neptune, RDS, SQS, ALB, ...) + * - a fallback alarm + email per notification lambda, guarding against the alerting pipeline + * itself failing silently + */ +export class AlarmingConstruct extends Construct { + readonly alarmTopic: Topic + readonly publishAlarmToSlackFunction: LambdaFunction + readonly publishErrorLogsToSlackFunction: LambdaFunction + + private readonly fallbackAlarmEmail: string + + constructor(scope: Construct, id: string, props: AlarmingConstructProps) { + super(scope, id) + + this.fallbackAlarmEmail = props.fallbackAlarmEmail + + this.alarmTopic = new Topic(this, 'AlarmTopic', { + displayName: 'Alarm Topic', + topicName: props.alarmTopicName ?? `${Stack.of(this).stackName}-AlarmTopic`, + }) + + const slackWebhookApiSecret = Secret.fromSsmParameter( + StringParameter.fromSecureStringParameterAttributes(this, 'SlackWebhookEndpoint', { + parameterName: props.slackWebhookEndpointSsmParamKey, + }), + ) + + this.publishAlarmToSlackFunction = this.createPublishAlarmToSlackLambda(slackWebhookApiSecret) + this.publishErrorLogsToSlackFunction = this.createPublishErrorLogsToSlackLambda(slackWebhookApiSecret) + } + + /** + * Creates a CloudWatch Alarm on any metric and routes it to the shared alarm topic (and + * therefore to `publishAlarmToSlackFunction`). Use this for Lambda errors, Fargate CPU/memory, + * Neptune connections, SQS queue depth, ALB 5xx, etc. + */ + addMetricAlarm(scope: Construct, props: AddMetricAlarmProps): Alarm { + const alarmName = props.alarmName ?? `${Stack.of(scope).stackName}-${props.id}` + + const metadata: AlarmMetadata = { + region: Stack.of(scope).region, + description: props.description ?? `Alarm for ${alarmName}`, + logGroupName: props.logGroupName, + } + + const alarm = new Alarm(scope, props.id, { + alarmName, + alarmDescription: JSON.stringify(metadata), + metric: props.metric, + threshold: props.threshold ?? 1, + comparisonOperator: props.comparisonOperator ?? ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + evaluationPeriods: props.evaluationPeriods ?? 1, + treatMissingData: props.treatMissingData ?? TreatMissingData.NOT_BREACHING, + }) + + // import the topic by ARN (rather than referencing `this.alarmTopic` directly) to avoid + // CloudFormation cross-stack export/import coupling when `scope` lives in a different stack + // than this construct. see https://github.com/aws/aws-cdk/issues/28936 + const topic = Topic.fromTopicAttributes(scope, `${props.id}Topic`, { + topicArn: this.alarmTopic.topicArn, + }) + alarm.addAlarmAction(new SnsAction(topic)) + + return alarm + } + + /** + * Subscribes `publishErrorLogsToSlackFunction` to a log group's error events, filtering for + * structured JSON log lines (defaults to `{ $.level = "ERROR" }`). Works for any service + * writing to a CloudWatch log group (Lambda, ECS/Fargate via the awslogs driver, etc.). + */ + addLogErrorSubscription(scope: Construct, props: AddLogErrorSubscriptionProps): void { + // using function attributes rather than the direct reference to avoid cross-stack CFN + // export/import churn, see https://github.com/aws/aws-cdk/issues/28936 + const destination = LambdaFunction.fromFunctionAttributes(scope, `${props.id}Destination`, { + functionArn: this.publishErrorLogsToSlackFunction.functionArn, + sameEnvironment: true, + }) + + props.logGroup.addSubscriptionFilter(props.id, { + destination: new LambdaDestination(destination, { + addPermissions: props.addPermissions ?? true, + }), + filterPattern: { + logPatternString: props.filterPattern ?? '{ $.level = "ERROR" }', + }, + filterName: props.filterName ?? `Error log subscription filter for ${props.logGroup.logGroupName}`, + }) + } + + /** + * Convenience wrapper combining common lambda alarms. + * Error alarm is always enabled, while log subscription and throttles can be disabled via `props`. + */ + setupAlarmForLambda(scope: Construct, lambda: LambdaFunction, props: SetupLambdaAlarmProps = {}): void { + // using node ID because this cannot contain references + const baseName = lambda.node.id + + if (props.addErrorLogSubscription ?? true) { + this.addLogErrorSubscription(scope, { + id: `${baseName}ErrorLogsSubscriptionFilter`, + logGroup: lambda.logGroup, + }) + } + + this.addMetricAlarm(scope, { + id: `${baseName}ErrorsAlarm`, + metric: lambda.metricErrors(), + logGroupName: lambda.logGroup.logGroupName, + description: `Error alarm for log group ${lambda.logGroup.logGroupName}`, + }) + + if (props.addThrottlesAlarm ?? true) { + this.addMetricAlarm(scope, { + id: `${baseName}ThrottlesAlarm`, + metric: lambda.metricThrottles(), + logGroupName: lambda.logGroup.logGroupName, + description: `Throttle alarm for log group ${lambda.logGroup.logGroupName}`, + }) + } + } + + private createPublishAlarmToSlackLambda(slackWebhookApiSecret: Secret): LambdaFunction { + const functionName = `${Stack.of(this).stackName}-publish-alarm-to-slack-fn` + const lambdaFunction = this.createNotificationLambda('PublishAlarmToSlackLambda', functionName, { + bundleInfo: handlerToBundleInfo(LambdaFunctionName.PUBLISH_ALARM_TO_SLACK, __dirname, esbuildOutDir), + slackWebhookApiSecret, + // enriches alarm notifications with recent log events of the correlated log group + needsLogsReadAccess: true, + }) + + this.alarmTopic.addSubscription(new LambdaSubscription(lambdaFunction)) + this.setupFallbackAlarm(lambdaFunction, functionName) + + return lambdaFunction + } + + private createPublishErrorLogsToSlackLambda(slackWebhookApiSecret: Secret): LambdaFunction { + const functionName = `${Stack.of(this).stackName}-publish-error-logs-to-slack-fn` + const lambdaFunction = this.createNotificationLambda('PublishErrorLogsToSlackLambda', functionName, { + bundleInfo: handlerToBundleInfo(LambdaFunctionName.PUBLISH_ERROR_LOGS_TO_SLACK, __dirname, esbuildOutDir), + slackWebhookApiSecret, + }) + + this.setupFallbackAlarm(lambdaFunction, functionName) + + return lambdaFunction + } + + private createNotificationLambda( + constructId: string, + functionName: string, + opts: { bundleInfo: BundleInfo; slackWebhookApiSecret: Secret; needsLogsReadAccess?: boolean }, + ): LambdaFunction { + const lambdaFunction = new LambdaFunction(this, constructId, { + code: Code.fromAsset(opts.bundleInfo.outPath), + handler: `${opts.bundleInfo.baseFilename}.${opts.bundleInfo.handlerName}`, + functionName, + runtime: Runtime.NODEJS_24_X, + environment: { + SLACK_WEBHOOK_ENDPOINT_SSM_PARAM_ARN: opts.slackWebhookApiSecret.arn, + }, + timeout: Duration.seconds(30), + }) + + // grant read access to the slack webhook secret + opts.slackWebhookApiSecret.grantRead(lambdaFunction) + + if (opts.needsLogsReadAccess) { + // grant access to fetch log events for enriching alarm notifications + lambdaFunction.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['logs:FilterLogEvents'], + resources: ['*'], // TODO be more restrictive here + }), + ) + } + + return lambdaFunction + } + + /** + * If a notification lambda (alarm forwarder or log forwarder) fails itself, the normal alarm + * pipeline can't be trusted to report it. This sets up an independent alarm on that lambda's + * own error metric with a direct email subscription, bypassing the shared topic entirely. + */ + private setupFallbackAlarm(lambdaFunction: IFunction, baseName: string): void { + const topicName = `${baseName}FallbackAlarmTopic` + const topic = new Topic(this, topicName, { + displayName: `Fallback Alarming Topic for ${baseName}`, + topicName, + }) + + const alarmName = `${baseName}FallbackAlarm` + const alarm = new Alarm(this, alarmName, { + alarmName, + alarmDescription: `Fallback Alarm for ${baseName}`, + metric: lambdaFunction.metricErrors(), + threshold: 1, + comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + evaluationPeriods: 1, + treatMissingData: TreatMissingData.NOT_BREACHING, + }) + + alarm.addAlarmAction(new SnsAction(topic)) + topic.addSubscription(new EmailSubscription(this.fallbackAlarmEmail)) + } +} diff --git a/packages/cdk-utilities/src/lib/functions/publish-alarm-to-slack-fn.spec.ts b/packages/cdk-utilities/src/lib/functions/publish-alarm-to-slack-fn.spec.ts new file mode 100644 index 00000000..51468492 --- /dev/null +++ b/packages/cdk-utilities/src/lib/functions/publish-alarm-to-slack-fn.spec.ts @@ -0,0 +1,138 @@ +import type { Context, SNSEvent } from 'aws-lambda' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +const ssmMock = vi.hoisted(() => vi.fn()) +vi.mock('@shiftcode/lambda-utilities/ssm', () => ({ fetchSsmStringParamValue: ssmMock })) + +const cloudWatchLogsSendMock = vi.hoisted(() => vi.fn()) +vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({ + CloudWatchLogs: class { + filterLogEvents = cloudWatchLogsSendMock + }, +})) + +const fetchMock = vi.hoisted(() => vi.fn()) +vi.stubGlobal('fetch', fetchMock) + +const { handler } = await import('./publish-alarm-to-slack-fn.js') + +interface SlackAttachment { + fallback: string + color: string + pretext: string + title?: string + text?: string + // eslint-disable-next-line @typescript-eslint/naming-convention -- Slack's attachment API uses snake_case + author_name?: string + actions?: Array<{ type: string; text: string; url: string }> +} + +type FetchCall = [string, { method: string; headers: Record; body: string }] + +function snsEvent(message: Record): SNSEvent { + return { Records: [{ Sns: { Message: JSON.stringify(message) } } as never] } +} + +function attachmentFromFetchCall(index: number): SlackAttachment { + const [, options] = fetchMock.mock.calls[index] as FetchCall + const body = JSON.parse(options.body) as { attachments: SlackAttachment[] } + return body.attachments[0] +} + +describe('publish-alarm-to-slack-fn handler', () => { + beforeEach(() => { + vi.clearAllMocks() + ssmMock.mockResolvedValue('https://hooks.slack.com/services/test') + cloudWatchLogsSendMock.mockResolvedValue({ events: [] }) + fetchMock.mockResolvedValue({ ok: true, status: 200 }) + process.env.SLACK_WEBHOOK_ENDPOINT_SSM_PARAM_ARN = 'arn:aws:ssm:eu-central-1:123456789012:parameter/slack' + }) + + test('ignores non-ALARM state changes', async () => { + await handler(snsEvent({ NewStateValue: 'OK', AlarmName: 'MyAlarm' }), {} as Context, () => undefined) + + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('posts a slack message for ALARM state changes', async () => { + await handler( + snsEvent({ + NewStateValue: 'ALARM', + NewStateReason: 'Threshold breached', + StateChangeTime: '2024-01-01T00:00:00.000Z', + AlarmName: 'MyAlarm', + AlarmDescription: JSON.stringify({ region: 'eu-central-1', description: 'Alarm for MyAlarm' }), + Trigger: { Period: 60, EvaluationPeriods: 1 }, + }), + {} as Context, + () => undefined, + ) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/test', + expect.objectContaining({ method: 'POST' }), + ) + expect(attachmentFromFetchCall(0)).toMatchObject({ + color: 'danger', + pretext: '[2024-01-01T00:00:00.000Z] *MyAlarm* - Threshold breached', + }) + }) + + test('enriches the slack message with recent log events when a log group is correlated', async () => { + cloudWatchLogsSendMock.mockResolvedValue({ + events: [ + { message: 'boom', logStreamName: 'stream', timestamp: 1_700_000_000_000, eventId: 'evt-1' }, + { + message: 'Task timed out after 3.00 seconds', + logStreamName: 'stream', + timestamp: 1_700_000_000_001, + eventId: 'evt-2', + }, + ], + }) + + await handler( + snsEvent({ + NewStateValue: 'ALARM', + NewStateReason: 'Threshold breached', + StateChangeTime: '2024-01-01T00:00:00.000Z', + AlarmName: 'MyAlarm', + AlarmDescription: JSON.stringify({ + region: 'eu-central-1', + description: 'Alarm for MyAlarm', + logGroupName: '/aws/lambda/my-fn', + }), + Trigger: { Period: 60, EvaluationPeriods: 1 }, + }), + {} as Context, + () => undefined, + ) + + expect(cloudWatchLogsSendMock).toHaveBeenCalled() + const attachment = attachmentFromFetchCall(0) + expect(attachment.text).toContain('Task timed out after 3.00 seconds') + expect(attachment.author_name).toBe(':alarm_clock: Timeout error') + expect(attachment.actions?.[0].url).toContain( + 'group=/aws/lambda/my-fn;stream=stream;reftime=1700000000001;refid=evt-2', + ) + }) + + test('throws when the slack webhook call fails', async () => { + fetchMock.mockRejectedValue(new Error('network error')) + + await expect( + handler( + snsEvent({ + NewStateValue: 'ALARM', + NewStateReason: 'Threshold breached', + StateChangeTime: '2024-01-01T00:00:00.000Z', + AlarmName: 'MyAlarm', + AlarmDescription: JSON.stringify({ region: 'eu-central-1', description: 'Alarm for MyAlarm' }), + Trigger: { Period: 60, EvaluationPeriods: 1 }, + }), + {} as Context, + () => undefined, + ), + ).rejects.toThrow('network error') + }) +}) diff --git a/packages/cdk-utilities/src/lib/functions/publish-alarm-to-slack-fn.ts b/packages/cdk-utilities/src/lib/functions/publish-alarm-to-slack-fn.ts new file mode 100644 index 00000000..1db11a91 --- /dev/null +++ b/packages/cdk-utilities/src/lib/functions/publish-alarm-to-slack-fn.ts @@ -0,0 +1,224 @@ +import { CloudWatchLogs, FilterLogEventsRequest, FilterLogEventsResponse } from '@aws-sdk/client-cloudwatch-logs' +import { fetchSsmStringParamValue } from '@shiftcode/lambda-utilities/ssm' +import { simpleLambdaLogger as simpleLogger } from '@shiftcode/logger/node' +import { Context, SNSEvent, SNSHandler, SNSMessage } from 'aws-lambda' + +import type { AlarmMetadata } from '../alarming.construct.js' +import { CustomCloudWatchAlarmMessage } from '../models/custom-cloudwatch-alarm-message.model.js' +import { SlackNotification } from '../models/slack-notification.model.js' +import { + convertMessageToLogEventErrorType, + createLogStreamUrl, + isLogEventMessageError, + logEventErrorConfiguration, + logEventExcludePattern, +} from '../utils/cloudwatch.utils.js' + +const logger = simpleLogger('PublishAlarmToSlackFn') + +let slackWebhookEndpointSecret: string | undefined = undefined + +async function getSlackWebhookEndpoint(): Promise { + if (!slackWebhookEndpointSecret) { + // fetch the slack webhook endpoint from the SSM parameter store if it's not already cached in memory + slackWebhookEndpointSecret = await fetchSsmStringParamValue( + process.env.SLACK_WEBHOOK_ENDPOINT_SSM_PARAM_ARN || 'arn-not-provided', + true, + ) + } + return slackWebhookEndpointSecret +} + +export const handler: SNSHandler = async (event: SNSEvent, _: Context) => { + logger.info('payload', event) + + const snsMessage = event.Records[0].Sns + const message = parseSNSAlarmDescriptionToCustomContent(snsMessage) + if (!message) { + logger.error('message does not satisfy the required format') + return + } + + if (message.NewStateValue !== 'ALARM') { + logger.info('no action required for non-ALARM state') + return + } + + const slackWebhookEndpoint = await getSlackWebhookEndpoint() + if (!slackWebhookEndpoint) { + logger.error('slack webhook endpoint not found in SSM') + return + } + + const { + NewStateValue: newStateValue, + StateChangeTime: stateChangeTime, + AlarmName: alarmName, + NewStateReason: newStateReason, + } = message + const attachment: SlackNotification = { + fallback: `[${stateChangeTime}] ${alarmName} - ${newStateReason}`, + color: newStateValue === 'ALARM' ? 'danger' : 'good', + pretext: `[${stateChangeTime}] *${alarmName}* - ${newStateReason}`, + } + + const errorLogs = await fetchCloudWatchErrors(message) + if (errorLogs) { + attachment.author_name = errorLogs.errorType + attachment.title = ':bookmark_tabs: CloudWatch Log events:' + attachment.mrkdwn_in = ['text'] + attachment.text = errorLogs.text.replace('&', '&').replace('<', '<').replace('>', '>') + attachment.actions = [ + { + type: 'button', + text: ':mag_right: See full CloudWatch Log Stream', + url: errorLogs.detailUrl, + }, + ] + } + + const messageBody = { + attachments: [attachment], + } + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify(messageBody), + headers: { + 'Content-Type': 'application/json', + }, + } + + try { + await fetch(slackWebhookEndpoint, options) + logger.info('successfully posted to slack') + } catch (err) { + logger.error('there was an error with the call to slack webhook', err) + throw err + } +} + +async function fetchCloudWatchErrors(message: CustomCloudWatchAlarmMessage) { + if (!message.AlarmDescription.logGroupName) { + return null + } + + try { + const timestamp = Date.parse(message.StateChangeTime) + + // period multiplied with evaluation periods in ms (minus 60 seconds buffer because the alarm is not triggered realtime) + const offset = (message.Trigger.Period * message.Trigger.EvaluationPeriods + 60) * 1000 + const logGroupName = message.AlarmDescription.logGroupName + const region = message.AlarmDescription.region + const parameters: FilterLogEventsRequest = { + logGroupName, + startTime: timestamp - offset, + endTime: timestamp, + } + logger.debug('the filter log event params:', parameters) + + const filteredLogEvents = await fetchLogEvents(parameters) + if (!filteredLogEvents.events?.length) { + logger.info('no log events found') + return null + } + + logger.debug(`fetched a total of ${filteredLogEvents.events.length} log events.`) + + const errorLogs = filteredLogEvents.events.filter((ev) => isLogEventMessageError(ev.message)) + if (!errorLogs.length) { + logger.info('no error logs found') + return null + } + + const firstWithLogStreamDetails = errorLogs.find( + ( + fle, + ): fle is { + logStreamName: string + timestamp: number + eventId: string + message?: string + } => !!fle.logStreamName && !!fle.timestamp && !!fle.eventId, + ) + if (!firstWithLogStreamDetails) { + logger.error('missing logStreamName, timestamp or eventId in log event', errorLogs) + return null + } + + const detailUrl = createLogStreamUrl( + logGroupName, + region, + firstWithLogStreamDetails.logStreamName, + firstWithLogStreamDetails.timestamp, + firstWithLogStreamDetails.eventId, + ) + const eventMessageErrorType = convertMessageToLogEventErrorType(firstWithLogStreamDetails.message) + const errorType = eventMessageErrorType + ? logEventErrorConfiguration[eventMessageErrorType].slackErrorTypeMessage + : '' + const text = errorLogs + .map((fle) => { + const eventTime = `${new Date().setTime(fle.timestamp || 0)}` + const eventMessage = fle.message + ? `${fle.message.replace(logEventExcludePattern, '').trim()}` + : `${fle.message}` + const eventMessageErrorTypeInternal = convertMessageToLogEventErrorType(eventMessage) + + if (!eventMessageErrorTypeInternal) { + return `\n*${eventTime}*\n${eventMessage}\n` + } + return `\n*\`${eventTime}\`*\n\`\`\`${eventMessage}\`\`\`\n` + }) + .join('') + + return { detailUrl, errorType, text } as const + } catch (err) { + logger.error('cloudWatchLogs fetch error:', err) + } + + return null +} + +async function fetchLogEvents(parameters: FilterLogEventsRequest): Promise { + const cloudWatchLogs = new CloudWatchLogs() + logger.debug(`fetching log events`) + const filteredLogEvents = await cloudWatchLogs.filterLogEvents(parameters) + + let nextToken = filteredLogEvents.nextToken + while (nextToken) { + parameters.nextToken = nextToken + logger.debug('the nextToken filter log event params:', parameters) + const nextFilteredLogEvents = await cloudWatchLogs.filterLogEvents(parameters) + if (filteredLogEvents.events && nextFilteredLogEvents.events) { + logger.debug( + `nextToken available, fetch more log events. already fetched ${filteredLogEvents.events.length} + ${nextFilteredLogEvents.events.length}`, + ) + filteredLogEvents.events = [...filteredLogEvents.events, ...nextFilteredLogEvents.events] + } + nextToken = nextFilteredLogEvents.nextToken + } + + return filteredLogEvents +} + +const parseSNSAlarmDescriptionToCustomContent = (snsMessage: SNSMessage): CustomCloudWatchAlarmMessage | null => { + try { + const alarmDescriptionKey = 'AlarmDescription' satisfies keyof CustomCloudWatchAlarmMessage + const reviver = (key: string, value: unknown): unknown => { + if (key !== alarmDescriptionKey) { + return value + } + + try { + return JSON.parse(value as string) as AlarmMetadata + } catch { + throw new Error(`error parsing ${alarmDescriptionKey} to AlarmMetadata`) + } + } + return JSON.parse(snsMessage.Message, reviver) as CustomCloudWatchAlarmMessage + } catch (e) { + logger.error('error parsing message JSON', e) + return null + } +} diff --git a/packages/cdk-utilities/src/lib/functions/publish-error-logs-to-slack-fn.spec.ts b/packages/cdk-utilities/src/lib/functions/publish-error-logs-to-slack-fn.spec.ts new file mode 100644 index 00000000..29094ab3 --- /dev/null +++ b/packages/cdk-utilities/src/lib/functions/publish-error-logs-to-slack-fn.spec.ts @@ -0,0 +1,172 @@ +import { gzipSync } from 'node:zlib' + +import type { CloudWatchLogsEvent, Context } from 'aws-lambda' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +const ssmMock = vi.hoisted(() => vi.fn()) +vi.mock('@shiftcode/lambda-utilities/ssm', () => ({ fetchSsmStringParamValue: ssmMock })) + +const fetchMock = vi.hoisted(() => vi.fn()) +vi.stubGlobal('fetch', fetchMock) + +const { handler } = await import('./publish-error-logs-to-slack-fn.js') + +interface SlackBlock { + type: string + text?: { type: string; text: string } + elements?: Array<{ type: string; text?: string | { type: string; text: string }; url?: string }> +} + +interface SlackAttachment { + fallback: string + // eslint-disable-next-line @typescript-eslint/naming-convention -- Slack's attachment API uses snake_case + author_name: string + footer: string +} + +type FetchCall = [string, { method: string; headers: Record; body: string }] + +function cloudWatchLogsEvent( + logGroup: string, + logStream: string, + logEvents: Array<{ message: string }>, +): CloudWatchLogsEvent { + const decoded = { + logGroup, + logStream, + logEvents: logEvents.map((e, i) => ({ id: `${i}`, timestamp: 1_700_000_000_000 + i, message: e.message })), + } + const data = gzipSync(Buffer.from(JSON.stringify(decoded))).toString('base64') + return { awslogs: { data } } +} + +function blocksFromFetchCall(index: number): SlackBlock[] { + const [, options] = fetchMock.mock.calls[index] as FetchCall + const body = JSON.parse(options.body) as { blocks: SlackBlock[] } + return body.blocks +} + +function attachmentsFromFetchCall(index: number): SlackAttachment[] { + const [, options] = fetchMock.mock.calls[index] as FetchCall + const body = JSON.parse(options.body) as { attachments: SlackAttachment[] } + return body.attachments +} + +describe('publish-error-logs-to-slack-fn handler', () => { + beforeEach(() => { + vi.clearAllMocks() + ssmMock.mockResolvedValue('https://hooks.slack.com/services/test') + fetchMock.mockResolvedValue({ ok: true, status: 200, text: () => Promise.resolve('ok') }) + process.env.SLACK_WEBHOOK_ENDPOINT_SSM_PARAM_ARN = 'arn:aws:ssm:eu-central-1:123456789012:parameter/slack' + process.env.AWS_REGION = 'eu-central-1' + }) + + test('posts one slack message per log event', async () => { + await handler( + cloudWatchLogsEvent('/aws/lambda/my-fn', '2024/01/01/[$LATEST]abc', [ + { message: JSON.stringify({ level: 'ERROR', logger: 'MyService', message: 'first error' }) }, + { message: JSON.stringify({ level: 'ERROR', logger: 'MyService', message: 'second error' }) }, + ]), + {} as Context, + () => undefined, + ) + + expect(fetchMock).toHaveBeenCalledTimes(2) + const [firstUrl] = fetchMock.mock.calls[0] as FetchCall + expect(firstUrl).toBe('https://hooks.slack.com/services/test') + + const blocks = blocksFromFetchCall(0) + const messageSection = blocks.find((b) => b.type === 'section') + expect(messageSection?.text?.text).toContain('first error') + const actionsBlock = blocks.find((b) => b.type === 'actions') + expect(actionsBlock?.elements?.[1].url).toContain('group=/aws/lambda/my-fn;stream=2024/01/01/[$LATEST]abc') + }) + + test('escapes slack markup characters in the message', async () => { + await handler( + cloudWatchLogsEvent('/aws/lambda/my-fn', 'stream', [ + { message: JSON.stringify({ level: 'ERROR', message: ' & "quote"' }) }, + ]), + {} as Context, + () => undefined, + ) + + const blocks = blocksFromFetchCall(0) + const messageSection = blocks.find((b) => b.type === 'section') + expect(messageSection?.text?.text).toContain('<tag> & "quote"') + }) + + test('extracts logger name from a structured @shiftcode/logger JSON log line', async () => { + await handler( + cloudWatchLogsEvent('/aws/lambda/my-fn', 'stream', [ + { message: JSON.stringify({ level: 'ERROR', logger: 'MyService', message: 'something failed' }) }, + ]), + {} as Context, + () => undefined, + ) + + const blocks = blocksFromFetchCall(0) + const contextBlocks = blocks.filter((b) => b.type === 'context') + const loggerElement = contextBlocks.flatMap((b) => b.elements ?? []).find((e) => e.text === 'MyService') + expect(loggerElement).toBeDefined() + expect(attachmentsFromFetchCall(0)).toEqual([]) + }) + + test('renders the error stack as a separate attachment with tabs replaced', async () => { + await handler( + cloudWatchLogsEvent('/aws/lambda/my-fn', 'stream', [ + { + message: JSON.stringify({ + level: 'ERROR', + logger: 'MyService', + message: 'something failed', + error: { name: 'Error', message: 'boom', stack: 'Error: boom\n\tat foo\n\tat bar' }, + }), + }, + ]), + {} as Context, + () => undefined, + ) + + const [attachment] = attachmentsFromFetchCall(0) + expect(attachment).toMatchObject({ author_name: 'Exception' }) + expect(attachment.footer).toBe('Error: boom\n at foo\n at bar') + }) + + test('falls back to the error message when no stack is present', async () => { + await handler( + cloudWatchLogsEvent('/aws/lambda/my-fn', 'stream', [ + { + message: JSON.stringify({ + level: 'ERROR', + logger: 'MyService', + error: { name: 'Error', message: 'boom' }, + }), + }, + ]), + {} as Context, + () => undefined, + ) + + const [attachment] = attachmentsFromFetchCall(0) + expect(attachment.footer).toBe('boom') + }) + + test('unwraps the default Lambda log format (timestamp\\trequestId\\tlevel\\t{json}) before parsing', async () => { + const structuredMessage = JSON.stringify({ level: 'ERROR', logger: 'MyService', message: 'something failed' }) + const plaintextLambdaLogLine = `2024-01-01T00:00:00.000Z\treq-id-123\tERROR\t${structuredMessage}` + + await handler( + cloudWatchLogsEvent('/aws/lambda/my-fn', 'stream', [{ message: plaintextLambdaLogLine }]), + {} as Context, + () => undefined, + ) + + const blocks = blocksFromFetchCall(0) + const messageSection = blocks.find((b) => b.type === 'section') + expect(messageSection?.text?.text).toContain('something failed') + const contextBlocks = blocks.filter((b) => b.type === 'context') + const loggerElement = contextBlocks.flatMap((b) => b.elements ?? []).find((e) => e.text === 'MyService') + expect(loggerElement).toBeDefined() + }) +}) diff --git a/packages/cdk-utilities/src/lib/functions/publish-error-logs-to-slack-fn.ts b/packages/cdk-utilities/src/lib/functions/publish-error-logs-to-slack-fn.ts new file mode 100644 index 00000000..bb1ef8bb --- /dev/null +++ b/packages/cdk-utilities/src/lib/functions/publish-error-logs-to-slack-fn.ts @@ -0,0 +1,233 @@ +import { randomUUID } from 'node:crypto' +import { gunzip } from 'node:zlib' + +import { fetchSsmStringParamValue } from '@shiftcode/lambda-utilities/ssm' +import { simpleLambdaLogger as simpleLogger } from '@shiftcode/logger/node' +import { + ActionsBlock, + Button, + ContextBlock, + HeaderBlock, + KnownBlock, + MessageAttachment, + MrkdwnElement, + PlainTextElement, + SectionBlock, +} from '@slack/types' +import { CloudWatchLogsDecodedData, CloudWatchLogsEvent, CloudWatchLogsHandler, Context } from 'aws-lambda' + +import { + createLogInsightsUrl, + createLogStreamUrl, + LogInsightsQueryDetails, + parsePlaintextLambdaLogMessage, +} from '../utils/cloudwatch.utils.js' +import { escapeForSlack, replaceTabs } from '../utils/slack-messages.utils.js' + +const logger = simpleLogger('PublishErrorLogToSlackFn') + +let slackWebhookEndpointSecret: string | undefined = undefined + +async function getSlackWebhookEndpoint(): Promise { + if (!slackWebhookEndpointSecret) { + // fetch the slack webhook endpoint from the SSM parameter store if it's not already cached in memory + slackWebhookEndpointSecret = await fetchSsmStringParamValue( + process.env.SLACK_WEBHOOK_ENDPOINT_SSM_PARAM_ARN || 'arn-not-provided', + true, + ) + } + return slackWebhookEndpointSecret +} + +async function postSlackMessage(blocks: KnownBlock[], attachments: MessageAttachment[], slackWebhookEndpoint: string) { + const messageBody = { + blocks, + attachments, + } + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify(messageBody), + headers: { + 'Content-Type': 'application/json', + }, + } + + try { + const response = await fetch(slackWebhookEndpoint, options) + const responseText = await response.text() + logger.info(`successfully posted to slack with response code ${response.status} and responseText`, { + responseText, + }) + } catch (err) { + logger.error('there was an error with the call to slack webhook', err) + throw err + } +} + +const gunzipPromiseWrapper = (data: Buffer) => + new Promise((resolve, reject) => { + gunzip(data, (err, responseObject) => { + if (err) { + return reject(err) + } + return resolve(responseObject) + }) + }) + +/** + * Expected shape of a structured `@shiftcode/logger` JSON log line (see `JsonLogObjectData`). + * Any other format still works, just without `logger`/exception enrichment (falls back to raw + * `message` from `JSON.parse` failing being handled by the caller). + */ +interface ParsedLogMessage { + message?: string + logger?: string + error?: { message?: string; stack?: string } +} + +export const handler: CloudWatchLogsHandler = async (event: CloudWatchLogsEvent, _: Context) => { + logger.info('event', event) + + const slackWebhookEndpoint = await getSlackWebhookEndpoint() + if (slackWebhookEndpoint) { + logger.info('successfully resolved slack credentials') + } + + const region = process.env.AWS_REGION ?? '' + + const decodedData = Buffer.from(event.awslogs.data, 'base64') + const unzippedData = await gunzipPromiseWrapper(decodedData) + const eventData: CloudWatchLogsDecodedData = JSON.parse(unzippedData.toString()) + logger.info('eventData', eventData) + + const messages: Array<{ blocks: KnownBlock[]; attachments: MessageAttachment[] }> = eventData.logEvents.map( + (logEvent) => { + logger.info('logEvent', logEvent) + + const detailUrl = createLogStreamUrl( + eventData.logGroup, + region, + eventData.logStream, + logEvent.timestamp, + logEvent.id, + ) + + // convert from millisecond timestamp + const timestampSeconds = Math.floor(logEvent.timestamp / 1000) + const parsedLambdaLog = parsePlaintextLambdaLogMessage(logEvent.message) + logger.info('parsedLambdaLog', parsedLambdaLog) + const parsedMessage = ( + parsedLambdaLog ? JSON.parse(parsedLambdaLog.message) : JSON.parse(logEvent.message) + ) as ParsedLogMessage + + const logEventDate = new Date(logEvent.timestamp) + const startDate = new Date(logEvent.timestamp - 15 * 60 * 1000) + + // query to show all logs of the given lambda run (requestId filter) + const editorString = [ + 'fields @timestamp, level, message, data, logger', + `| filter @requestId = '${parsedLambdaLog?.requestId}'`, + 'sort @timestamp desc', + 'limit 100', + ].join('\n') + + const logInsightsQueryDetails: LogInsightsQueryDetails = { + editorString, + timeType: 'ABSOLUTE', + start: startDate.toISOString(), + end: logEventDate.toISOString(), + source: [eventData.logGroup], + tz: 'LOCAL', + lang: 'CWLI', + queryId: randomUUID(), + } + const logInsightsUrl = createLogInsightsUrl(logInsightsQueryDetails, region) + + const blocks: KnownBlock[] = [ + { + type: 'header', + text: { + type: 'plain_text', + text: ':bangbang: ERROR log', + }, + } satisfies HeaderBlock, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: escapeForSlack(eventData.logGroup), + } satisfies MrkdwnElement, + ], + } satisfies ContextBlock, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Message*\n${escapeForSlack(parsedMessage.message ?? '')}`, + }, + } satisfies SectionBlock, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: ``, + } satisfies MrkdwnElement, + parsedMessage.logger + ? ({ + type: 'plain_text', + text: escapeForSlack(parsedMessage.logger ?? ''), + } satisfies PlainTextElement) + : null, + ].filter((value) => value !== null), + } satisfies ContextBlock, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: 'Log Insights', + emoji: true, + }, + url: logInsightsUrl, + } satisfies Button, + { + type: 'button', + text: { + type: 'plain_text', + text: 'Raw Log Stream', + emoji: true, + }, + url: detailUrl, + } satisfies Button, + ], + } satisfies ActionsBlock, + ] + + // @shiftcode/logger's JSON error attribute is nested under `error.stack`/`error.message` + // (see `ErrorAttributes` in @shiftcode/logger) rather than a flat `exception` string + const exceptionText = parsedMessage.error ? parsedMessage.error.stack || parsedMessage.error.message : null + + return { + blocks, + attachments: exceptionText + ? [ + { + fallback: 'none', + author_name: 'Exception', + footer: replaceTabs(exceptionText), + } satisfies MessageAttachment, + ] + : [], + } + }, + ) + + await Promise.all( + messages.map((message) => postSlackMessage(message.blocks, message.attachments, slackWebhookEndpoint)), + ) +} diff --git a/packages/cdk-utilities/src/lib/lambda-function-name.enum.ts b/packages/cdk-utilities/src/lib/lambda-function-name.enum.ts new file mode 100644 index 00000000..848a51ec --- /dev/null +++ b/packages/cdk-utilities/src/lib/lambda-function-name.enum.ts @@ -0,0 +1,4 @@ +export enum LambdaFunctionName { + PUBLISH_ALARM_TO_SLACK = 'functions/publish-alarm-to-slack-fn.handler', + PUBLISH_ERROR_LOGS_TO_SLACK = 'functions/publish-error-logs-to-slack-fn.handler', +} diff --git a/packages/cdk-utilities/src/lib/models/custom-cloudwatch-alarm-message.model.ts b/packages/cdk-utilities/src/lib/models/custom-cloudwatch-alarm-message.model.ts new file mode 100644 index 00000000..2bc06b60 --- /dev/null +++ b/packages/cdk-utilities/src/lib/models/custom-cloudwatch-alarm-message.model.ts @@ -0,0 +1,20 @@ +import type { AlarmMetadata } from '../alarming.construct.js' + +export type AlarmStateValue = 'ALARM' | 'OK' | 'INSUFFICIENT_DATA' + +/** + * Shape of the `Message` field of an SNS notification published by a CloudWatch Alarm created + * via {@link AlarmingConstruct.addMetricAlarm}. `AlarmDescription` is JSON-parsed back into + * `AlarmMetadata` (see `parseSNSAlarmDescriptionToCustomContent`). + */ +export interface CustomCloudWatchAlarmMessage { + readonly AlarmName: string + readonly AlarmDescription: AlarmMetadata + readonly StateChangeTime: string + readonly NewStateValue: AlarmStateValue + readonly NewStateReason: string + readonly Trigger: { + readonly Period: number + readonly EvaluationPeriods: number + } +} diff --git a/packages/cdk-utilities/src/lib/models/slack-notification.model.ts b/packages/cdk-utilities/src/lib/models/slack-notification.model.ts new file mode 100644 index 00000000..9642455e --- /dev/null +++ b/packages/cdk-utilities/src/lib/models/slack-notification.model.ts @@ -0,0 +1,23 @@ +/** + * Slack "attachment" payload (legacy attachments API, as opposed to Block Kit), used by + * `publish-alarm-to-slack-fn.ts` to post CloudWatch Alarm state changes. + * see https://api.slack.com/reference/messaging/attachments + */ +export interface SlackNotification { + fallback: string + color: string + pretext?: string + // eslint-disable-next-line @typescript-eslint/naming-convention -- Slack's attachment API uses snake_case + author_name?: string + title?: string + // eslint-disable-next-line @typescript-eslint/naming-convention -- Slack's attachment API uses snake_case + mrkdwn_in?: string[] + text?: string + actions?: Array<{ type: string; text: string; url: string }> + ts?: number + fields?: Array<{ + title?: string + value?: string + short?: boolean + }> +} diff --git a/packages/cdk-utilities/src/lib/utils/cloudwatch.utils.ts b/packages/cdk-utilities/src/lib/utils/cloudwatch.utils.ts new file mode 100644 index 00000000..26a475af --- /dev/null +++ b/packages/cdk-utilities/src/lib/utils/cloudwatch.utils.ts @@ -0,0 +1,108 @@ +import { stringify as stringifyJsurl } from 'jsurl' + +const logEventErrorTypes = ['timeout', 'memory', 'missingModule', 'general'] as const +export type LogEventErrorType = (typeof logEventErrorTypes)[number] + +export const logEventErrorConfiguration: Record< + LogEventErrorType, + { + readonly isTypeRegex: RegExp + readonly slackErrorTypeMessage: string + } +> = { + timeout: { + isTypeRegex: /\b(Task timed out after)\b/i, + slackErrorTypeMessage: ':alarm_clock: Timeout error', + }, + memory: { + isTypeRegex: /\b(Process exited before completing request)\b/i, + slackErrorTypeMessage: ':thermometer: Out of memory error', + }, + missingModule: { + isTypeRegex: /\b(missing on module)\b/i, + slackErrorTypeMessage: ':hammer_and_wrench: Configuration error', + }, + general: { + isTypeRegex: /\b(error:\s)\b|\b(\serror\s)\b|\b(errorMessage)/i, + slackErrorTypeMessage: ':no_entry: Function error', + }, +} + +const logEventReplaceTimestamp = '(\\b[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z\\b)' // Time Stamp +const logEventReplaceWhiteSpace = '(\\s)' // White Space +const logEventReplaceProcessId = '([a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12})' // Process ID + +export const createLogStreamUrl = ( + logGroupName: string, + region: string, + logStreamName: string, + timeStamp: number, + eventId: string, +): string => + `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logEventViewer:group=${logGroupName};stream=${logStreamName};reftime=${timeStamp};refid=${eventId}` + +/** + * Checks if a log event message contains a keyword suggesting it's an error message + * we check for general errors & resource constraints: TIMEOUT, OUT OF MEMORY and configuration failures + * returns true if error was found + */ +export const isLogEventMessageError = (message = ''): boolean => !!convertMessageToLogEventErrorType(message) + +export const convertMessageToLogEventErrorType = (message: string = ''): LogEventErrorType | null => + logEventErrorTypes.find((key) => message.search(logEventErrorConfiguration[key].isTypeRegex) > -1) || null + +export const logEventExcludePattern = new RegExp( + logEventReplaceTimestamp + logEventReplaceWhiteSpace + logEventReplaceProcessId, + 'i', +) + +// log insights +export function uriEncoding(text: string) { + const encoded = encodeURIComponent(text) + return encoded.replaceAll('%', '$') +} + +export interface LogInsightsQueryDetails { + editorString: string + timeType: 'ABSOLUTE' + start: string + end: string + source: string[] + tz: 'LOCAL' | 'UTC' + lang: 'CWLI' + unit?: 'seconds' + queryId?: string +} + +// https://repost.aws/questions/QUkdGEQP7rQZmDBUaB2Ai2Qg/aws-cloudwatch-log-insights-generate-url +// https://stackoverflow.com/questions/60796991/is-there-a-way-to-generate-the-aws-console-urls-for-cloudwatch-log-group-filters +export function createLogInsightsUrl(queryDetails: LogInsightsQueryDetails, region: string) { + const encodedQueryDetails = stringifyJsurl(queryDetails) + const urlPartial = `logs-insights?queryDetail=${encodedQueryDetails}` + const encodedUrlPartial = uriEncoding(urlPartial) + + return `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logsV2:${encodedUrlPartial}` +} + +export function parsePlaintextLambdaLogMessage(rawMessageString: string): + | { + timestamp: string + requestId: string + level: string + message: string + } + | undefined { + // example log message (tab-separated) + // 2025-11-28T10:51:00.947Z 73b4eccf-d677-5efd-a50b-8d6c56b4d6e0 INFO {"logger":"EmailEventProcessingFn","message":"xyz"} + // See https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-logformat.html#monitoring-cloudwatchlogs-format-default + const match = rawMessageString.match(/(.*)\t(.*)\t(.*)\t({.*})/) + if (match) { + return { + timestamp: match[1], + requestId: match[2], + level: match[3], + message: match[4], + } + } + return undefined +} diff --git a/packages/cdk-utilities/src/lib/utils/slack-messages.utils.ts b/packages/cdk-utilities/src/lib/utils/slack-messages.utils.ts new file mode 100644 index 00000000..8aa8bf98 --- /dev/null +++ b/packages/cdk-utilities/src/lib/utils/slack-messages.utils.ts @@ -0,0 +1,11 @@ +export function escapeForSlack(text: string) { + // https://api.slack.com/reference/surfaces/formatting#escaping + if (text) { + return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + } + return '' +} + +export function replaceTabs(text: string) { + return text.replaceAll('\t', ' ') +} diff --git a/packages/cdk-utilities/src/scripts/esbuild.script.ts b/packages/cdk-utilities/src/scripts/esbuild.script.ts new file mode 100644 index 00000000..32a0e8df --- /dev/null +++ b/packages/cdk-utilities/src/scripts/esbuild.script.ts @@ -0,0 +1,54 @@ +import { handlerToBundleInfo } from '@shiftcode/iac-utilities' +import { getEnumValues } from '@shiftcode/utilities' +import { build, BuildOptions } from 'esbuild' + +import { LambdaFunctionName } from '../lib/lambda-function-name.enum.js' + +function createConfig(handler: LambdaFunctionName): BuildOptions { + const bundleInfo = handlerToBundleInfo(handler, './dist/lib', './dist/esbuild') + + return { + platform: 'node', + target: 'es2022', + format: 'esm', + // inline all dependencies + bundle: true, + entryPoints: [bundleInfo.entryPoint], + outfile: `${bundleInfo.outPath}${bundleInfo.baseFilename}.mjs`, + minify: false, + treeShaking: true, + banner: { + /* + * The banner content will be added to the beginning of the out file. This helps to prevent the following bug at + * runtime: + * > Dynamic require of "crypto" is not supported + * + * Since we are running in ESM context there is no "require" implement, but rather we want to use the + * createRequire function provided by node:module to provide the require function. + * + * Note on alias: CreateRequire alias is here to prevent duplicate error if user-land implementation of + * createRequire exists. + */ + js: `import { createRequire as createRequire_aix6bKft } from 'node:module';const require = createRequire_aix6bKft(import.meta.url);`, + }, + } +} + +async function run() { + const configurations = getEnumValues(LambdaFunctionName).map(createConfig) + + for (const config of configurations) { + await build(config) + + console.log(`successfully built ${config.outfile}`) + } +} + +try { + await run() + + console.log('Compiled all lambda functions') +} catch (err) { + console.error(err) + process.exit(1) +} diff --git a/tsconfig.lint.json b/tsconfig.lint.json index fcf91a07..1950bde8 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -12,10 +12,13 @@ "@shiftcode/eslint-plugin-rules": ["${configDir}/../eslint-plugin-rules/src/index.ts"], "@shiftcode/utilities": ["${configDir}/../utilities/src/index.ts"], "@shiftcode/logger": ["${configDir}/../logger/src/index.ts"], + "@shiftcode/logger/node": ["${configDir}/../logger/src/node/public-api.ts"], "@shiftcode/inversify-logger": ["${configDir}/../inversify-logger/src/index.ts"], "@shiftcode/branch-utilities": ["${configDir}/../branch-utilities/src/index.ts"], - "@shiftcode/lambda-utilities": ["${configDir}/../lambda-utilities/src/index.ts"], - "@shiftcode/lambda-utilities/s3": ["${configDir}/../lambda-utilities/src/s3/index.ts"] + "@shiftcode/iac-utilities": ["${configDir}/../iac-utilities/src/index.ts"], + "@shiftcode/lambda-utilities/s3": ["${configDir}/../lambda-utilities/src/s3/index.ts"], + "@shiftcode/lambda-utilities/ssm": ["${configDir}/../lambda-utilities/src/ssm/index.ts"], + "@shiftcode/lambda-utilities": ["${configDir}/../lambda-utilities/src/index.ts"] } }, "exclude": [] diff --git a/tsconfig.spec.json b/tsconfig.spec.json index 708e3426..6b9d9faa 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -13,9 +13,12 @@ "@shiftcode/eslint-plugin-rules": ["${configDir}/../eslint-plugin-rules/src/index.ts"], "@shiftcode/utilities": ["${configDir}/../utilities/src/index.ts"], "@shiftcode/logger": ["${configDir}/../logger/src/index.ts"], + "@shiftcode/logger/node": ["${configDir}/../logger/src/node/public-api.ts"], "@shiftcode/inversify-logger": ["${configDir}/../inversify-logger/src/index.ts"], "@shiftcode/branch-utilities": ["${configDir}/../branch-utilities/src/index.ts"], + "@shiftcode/iac-utilities": ["${configDir}/../iac-utilities/src/index.ts"], "@shiftcode/lambda-utilities/s3": ["${configDir}/../lambda-utilities/src/s3/index.ts"], + "@shiftcode/lambda-utilities/ssm": ["${configDir}/../lambda-utilities/src/ssm/index.ts"], "@shiftcode/lambda-utilities": ["${configDir}/../lambda-utilities/src/index.ts"] } },