Skip to content

Commit

Permalink
Merge pull request #2 from Informatievlaanderen/chore/add-prometheus-…
Browse files Browse the repository at this point in the history
…metrics

Chore/add prometheus metrics
  • Loading branch information
rorlic authored Mar 29, 2024
2 parents 11a357b + abde08b commit 1ebd1c3
Show file tree
Hide file tree
Showing 5 changed files with 51 additions and 38 deletions.
9 changes: 3 additions & 6 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions

name: 1. Build & Test JMeter Runner
name: 1. Build JMeter Runner

on:
workflow_dispatch:
Expand All @@ -28,7 +28,7 @@ jobs:
- uses: actions/checkout@v4

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

Expand All @@ -44,13 +44,10 @@ jobs:
format: HTML

- name: Upload Test results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Depcheck report jmeter-runner-${{ matrix.node-version }}
path: ${{ github.workspace }}/reports/

- name: Build
run: npm run build

- name: Test
run: npm test
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"description": "Test runner for jmeter",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc -p tsconfig.json",
"start": "npm run build && node dist/server.js"
},
Expand Down
55 changes: 30 additions & 25 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import * as cp from 'node:child_process';
import { XMLParser } from "fast-xml-parser";
import { read } from 'read-last-lines';

import { JMeterTest, TestRun, TestRunStatus } from "./interfaces";
import { JMeterTest, TestRun, TestRunStatus, ControllerConfig } from "./interfaces";
import { Gauge } from 'prom-client';

export const metadataName = 'metadata.json';
const testName = 'test.jmx';
Expand Down Expand Up @@ -49,6 +50,11 @@ export class Controller {

private _testsById: TestRunDatabase = {};
private _testParser = new XMLParser({ stopNodes: ['jmeterTestPlan.hashTree.hashTree'], ignoreAttributes: false, attributeNamePrefix: '_' });
private _testDuration: Gauge = new Gauge({
name: 'jmeter_test_duration',
help: 'jmeter test duration (in seconds)',
labelNames: ['category', 'name'],
});

private get _tests() {
return Object.values(this._testsById);
Expand Down Expand Up @@ -76,7 +82,7 @@ export class Controller {
}

private _writeMetadata(run: TestRun) {
const metadata = path.join(this._baseFolder, run.id, metadataName);
const metadata = path.join(this._config.baseFolder, run.id, metadataName);
this._write(metadata, JSON.stringify(run));
}

Expand All @@ -88,21 +94,18 @@ export class Controller {
fs.writeFileSync(fullPathName, data, { encoding: 'utf8', flush: true });
}

constructor(
private _baseFolder: string,
private _baseUrl: string,
private _refreshTimeInSeconds: number,
private _logFolder: string,
private _silent: boolean) { }
constructor(private _config: ControllerConfig) {
_config.register.registerMetric(this._testDuration);
}

public get runningCount(): number {
return Object.values(this._testsById).filter(x => x.run.status == TestRunStatus.running).length;
}

public async importTestRuns() {
const folders = await this._getSubDirectories(this._baseFolder);
const folders = await this._getSubDirectories(this._config.baseFolder);
folders.forEach(id => {
const metadata = path.join(this._baseFolder, id, metadataName);
const metadata = path.join(this._config.baseFolder, id, metadataName);
if (fs.existsSync(metadata)) {
const fd = fs.openSync(metadata, 'r');
try {
Expand Down Expand Up @@ -132,8 +135,8 @@ export class Controller {
}

public deleteTest(id: string) {
const testRunData = path.join(this._baseFolder, id);
const logFile = path.join(this._logFolder, `${id}.log`);
const testRunData = path.join(this._config.baseFolder, id);
const logFile = path.join(this._config.logFolder, `${id}.log`);

const exists = this.testExists(id);
if (exists) {
Expand All @@ -151,14 +154,14 @@ export class Controller {

const testDataExists = fs.existsSync(testRunData);
if (testDataExists) {
if (!this._silent) console.info(`[INFO] Deleting test data at ${testRunData}...`);
if (!this._config.silent) console.info(`[INFO] Deleting test data at ${testRunData}...`);
fs.rmSync(testRunData, { recursive: true, force: true });
console.warn(`[WARN] Deleted test data at ${testRunData}.`);
}

const logFileExists = fs.existsSync(logFile);
if (logFileExists) {
if (!this._silent) console.info(`[INFO] Deleting log file at ${logFile}...`);
if (!this._config.silent) console.info(`[INFO] Deleting log file at ${logFile}...`);
fs.rmSync(logFile);
console.warn(`[WARN] Deleted log file at ${logFile}.`);
}
Expand All @@ -174,11 +177,11 @@ export class Controller {
const test = this._getTest(id);
if (!test) throw new Error(`Test ${id} does not exist.`);

const logs = path.join(this._logFolder, `${id}.log`);
const logs = path.join(this._config.logFolder, `${id}.log`);
const output = limit ? await read(logs, limit) : this._read(logs);
const data = {
...test.run,
refresh: test.run.status === TestRunStatus.running ? this._refreshTimeInSeconds : false,
refresh: test.run.status === TestRunStatus.running ? this._config.refreshTimeInSeconds : false,
output: output,
};
return Mustache.render(statusTemplate, data);
Expand All @@ -188,7 +191,7 @@ export class Controller {
const tests = this._tests;

if (!tests.length) {
return Mustache.render(noTestsFoundTemplate, { refresh: this._refreshTimeInSeconds });
return Mustache.render(noTestsFoundTemplate, { refresh: this._config.refreshTimeInSeconds });
}

const runs = tests
Expand All @@ -197,11 +200,11 @@ export class Controller {
.map(test => {
switch (test.status) {
case TestRunStatus.done:
return { ...test, link: `${this._baseUrl}/${test.id}/results/`, text: 'results' };
return { ...test, link: `${this._config.baseUrl}/${test.id}/results/`, text: 'results' };
case TestRunStatus.cancelled:
return { ...test, link: `${this._baseUrl}/${test.id}`, text: 'output' };
return { ...test, link: `${this._config.baseUrl}/${test.id}`, text: 'output' };
case TestRunStatus.running:
return { ...test, link: `${this._baseUrl}/${test.id}`, text: 'status' };
return { ...test, link: `${this._config.baseUrl}/${test.id}`, text: 'status' };
default:
throw new Error(`Unknown test status: `, test.status);
}
Expand All @@ -215,21 +218,22 @@ export class Controller {
});

const data = {
refresh: this._refreshTimeInSeconds,
refresh: this._config.refreshTimeInSeconds,
tests: runsByCategoryAndName,
};
return Mustache.render(overviewTemplate, data);
}

public async scheduleTestRun(body: string, category: string | undefined) {
const id = uuidv4();
const folder = path.join(this._baseFolder, id);
const folder = path.join(this._config.baseFolder, id);
fs.mkdirSync(folder);

await fsp.writeFile(path.join(folder, testName), body);

const parsed = this._testParser.parse(body) as JMeterTest;
const timestamp = new Date().toISOString();
const endTimer = this._testDuration.startTimer();

const jmeter = cp.spawn('jmeter', ['-n', '-t', `${testName}`, '-l', `${reportName}`, '-e', '-o', `${resultsFolder}`], { cwd: folder });
const run = {
Expand All @@ -245,17 +249,18 @@ export class Controller {

jmeter.on('close', (code) => {
try {
const updatedTest = { run: { ...run, status: TestRunStatus.done, code: code }, process: jmeter } as Test;
const duration = endTimer({ category: run.category, name: run.name });
const updatedTest = { run: { ...run, status: TestRunStatus.done, code: code, duration: duration }, process: jmeter } as Test;
this._writeMetadata(this._upsertTest(updatedTest).run);
} catch (error) {
console.error('Failed to write metadata because: ', error);
}
});

const logs = path.join(this._logFolder, `${id}.log`);
const logs = path.join(this._config.logFolder, `${id}.log`);
jmeter.stdout.pipe(fs.createWriteStream(logs, { encoding: 'utf8', flags: 'a', flush: true, autoClose: true, emitClose: false }));

const statusUrl = `${this._baseUrl}/${id}`;
const statusUrl = `${this._config.baseUrl}/${id}`;
const resultsUrl = `${statusUrl}/results/`;
return { id: id, status: statusUrl, results: resultsUrl };
}
Expand Down
12 changes: 12 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Registry, PrometheusContentType } from 'prom-client';

export interface JMeterTest {
jmeterTestPlan: {
hashTree: {
Expand All @@ -21,4 +23,14 @@ export interface TestRun {
timestamp: string;
status: TestRunStatus;
code: number | undefined;
duration: number | undefined;
}

export interface ControllerConfig {
baseFolder: string,
baseUrl: string,
logFolder: string,
refreshTimeInSeconds: number,
silent: boolean,
register: Registry<PrometheusContentType>
}
12 changes: 6 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@ import fastify from 'fastify'
import fastifyStatic from '@fastify/static';
import minimist from 'minimist'
import fs from 'node:fs';
import * as prometheus from 'prom-client';
import { Registry, collectDefaultMetrics } from 'prom-client';

import { Controller, metadataName } from './controller';
import { ControllerConfig } from './interfaces';

const megabyte = 1048576;
const server = fastify({ bodyLimit: 10 * megabyte });

const register = new prometheus.Registry();
register.setDefaultLabels({app: 'jmeter-runner'});
const register = new Registry();
register.setDefaultLabels({ app: 'jmeter-runner' });

prometheus.collectDefaultMetrics({
collectDefaultMetrics({
register: register,
prefix: 'node_',
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});

server.get('/prometheus', async (_, reply) => {
Expand Down Expand Up @@ -54,7 +54,7 @@ if (!fs.existsSync(logFolder)) {
logFolder = fs.realpathSync(logFolder);
console.info("Storing logs in: ", logFolder);

const controller = new Controller(baseFolder, baseUrl, refreshTimeInSeconds, logFolder, silent);
const controller = new Controller({ baseFolder, baseUrl, refreshTimeInSeconds, logFolder, silent, register } as ControllerConfig);

function checkApiKey(request: any, apiKey: string): boolean {
return !apiKey || request.headers['x-api-key'] === apiKey;
Expand Down

0 comments on commit 1ebd1c3

Please sign in to comment.