artillery-engine-apigw-fronted-lambda 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # artillery-engine-apigw-fronted-lambda
2
+
3
+ A custom artillery engine that provides the capability to run artillery performance tests directly against lambdas that expect to be invoked by API Gateway
4
+
5
+ Adapted from https://github.com/artilleryio/artillery/blob/main/examples/artillery-engine-example/index.js
6
+
7
+ ## Compatibility
8
+
9
+ This project is designed to work with [artillery](https://github.com/artilleryio/artillery)@^2.0.0
10
+
11
+ ## Install
12
+
13
+ Install the plugin:
14
+
15
+ ```bash
16
+ npm install -D artillery-engine-apigw-fronted-lambda
17
+ ```
18
+
19
+ add it to your `config.engines` object in your performance test payload yml file and set the engine for each scenario:
20
+
21
+ ```yml
22
+ config:
23
+ target: my-stack-name
24
+ phases:
25
+ - arrivalCount: 1
26
+ duration: 1
27
+ engines:
28
+ apigw-fronted-lambda: {}
29
+ scenarios:
30
+ - name: do-something
31
+ engine: apigw-fronted-lambda
32
+ flow:
33
+ - invoke:
34
+ lambda: my-lambda-name
35
+ event:
36
+ pathParameters: ... # object
37
+ queryStringParameters: ... # object
38
+ body: ... # Can be an object or a string. If an object is given it is stringified
39
+ capture:
40
+ json: $.jsonPath
41
+ as: variableName
42
+ - log: 'My variable: {{ variableName }}'
43
+ ```
package/build/index.js ADDED
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ /**
3
+ * THIS CUSTOM ENGINE HAS BEEN ADAPTED FROM https://github.com/artilleryio/artillery/blob/main/examples/artillery-engine-example/index.js
4
+ * IN ORDER TO PROVIDE THE CAPABILITY TO DIRECTLY INVOKE LAMBDAS
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const async_1 = require("async");
8
+ const int_commons_1 = require("@artilleryio/int-commons");
9
+ const client_lambda_1 = require("@aws-sdk/client-lambda");
10
+ const jsonpath_plus_1 = require("jsonpath-plus");
11
+ const APIGWFrontedLambdaFetcher_1 = require("./lib/APIGWFrontedLambdaFetcher");
12
+ const { template } = int_commons_1.engine_util;
13
+ (function overrideConsoleLog() {
14
+ const originalLog = console.log;
15
+ console.log = (...args) => {
16
+ const output = args.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' ');
17
+ if (output.includes('_aws')) {
18
+ process.stdout.write(output); // Write to stdout without adding an extra newline
19
+ }
20
+ else {
21
+ originalLog(...args); // Use the original console.log for everything else
22
+ }
23
+ };
24
+ })();
25
+ /**
26
+ * Simple custom engine that invokes a lambda function when an "invoke"
27
+ * action is found.
28
+ */
29
+ class LambdaEngine {
30
+ // Artillery initializes each engine with the following arguments:
31
+ //
32
+ // - script is the entire script object, with .config and .scenarios properties
33
+ // - ee is an EventEmitter we can use to subscribe to events from Artillery, and
34
+ // to report custom metrics
35
+ // - helpers is a collection of utility functions
36
+ constructor(script, ee, helpers) {
37
+ this.script = script;
38
+ this.ee = ee;
39
+ this.helpers = helpers;
40
+ this.target = script.config.target;
41
+ this.opts = { ...script.config.engines['apigw-fronted-lambda'] };
42
+ this.fetcher = new APIGWFrontedLambdaFetcher_1.default(new client_lambda_1.LambdaClient({ region: this.opts.region ?? 'ap-southeast-2' }));
43
+ }
44
+ // For each scenario in the script using this engine, Artillery calls this function
45
+ // to create a VU function
46
+ createScenario(scenarioSpec, ee) {
47
+ const tasks = scenarioSpec.flow.map((rs) => this.step(rs, ee));
48
+ return function scenario(initialContext, callback) {
49
+ ee.emit('started');
50
+ function vuInit(vuCallback) {
51
+ // we can run custom VU-specific init code here
52
+ return vuCallback(null, initialContext);
53
+ }
54
+ const steps = [vuInit].concat(tasks);
55
+ async_1.default.waterfall(steps, (err, context) => {
56
+ if (err) {
57
+ console.error(err);
58
+ }
59
+ return callback(err, context);
60
+ });
61
+ };
62
+ }
63
+ // This is a convenience function where we delegate common actions like loop, log, and think,
64
+ // and handle actions which are custom for our engine, i.e. the "invoke" action in this case
65
+ step(rs, ee) {
66
+ if (rs.loop) {
67
+ const steps = rs.loop.map((loopStep) => this.step(loopStep, ee));
68
+ return this.helpers.createLoopWithCount(rs.count || -1, steps, { overValues: rs.over });
69
+ }
70
+ if (rs.log) {
71
+ return (context, callback) => {
72
+ console.log(template(rs.log, context));
73
+ return process.nextTick(() => {
74
+ callback(null, context);
75
+ });
76
+ };
77
+ }
78
+ if (rs.think) {
79
+ return this.helpers.createThink(rs, this.script.config.defaults?.think || {});
80
+ }
81
+ if (rs.function) {
82
+ return (context, callback) => {
83
+ const func = this.script.config.processor[rs.function];
84
+ if (!func) {
85
+ return process.nextTick(() => {
86
+ callback(null, context);
87
+ });
88
+ }
89
+ return func(context, ee, () => callback(null, context));
90
+ };
91
+ }
92
+ if (rs.invoke) {
93
+ const { lambda, event, capture } = rs.invoke;
94
+ const fullLambdaName = `${this.target}-${lambda}`;
95
+ const eventBody = typeof event.body === 'object' ? JSON.stringify(event.body) : event.body;
96
+ return (context, callback) => {
97
+ // Resolve each of the path parameters and query string parameters
98
+ const pathParametersResolved = Object.keys(event.pathParameters || {}).reduce((acc, key) => {
99
+ acc[key] = template(event.pathParameters[key], context);
100
+ return acc;
101
+ }, {});
102
+ const queryStringParametersResolved = Object.keys(event.queryStringParameters || {}).reduce((acc, key) => {
103
+ acc[key] = template(event.queryStringParameters[key], context);
104
+ return acc;
105
+ }, {});
106
+ this.fetcher
107
+ .fetch({
108
+ lambdaName: fullLambdaName,
109
+ pathParameters: pathParametersResolved,
110
+ queryStringParameters: queryStringParametersResolved,
111
+ body: eventBody,
112
+ })
113
+ .then((response) => {
114
+ // Capture the response body into a context variable if needed
115
+ if (capture && capture.json && capture.as) {
116
+ try {
117
+ const [extractedValue] = (0, jsonpath_plus_1.JSONPath)({ path: capture.json, json: response.data });
118
+ if (extractedValue) {
119
+ context.vars[capture.as] = extractedValue;
120
+ }
121
+ else {
122
+ console.warn(`No value found for JSONPath: ${capture.json}`);
123
+ }
124
+ }
125
+ catch (err) {
126
+ console.error('Error processing JSONPath:', err);
127
+ }
128
+ }
129
+ })
130
+ .catch((err) => {
131
+ console.error('Error fetching lambda:', err);
132
+ })
133
+ .finally(() => {
134
+ ee.emit('counter', 'lambda.invocation_count', 1);
135
+ return callback(null, context);
136
+ });
137
+ };
138
+ }
139
+ //
140
+ // Ignore any unrecognized actions:
141
+ //
142
+ return function doNothing(context, callback) {
143
+ return callback(null, context);
144
+ };
145
+ }
146
+ }
147
+ module.exports = LambdaEngine;
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const client_lambda_1 = require("@aws-sdk/client-lambda");
4
+ const aws_embedded_metrics_1 = require("aws-embedded-metrics");
5
+ /**
6
+ * Fetches JSON data by invoking a lambda that would ordinarily be invoked via a HTTP/S request through API Gateway.
7
+ */
8
+ class APIGWFrontedLambdaFetcher {
9
+ /**
10
+ * @param lambdaClient An instance of an @aws-sdk/client-lambda client
11
+ * @param options.reportMetrics Whether to report response codes for the fetcher to the custom metric. Optional. Default is true.
12
+ */
13
+ constructor(lambdaClient, options = {}) {
14
+ this.lambdaClient = lambdaClient;
15
+ this.reportMetrics = options.reportMetrics ?? true;
16
+ }
17
+ /**
18
+ * Puts a custom metric for the response code of a Lambda function into CloudWatch.
19
+ * @param lambdaName The name of the Lambda function
20
+ * @param statusCode The status code of the response
21
+ */
22
+ async putResponseCodeMetric(lambdaName, statusCode) {
23
+ if (!this.reportMetrics) {
24
+ return;
25
+ }
26
+ const metrics = (0, aws_embedded_metrics_1.createMetricsLogger)();
27
+ metrics.setNamespace('LambdaResponses');
28
+ metrics.setDimensions({ Lambda: lambdaName });
29
+ metrics.putMetric(`StatusCode_${statusCode}`, 1, aws_embedded_metrics_1.Unit.Count);
30
+ await metrics.flush();
31
+ }
32
+ /**
33
+ * The fetcher function that fetches data from the origin lambda.
34
+ * Data is expected to be returned from the lambda invocation in the format { statusCode: number, body: string }
35
+ * The `body` is expected to be a JSON string.
36
+ * @param options.lambdaName The name of the Lambda function to fetch data from
37
+ * @param options.pathParameters The path parameters to invoke the Lambda function with
38
+ * @param options.queryStringParameters The query parameters to invoke the Lambda function with
39
+ * @param options.body The body to invoke the Lambda function with
40
+ * @returns {FetchResponse<T>} The JSON body and statusCode returned from the Lambda function response
41
+ */
42
+ async fetch(options) {
43
+ const { lambdaName, pathParameters, queryStringParameters, body: eventBody } = options;
44
+ const event = { pathParameters, queryStringParameters, body: eventBody };
45
+ const command = new client_lambda_1.InvokeCommand({
46
+ FunctionName: lambdaName,
47
+ InvocationType: 'RequestResponse',
48
+ Payload: JSON.stringify(event),
49
+ });
50
+ // Invoke the Lambda function with a single retry
51
+ const response = await this.lambdaClient.send(command).catch(async (error) => {
52
+ // Log initial failure to custom metric
53
+ this.putResponseCodeMetric(lambdaName, 500);
54
+ // Wait 50ms before retrying
55
+ await new Promise((resolve) => {
56
+ setTimeout(resolve, 50);
57
+ });
58
+ console.error(`[APIGWFrontedLambdaFetcher] Retrying Lambda invocation (${lambdaName}) after error: ${error}`);
59
+ // Retry the Lambda invocation
60
+ return this.lambdaClient.send(command).catch((err) => {
61
+ // Log final failure to custom metric
62
+ this.putResponseCodeMetric(lambdaName, 500);
63
+ throw err;
64
+ });
65
+ });
66
+ if (response.Payload) {
67
+ let text;
68
+ // Parse the response
69
+ try {
70
+ text = response.Payload.transformToString();
71
+ const { statusCode, body, headers } = JSON.parse(text);
72
+ const code = statusCode ?? response.StatusCode;
73
+ await this.putResponseCodeMetric(lambdaName, code);
74
+ return {
75
+ data: JSON.parse(body),
76
+ statusCode: code,
77
+ ttl: this.parseCacheControlSeconds(headers?.['Cache-Control']),
78
+ };
79
+ }
80
+ catch (err) {
81
+ console.debug(`[APIGWFrontedLambdaFetcher] Error transforming lambda payload. Text:\n${text}`);
82
+ await this.putResponseCodeMetric(lambdaName, response.StatusCode);
83
+ throw err;
84
+ }
85
+ }
86
+ throw new Error(`[APIGWFrontedLambdaFetcher] No payload returned from Lambda invocation. Response: ${JSON.stringify(response, null, 2)}`);
87
+ }
88
+ /**
89
+ * Generates a cache key for a given Lambda function and its parameters
90
+ * The order of parameters is not relevant, as they are sorted before serialization.
91
+ * If the request includes a body, it is NOT included in the cache key. Any request with a body
92
+ * will cause this function to throw an error and be treated as if it were a cache miss.
93
+ * @param options.lambdaName The name of the Lambda function
94
+ * @param options.pathParameters The path parameters to invoke the Lambda function with
95
+ * @param options.queryStringParameters The query parameters to invoke the Lambda function with
96
+ * @param options.body If included, causes the function to throw an error
97
+ * @param options.uncacheable If true, throws an error indicating that the request is uncacheable
98
+ */
99
+ generateCacheKey(options) {
100
+ const { lambdaName, pathParameters, queryStringParameters, body, uncacheable } = options;
101
+ if (uncacheable) {
102
+ throw new Error('[APIGWFrontedLambdaFetcher] Uncacheable option is set to true, cannot generate cache key.');
103
+ }
104
+ if (body) {
105
+ throw new Error('[APIGWFrontedLambdaFetcher] Body is not supported in cache keys for Lambda function requests');
106
+ }
107
+ // Serialize parameters in a consistent order
108
+ const pathParamsString = pathParameters
109
+ ? Object.entries(pathParameters)
110
+ .sort()
111
+ .map(([k, v]) => `${k}=${v}`)
112
+ .join('&')
113
+ : '';
114
+ const queryParamsString = queryStringParameters
115
+ ? Object.entries(queryStringParameters)
116
+ .sort()
117
+ .map(([k, v]) => `${k}=${v}`)
118
+ .join('&')
119
+ : '';
120
+ // Build and return the cache key
121
+ return `${lambdaName}:${pathParamsString}:${queryParamsString}`;
122
+ }
123
+ /**
124
+ * Parses the Cache-Control header to extract the max-age or s-maxage value in seconds.
125
+ * 0 is returned if no-cache or no-store is present.
126
+ * If the header is not present or does not contain a valid cache-control directive, null is returned.
127
+ * @param header The Cache-Control header value
128
+ */
129
+ parseCacheControlSeconds(cacheControlHeader) {
130
+ if (typeof cacheControlHeader !== 'string')
131
+ return null;
132
+ // If the header contains 'no-cache' or 'no-store', return 0 to indicate no caching
133
+ if (cacheControlHeader.includes('no-cache') || cacheControlHeader.includes('no-store')) {
134
+ return 0;
135
+ }
136
+ // Match s-maxage=* first
137
+ const sMaxAgeMatch = cacheControlHeader.match(/\bs-maxage=(\d+)\b/i);
138
+ if (sMaxAgeMatch) {
139
+ return Number.parseInt(sMaxAgeMatch[1], 10);
140
+ }
141
+ // If no s-maxage, match max-age=*
142
+ const maxAgeMatch = cacheControlHeader.match(/\bmax-age=(\d+)\b/i);
143
+ if (maxAgeMatch) {
144
+ return Number.parseInt(maxAgeMatch[1], 10);
145
+ }
146
+ return null; // No valid cache-control directive found
147
+ }
148
+ }
149
+ exports.default = APIGWFrontedLambdaFetcher;
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "description": "A custom artillery engine to invoke lambdas fronted by API gateway",
3
+ "files": [
4
+ "build",
5
+ "README.md"
6
+ ],
7
+ "license": "MIT",
8
+ "main": "./build/index.js",
9
+ "name": "artillery-engine-apigw-fronted-lambda",
10
+ "scripts": {
11
+ "build": "tsc --outDir build",
12
+ "lint": "eslint . --ignore-pattern build/",
13
+ "test": "jest"
14
+ },
15
+ "dependencies": {
16
+ "@aws-sdk/client-lambda": "^3.709.0",
17
+ "async": "^3.2.6",
18
+ "aws-embedded-metrics": "^4.2.1",
19
+ "jsonpath-plus": "^10.4.0"
20
+ },
21
+ "devDependencies": {
22
+ "@commitlint/cli": "^21.0.1",
23
+ "@commitlint/config-conventional": "^21.0.1",
24
+ "@semantic-release/commit-analyzer": "^13.0.1",
25
+ "@semantic-release/git": "^10.0.1",
26
+ "@semantic-release/npm": "^13.1.5",
27
+ "@types/artillery": "^2.0.194",
28
+ "@types/async": "^3.2.25",
29
+ "@types/aws-lambda": "^8.10.161",
30
+ "@types/debug": "^4.1.13",
31
+ "@types/jest": "^30.0.0",
32
+ "@types/node": "^22.19.19",
33
+ "aws-sdk-client-mock": "^4.1.0",
34
+ "aws-sdk-client-mock-jest": "^4.1.0",
35
+ "bom-eslint-config": "^1.0.1",
36
+ "commitlint": "^21.0.1",
37
+ "jest": "^30.4.2",
38
+ "semantic-release": "^25.0.3",
39
+ "ts-jest": "^29.4.9",
40
+ "typescript": "^5.9.3"
41
+ },
42
+ "peerDependencies": {
43
+ "artillery": "^2.0.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ },
49
+ "version": "2.0.0"
50
+ }