diff --git a/migrations/1709293662942_notification-type.js b/migrations/1709293662942_notification-type.js new file mode 100644 index 00000000..61236b54 --- /dev/null +++ b/migrations/1709293662942_notification-type.js @@ -0,0 +1,11 @@ +exports.up = (pgm) => { + pgm.createType("notification_type", ["report_detail", "degradation"] ) + pgm.addColumn({ schema: "jtl", name: "notifications" }, { + notification_type: { + type: "notification_type", + "default": "report_detail", + notNull: true, + }, + }) + pgm.renameColumn({ schema: "jtl", name: "notifications" }, "type", "channel") +} diff --git a/src/server/controllers/item/shared/item-data-processing.ts b/src/server/controllers/item/shared/item-data-processing.ts index 02433370..bbd192f6 100644 --- a/src/server/controllers/item/shared/item-data-processing.ts +++ b/src/server/controllers/item/shared/item-data-processing.ts @@ -32,7 +32,7 @@ import { } from "../../../queries/items" import { ReportStatus } from "../../../queries/items.model" import { getScenarioSettings } from "../../../queries/scenario" -import { sendNotifications } from "../../../utils/notifications/send-notification" +import { sendDegradationNotifications, sendReportNotifications } from "../../../utils/notifications/send-notification" import { scenarioThresholdsCalc } from "../utils/scenario-thresholds-calc" import { extraIntervalMilliseconds } from "./extra-intervals-mapping" import { AnalyticsEvent } from "../../../utils/analytics/anyltics-event" @@ -139,11 +139,14 @@ export const itemDataProcessing = async ({ projectName, scenarioName, itemId }) const thresholdResult = scenarioThresholdsCalc(labelStats, baselineReport.stats, scenarioSettings) if (thresholdResult) { await db.none(saveThresholdsResult(projectName, scenarioName, itemId, thresholdResult)) + if (!thresholdResult.passed) { + await sendDegradationNotifications(projectName, scenarioName, itemId) + } } } } - await sendNotifications(projectName, scenarioName, itemId, overview) + await sendReportNotifications(projectName, scenarioName, itemId, overview) await db.tx(async t => { await t.none(saveItemStats(itemId, JSON.stringify(labelStats), diff --git a/src/server/controllers/item/utils/scenario-thresholds-calc.ts b/src/server/controllers/item/utils/scenario-thresholds-calc.ts index 573bf8d6..60b5a20c 100644 --- a/src/server/controllers/item/utils/scenario-thresholds-calc.ts +++ b/src/server/controllers/item/utils/scenario-thresholds-calc.ts @@ -4,7 +4,7 @@ import { divide } from "mathjs" const PERC = 100 // eslint-disable-next-line max-len -export const scenarioThresholdsCalc = (labelStats: LabelStats[], baselineReportStats: LabelStats[], scenarioSettings) => { +export const scenarioThresholdsCalc = (labelStats: LabelStats[], baselineReportStats: LabelStats[], scenarioSettings): ThresholdValidation => { const results = [] if (!scenarioSettings.errorRate || !scenarioSettings.percentile || !scenarioSettings.throughput) { return undefined @@ -71,3 +71,27 @@ export const scenarioThresholdsCalc = (labelStats: LabelStats[], baselineReportS } } +export interface ThresholdValidation { + passed: boolean + results: ThresholdResultExtended[] + thresholds: { + errorRate: number + throughput: number + percentile: number + } +} + +interface ThresholdResultExtended { + passed: boolean + label: string + result: { + percentile: ThresholdResult + throughput: ThresholdValidation + errorRate: ThresholdResult + } +} + +interface ThresholdResult { + passed: boolean + diffValue: boolean +} diff --git a/src/server/controllers/scenario/notifications/create-notification-controller.ts b/src/server/controllers/scenario/notifications/create-notification-controller.ts index 33fe8713..3d86aa2f 100644 --- a/src/server/controllers/scenario/notifications/create-notification-controller.ts +++ b/src/server/controllers/scenario/notifications/create-notification-controller.ts @@ -5,7 +5,7 @@ import { StatusCode } from "../../../utils/status-code" export const createScenarioNotificationController = async (req: Request, res: Response) => { const { projectName, scenarioName } = req.params - const { type, url, name } = req.body - await db.none(createScenarioNotification(projectName, scenarioName, type, url, name)) + const { type, url, name, channel } = req.body + await db.none(createScenarioNotification(projectName, scenarioName, channel, url, name, type)) res.status(StatusCode.Created).send() } diff --git a/src/server/queries/scenario.ts b/src/server/queries/scenario.ts index ee75bbee..22f59681 100644 --- a/src/server/queries/scenario.ts +++ b/src/server/queries/scenario.ts @@ -151,7 +151,7 @@ export const getProcessingItems = (projectName, scenarioName, environment) => { export const scenarioNotifications = (projectName, scenarioName) => { return { - text: `SELECT notif.id, url, type, notif.name FROM jtl.notifications as notif + text: `SELECT notif.id, url, channel, notification_type as "type", notif.name FROM jtl.notifications as notif LEFT JOIN jtl.scenario as s ON s.id = notif.scenario_id LEFT JOIN jtl.projects as p ON p.id = s.project_id WHERE s.name = $2 AND p.project_name = $1`, @@ -159,14 +159,24 @@ export const scenarioNotifications = (projectName, scenarioName) => { } } -export const createScenarioNotification = (projectName, scenarioName, type, url, name) => { +export const scenarioNotificationsByType = (projectName, scenarioName, type) => { return { - text: `INSERT INTO jtl.notifications(scenario_id, type, url, name) VALUES(( + text: `SELECT notif.id, url, channel, notif.name FROM jtl.notifications as notif + LEFT JOIN jtl.scenario as s ON s.id = notif.scenario_id + LEFT JOIN jtl.projects as p ON p.id = s.project_id + WHERE s.name = $2 AND p.project_name = $1 AND notification_type = $3`, + values: [projectName, scenarioName, type], + } +} + +export const createScenarioNotification = (projectName, scenarioName, channel, url, name, type) => { + return { + text: `INSERT INTO jtl.notifications(scenario_id, channel, url, name, notification_type) VALUES(( SELECT s.id FROM jtl.scenario as s LEFT JOIN jtl.projects as p ON p.id = s.project_id WHERE s.name = $2 AND p.project_name = $1 - ), $3, $4, $5)`, - values: [projectName, scenarioName, type, url, name], + ), $3, $4, $5, $6)`, + values: [projectName, scenarioName, channel, url, name, type], } } diff --git a/src/server/schema-validator/scenario-schema.ts b/src/server/schema-validator/scenario-schema.ts index d41afa10..d84f9f7c 100644 --- a/src/server/schema-validator/scenario-schema.ts +++ b/src/server/schema-validator/scenario-schema.ts @@ -35,8 +35,9 @@ export const querySchema = { export const scenarioNotificationBodySchema = { url: Joi.string().max(URL_MAX_LENGTH).required(), - type: Joi.string().valid(["ms-teams", "gchat", "slack"]).required(), + channel: Joi.string().valid(["ms-teams", "gchat", "slack"]).required(), name: Joi.string().min(1).max(MAX_NUMBER).required(), + type: Joi.string().valid("report_detail", "degradation").required(), } diff --git a/src/server/utils/notifications/send-notification.spec.ts b/src/server/utils/notifications/send-notification.spec.ts index 79e6da94..294cf885 100644 --- a/src/server/utils/notifications/send-notification.spec.ts +++ b/src/server/utils/notifications/send-notification.spec.ts @@ -1,13 +1,13 @@ import { db } from "../../../db/db" -import { sendNotifications } from "./send-notification" +import { sendReportNotifications } from "./send-notification" const linkUrl = require("./link-url") import axios from "axios" const scenarioNotifications = require("../../queries/scenario") -const msTeamsTemplate = require("./templates/ms-teams-template") +const msTeamsTemplate = require("./templates/report/ms-teams-template") jest.mock("axios") -jest.mock("./templates/ms-teams-template") +jest.mock("./templates/report/ms-teams-template") jest.mock("../../../db/db") const OVERVIEW = { @@ -29,34 +29,34 @@ const OVERVIEW = { describe("sendNotification", () => { it("should call linkUrl", async () => { const spy = jest.spyOn(linkUrl, "linkUrl") - await sendNotifications("test", "test", "id", OVERVIEW) + await sendReportNotifications("test", "test", "id", OVERVIEW) expect(spy).toHaveBeenCalledTimes(1) }) it("should trigger `scenarioNotifications` query", async () => { - const spy = jest.spyOn(scenarioNotifications, "scenarioNotifications") - await sendNotifications("test", "test", "id", OVERVIEW) + const spy = jest.spyOn(scenarioNotifications, "scenarioNotificationsByType") + await sendReportNotifications("test", "test", "id", OVERVIEW) expect(spy).toHaveBeenCalledTimes(1) }) it("should not send any request if no notifications found in db", async () => { db.manyOrNone = jest.fn().mockImplementation(() => Promise.resolve([])) - await sendNotifications("test", "test", "id", OVERVIEW) + await sendReportNotifications("test", "test", "id", OVERVIEW) expect(axios).not.toHaveBeenCalled() }) it("should try to send notification request when found in db", async () => { const spy = jest.spyOn(msTeamsTemplate, "msTeamsTemplate") db.manyOrNone = jest.fn().mockImplementation(() => - Promise.resolve([{ url: "test", name: "test-name", type: "ms-teams" }])) + Promise.resolve([{ url: "test", name: "test-name", channel: "ms-teams" }])) const post = axios.post = jest.fn().mockImplementation(() => Promise.resolve({})) - await sendNotifications("test", "test", "id", OVERVIEW) + await sendReportNotifications("test", "test", "id", OVERVIEW) expect(spy).toHaveBeenCalledTimes(1) expect(post).toHaveBeenCalledTimes(1) }) it("should not throw an error when request failed", () => { db.manyOrNone = jest.fn().mockImplementation(() => - Promise.resolve([{ url: "test", name: "test-name", type: "ms-teams" }])) + Promise.resolve([{ url: "test", name: "test-name", channel: "ms-teams" }])) axios.post = jest.fn().mockImplementation(() => Promise.reject(new Error("failed"))) expect(async () => { - await sendNotifications("test", "test", "id", OVERVIEW) + await sendReportNotifications("test", "test", "id", OVERVIEW) }).not.toThrow() }) }) diff --git a/src/server/utils/notifications/send-notification.ts b/src/server/utils/notifications/send-notification.ts index bdfb552b..0541fdc4 100644 --- a/src/server/utils/notifications/send-notification.ts +++ b/src/server/utils/notifications/send-notification.ts @@ -1,21 +1,25 @@ import { db } from "../../../db/db" -import { scenarioNotifications } from "../../queries/scenario" +import { scenarioNotificationsByType } from "../../queries/scenario" import axios from "axios" -import { msTeamsTemplate } from "./templates/ms-teams-template" +import { msTeamsTemplate } from "./templates/report/ms-teams-template" import { logger } from "../../../logger" import { linkUrl } from "./link-url" import { Overview } from "../../data-stats/prepare-data" -import { gchatTemplate } from "./templates/gchat-template" -import { slackTemplate } from "./templates/slack-template" +import { gchatTemplate } from "./templates/report/gchat-template" +import { slackTemplate } from "./templates/report/slack-template" +import { msTeamsDegradationTemplate } from "./templates/degradation/ms-teams-degradation-template" +import { gchatDegradationTemplate } from "./templates/degradation/gchat-degradation-template" +import { slackDegradationTemplate } from "./templates/degradation/slack-degradation-template" -export const sendNotifications = async (projectName, scenarioName, id, overview: Overview) => { +export const sendReportNotifications = async (projectName, scenarioName, id, overview: Overview) => { try { - const notifications: Notification[] = await db.manyOrNone(scenarioNotifications(projectName, scenarioName)) + const notifications: Notification[] = await db.manyOrNone( + scenarioNotificationsByType(projectName, scenarioName, NotificationType.ReportDetail)) const url = linkUrl(projectName, scenarioName, id) notifications.map(async (notif) => { - const messageTemplate = NotificationTemplate.get(notif.type) + const messageTemplate = NotificationReportTemplate.get(notif.channel) try { - logger.info(`sending notification to: ${notif.type}`) + logger.info(`sending notification to: ${notif.channel}`) const payload = messageTemplate(scenarioName, url, overview) await axios.post(notif.url, payload, { headers: { @@ -31,16 +35,53 @@ export const sendNotifications = async (projectName, scenarioName, id, overview: } } +export const sendDegradationNotifications = async (projectName, scenarioName, id) => { + try { + const notifications: Notification[] = await db.manyOrNone( + scenarioNotificationsByType(projectName, scenarioName, NotificationType.Degradation)) + const url = linkUrl(projectName, scenarioName, id) + notifications.map(async (notif) => { + const messageTemplate = NotificationDegradationTemplate.get(notif.channel) + try { + logger.info(`sending degradation notification to: ${notif.channel}`) + const payload = messageTemplate(scenarioName, url) + await axios.post(notif.url, payload, { + headers: { + "content-type": "application/json", + }, + }) + } catch(error) { + logger.error(`error while sending notification: ${error}`) + } + }) + } catch(error) { + logger.error(`Error notification processing: ${error}`) + } + +} + +enum NotificationType { + ReportDetail = "report_detail", + Degradation = "degradation", +} + interface Notification { id: string url: string - type: string + channel: string } -const NotificationTemplate = new Map([ +const NotificationReportTemplate = new Map([ ["ms-teams", msTeamsTemplate], ["gchat", gchatTemplate], ["slack", slackTemplate], ]) +const NotificationDegradationTemplate = + new Map([ + ["ms-teams", msTeamsDegradationTemplate], + ["gchat", gchatDegradationTemplate], + ["slack", slackDegradationTemplate], + ]) + diff --git a/src/server/utils/notifications/templates/degradation/gchat-degradation-template.spec.ts b/src/server/utils/notifications/templates/degradation/gchat-degradation-template.spec.ts new file mode 100644 index 00000000..d1b54446 --- /dev/null +++ b/src/server/utils/notifications/templates/degradation/gchat-degradation-template.spec.ts @@ -0,0 +1,55 @@ +import { gchatDegradationTemplate } from "./gchat-degradation-template" + +describe("GChat Degradation template", () => { + it("should return correct card payload when url provided", () => { + const template = gchatDegradationTemplate("scenarioName", "http://localhost") + expect(template).toEqual( { + cards: [ + { + header: { + imageUrl: "", + subtitle: "Performance Degradation Detected for scenario: scenarioName", + title: "JTL Reporter", + }, + sections: [ + { + widgets: [ + { + buttons: [ + { + textButton: { + onClick: { + openLink: { + url: "http://localhost", + }, + }, + text: "OPEN RESULTS", + }, + }, + ], + }, + ], + }, + ], + }, + ], + } + ) + }) + it("should return card payload when no url provided", () => { + const template = gchatDegradationTemplate("scenarioName", undefined) + expect(template).toEqual({ + cards: [ + { + header: { + imageUrl: "", + subtitle: "Performance Degradation Detected for scenario: scenarioName", + title: "JTL Reporter", + }, + sections: [], + }, + ], + } + ) + }) +}) diff --git a/src/server/utils/notifications/templates/degradation/gchat-degradation-template.ts b/src/server/utils/notifications/templates/degradation/gchat-degradation-template.ts new file mode 100644 index 00000000..a5feec16 --- /dev/null +++ b/src/server/utils/notifications/templates/degradation/gchat-degradation-template.ts @@ -0,0 +1,35 @@ + +export const gchatDegradationTemplate = (scenarioName: string, url) => { + const cardPayload = { + cards: [{ + header: { + title: "JTL Reporter", + subtitle: `Performance Degradation Detected for scenario: ${scenarioName}`, + imageUrl: "", + }, + sections: [], + }], + } + + if (url) { + (cardPayload.cards[0].sections as any).push({ + widgets: [ + { + buttons: [ + { + textButton: { + text: "OPEN RESULTS", + onClick: { + openLink: { + url, + }, + }, + }, + }, + ], + }, + ], + }) + } + return cardPayload +} diff --git a/src/server/utils/notifications/templates/degradation/ms-teams-degradation-template.spec.ts b/src/server/utils/notifications/templates/degradation/ms-teams-degradation-template.spec.ts new file mode 100644 index 00000000..8a48d405 --- /dev/null +++ b/src/server/utils/notifications/templates/degradation/ms-teams-degradation-template.spec.ts @@ -0,0 +1,55 @@ +import { msTeamsDegradationTemplate } from "./ms-teams-degradation-template" + +describe("MS Teams Degradation template", () => { + it("should return correct card payload when url provided", () => { + const template = msTeamsDegradationTemplate("scenarioName", "http://localhost") + expect(template).toEqual({ + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: { + type: "AdaptiveCard", + body: [ + { + type: "TextBlock", + size: "Medium", + weight: "Bolder", + text: "Performance Degradation Detected for scenario: scenarioName", + }, + ], + actions: [ + { type: "Action.OpenUrl", title: "View", url: "http://localhost" }], + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.2", + }, + }], + }) + }) + it("should return card payload when no url provided", () => { + const template = msTeamsDegradationTemplate("scenarioName", undefined) + expect(template).toEqual({ + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: { + type: "AdaptiveCard", + body: [ + { + type: "TextBlock", + size: "Medium", + weight: "Bolder", + text: "Performance Degradation Detected for scenario: scenarioName", + }, + ], + actions: [], + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.2", + }, + }], + }) + }) +}) diff --git a/src/server/utils/notifications/templates/degradation/ms-teams-degradation-template.ts b/src/server/utils/notifications/templates/degradation/ms-teams-degradation-template.ts new file mode 100644 index 00000000..7d3f69e9 --- /dev/null +++ b/src/server/utils/notifications/templates/degradation/ms-teams-degradation-template.ts @@ -0,0 +1,37 @@ +export const msTeamsDegradationTemplate = (scenarioName: string, url) => { + const cardPayload = { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: { + type: "AdaptiveCard", + body: [ + { + type: "TextBlock", + size: "Medium", + weight: "Bolder", + text: `Performance Degradation Detected for scenario: ${scenarioName}`, + }, + ], + actions: [], + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.2", + }, + + }, + ], + } + + if (url) { + cardPayload.attachments[0].content.actions.push({ + type: "Action.OpenUrl", + title: "View", + url, + }) + } + return cardPayload +} + + diff --git a/src/server/utils/notifications/templates/degradation/slack-degradation-template.spec.ts b/src/server/utils/notifications/templates/degradation/slack-degradation-template.spec.ts new file mode 100644 index 00000000..e087f1b5 --- /dev/null +++ b/src/server/utils/notifications/templates/degradation/slack-degradation-template.spec.ts @@ -0,0 +1,41 @@ +import { slackDegradationTemplate } from "./slack-degradation-template" + +describe("Slack Degradation template", () => { + it("should return correct card payload when url provided", () => { + const template = slackDegradationTemplate("scenarioName", "http://localhost") + console.log(JSON.stringify(template)) + expect(template).toEqual({ + blocks: [ + { type: "header", text: { type: "plain_text", text: "JTL Reporter" } }, + { + type: "header", text: { + type: "plain_text", + text: "Performance Degradation Detected for scenario: scenarioName", + }, + }, + { + type: "section", + fields: { + type: "button", + text: { type: "plain_text", text: "View", emoji: true }, + value: "click_me_123", + url: "http://localhost", + action_id: "button-action", + }, + }, + ], + } + ) + }) + it("should return card payload when no url provided", () => { + const template = slackDegradationTemplate("scenarioName", undefined) + expect(template).toEqual({ + blocks: [ + { type: "header", text: { type: "plain_text", text: "JTL Reporter" } }, + { + type: "header", + text: { type: "plain_text", text: "Performance Degradation Detected for scenario: scenarioName" }, + }], + }) + }) +}) diff --git a/src/server/utils/notifications/templates/degradation/slack-degradation-template.ts b/src/server/utils/notifications/templates/degradation/slack-degradation-template.ts new file mode 100644 index 00000000..f80a46d3 --- /dev/null +++ b/src/server/utils/notifications/templates/degradation/slack-degradation-template.ts @@ -0,0 +1,27 @@ +export const slackDegradationTemplate = (scenarioName: string, url) => { + const card = { + blocks: [ + { type: "header", text: { type: "plain_text", text: "JTL Reporter" } }, + { type: "header", text: { type: "plain_text", + text: `Performance Degradation Detected for scenario: ${scenarioName}` } }, + ], + } + + if (url) { + (card.blocks as any).push({ + type: "section", + fields: { + type: "button", + text: { + type: "plain_text", + text: "View", + emoji: true, + }, + value: "click_me_123", + url, + action_id: "button-action", + }, + }) + } + return card +} diff --git a/src/server/utils/notifications/templates/gchat-template.spec.ts b/src/server/utils/notifications/templates/report/gchat-template.spec.ts similarity index 100% rename from src/server/utils/notifications/templates/gchat-template.spec.ts rename to src/server/utils/notifications/templates/report/gchat-template.spec.ts diff --git a/src/server/utils/notifications/templates/gchat-template.ts b/src/server/utils/notifications/templates/report/gchat-template.ts similarity index 96% rename from src/server/utils/notifications/templates/gchat-template.ts rename to src/server/utils/notifications/templates/report/gchat-template.ts index 96122415..42993d57 100644 --- a/src/server/utils/notifications/templates/gchat-template.ts +++ b/src/server/utils/notifications/templates/report/gchat-template.ts @@ -1,4 +1,4 @@ -import { Overview } from "../../../data-stats/prepare-data" +import { Overview } from "../../../../data-stats/prepare-data" export const gchatTemplate = (scenarioName: string, url, overview: Overview) => { const cardPayload = { diff --git a/src/server/utils/notifications/templates/ms-teams-template.spec.ts b/src/server/utils/notifications/templates/report/ms-teams-template.spec.ts similarity index 100% rename from src/server/utils/notifications/templates/ms-teams-template.spec.ts rename to src/server/utils/notifications/templates/report/ms-teams-template.spec.ts diff --git a/src/server/utils/notifications/templates/ms-teams-template.ts b/src/server/utils/notifications/templates/report/ms-teams-template.ts similarity index 95% rename from src/server/utils/notifications/templates/ms-teams-template.ts rename to src/server/utils/notifications/templates/report/ms-teams-template.ts index 1a629847..380d83da 100644 --- a/src/server/utils/notifications/templates/ms-teams-template.ts +++ b/src/server/utils/notifications/templates/report/ms-teams-template.ts @@ -1,4 +1,4 @@ -import { Overview } from "../../../data-stats/prepare-data" +import { Overview } from "../../../../data-stats/prepare-data" export const msTeamsTemplate = (scenarioName: string, url, overview: Overview) => { const cardPayload = { diff --git a/src/server/utils/notifications/templates/slack-template.spec.ts b/src/server/utils/notifications/templates/report/slack-template.spec.ts similarity index 100% rename from src/server/utils/notifications/templates/slack-template.spec.ts rename to src/server/utils/notifications/templates/report/slack-template.spec.ts diff --git a/src/server/utils/notifications/templates/slack-template.ts b/src/server/utils/notifications/templates/report/slack-template.ts similarity index 95% rename from src/server/utils/notifications/templates/slack-template.ts rename to src/server/utils/notifications/templates/report/slack-template.ts index ceda48f2..5f24d25a 100644 --- a/src/server/utils/notifications/templates/slack-template.ts +++ b/src/server/utils/notifications/templates/report/slack-template.ts @@ -1,4 +1,4 @@ -import { Overview } from "../../../data-stats/prepare-data" +import { Overview } from "../../../../data-stats/prepare-data" export const slackTemplate = (scenarioName: string, url, overview: Overview) => { const card = {