diff --git a/package.json b/package.json index 7c98797..fe4518e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "barky", - "version": "1.0.54", + "version": "1.0.55", "description": "A simple cloud services watchdog with digest notification support & no external dependencies", "homepage": "https://github.com/Rohland/barky#readme", "main": "dist/cli.js", diff --git a/src/evaluators/base.spec.ts b/src/evaluators/base.spec.ts index 92549b5..6d5a3c8 100644 --- a/src/evaluators/base.spec.ts +++ b/src/evaluators/base.spec.ts @@ -39,6 +39,46 @@ describe("base evaluator", () => { }); }); }); + describe("when app is missing name", () => { + it("should use inferred name by key", async () => { + // arrange + const myApp = {}; + const config = { + ["web"]: { + myApp + } + }; + const e = new WebEvaluator(config); + + // act + const apps = e.getAppsToEvaluate() + + // assert + expect(apps.length).toEqual(1); + expect(apps[0].name).toEqual("myApp"); + }); + }); + describe("when app is not missing name", () => { + it("should use name", async () => { + // arrange + const myApp = { + name: "test" + }; + const config = { + ["web"]: { + myApp + } + }; + const e = new WebEvaluator(config); + + // act + const apps = e.getAppsToEvaluate() + + // assert + expect(apps.length).toEqual(1); + expect(apps[0].name).toEqual("test"); + }); + }); }); describe("given an evaluator", () => { @@ -57,9 +97,9 @@ describe("base evaluator", () => { const apps3 = evaluator.getAppsToEvaluate(); // assert - expect(apps1).toEqual([{ type: "custom" }]); - expect(apps2).toEqual([{ type: "custom" }]); - expect(apps3).toEqual([{ type: "custom" }]); + expect(apps1).toEqual([{ type: "custom", name: "app1" }]); + expect(apps2).toEqual([{ type: "custom", name: "app1" }]); + expect(apps3).toEqual([{ type: "custom", name: "app1" }]); expect(evaluator.skippedApps).toEqual([]); }); }); @@ -351,7 +391,7 @@ describe("base evaluator", () => { this.results.push(result); } - configureAndExpandApp(_app: IApp, _name: string): IApp[] { + configureAndExpandApp(_app: IApp): IApp[] { return []; } @@ -469,7 +509,7 @@ class CustomEvaluator extends BaseEvaluator { super(config); } - configureAndExpandApp(app: IApp, _name: string): IApp[] { + configureAndExpandApp(app: IApp): IApp[] { return [app]; } diff --git a/src/evaluators/base.ts b/src/evaluators/base.ts index f433d71..128f53d 100644 --- a/src/evaluators/base.ts +++ b/src/evaluators/base.ts @@ -98,7 +98,7 @@ export abstract class BaseEvaluator { protected abstract dispose(): Promise; - abstract configureAndExpandApp(app: IApp, name: string): IApp[]; + abstract configureAndExpandApp(app: IApp): IApp[]; abstract get type(): EvaluatorType; @@ -115,10 +115,11 @@ export abstract class BaseEvaluator { const apps = []; for (let name of appNames) { const app = this.config[name]; - const expanded = this.configureAndExpandApp(app, name); + app.name ??= name; + const expanded = this.configureAndExpandApp(app); expanded.forEach(x => { x.type = this.type; - if (this.shouldEvaluateApp(x, name)) { + if (this.shouldEvaluateApp(x)) { apps.push(x); } }); @@ -128,23 +129,21 @@ export abstract class BaseEvaluator { return expanded; } - private shouldEvaluateApp( - app: IApp, - name: string): boolean { + private shouldEvaluateApp(app: IApp): boolean { if (!app.every) { return true; } const durationMs = parsePeriodToSeconds(app.every) * 1000; const everyCount = Math.round(durationMs / LoopMs); - const key = `${ this.type }-${ name }`; + const key = `${ this.type }-${ app.name }`; const count = executionCounter.get(key) ?? 0; const shouldEvaluate = count % everyCount === 0; executionCounter.set(key, count + 1); if (!shouldEvaluate) { - log(`skipping ${ this.type } check for '${ name }' - every set to: ${ app.every }`); + log(`skipping ${ this.type } check for '${ app.name }' - every set to: ${ app.every }`); this.skippedApps.push({ ...app, - ...this.generateSkippedAppUniqueKey(name) + ...this.generateSkippedAppUniqueKey(app.name) }); } return shouldEvaluate; diff --git a/src/evaluators/mysql.ts b/src/evaluators/mysql.ts index 9e1fd65..81e9cff 100644 --- a/src/evaluators/mysql.ts +++ b/src/evaluators/mysql.ts @@ -17,8 +17,8 @@ export class MySqlEvaluator extends BaseEvaluator { return EvaluatorType.mysql; } - configureAndExpandApp(app: IApp, name: string): IApp[] { - return getAppVariations(app, name).map(variant => { + configureAndExpandApp(app: IApp): IApp[] { + return getAppVariations(app).map(variant => { return { timeout: 15000, ...app, @@ -54,7 +54,17 @@ async function tryEvaluate(app: IApp): Promise { const timer = startClock(); const results = await runQuery(connection, app); app.timeTaken = stopClock(timer); - return validateResults(app, results); + const finalResults = validateResults(app, results); + return finalResults.length > 0 + ? finalResults + : new MySqlResult( // no results from mysql means OK for all identifiers! + app.name, + "*", + "inferred", + "OK", + app.timeTaken, + true, + app); } catch (err) { log(`Error evaluating app ${ app.name }: ${ err.message }`, err); return new MonitorFailureResult( @@ -65,7 +75,7 @@ async function tryEvaluate(app: IApp): Promise { } } -export function validateResults(app, results): Result[] { +export function validateResults(app: IApp, results: Result[]): Result[] { return results.map(row => { const identifier = row[app.identifier] ?? app.identifier; const rules = findTriggerRulesFor(identifier, app); @@ -73,7 +83,7 @@ export function validateResults(app, results): Result[] { }); } -function generateVariablesAndValues(row, app) { +function generateVariablesAndValues(row, app: IApp) { const variables = Object.keys(row).filter(x => x !== app.identifier); const values = {}; const emit: Array = app.emit ?? []; @@ -87,8 +97,8 @@ function generateVariablesAndValues(row, app) { } export function validateRow( - app, - identifier, + app: IApp, + identifier: string, row, rules: IRule[]): MySqlResult { if (!rules || rules.length === 0) { diff --git a/src/evaluators/sumo.ts b/src/evaluators/sumo.ts index dd1c99b..9bdf0fa 100644 --- a/src/evaluators/sumo.ts +++ b/src/evaluators/sumo.ts @@ -22,8 +22,8 @@ export class SumoEvaluator extends BaseEvaluator { return EvaluatorType.sumo; } - configureAndExpandApp(app: IApp, name: string): IApp[] { - return getAppVariations(app, name).map(variant => { + configureAndExpandApp(app: IApp): IApp[] { + return getAppVariations(app).map(variant => { return { timeout: 10000, ...app, diff --git a/src/evaluators/web.ts b/src/evaluators/web.ts index 153ff8c..0909249 100644 --- a/src/evaluators/web.ts +++ b/src/evaluators/web.ts @@ -19,8 +19,8 @@ export class WebEvaluator extends BaseEvaluator { return await tryEvaluate(app); } - configureAndExpandApp(app: IApp, name: string): IApp[] { - return getAppVariations(app, name).map(variant => { + configureAndExpandApp(app: IApp): IApp[] { + return getAppVariations(app).map(variant => { return { ...app, ...variant diff --git a/src/lib/utility.spec.ts b/src/lib/utility.spec.ts index f8554d7..ff6b5f4 100644 --- a/src/lib/utility.spec.ts +++ b/src/lib/utility.spec.ts @@ -1,4 +1,12 @@ -import { flatten, hash, initLocaleAndTimezone, isToday, shortHash, toLocalTimeString } from "./utility"; +import { + flatten, + hash, + initLocaleAndTimezone, + isToday, + shortHash, + toLocalTimeString, + tryExecuteTimes +} from "./utility"; describe("utility functions", () => { describe("flatten", () => { @@ -197,4 +205,57 @@ describe("utility functions", () => { }); }); }); + + describe("tryExecuteTimes", () => { + describe("on success", () => { + it("should return", async () => { + // arrange + const func = jest.fn().mockResolvedValue("ok"); + + // act + const result = await tryExecuteTimes("test", 3, func); + + // assert + expect(func).toHaveBeenCalledTimes(1); + expect(result).toEqual("ok"); + }); + }); + describe("on failure but then success", () => { + it("should return success", async () => { + // arrange + const func = jest.fn().mockRejectedValueOnce(new Error("test")).mockResolvedValue("ok"); + + // act + const result = await tryExecuteTimes("test", 3, func); + + // assert + expect(func).toHaveBeenCalledTimes(2); + expect(result).toEqual("ok"); + }); + }); + describe("on successive failure", () => { + it("should retry the number of times and throw", async () => { + // arrange + const func = jest.fn().mockRejectedValue(new Error("test")); + + // act + await expect(tryExecuteTimes("test", 3, func)).rejects.toThrow("test"); + + // assert + expect(func).toHaveBeenCalledTimes(3); + }); + describe("with throw set to false", () => { + it("should not throw", async () => { + // arrange + const func = jest.fn().mockRejectedValue(new Error("test")); + + // act + await tryExecuteTimes("test", 3, func, false, 0); + + // assert + expect(func).toHaveBeenCalledTimes(3); + }); + }); + }); + }); }); diff --git a/src/lib/utility.ts b/src/lib/utility.ts index 9ab30e6..cd3bda8 100644 --- a/src/lib/utility.ts +++ b/src/lib/utility.ts @@ -1,4 +1,6 @@ import * as crypto from "crypto"; +import { log } from "../models/logger"; +import { sleepMs } from "./sleep"; Error.stackTraceLimit = Infinity; @@ -95,3 +97,27 @@ export function shortHash(key: string) { .update(key ?? "") .digest("hex"); } + +export async function tryExecuteTimes( + label: string, + times: number, + func: () => Promise, + throwOnEventualFailure: boolean = true, + delayBetweenAttempts: number = 500): Promise { + let counter = 0; + let lastError = null; + while(counter++ < times) { + try { + return await func(); + } catch(err) { + const msg = `Error ${ label }: ${ err.message }`; + log(msg, err); + lastError = err; + } + await sleepMs(delayBetweenAttempts); + } + if (throwOnEventualFailure && lastError) { + throw lastError; + } + return null; +} diff --git a/src/models/app.spec.ts b/src/models/app.spec.ts index b7cd033..1f279e4 100644 --- a/src/models/app.spec.ts +++ b/src/models/app.spec.ts @@ -11,12 +11,13 @@ describe("getVariations", () => { it("should return app as is", async () => { // arrange const app = { + name: "codeo.co.za", url: "https://www.codeo.co.za", "vary-by": varyBy }; // act - const result = getAppVariations(app, "codeo.co.za"); + const result = getAppVariations(app); // assert expect(result).toEqual([{ @@ -34,7 +35,7 @@ describe("getVariations", () => { }; // act - const result = getAppVariations(app, "codeo.co.za"); + const result = getAppVariations(app); // assert expect(result).toEqual([{ @@ -55,11 +56,12 @@ describe("getVariations", () => { ])(`when given %s`, (varyBy, name, expected) => { it("should return variant names", async () => { const app = { - "vary-by": varyBy + "vary-by": varyBy, + name }; // act - const result = getAppVariations(app, name); + const result = getAppVariations(app); // assert const expectedResults = expected.map(x => ({ @@ -81,12 +83,13 @@ describe("getVariations", () => { ])(`when given %s`, (varyBy, url, expected) => { it("should return expected", async () => { const app = { + name: "codeo", url: url, "vary-by": varyBy }; // act - const result = getAppVariations(app, "codeo"); + const result = getAppVariations(app); // assert const expectedResults = expected.map(x => ({ @@ -97,33 +100,6 @@ describe("getVariations", () => { }); }); }); - describe("queries", () => { - describe.each([ - [null, "codeo", ["codeo"]], - [undefined, "codeo", ["codeo"]], - [[], "codeo", ["codeo"]], - [["a"], "codeo-$1", ["codeo-a"]], - [["a", "b"], "codeo-$1", ["codeo-a", "codeo-b"]], - [[["a", "b"]], "codeo-$1-$2", ["codeo-a-b"]], - ])(`when given %s`, (varyBy, query, expected) => { - it("should return variant names", async () => { - const app = { - "vary-by": varyBy, - query - }; - - // act - const result = getAppVariations(app, "codeo"); - - // assert - const expectedResults = expected.map(x => ({ - name: "codeo", - query: x - })); - expect(result).toEqual(expectedResults); - }); - }); - }); }); }); }); diff --git a/src/models/app.ts b/src/models/app.ts index 983b3a3..a4ca0be 100644 --- a/src/models/app.ts +++ b/src/models/app.ts @@ -40,11 +40,10 @@ export class AppVariant { } } -export function getAppVariations(app: any, name: string): AppVariant[] { +export function getAppVariations(app: any): AppVariant[] { const apps = app["vary-by"]?.length > 0 ? app["vary-by"] : [null]; - app.name ??= name; return apps.map(variant => { return new AppVariant(app, variant); }); diff --git a/src/models/channels/slack.ts b/src/models/channels/slack.ts index 9d9425b..c656cd7 100644 --- a/src/models/channels/slack.ts +++ b/src/models/channels/slack.ts @@ -2,9 +2,8 @@ import { Snapshot } from "../snapshot"; import { AlertState } from "../alerts"; import { ChannelConfig, ChannelType } from "./base"; import axios from "axios"; -import { pluraliseWithS, toLocalTimeString } from "../../lib/utility"; +import { pluraliseWithS, toLocalTimeString, tryExecuteTimes } from "../../lib/utility"; import { AlertConfiguration } from "../alert_configuration"; -import { log } from "../logger"; export class SlackChannelConfig extends ChannelConfig { public channel: string; @@ -171,72 +170,73 @@ export class SlackChannelConfig extends ChannelConfig { message: string, state?: any, reply: boolean = false): Promise { - const body = { - channel: state?.channel ?? this.channel, - text: message, - unfurl_links: false - }; - const postMessageUrl = "https://slack.com/api/chat.postMessage"; - const updateMessageUrl = "https://slack.com/api/chat.update"; - let url = postMessageUrl; - if (reply && state?.ts) { - body["thread_ts"] = state.ts; - } else { - body["ts"] = state?.ts; - if (state) { - url = updateMessageUrl; - } - } - const config = { - method: 'post', - url, - headers: { - 'Authorization': `Bearer ${ this.token }`, - 'Content-type': 'application/json;charset=utf-8', - 'Accept': '*/*', - }, - data: JSON.stringify(body) - }; - try { - const result = await axios.request(config); - if (result.data?.error) { - throw new Error(result.data.error); - } - return { - channel: result.data.channel, - ts: result.data.ts - }; - } catch (err) { - const msg = `Error posting to Slack: ${ err.message }`; - log(msg, err); - throw new Error(msg); - } + return await tryExecuteTimes( + `posting to slack`, + 3, + async () => { + const body = { + channel: state?.channel ?? this.channel, + text: message, + unfurl_links: false + }; + const postMessageUrl = "https://slack.com/api/chat.postMessage"; + const updateMessageUrl = "https://slack.com/api/chat.update"; + let url = postMessageUrl; + if (reply && state?.ts) { + body["thread_ts"] = state.ts; + } else { + body["ts"] = state?.ts; + if (state) { + url = updateMessageUrl; + } + } + const config = { + method: 'post', + url, + headers: { + 'Authorization': `Bearer ${ this.token }`, + 'Content-type': 'application/json;charset=utf-8', + 'Accept': '*/*', + }, + data: JSON.stringify(body) + }; + const result = await axios.request(config); + if (result.data?.error) { + throw new Error(result.data.error); + } + return { + channel: result.data.channel, + ts: result.data.ts + }; + }); } private async reactToSlackMessage(state: any, reaction: string) { if (!state) { return; } - const body = { - name: reaction, - channel: state?.channel ?? this.channel, - timestamp: state.ts - }; - const config = { - method: 'post', - url: "https://slack.com/api/reactions.add", - headers: { - 'Authorization': `Bearer ${ this.token }`, - 'Content-type': 'application/json;charset=utf-8', - 'Accept': '*/*', + return await tryExecuteTimes( + `reacting to slack message with ${ reaction }`, + 3, + async () => { + const body = { + name: reaction, + channel: state?.channel ?? this.channel, + timestamp: state.ts + }; + const config = { + method: 'post', + url: "https://slack.com/api/reactions.add", + headers: { + 'Authorization': `Bearer ${ this.token }`, + 'Content-type': 'application/json;charset=utf-8', + 'Accept': '*/*', + }, + data: JSON.stringify(body) + }; + await axios.request(config); }, - data: JSON.stringify(body) - }; - try { - await axios.request(config); - } catch (err) { - const msg = `Error posting to Slack: ${ err.message }`; - log(msg, err); - } + false); + } }