artillery
Version:
Cloud-scale load testing. https://www.artillery.io
363 lines (306 loc) β’ 10.2 kB
text/typescript
import createDebug from 'debug';
import moment from 'moment';
const debug = createDebug('plugin:slack');
const MAX_SLACK_TEST_NAME_LENGTH = 80;
class SlackPlugin {
// Untyped JS class - properties assigned dynamically
[key: string]: any;
constructor(script, events) {
this.script = script;
this.events = events;
if (process.env.LOCAL_WORKER_ID || process.env.WORKER_ID) {
debug('Running in a worker, exiting');
return;
}
this.config = script.config.plugins.slack || {};
if (!this.config.webhookUrl) {
throw new SlackPluginError('Slack webhook URL not provided');
}
if (global.artillery?.cloudEnabled) {
debug('Artillery Cloud enabled, configuring Run URL');
this.cloudEnabled = true;
const baseUrl =
process.env.ARTILLERY_CLOUD_ENDPOINT || 'https://app.artillery.io';
this.cloudTestRunUrl = `${baseUrl}/load-tests/${global.artillery.testRunId}`;
}
const nameTag = (global.artillery.testInfo?.tags || []).find(
(t) => t.name === 'name'
);
if (nameTag) {
this.testName = truncateForSlackHeader(nameTag.value);
}
if (
this.script.config.plugins.ensure &&
Object.keys(this.script.config.plugins.ensure).length > 0
) {
this.ensureEnabled = true;
global.artillery.globalEvents.on('checks', async (checkTests) => {
this.ensureChecks = {
failed: 0,
total: 0,
passed: 0,
checkList: []
};
debug('Sorting and formatting ensure checks');
checkTests
.sort((a, b) => (a.result < b.result ? 1 : -1))
.forEach((check) => {
this.ensureChecks.total += 1;
if (check.result !== 1) {
if (check.strict) {
this.exitCode = 1;
}
this.ensureChecks.failed += 1;
this.ensureChecks.checkList.push({
text: check.original,
passed: false,
optional: !check.strict
});
} else {
this.ensureChecks.passed += 1;
this.ensureChecks.checkList.push({
text: check.original,
passed: true,
optional: false
});
}
});
// When ensure is enabled, whether the beforeExit or the checks event will be triggered first will depend on the order of plugins in the test script
// Since we need data from both events, first event triggered will store the data and the second event will send the report
// Make sure exitCode is properly set before potentially sending the report
if (this.exitCode === undefined || this.exitCode === null) {
this.exitCode = global.artillery.suggestedExitCode ?? 0;
debug('Setting initial exitCode in checks event:', this.exitCode);
}
if (this.report && !this.reportSent) {
debug('Sending report from checks event');
await this.sendReport(this.report, this.ensureChecks);
this.reportSent = true;
}
});
}
global.artillery.ext({
ext: 'beforeExit',
method: async (opts) => {
this.exitCode =
global.artillery.suggestedExitCode ?? opts.exitCode ?? 0;
if (this.ensureEnabled && !this.ensureChecks && !this.reportSent) {
this.report = opts.report;
} else {
debug('Sending report from beforeExit event');
await this.sendReport(opts.report, this.ensureChecks);
this.reportSent = true;
}
}
});
debug('Slack plugin initialised!');
}
getErrors(report) {
const errorList = [];
for (const [key, value] of Object.entries(report.counters).filter(([key]) =>
key.startsWith('errors.')
)) {
errorList.push(`β ${key.replace('errors.', '')} (${value})`);
}
return errorList;
}
assembleSlackPayload(report, ensureChecks) {
const errorList = this.getErrors(report);
const duration =
report.lastMetricAt != null && report.firstMetricAt != null
? report.lastMetricAt - report.firstMetricAt
: undefined;
// Ensure exitCode has a value before using it
const exitCode =
this.exitCode !== undefined && this.exitCode !== null ? this.exitCode : 0;
const testLabel = this.testName ? ` (${this.testName})` : '';
const headerText =
exitCode === 0
? `π’ Artillery test run finished${testLabel}`
: `π΄ Artillery test run failed${testLabel}`;
const payloadTemplate: any = {
text: headerText,
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: headerText,
emoji: true
}
}
]
};
let errorsText = '*Errors*\nNone';
if (errorList.length > 0) {
// Only show first 10 errors to avoid Slack message length limit
const maxErrors = 10;
const trimmedList = errorList.slice(0, maxErrors);
if (errorList.length > maxErrors) {
trimmedList.push(`β ${errorList.length - maxErrors} moreβ¦`);
}
errorsText = `*Errors (${errorList.length})*\n\`\`\`\n${trimmedList.join(
'\n'
)}\n\`\`\``;
}
const metricBlocks = [
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*VUs*\n${report.counters['vusers.completed']} completed / ${report.counters['vusers.created']} created`
},
...(duration != null
? [
{
type: 'mrkdwn',
text: `*Duration*\n${formatDuration(duration)}`
}
]
: [])
]
}
];
let checksText = '*Checks*\nNone defined';
if (this.ensureChecks) {
// Show summary if more than 10 checks to avoid Slack message length limit
if (this.ensureChecks.total > 10) {
let summaryText = '';
if (ensureChecks.passed > 0) {
summaryText += `π’ ${ensureChecks.passed} checks passed\n`;
}
if (ensureChecks.failed > 0) {
summaryText += `π΄ ${ensureChecks.failed} checks failed`;
}
summaryText = summaryText.trim();
checksText = `*Checks (${ensureChecks.passed}/${ensureChecks.total})*\n\`\`\`\n${summaryText}\n\`\`\``;
} else {
const formattedChecks = this.ensureChecks.checkList.map(
(check) =>
`${check.passed ? 'π’' : 'π΄'} ${check.text}${
check.optional ? ' (optional)' : ''
}`
);
checksText = `*Checks (${ensureChecks.passed} / ${
ensureChecks.total
})*\n\`\`\`\n${formattedChecks.join('\n')}\n\`\`\``;
}
}
metricBlocks.push({
type: 'section',
fields: [
{
type: 'mrkdwn',
text: checksText
},
{
type: 'mrkdwn',
text: errorsText
}
]
});
payloadTemplate.blocks = payloadTemplate.blocks.concat(
metricBlocks as any
);
if (this.cloudEnabled) {
payloadTemplate.blocks.push({
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'See report on Artillery Cloud',
emoji: true
},
url: this.cloudTestRunUrl,
style: 'primary'
}
]
});
}
// TODO: stringify in the caller
return JSON.stringify(payloadTemplate);
}
async sendReport(report, ensureChecks) {
if (this.config.notifyOnFailureOnly) {
const hasFailed = this.exitCode !== 0;
if (!hasFailed) {
debug('Test passed, notifyOnFailureOnly is set, skipping notification');
this.finished = true;
return;
}
}
const payload = this.assembleSlackPayload(report, ensureChecks);
if (process.env.ARTILLERY_SLACK_PLUGIN_DEBUG) {
console.log('Slack plugin payload:');
console.log(payload);
this.finished = true;
return;
}
const got = (await import('got')).default;
try {
const res = await got.post(this.config.webhookUrl, {
headers: {
'Content-Type': 'application/json'
},
body: payload
});
debug('Slack response:', (res as any).status, (res as any).statusText);
this.finished = true;
} catch (err) {
this.finished = true;
console.error(`Slack Plugin: Failed to send report to Slack: ${err}`);
}
}
async cleanup(done) {
debug('Cleaning up');
done();
}
}
class SlackPluginError extends Error {
constructor(message) {
super(message);
this.name = 'SlackPluginError';
}
}
// from artillery/lib/util.js
function formatDuration(durationInMs) {
const duration = moment.duration(durationInMs);
const days = duration.days();
const hours = duration.hours();
const minutes = duration.minutes();
const seconds = duration.seconds();
const timeComponents = [];
if (days) {
timeComponents.push(`${days} ${maybePluralize(days, 'day')}`);
}
if (hours || days) {
timeComponents.push(`${hours} ${maybePluralize(hours, 'hour')}`);
}
if (minutes || hours || days) {
timeComponents.push(`${minutes} ${maybePluralize(minutes, 'minute')}`);
}
timeComponents.push(`${seconds} ${maybePluralize(seconds, 'second')}`);
return timeComponents.join(', ');
}
// from artillery/lib/util.js
function maybePluralize(amount, singular, plural = `${singular}s`) {
return amount === 1 ? singular : plural;
}
function truncateForSlackHeader(value) {
if (value === undefined || value === null) {
return undefined;
}
const normalized = String(value).trim();
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_SLACK_TEST_NAME_LENGTH) {
return normalized;
}
return `${normalized.slice(0, MAX_SLACK_TEST_NAME_LENGTH - 1)}β¦`;
}
const LEGACY_METRICS_FORMAT = false;
export { SlackPlugin as Plugin, LEGACY_METRICS_FORMAT };