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 +43 -0
- package/build/index.js +147 -0
- package/build/lib/APIGWFrontedLambdaFetcher.js +149 -0
- package/package.json +50 -0
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
|
+
}
|