src/DataSource.ts
import _ from 'lodash';
import Keyv from 'keyv';
import {
DataQueryRequest,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
dateTimeFormat,
dateTimeParse,
FieldType,
MutableDataFrame,
} from '@grafana/data';
import { getBackendSrv, getTemplateSrv } from '@grafana/runtime';
import {
defaultQuery,
K6Check,
K6CloudDataSourceOptions,
K6CloudQuery,
K6Metric,
K6Organization,
K6Project,
K6QueryType,
K6Series,
K6Test,
K6TestRun,
K6TestRunListItem,
K6Threshold,
K6Url,
K6VariableQuery,
K6VariableQueryType,
} from './types';
import {
getEnumFromMetricType,
getMetricFromMetricNameAndTags,
getRunStatusFromEnum,
getUnitFromMetric,
reduceByObjectProp,
} from './utils';
const PERCENTILE_REGEX = new RegExp('p\\(([0-9.]+)\\)', 'i');
const VAR_QUERY_ID_REGEX = new RegExp('([0-9]+)(: (.+))?', 'i');
// TODO: make these dynamic based on test run status
const CACHE_TTL_ORGANIZATIONS = 60000;
const CACHE_TTL_PROJECTS = 60000;
const CACHE_TTL_TESTS = 60000;
const CACHE_TTL_TEST_RUNS = 60000;
const CACHE_TTL_METRICS = 60000;
const CACHE_TTL_URLS = 10000;
const CACHE_TTL_CHECKS = 10000;
const CACHE_TTL_THRESHOLDS = 10000;
export class DataSource extends DataSourceApi<K6CloudQuery, K6CloudDataSourceOptions> {
url?: string;
cache?: Keyv;
constructor(instanceSettings: DataSourceInstanceSettings<K6CloudDataSourceOptions>) {
super(instanceSettings);
this.url = instanceSettings.url;
this.cache = new Keyv();
}
async query(options: DataQueryRequest<K6CloudQuery>): Promise<DataQueryResponse> {
let data = { data: [] as MutableDataFrame[] };
for (const target of options.targets) {
if (target.hide) {
continue;
}
const { refId, qtype, projectId, testId, testRunId, metric, aggregation, tags } = _.defaults(
target,
defaultQuery
);
// Resolve template variables.
const resolvedOrgId = this.resolveVar('$organization');
const resolvedProjectId = this.resolveVar(projectId);
const resolvedTestId = this.resolveVar(testId);
const resolvedTestRunId = this.resolveVar(testRunId);
// Handle supported query types
if (qtype === K6QueryType.TEST_RUNS) {
if (resolvedOrgId && resolvedProjectId && resolvedTestId) {
const testRuns = await this.getTestRunsForTest(resolvedTestId);
const fields = this.convertTestRunsToFields(testRuns);
data.data.push(
new MutableDataFrame({
refId: refId,
fields: fields,
})
);
}
} else if (qtype === K6QueryType.CHECKS) {
if (resolvedTestRunId) {
const checksList = await this.getChecksForTestRun(resolvedTestRunId);
const fields = this.convertChecksToFields(checksList);
data.data.push(
new MutableDataFrame({
refId: refId,
fields: fields,
})
);
}
} else if (qtype === K6QueryType.THRESHOLDS) {
if (resolvedTestRunId) {
const thresholdsList = await this.getThresholdsForTestRun(resolvedTestRunId);
const fields = this.convertThresholdsToFields(thresholdsList);
data.data.push(
new MutableDataFrame({
refId: refId,
fields: fields,
})
);
}
} else if (qtype === K6QueryType.URLS) {
if (resolvedTestRunId) {
const urlsList = await this.getURLsForTestRun(resolvedTestRunId);
const fields = this.convertURLsToFields(urlsList);
data.data.push(
new MutableDataFrame({
refId: refId,
fields: fields,
})
);
}
} else {
if (
resolvedTestRunId &&
metric &&
(tags === undefined || tags.size === 0 || _.indexOf(Array.from(tags.values()), '') === -1)
) {
const metricsList = await this.getMetricsForTestRun(resolvedTestRunId);
const metricObj = getMetricFromMetricNameAndTags(metricsList, metric, tags);
const series = await this.getSeriesForMetric(resolvedTestRunId, metricObj!.id, aggregation, tags);
const fields = this.convertSeriesToFields(series, metricObj!, tags, aggregation);
data.data.push(
new MutableDataFrame({
refId: refId,
fields: fields,
})
);
}
}
}
return data;
}
async metricFindQuery(query: K6VariableQuery) {
const orgId = this.resolveVar('$organization');
const projectId = this.resolveVar('$project');
const testId = this.resolveVar('$test');
// Retrieve DataQueryResponse based on query.
if (query.qtype === K6VariableQueryType.ORGANIZATIONS) {
const orgs = await this.getOrganizations();
return orgs.map((o) => ({ text: `${o.id}: ${o.name}` }));
} else if (query.qtype === K6VariableQueryType.PROJECTS && orgId) {
const projects = await this.getProjectsForOrganization(orgId);
return projects.map((p) => ({ text: `${p.id}: ${p.name}` }));
} else if (query.qtype === K6VariableQueryType.TESTS && projectId) {
const tests = await this.getTestsForProject(projectId);
return tests.map((t) => ({ text: `${t.id}: ${t.name}` }));
} else if (query.qtype === K6VariableQueryType.TEST_RUNS && testId) {
const testRuns = await this.getTestRunsForTest(testId);
return testRuns.map((r) => ({
text: `${r.id}: ${dateTimeFormat(r.created, {
defaultWithMS: false,
format: 'YYYY-MM-DD HH:mm:ss',
})} (${r.vus} VUs, ${r.duration}s)`,
}));
}
return [];
}
async testDatasource() {
return getBackendSrv()
.datasourceRequest({
method: 'GET',
url: `${this.url}/base/v3/organizations`,
requestId: 'my-plugin-v3-accounts-me',
})
.then(() => {
return {
status: 'success',
message: 'Authentication successful',
};
})
.catch((error) => {
return {
status: 'error',
message: error?.data?.error?.message ?? 'Could not verify API Token',
};
});
}
convertTestRunsToFields(testRuns: K6TestRun[]) {
let testRunsList: K6TestRunListItem[] = [];
for (const r of testRuns) {
let i: K6TestRunListItem = {
testRunId: r.id,
testRunCreated: r.created,
testRunStarted: r.started,
testRunStartedEpoch: dateTimeParse(r.started).unix() * 1000,
testRunEnded: r.ended,
testRunEndedEpoch: dateTimeParse(r.ended).unix() * 1000,
testRunStatus: r.runStatus,
testRunResultStatus: r.resultStatus,
testRunVUs: r.vus,
testRunDuration: r.duration,
testRunLoadTime: r.loadTime,
};
testRunsList.push(i);
}
return [
{ name: 'Test Run ID', values: reduceByObjectProp(testRunsList, 'testRunId'), type: FieldType.number },
{
name: 'Test Run Created',
values: reduceByObjectProp(testRunsList, 'testRunCreated'),
type: FieldType.time,
},
{
name: 'Test Run Started',
values: reduceByObjectProp(testRunsList, 'testRunStarted'),
type: FieldType.time,
},
{
name: 'Test Run Started Epoch',
values: reduceByObjectProp(testRunsList, 'testRunStartedEpoch'),
type: FieldType.number,
},
{
name: 'Test Run Ended',
values: reduceByObjectProp(testRunsList, 'testRunEnded'),
type: FieldType.time,
},
{
name: 'Test Run Ended Epoch',
values: reduceByObjectProp(testRunsList, 'testRunEndedEpoch'),
type: FieldType.number,
},
{
name: 'Test Run Status',
values: _.map(reduceByObjectProp(testRunsList, 'testRunStatus'), getRunStatusFromEnum),
type: FieldType.string,
},
{
name: 'Test Run Result Status',
values: reduceByObjectProp(testRunsList, 'testRunResultStatus'),
type: FieldType.number,
},
{
name: 'Test Run VUs',
values: reduceByObjectProp(testRunsList, 'testRunVUs'),
type: FieldType.number,
config: { unit: 'VUs' },
},
{
name: 'Test Run Duration',
values: reduceByObjectProp(testRunsList, 'testRunDuration'),
type: FieldType.number,
config: { unit: 's' },
},
{
name: 'Test Run Load Time',
values: reduceByObjectProp(testRunsList, 'testRunLoadTime'),
type: FieldType.number,
config: { unit: 'ms' },
},
];
}
convertChecksToFields(checksList: K6Check[]) {
return [
{ name: 'ID', values: reduceByObjectProp(checksList, 'id'), type: FieldType.string },
{ name: 'Name', values: reduceByObjectProp(checksList, 'name'), type: FieldType.string },
{ name: 'Count', values: reduceByObjectProp(checksList, 'totalCount'), type: FieldType.number },
{ name: 'Successful', values: reduceByObjectProp(checksList, 'successCount'), type: FieldType.number },
{
name: 'Success rate',
values: _.map(checksList, (c) => {
return (c.successCount / c.totalCount) * 100.0;
}),
type: FieldType.number,
config: { unit: '%' },
},
];
}
convertThresholdsToFields(thresholdsList: K6Threshold[]) {
return [
{ name: 'ID', values: reduceByObjectProp(thresholdsList, 'id'), type: FieldType.string },
{ name: 'Threshold', values: reduceByObjectProp(thresholdsList, 'name'), type: FieldType.string },
{
name: 'Metric value',
values: reduceByObjectProp(thresholdsList, 'calculatedValue'),
type: FieldType.number,
},
{
name: 'Pass/Fail',
values: _.map(thresholdsList, (t) => (t.tainted ? 1 : 0)),
type: FieldType.number,
},
];
}
convertURLsToFields(urlsList: K6Url[]) {
return [
{ name: 'ID', values: reduceByObjectProp(urlsList, 'id'), type: FieldType.string },
{ name: 'URL', values: reduceByObjectProp(urlsList, 'url'), type: FieldType.string },
{ name: 'Method', values: reduceByObjectProp(urlsList, 'method'), type: FieldType.string },
{ name: 'Status', values: reduceByObjectProp(urlsList, 'status'), type: FieldType.number },
{ name: 'Count', values: reduceByObjectProp(urlsList, 'count'), type: FieldType.number },
{ name: 'Min', values: reduceByObjectProp(urlsList, 'min'), type: FieldType.number, config: { unit: 'ms' } },
{
name: 'Mean',
values: reduceByObjectProp(urlsList, 'mean'),
type: FieldType.number,
config: { unit: 'ms' },
},
{ name: 'P95', values: reduceByObjectProp(urlsList, 'p95'), type: FieldType.number, config: { unit: 'ms' } },
{ name: 'P99', values: reduceByObjectProp(urlsList, 'p99'), type: FieldType.number, config: { unit: 'ms' } },
{ name: 'Max', values: reduceByObjectProp(urlsList, 'max'), type: FieldType.number, config: { unit: 'ms' } },
];
}
convertSeriesToFields(series: K6Series, metric: K6Metric, tags?: Map<string, string>, aggregation?: string) {
const timeValues = _.map(series.values, (d) => {
return dateTimeParse(d.timestamp).unix() * 1000;
});
const values = _.map(series.values, (d) => {
return d.value;
});
let valueName = metric!.name;
let tagsList = '';
if (tags) {
tagsList = _.join(
_.map(Object.fromEntries(tags), (value, key) => `${key}:${value}`),
', '
);
if (tagsList !== '') {
valueName = `${valueName}<${tagsList}>`;
}
}
if (aggregation) {
let m = aggregation.match(PERCENTILE_REGEX);
if (m?.length === 2) {
let percentile = parseFloat(m[1]);
valueName = `p(${percentile}, ${valueName})`;
} else {
valueName = `${aggregation}(${valueName})`;
}
}
return [
{ name: 'Time', values: timeValues, type: FieldType.time },
{
name: valueName,
values: values,
type: FieldType.number,
config: { unit: getUnitFromMetric(metric!, aggregation) },
},
];
}
async getOrganizations() {
const cacheKey = `getOrganizations`;
let data: K6Organization[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/v3/organizations`,
method: 'GET',
requestId: 'k6-cloud-v3-organizations',
})
.then((response) => {
return _.map(response.data.organizations, (org) => {
return {
id: Number(org.id),
name: String(org.name),
};
});
})
.catch((error) => {
console.log('ERROR (getProjects): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_ORGANIZATIONS);
}
return data;
}
async getProjectsForOrganization(organizationId: number) {
const cacheKey = `getProjectsForOrganization(${organizationId})`;
let data: K6Project[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/v3/organizations/${organizationId}/projects`,
method: 'GET',
requestId: `k6-cloud-v3-organizations-${organizationId}-projects`,
})
.then((response) => {
return _.map(response.data.projects, (project) => {
return {
id: Number(project.id),
name: String(project.name),
organizationId: organizationId,
organizationName: '',
};
});
})
.catch((error) => {
console.log('ERROR (getProjectsForOrganization): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_PROJECTS);
}
return data;
}
async getAllProjects() {
const srv = getBackendSrv();
return srv
.datasourceRequest({
url: this.url + '/base' + '/v3/organizations',
method: 'GET',
requestId: 'k6-cloud-v3-organizations',
})
.then((response) => {
return Promise.all(
_.map(response.data.organizations, (org) => {
return srv
.datasourceRequest({
url: `${this.url}/base/v3/organizations/${org.id}/projects`,
method: 'GET',
requestId: `k6-cloud-v3-organizations-${org.id}-projects`,
})
.then((response) => {
return _.map(response.data.projects, (project) => {
return {
id: Number(project.id),
name: String(project.name),
organizationId: Number(org.id),
organizationName: String(org.name),
};
});
})
.catch((error) => {
console.log('ERROR (getAllProjects): ', error);
return [];
});
})
);
})
.catch((error) => {
console.log('ERROR (getAllProjects): ', error);
return [];
});
}
async getTestsForProject(projectId: number) {
const cacheKey = `getTestsForProject(${projectId})`;
let data: K6Test[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/loadtests/v2/tests?project_id=${projectId}&page=1&page_size=25&order_by=-last_run_time`,
method: 'GET',
requestId: `k6-cloud-loadtests-v2-project-${projectId}`,
})
.then((response) => {
return _.sortBy(
_.map(response.data['k6-tests'], (t) => {
return {
id: Number(t.id),
projectId: Number(t.project_id),
name: String(t.name),
lastTestRunId: t.last_test_run_id ? Number(t.last_test_run_id) : undefined,
created: new Date(t.created),
};
}),
['-lastTestRunId', '-created']
);
})
.catch((error) => {
console.log('ERROR (getTestsForProject): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_TESTS);
}
return data;
}
async getTestRunsForTest(testId: number) {
const cacheKey = `getTestRunsForTest(${testId})`;
let data: K6TestRun[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/loadtests/v2/runs?page=1&page_size=50&test_id=${testId}&order_by=-started&exclude_events=true&method=0.95`,
method: 'GET',
requestId: `k6-cloud-loadtests-v2-runs-${testId}`,
})
.then((response) => {
return _.sortBy(
_.map(response.data['k6-runs'], (t) => {
return {
id: Number(t.id),
created: new Date(t.created),
started: new Date(t.started),
ended: new Date(t.ended),
duration: Number(t.duration),
vus: Number(t.vus),
loadTime: parseFloat(t.load_time),
rpsAvg: parseFloat(t.rps_avg),
rpsMax: parseFloat(t.rps_max),
runStatus: Number(t.run_status),
resultStatus: Number(t.result_status),
};
}),
['-created']
);
})
.catch((error) => {
console.log('ERROR (getTestRunsForTest): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_TEST_RUNS);
}
return data;
}
async getURLsForTestRun(testRunId: number) {
const cacheKey = `getURLsForTestRun(${testRunId})`;
let data: K6Url[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/loadtests/v3/urls?test_run_id=${testRunId}`,
method: 'GET',
requestId: `k6-cloud-loadtests-v3-urls-${testRunId}`,
})
.then((response) => {
return _.map(response.data['k6-urls'], (u) => {
return {
id: String(u.id),
projectId: Number(u.project_id),
testRunId: Number(u.test_run_id),
groupId: u.group_id ? String(u.group_id) : undefined,
metrics: undefined,
url: String(u.name),
method: String(u.method),
status: Number(u.status),
httpStatus: Number(u.http_status),
isWebSocket: !!u.is_web_socket,
count: Number(u.count),
loadTime: parseFloat(u.load_time),
min: parseFloat(u.min),
max: parseFloat(u.max),
mean: parseFloat(u.mean),
stdev: parseFloat(u.stdev),
p95: parseFloat(u.p95),
p99: parseFloat(u.p99),
};
});
})
.catch((error) => {
console.log('ERROR (getURLsForTestRun): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_URLS);
}
return data;
}
async getChecksForTestRun(testRunId: number) {
const cacheKey = `getChecksForTestRun(${testRunId})`;
let data: K6Check[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/loadtests/v3/checks?test_run_id=${testRunId}`,
method: 'GET',
requestId: `k6-cloud-loadtests-v3-checks-${testRunId}`,
})
.then((response) => {
return _.map(response.data['k6-checks'], (c) => {
return {
id: String(c.id),
projectId: Number(c.project_id),
testRunId: Number(c.test_run_id),
groupId: c.group_id ? String(c.group_id) : undefined,
metrics: undefined,
name: String(c.name),
firstSeen: new Date(c.first_seen),
success: !!c.success,
successCount: Number(c.success_count),
totalCount: Number(c.total_count),
};
});
})
.catch((error) => {
console.log('ERROR (getChecksForTestRun): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_CHECKS);
}
return data;
}
async getThresholdsForTestRun(testRunId: number) {
const cacheKey = `getThresholdsForTestRun(${testRunId})`;
let data: K6Threshold[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/loadtests/v2/thresholds?test_run_id=${testRunId}`,
method: 'GET',
requestId: `k6-cloud-loadtests-v2-thresholds-${testRunId}`,
})
.then((response) => {
return _.map(response.data['k6-thresholds'], (t) => {
return {
id: String(t.id),
testRunId: Number(t.test_run_id),
metricId: String(t.metric_id),
name: String(t.name),
stat: String(t.stat),
calculatedValue: parseFloat(t.calculated_value),
tainted: !!t.tainted,
taintedAt: t.tainted_at ? new Date(t.tainted_at) : undefined,
value: parseFloat(t.value),
};
});
})
.catch((error) => {
console.log('ERROR (getThresholdsForTestRun): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_THRESHOLDS);
}
return data;
}
async getMetricsForTestRun(testRunId: number) {
const cacheKey = `getMetricsForTestRun(${testRunId})`;
let data: K6Metric[] = await this.cache?.get(cacheKey);
if (!data) {
const srv = getBackendSrv();
data = await srv
.datasourceRequest({
url: `${this.url}/base/loadtests/v2/metrics?test_run_id=${testRunId}`,
method: 'GET',
// Note: no "requestId" here as we want to support running several panel queries
// in parallel which depends on resolving metric names to metric IDs for the k6 Cloud API.
})
.then((response) => {
return _.map(response.data['k6-metrics'], (t) => {
return {
id: String(t.id),
name: String(t.name),
type: getEnumFromMetricType(String(t.type)),
contains: String(t.contains),
groupId: t.group_id ? String(t.group_id) : undefined,
urlId: t.url_id ? String(t.url_id) : undefined,
thresholdId: t.threshold_id ? String(t.threshold_id) : undefined,
checkId: t.check_id ? String(t.check_id) : undefined,
tags: _.isObject(t.tags) ? new Map<string, string>(_.toPairs(t.tags)) : undefined,
};
});
})
.catch((error) => {
console.log('ERROR (getMetricsForTestRun): ', error);
return [];
});
await this.cache?.set(cacheKey, data, CACHE_TTL_METRICS);
}
return data;
}
async getSeriesForMetric(testRunId: number, metricId: string, aggregation?: string, tags?: Map<string, string>) {
aggregation = aggregation ? aggregation : 'avg';
let m = aggregation.match(PERCENTILE_REGEX);
if (m?.length === 2) {
let percentile = parseFloat(m[1]);
aggregation = `${percentile / 100.0}`;
}
let tagsQs = '';
if (tags) {
tagsQs = _.join(
Array.from(tags, (value) => `${encodeURIComponent(value[0])}:${encodeURIComponent(value[1])}`),
`&${encodeURIComponent('tags[]')}=`
);
}
if (tagsQs) {
tagsQs = `${encodeURIComponent('tags[]')}=${tagsQs}`;
}
return getBackendSrv()
.datasourceRequest({
url: `${this.url}/base/loadtests/v2/series?test_run_id=${testRunId}&ids%5B%5D=${metricId}&method=${aggregation}&${tagsQs}`,
method: 'GET',
requestId: `k6-cloud-loadtests-v2-series-${testRunId}-${metricId}`,
})
.then((response) => {
const d = response.data['k6-serie'][0];
return {
id: String(d.id),
metricId: String(d.metric_id),
aggregation: String(d.method),
values: _.map(d.values || [], (p) => ({ timestamp: p.timestamp, value: parseFloat(p.value) })),
};
})
.catch((error) => {
return {
id: error,
metricId: error,
aggregation: '',
values: [],
};
});
}
async getTagsForTestRun(testRunId: number) {
return getBackendSrv()
.datasourceRequest({
url: `${this.url}/base/loadtests/v2/runs/${testRunId}/tags`,
method: 'GET',
requestId: `k6-cloud-loadtests-v2-runs-${testRunId}-tags`,
})
.then((response) => {
return response.data;
})
.catch((error) => {
console.log('ERROR (getTagsForTestRun): ', error);
return [];
});
}
async getTagValuesForTag(testRunId: number, tag: string) {
return getBackendSrv()
.datasourceRequest({
url: `${this.url}/base/loadtests/v2/runs/${testRunId}/tag-values/${tag}`,
method: 'GET',
requestId: `k6-cloud-loadtests-v2-runs-${testRunId}-tag-values-${tag}`,
})
.then((response) => {
return response.data;
})
.catch((error) => {
console.log('ERROR (getTagValuesForTag): ', error);
return [];
});
}
resolveVar(target: string, defaultValue?: number) {
const resolved = getTemplateSrv().replace(target)?.match(VAR_QUERY_ID_REGEX);
return resolved ? Number(resolved[1]) : defaultValue;
}
}