From 1f7899a2127a6eb2d357358ff7baebc70c9c9937 Mon Sep 17 00:00:00 2001 From: raji-unni Date: Wed, 14 Feb 2024 22:51:23 +0530 Subject: [PATCH 1/5] Post processor for experimentation --- src/experimentation/handler.js | 85 +++++ test/experimentation/handler.test.js | 165 +++++++++ test/fixtures/experimentation-data.js | 328 ++++++++++++++++++ .../slack-experimentation-request-data.js | 42 +++ 4 files changed, 620 insertions(+) create mode 100644 src/experimentation/handler.js create mode 100644 test/experimentation/handler.test.js create mode 100644 test/fixtures/experimentation-data.js create mode 100644 test/fixtures/slack-experimentation-request-data.js diff --git a/src/experimentation/handler.js b/src/experimentation/handler.js new file mode 100644 index 00000000..02386927 --- /dev/null +++ b/src/experimentation/handler.js @@ -0,0 +1,85 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { badRequest, noContent } from '@adobe/spacecat-shared-http-utils'; +import { hasText, isObject } from '@adobe/spacecat-shared-utils'; + +import { + uploadSlackFile, postSlackMessage, markdown, section, +} from '../support/slack.js'; + +function convertToCSV(array) { + const headers = Object.keys(array[0]).join(','); + const rows = array.map((item) => Object.values(item).map((value) => { + if (typeof value === 'object' && value !== null) { + return `"${JSON.stringify(value)}"`; + } + return `"${value}"`; + }).join(',')).join('\r\n'); + return `${headers}\r\n${rows}\r\n`; +} + +export function buildExperimentationSlackMessage(url, auditResult) { + const blocks = []; + blocks.push(section({ + text: markdown(`For *${url}*, ${auditResult.length} experiments have been run in the *last week*.\n More information is below :`), + })); + for (let i = 0; i < Math.min(3, auditResult.length); i += 1) { + const topLine = section({ + text: markdown(`:arrow-red2: * Experiment - ${auditResult[i].experiment}| Period - ${auditResult[i].time5} to ${auditResult[i].time95} | Confidence - ${auditResult[i].p_value} | Events - ${auditResult[i].variant_experimentations} | Conversion - ${auditResult[i].variant_conversions} | Conversion Rate - ${auditResult[i].variant_conversion_rate} *`), + }); + blocks.push(topLine); + } + return blocks; +} + +function isValidMessage(message) { + return hasText(message.url) + && isObject(message.auditContext?.slackContext) + && Array.isArray(message.auditResult) + && Object.values(message.auditResult).every((result) => isObject(result)); +} + +export default async function experimentationHandler(message, context) { + const { log } = context; + const { url, auditResult, auditContext } = message; + const { env: { SLACK_BOT_TOKEN: token } } = context; + if (!isValidMessage(message)) { + return badRequest('Required parameters missing in the message or no experimentation data available'); + } + const { channel, ts } = auditContext.slackContext; + const csvData = convertToCSV(auditResult); + log.info(`Converted to csv ${csvData}`); + const file = new Blob([csvData], { type: 'text/csv' }); + + try { + const urlWithProtocolStripped = url?.replace(/^(https?:\/\/)/, ''); + const urlWithDotsAndSlashesReplaced = urlWithProtocolStripped?.replace(/\./g, '-')?.replace(/\//g, '-'); + const fileName = `experiments-${urlWithDotsAndSlashesReplaced}-${new Date().toISOString().split('T')[0]}.csv`; + const text = `For *${url}*, ${auditResult.length} experiment(s) were detected.\nThe following CSV file contains a detailed report for all experiments:`; + // send alert to the slack channel - group under a thread if ts value exists + const slackMessage = buildExperimentationSlackMessage(url, auditResult); + await postSlackMessage(token, { + blocks: slackMessage, + channel, + ts, + }); + await uploadSlackFile(token, { + file, fileName, channel, ts, text, + }); + log.info(`Successfully reported experiment details for ${url}`); + } catch (e) { + log.error(`Failed to send slack message to report broken backlinks for ${url}.`); + } + + return noContent(); +} diff --git a/test/experimentation/handler.test.js b/test/experimentation/handler.test.js new file mode 100644 index 00000000..54c35816 --- /dev/null +++ b/test/experimentation/handler.test.js @@ -0,0 +1,165 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +/* eslint-env mocha */ + +import sinon from 'sinon'; +import chai from 'chai'; +import sinonChai from 'sinon-chai'; +import chaiAsPromised from 'chai-as-promised'; +import nock from 'nock'; +import experimentationHandler from '../../src/experimentation/handler.js'; +import { expectedAuditResult } from '../fixtures/experimentation-data.js'; +import { getQueryParams } from '../../src/support/slack.js'; +import { slackRumRequestData } from '../fixtures/slack-experimentation-request-data.js'; + +chai.use(sinonChai); +chai.use(chaiAsPromised); +const { expect } = chai; + +const sandbox = sinon.createSandbox(); + +describe('experimentation handler', () => { + let message; + let context; + let mockLog; + + beforeEach('setup', () => { + message = { + url: 'space.cat', + auditContext: { + finalUrl: 'www.space.cat', + slackContext: { + channel: 'channel-id', + ts: 'thread-id', + }, + }, + auditResult: expectedAuditResult, + }; + + mockLog = { + info: sinon.spy(), + warn: sinon.spy(), + error: sinon.spy(), + }; + + context = { + log: mockLog, + env: { + SLACK_BOT_TOKEN: 'token', + }, + }; + }); + + afterEach('clean', () => { + sandbox.restore(); + nock.cleanAll(); + }); + + it('rejects when url is missing', async () => { + delete message.url; + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(400); + }); + + it('rejects when auditResult is missing', async () => { + delete message.auditResult; + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(400); + }); + + it('rejects when auditResult is not an object', async () => { + message.auditResult = 'Not an Object'; + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(400); + }); + + it('rejects when auditResult is not an object of objects', async () => { + message.auditResult = { + experiment: '24-101c-lp-enhanced-applicant-tracking-system', + }; + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(400); + }); + + it('rejects when auditContext is missing', async () => { + delete message.auditContext; + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(400); + }); + + it('rejects when slackContext is missing in auditContext', async () => { + delete message.auditContext.slackContext; + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(400); + }); + + it('throws error when slack api fails to upload file', async () => { + const { channel, ts } = message.auditContext.slackContext; + nock('https://slack.com', { + reqheaders: { + authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, + }, + }) + .post('/api/files.upload') + .times(1) + .reply(500); + nock('https://slack.com', { + reqheaders: { + authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, + }, + }) + .get('/api/chat.postMessage') + .query(getQueryParams(slackRumRequestData, channel, ts)) + .reply(200, { + ok: 'success', + channel: 'ch-1', + ts: 'ts-1', + }); + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(204); + expect(mockLog.error).to.have.been.calledOnce; + }); + + it('sends a slack message when there are experiment results', async () => { + const { channel, ts } = message.auditContext.slackContext; + nock('https://slack.com', { + reqheaders: { + authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, + }, + }) + .post('/api/files.upload') + .times(1) + .reply(200, { + ok: true, + file: { + url_private: 'slack-file-url', + }, + }); + + nock('https://slack.com', { + reqheaders: { + authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, + }, + }) + .get('/api/chat.postMessage') + .query(getQueryParams(slackRumRequestData, channel, ts)) + .reply(200, { + ok: 'success', + channel: 'ch-1', + ts: 'ts-1', + }); + + const resp = await experimentationHandler(message, context); + expect(resp.status).to.equal(204); + expect(mockLog.error).to.not.have.been.called; + }); +}); diff --git a/test/fixtures/experimentation-data.js b/test/fixtures/experimentation-data.js new file mode 100644 index 00000000..615abdd2 --- /dev/null +++ b/test/fixtures/experimentation-data.js @@ -0,0 +1,328 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export const expectedAuditResult = [ + { + experiment: '24-101c-lp-enhanced-applicant-tracking-system', + p_value: 0.5000000005, + variant: 'challenger-2', + variant_experimentations: '1300', + variant_conversions: '300', + variant_conversion_rate: '0.230769231', + time5: '2024-02-01 17:00:19+00', + time95: '2024-02-07 20:00:55+00', + }, + { + experiment: '24-101c-lp-enhanced-applicant-tracking-system', + p_value: 0.5000000005, + variant: 'challenger-1', + variant_experimentations: '1100', + variant_conversions: '300', + variant_conversion_rate: '0.272727273', + time5: '2024-02-01 00:00:08+00', + time95: '2024-02-07 23:00:58+00', + }, + { + experiment: '24-101a-lp-enhanced-onboarding', + p_value: 0.5000000005, + variant: 'challenger-1', + variant_experimentations: '2300', + variant_conversions: '800', + variant_conversion_rate: '0.347826087', + time5: '2024-02-01 13:00:04+00', + time95: '2024-02-07 21:00:08+00', + }, + { + experiment: '24-101a-lp-enhanced-onboarding', + p_value: 0.5000000005, + variant: 'challenger-2', + variant_experimentations: '3100', + variant_conversions: '300', + variant_conversion_rate: '0.096774194', + time5: '2024-02-01 15:00:13+00', + time95: '2024-02-07 20:00:18+00', + }, + { + experiment: '2-21-free-trial-cp-delay-load', + p_value: 0.3431751933689274, + variant: 'challenger-1', + variant_experimentations: '2400', + variant_conversions: '2000', + variant_conversion_rate: '0.833333333', + time5: '2024-02-01 00:00:08+00', + time95: '2024-02-07 23:00:12+00', + }, + { + experiment: '2-21-free-trial-cp-delay-load', + p_value: 0.47701597063430096, + variant: 'challenger-2', + variant_experimentations: '2400', + variant_conversions: '1300', + variant_conversion_rate: '0.541666667', + time5: '2024-02-01 18:00:00+00', + time95: '2024-02-07 19:00:09+00', + }, +]; + +export const rumData = { + ':names': [ + 'results', + 'meta', + ], + ':type': 'multi-sheet', + ':version': 3, + results: { + limit: 6, + offset: 0, + total: 6, + data: [ + { + experiment: '24-101c-lp-enhanced-applicant-tracking-system', + variant: 'challenger-2', + tdiff: 6, + variant_experimentation_events: 13, + control_experimentation_events: 0, + variant_conversion_events: 3, + control_conversion_events: 0, + variant_experimentations: '1300', + control_experimentations: '0', + variant_conversions: '300', + control_conversions: '0', + variant_conversion_rate: '0.230769231', + control_conversion_rate: '0', + topurl: 'https://www.bamboohr.com/pl-pages/applicant-tracking-system-a2', + time95: '2024-02-07 20:00:55+00', + time5: '2024-02-01 17:00:19+00', + pooled_sample_proportion: 0.23076923076923078, + pooled_standard_error: null, + test: null, + p_value: 0.5000000005, + remaining_runtime: 494, + }, + { + experiment: '24-101c-lp-enhanced-applicant-tracking-system', + variant: 'challenger-1', + tdiff: 6, + variant_experimentation_events: 11, + control_experimentation_events: 0, + variant_conversion_events: 3, + control_conversion_events: 0, + variant_experimentations: '1100', + control_experimentations: '0', + variant_conversions: '300', + control_conversions: '0', + variant_conversion_rate: '0.272727273', + control_conversion_rate: '0', + topurl: 'https://www.bamboohr.com/pl-pages/applicant-tracking-system-a1', + time95: '2024-02-07 23:00:58+00', + time5: '2024-02-01 00:00:08+00', + pooled_sample_proportion: 0.2727272727272727, + pooled_standard_error: null, + test: null, + p_value: 0.5000000005, + remaining_runtime: 494, + }, + { + experiment: '24-101a-lp-enhanced-onboarding', + variant: 'challenger-1', + tdiff: 6, + variant_experimentation_events: 23, + control_experimentation_events: 0, + variant_conversion_events: 8, + control_conversion_events: 0, + variant_experimentations: '2300', + control_experimentations: '0', + variant_conversions: '800', + control_conversions: '0', + variant_conversion_rate: '0.347826087', + control_conversion_rate: '0', + topurl: 'https://www.bamboohr.com/pl-pages/onboarding-c1', + time95: '2024-02-07 21:00:08+00', + time5: '2024-02-01 13:00:04+00', + pooled_sample_proportion: 0.34782608695652173, + pooled_standard_error: null, + test: null, + p_value: 0.5000000005, + remaining_runtime: 267, + }, + { + experiment: '24-101a-lp-enhanced-onboarding', + variant: 'challenger-2', + tdiff: 6, + variant_experimentation_events: 31, + control_experimentation_events: 0, + variant_conversion_events: 3, + control_conversion_events: 0, + variant_experimentations: '3100', + control_experimentations: '0', + variant_conversions: '300', + control_conversions: '0', + variant_conversion_rate: '0.096774194', + control_conversion_rate: '0', + topurl: 'https://www.bamboohr.com/pl-pages/onboarding-c2', + time95: '2024-02-07 20:00:18+00', + time5: '2024-02-01 15:00:13+00', + pooled_sample_proportion: 0.0967741935483871, + pooled_standard_error: null, + test: null, + p_value: 0.5000000005, + remaining_runtime: 267, + }, + { + experiment: '2-21-free-trial-cp-delay-load', + variant: 'challenger-1', + tdiff: 6, + variant_experimentation_events: 24, + control_experimentation_events: 20, + variant_conversion_events: 20, + control_conversion_events: 10, + variant_experimentations: '2400', + control_experimentations: '2000', + variant_conversions: '2000', + control_conversions: '1000', + variant_conversion_rate: '0.833333333', + control_conversion_rate: '0.5', + topurl: 'https://www.bamboohr.com/signup/c1', + time95: '2024-02-07 23:00:12+00', + time5: '2024-02-01 00:00:08+00', + pooled_sample_proportion: 0.6818181818181818, + pooled_standard_error: 0.8254647450373558, + test: 0.40381292478446823, + p_value: 0.3431751933689274, + remaining_runtime: 64, + }, + { + experiment: '2-21-free-trial-cp-delay-load', + variant: 'challenger-2', + tdiff: 6, + variant_experimentation_events: 24, + control_experimentation_events: 20, + variant_conversion_events: 13, + control_conversion_events: 10, + variant_experimentations: '2400', + control_experimentations: '2000', + variant_conversions: '1300', + control_conversions: '1000', + variant_conversion_rate: '0.541666667', + control_conversion_rate: '0.5', + topurl: 'https://www.bamboohr.com/signup/c2', + time95: '2024-02-07 19:00:09+00', + time5: '2024-02-01 18:00:00+00', + pooled_sample_proportion: 0.5227272727272727, + pooled_standard_error: 0.7228255661993029, + test: 0.05764415226634547, + p_value: 0.47701597063430096, + remaining_runtime: 64, + }, + ], + columns: [ + 'experiment', + 'variant', + 'tdiff', + 'variant_experimentation_events', + 'control_experimentation_events', + 'variant_conversion_events', + 'control_conversion_events', + 'variant_experimentations', + 'control_experimentations', + 'variant_conversions', + 'control_conversions', + 'variant_conversion_rate', + 'control_conversion_rate', + 'topurl', + 'time95', + 'time5', + 'pooled_sample_proportion', + 'pooled_standard_error', + 'test', + 'p_value', + 'remaining_runtime', + ], + }, + meta: { + limit: 13, + offset: 0, + total: 13, + columns: [ + 'name', + 'value', + 'type', + ], + data: [ + { + name: 'description', + value: 'Using Helix RUM data, get a report of conversion rates of experiment variants compared to control, including p value.', + type: 'query description', + }, + { + name: 'url', + value: 'www.bamboohr.com', + type: 'request parameter', + }, + { + name: 'interval', + value: 7, + type: 'request parameter', + }, + { + name: 'offset', + value: 0, + type: 'request parameter', + }, + { + name: 'startdate', + value: '-', + type: 'request parameter', + }, + { + name: 'enddate', + value: '-', + type: 'request parameter', + }, + { + name: 'timezone', + value: 'UTC', + type: 'request parameter', + }, + { + name: 'experiment', + value: '-', + type: 'request parameter', + }, + { + name: 'conversioncheckpoint', + value: 'click', + type: 'request parameter', + }, + { + name: 'sources', + value: '-', + type: 'request parameter', + }, + { + name: 'targets', + value: '-', + type: 'request parameter', + }, + { + name: 'threshold', + value: '500', + type: 'request parameter', + }, + { + name: 'limit', + value: null, + type: 'request parameter', + }, + ], + }, +}; diff --git a/test/fixtures/slack-experimentation-request-data.js b/test/fixtures/slack-experimentation-request-data.js new file mode 100644 index 00000000..90af4f92 --- /dev/null +++ b/test/fixtures/slack-experimentation-request-data.js @@ -0,0 +1,42 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export const slackRumRequestData = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'For *space.cat*, 6 experiments have been run in the *last week*.\n More information is below :', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':arrow-red2: * Experiment - 24-101c-lp-enhanced-applicant-tracking-system| Period - 2024-02-01 17:00:19+00 to 2024-02-07 20:00:55+00 | Confidence - 0.5000000005 | Events - 1300 | Conversion - 300 | Conversion Rate - 0.230769231 *', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':arrow-red2: * Experiment - 24-101c-lp-enhanced-applicant-tracking-system| Period - 2024-02-01 00:00:08+00 to 2024-02-07 23:00:58+00 | Confidence - 0.5000000005 | Events - 1100 | Conversion - 300 | Conversion Rate - 0.272727273 *', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':arrow-red2: * Experiment - 24-101a-lp-enhanced-onboarding| Period - 2024-02-01 13:00:04+00 to 2024-02-07 21:00:08+00 | Confidence - 0.5000000005 | Events - 2300 | Conversion - 800 | Conversion Rate - 0.347826087 *', + }, + }, +]; From 41d8c369218bd9774f9e21b0ad1014b59de07576 Mon Sep 17 00:00:00 2001 From: raji-unni Date: Thu, 15 Feb 2024 14:56:45 +0530 Subject: [PATCH 2/5] Updated a comment. --- src/experimentation/handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/experimentation/handler.js b/src/experimentation/handler.js index 02386927..39b70aba 100644 --- a/src/experimentation/handler.js +++ b/src/experimentation/handler.js @@ -78,7 +78,7 @@ export default async function experimentationHandler(message, context) { }); log.info(`Successfully reported experiment details for ${url}`); } catch (e) { - log.error(`Failed to send slack message to report broken backlinks for ${url}.`); + log.error(`Failed to send slack message to report experimentations done for ${url}.`); } return noContent(); From f706546942c023bb601a6b4f5e163f73d9ffcb4c Mon Sep 17 00:00:00 2001 From: raji-unni Date: Thu, 15 Feb 2024 16:23:24 +0530 Subject: [PATCH 3/5] Moved the convertToCSV fn to support/utils.js --- src/backlinks/handler.js | 12 +----------- src/experimentation/handler.js | 12 +----------- src/support/utils.js | 11 +++++++++++ 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/backlinks/handler.js b/src/backlinks/handler.js index a8115ecc..f5cf0358 100644 --- a/src/backlinks/handler.js +++ b/src/backlinks/handler.js @@ -13,17 +13,7 @@ import { badRequest, noContent } from '@adobe/spacecat-shared-http-utils'; import { hasText, isObject } from '@adobe/spacecat-shared-utils'; import { uploadSlackFile } from '../support/slack.js'; - -function convertToCSV(array) { - const headers = Object.keys(array[0]).join(','); - const rows = array.map((item) => Object.values(item).map((value) => { - if (typeof value === 'object' && value !== null) { - return `"${JSON.stringify(value)}"`; - } - return `"${value}"`; - }).join(',')).join('\r\n'); - return `${headers}\r\n${rows}\r\n`; -} +import { convertToCSV } from '../support/utils.js'; function isValidMessage(message) { return hasText(message.url) diff --git a/src/experimentation/handler.js b/src/experimentation/handler.js index 39b70aba..0ca3d7aa 100644 --- a/src/experimentation/handler.js +++ b/src/experimentation/handler.js @@ -12,22 +12,12 @@ import { badRequest, noContent } from '@adobe/spacecat-shared-http-utils'; import { hasText, isObject } from '@adobe/spacecat-shared-utils'; +import { convertToCSV } from '../support/utils.js'; import { uploadSlackFile, postSlackMessage, markdown, section, } from '../support/slack.js'; -function convertToCSV(array) { - const headers = Object.keys(array[0]).join(','); - const rows = array.map((item) => Object.values(item).map((value) => { - if (typeof value === 'object' && value !== null) { - return `"${JSON.stringify(value)}"`; - } - return `"${value}"`; - }).join(',')).join('\r\n'); - return `${headers}\r\n${rows}\r\n`; -} - export function buildExperimentationSlackMessage(url, auditResult) { const blocks = []; blocks.push(section({ diff --git a/src/support/utils.js b/src/support/utils.js index 34e73318..b0ef7c95 100644 --- a/src/support/utils.js +++ b/src/support/utils.js @@ -15,3 +15,14 @@ import { context as h2, h1 } from '@adobe/fetch'; export const { fetch } = process.env.HELIX_FETCH_FORCE_HTTP1 ? h1() : h2(); + +export function convertToCSV(array) { + const headers = Object.keys(array[0]).join(','); + const rows = array.map((item) => Object.values(item).map((value) => { + if (typeof value === 'object' && value !== null) { + return `"${JSON.stringify(value)}"`; + } + return `"${value}"`; + }).join(',')).join('\r\n'); + return `${headers}\r\n${rows}\r\n`; +} From 06d79339e03c36dd0bba78bdebde535d08093a82 Mon Sep 17 00:00:00 2001 From: raji-unni Date: Fri, 16 Feb 2024 12:59:08 +0530 Subject: [PATCH 4/5] Incorporated review comments --- src/experimentation/handler.js | 40 ++++++------ test/experimentation/handler.test.js | 94 +++++++--------------------- 2 files changed, 44 insertions(+), 90 deletions(-) diff --git a/src/experimentation/handler.js b/src/experimentation/handler.js index 0ca3d7aa..0ec44c61 100644 --- a/src/experimentation/handler.js +++ b/src/experimentation/handler.js @@ -12,10 +12,11 @@ import { badRequest, noContent } from '@adobe/spacecat-shared-http-utils'; import { hasText, isObject } from '@adobe/spacecat-shared-utils'; +import { BaseSlackClient, SLACK_TARGETS } from '@adobe/spacecat-shared-slack-client'; import { convertToCSV } from '../support/utils.js'; import { - uploadSlackFile, postSlackMessage, markdown, section, + markdown, section, } from '../support/slack.js'; export function buildExperimentationSlackMessage(url, auditResult) { @@ -32,43 +33,44 @@ export function buildExperimentationSlackMessage(url, auditResult) { return blocks; } -function isValidMessage(message) { +export function isValidMessage(message) { return hasText(message.url) - && isObject(message.auditContext?.slackContext) && Array.isArray(message.auditResult) && Object.values(message.auditResult).every((result) => isObject(result)); } export default async function experimentationHandler(message, context) { const { log } = context; - const { url, auditResult, auditContext } = message; - const { env: { SLACK_BOT_TOKEN: token } } = context; + const { url, auditResult } = message; + const { SLACK_OPS_CHANNEL_WORKSPACE_INTERNAL: slackChannel } = context.env; + const target = SLACK_TARGETS.WORKSPACE_INTERNAL; + + const slackClient = BaseSlackClient.createFrom(context, target); if (!isValidMessage(message)) { return badRequest('Required parameters missing in the message or no experimentation data available'); } - const { channel, ts } = auditContext.slackContext; const csvData = convertToCSV(auditResult); log.info(`Converted to csv ${csvData}`); - const file = new Blob([csvData], { type: 'text/csv' }); + const csvFile = new Blob([csvData], { type: 'text/csv' }); try { - const urlWithProtocolStripped = url?.replace(/^(https?:\/\/)/, ''); - const urlWithDotsAndSlashesReplaced = urlWithProtocolStripped?.replace(/\./g, '-')?.replace(/\//g, '-'); - const fileName = `experiments-${urlWithDotsAndSlashesReplaced}-${new Date().toISOString().split('T')[0]}.csv`; - const text = `For *${url}*, ${auditResult.length} experiment(s) were detected.\nThe following CSV file contains a detailed report for all experiments:`; + const slackMessage1 = `\nFor *${url}*, ${auditResult.length} experiment(s) were detected.\nThe following CSV file contains a detailed report for all experiments:`; // send alert to the slack channel - group under a thread if ts value exists - const slackMessage = buildExperimentationSlackMessage(url, auditResult); - await postSlackMessage(token, { - blocks: slackMessage, - channel, - ts, + const slackMessage2 = buildExperimentationSlackMessage(url, auditResult); + await slackClient.postMessage({ + channel: slackChannel, + text: slackMessage1, + }); + await slackClient.postMessage({ + channel: slackChannel, + text: slackMessage2, }); - await uploadSlackFile(token, { - file, fileName, channel, ts, text, + await slackClient.fileUpload({ + file: csvFile, }); log.info(`Successfully reported experiment details for ${url}`); } catch (e) { - log.error(`Failed to send slack message to report experimentations done for ${url}.`); + log.error(`Failed to send slack message to report experimentations done for ${url}. Reason :${e.message}`); } return noContent(); diff --git a/test/experimentation/handler.test.js b/test/experimentation/handler.test.js index 54c35816..d2c30484 100644 --- a/test/experimentation/handler.test.js +++ b/test/experimentation/handler.test.js @@ -18,8 +18,6 @@ import chaiAsPromised from 'chai-as-promised'; import nock from 'nock'; import experimentationHandler from '../../src/experimentation/handler.js'; import { expectedAuditResult } from '../fixtures/experimentation-data.js'; -import { getQueryParams } from '../../src/support/slack.js'; -import { slackRumRequestData } from '../fixtures/slack-experimentation-request-data.js'; chai.use(sinonChai); chai.use(chaiAsPromised); @@ -31,17 +29,12 @@ describe('experimentation handler', () => { let message; let context; let mockLog; + const channel = 'channel1'; + const thread = 'thread1'; beforeEach('setup', () => { message = { url: 'space.cat', - auditContext: { - finalUrl: 'www.space.cat', - slackContext: { - channel: 'channel-id', - ts: 'thread-id', - }, - }, auditResult: expectedAuditResult, }; @@ -54,7 +47,18 @@ describe('experimentation handler', () => { context = { log: mockLog, env: { - SLACK_BOT_TOKEN: 'token', + SLACK_TOKEN_WORKSPACE_INTERNAL: 'token', + SLACK_OPS_CHANNEL_WORKSPACE_INTERNAL: 'channel-id', + }, + }; + context.slackClients = { + WORKSPACE_INTERNAL_STANDARD: { + postMessage: sandbox.stub().resolves( + { channelId: channel, threadId: thread }, + ), + fileUpload: sandbox.stub().resolves( + { fileUrl: 'fileurl', channels: ['channel-1', 'channel-2'] }, + ), }, }; }); @@ -90,76 +94,24 @@ describe('experimentation handler', () => { expect(resp.status).to.equal(400); }); - it('rejects when auditContext is missing', async () => { - delete message.auditContext; - const resp = await experimentationHandler(message, context); - expect(resp.status).to.equal(400); - }); - - it('rejects when slackContext is missing in auditContext', async () => { - delete message.auditContext.slackContext; + it('sends a slack message when there are experimentation results', async () => { const resp = await experimentationHandler(message, context); - expect(resp.status).to.equal(400); + expect(resp.status).to.equal(204); + expect(mockLog.info).to.have.been.calledWith('Successfully reported experiment details for space.cat'); + expect(mockLog.error).to.not.have.been.called; }); - it('throws error when slack api fails to upload file', async () => { - const { channel, ts } = message.auditContext.slackContext; - nock('https://slack.com', { - reqheaders: { - authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, - }, - }) - .post('/api/files.upload') - .times(1) - .reply(500); - nock('https://slack.com', { - reqheaders: { - authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, - }, - }) - .get('/api/chat.postMessage') - .query(getQueryParams(slackRumRequestData, channel, ts)) - .reply(200, { - ok: 'success', - channel: 'ch-1', - ts: 'ts-1', - }); + it('throws error when slack api fails to post message', async () => { + context.slackClients.WORKSPACE_INTERNAL_STANDARD.postMessage.rejects(new Error('error')); const resp = await experimentationHandler(message, context); expect(resp.status).to.equal(204); expect(mockLog.error).to.have.been.calledOnce; }); - it('sends a slack message when there are experiment results', async () => { - const { channel, ts } = message.auditContext.slackContext; - nock('https://slack.com', { - reqheaders: { - authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, - }, - }) - .post('/api/files.upload') - .times(1) - .reply(200, { - ok: true, - file: { - url_private: 'slack-file-url', - }, - }); - - nock('https://slack.com', { - reqheaders: { - authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, - }, - }) - .get('/api/chat.postMessage') - .query(getQueryParams(slackRumRequestData, channel, ts)) - .reply(200, { - ok: 'success', - channel: 'ch-1', - ts: 'ts-1', - }); - + it('throws error when slack api fails to upload file', async () => { + context.slackClients.WORKSPACE_INTERNAL_STANDARD.fileUpload.rejects(new Error('error')); const resp = await experimentationHandler(message, context); expect(resp.status).to.equal(204); - expect(mockLog.error).to.not.have.been.called; + expect(mockLog.error).to.have.been.calledOnce; }); }); From e2394d5c53c786d1f9c3a07d7a179baeba7ca065 Mon Sep 17 00:00:00 2001 From: raji-unni Date: Fri, 16 Feb 2024 16:43:09 +0530 Subject: [PATCH 5/5] Added experimentation handler --- src/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.js b/src/index.js index 1b19157f..a9449d20 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ import notFoundHandler from './notfound/handler.js'; import backlinks from './backlinks/handler.js'; import notFoundInternalDigestHandler from './notfound/handler-internal.js'; import notFoundExternalDigestHandler from './notfound/handler-external.js'; +import experimentation from './experimentation/handler.js'; export const HANDLERS = { apex, @@ -29,6 +30,7 @@ export const HANDLERS = { '404-external': notFoundExternalDigestHandler, '404-internal': notFoundInternalDigestHandler, 'broken-backlinks': backlinks, + experimentation, }; function guardEnvironmentVariables(fn) {