From 7c0c493c35086d1f274af5c3ae4432d2e3e58fdf Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Thu, 8 Oct 2020 09:24:41 -0700 Subject: [PATCH] Sending events to POST /events endpoint (#15796) --- javascripts/events.js | 118 +++++++++ javascripts/index.js | 6 +- lib/hydro.js | 23 +- lib/schema-event-2.js | 373 +++++++++++++++------------- middleware/events.js | 24 +- middleware/index.js | 1 + middleware/req-utils.js | 6 + tests/rendering/events.js | 504 ++++++++++++++++++++++++++++++-------- tests/unit/hydro.js | 25 +- 9 files changed, 804 insertions(+), 276 deletions(-) create mode 100644 javascripts/events.js create mode 100644 middleware/req-utils.js diff --git a/javascripts/events.js b/javascripts/events.js new file mode 100644 index 000000000000..735bb3314612 --- /dev/null +++ b/javascripts/events.js @@ -0,0 +1,118 @@ +/* eslint-disable camelcase */ +import { v4 as uuidv4 } from 'uuid' +import Cookies from 'js-cookie' +import getCsrf from './get-csrf' + +const COOKIE_NAME = '_docs-events' + +let cookieValue + +export function getUserEventsId () { + if (cookieValue) return cookieValue + cookieValue = Cookies.get(COOKIE_NAME) + if (cookieValue) return cookieValue + cookieValue = uuidv4() + Cookies.set(COOKIE_NAME, cookieValue, { + secure: true, + sameSite: 'strict', + expires: 365 + }) + return cookieValue +} + +export async function sendEvent ({ + type, + version = '1.0.0', + page_render_duration, + exit_page_id, + exit_first_paint, + exit_dom_interactive, + exit_dom_complete, + exit_visit_duration, + exit_scroll_length, + link_url, + search_query, + search_context, + navigate_label, + survey_vote, + survey_comment, + survey_email, + experiment_name, + experiment_variation, + experiment_success +}) { + const response = await fetch('/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'CSRF-Token': getCsrf() + }, + body: JSON.stringify({ + type, // One of page, exit, link, search, navigate, survey, experiment + + context: { + // Primitives + event_id: uuidv4(), + user: getUserEventsId(), + version, + created: new Date().toISOString(), + + // Content information + path: location.pathname, + referrer: document.referrer, + search: location.search, + href: location.href, + site_language: location.pathname.split('/')[1], + + // Device information + // os: + // os_version: + // browser: + // browser_version: + viewport_width: document.documentElement.clientWidth, + viewport_height: document.documentElement.clientHeight, + + // Location information + timezone: new Date().getTimezoneOffset() / -60, + user_language: navigator.language + }, + + // Page event + page_render_duration, + + // Exit event + exit_page_id, + exit_first_paint, + exit_dom_interactive, + exit_dom_complete, + exit_visit_duration, + exit_scroll_length, + + // Link event + link_url, + + // Search event + search_query, + search_context, + + // Navigate event + navigate_label, + + // Survey event + survey_vote, + survey_comment, + survey_email, + + // Experiment event + experiment_name, + experiment_variation, + experiment_success + }) + }) + const data = response.ok ? await response.json() : {} + return data +} + +export default async function initializeEvents () { + await sendEvent({ type: 'page' }) +} diff --git a/javascripts/index.js b/javascripts/index.js index 7eda77be376a..b9bfb3ee8468 100644 --- a/javascripts/index.js +++ b/javascripts/index.js @@ -14,8 +14,9 @@ import localization from './localization' import helpfulness from './helpfulness' import experiment from './experiment' import { fillCsrf } from './get-csrf' +import initializeEvents from './events' -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { displayPlatformSpecificContent() explorer() search() @@ -27,7 +28,8 @@ document.addEventListener('DOMContentLoaded', () => { wrapCodeTerms() print() localization() - fillCsrf() + await fillCsrf() // this must complete before any POST calls helpfulness() experiment() + initializeEvents() }) diff --git a/lib/hydro.js b/lib/hydro.js index 48eaa59b0802..df06602ae5f5 100644 --- a/lib/hydro.js +++ b/lib/hydro.js @@ -1,10 +1,21 @@ const crypto = require('crypto') const fetch = require('node-fetch') +const SCHEMAS = { + page: 'docs.v0.PageEvent', + exit: 'docs.v0.ExitEvent', + link: 'docs.v0.LinkEvent', + search: 'docs.v0.SearchEvent', + navigate: 'docs.v0.NavigateEvent', + survey: 'docs.v0.SurveyEvent', + experiment: 'docs.v0.ExperimentEvent' +} + module.exports = class Hydro { - constructor ({ secret, endpoint }) { + constructor ({ secret, endpoint } = {}) { this.secret = secret || process.env.HYDRO_SECRET this.endpoint = endpoint || process.env.HYDRO_ENDPOINT + this.schemas = SCHEMAS } /** @@ -32,7 +43,13 @@ module.exports = class Hydro { * @param {[{ schema: string, value: any }]} events */ async publishMany (events) { - const body = JSON.stringify({ events }) + const body = JSON.stringify({ + events: events.map(({ schema, value }) => ({ + schema, + value: JSON.stringify(value), // We must double-encode the value property + cluster: 'potomac' // We only have ability to publish externally to potomac cluster + })) + }) const token = this.generatePayloadHmac(body) return fetch(this.endpoint, { @@ -41,7 +58,7 @@ module.exports = class Hydro { headers: { Authorization: `Hydro ${token}`, 'Content-Type': 'application/json', - 'X-Hydro-App': 'docs' + 'X-Hydro-App': 'docs-production' } }) } diff --git a/lib/schema-event-2.js b/lib/schema-event-2.js index a3b036b33058..801bf8249cef 100644 --- a/lib/schema-event-2.js +++ b/lib/schema-event-2.js @@ -1,13 +1,14 @@ const languages = require('./languages') -module.exports = { +const context = { + type: 'object', additionalProperties: false, required: [ 'event_id', - 'type', 'user', 'version', - 'created' + 'created', + 'path' ], properties: { // Required of all events @@ -16,11 +17,6 @@ module.exports = { description: 'The unique identifier of the event.', format: 'uuid' }, - type: { - type: 'string', - description: 'The type of the event.', - enum: ['page', 'exit', 'link', 'search', 'navigate', 'survey', 'experiment'] - }, user: { type: 'string', description: 'The unique identifier of the current user performing the event. Please use randomly generated values or hashed values; we don\'t want to be able to look up in a database.', @@ -29,18 +25,13 @@ module.exports = { version: { type: 'string', description: 'The version of the event schema.', - pattern: /^\d+(\.\d+)?(\.\d+)?$/ + pattern: '^\\d+(\\.\\d+)?(\\.\\d+)?$' // eslint-disable-line }, created: { type: 'string', format: 'date-time', description: 'The time we created the event; please reference UTC.' }, - token: { - type: 'string', - description: 'A honeypot.', - maxLength: 0 - }, // Content information path: { @@ -109,162 +100,214 @@ module.exports = { type: 'string', description: 'The browser value of `navigator.language`.' } - }, - oneOf: [{ - // *** type: page *** - required: [ - 'path', - 'href' - ], - properties: { - type: { - type: 'string', - pattern: /^page$/ - }, - page_render_duration: { - type: { - type: 'number', - description: 'How long the server took to render the page content, in seconds.', - minimum: 0.001 - } - } + } +} + +const pageSchema = { + additionalProperties: false, + required: [ + 'type', + 'context' + ], + properties: { + context, + type: { + type: 'string', + pattern: '^page$' + }, + page_render_duration: { + type: 'number', + description: 'How long the server took to render the page content, in seconds.', + minimum: 0.001 } - }, { - // *** type: exit *** - required: [ - 'exit_page_id' - ], - properties: { - type: { - type: 'string', - pattern: /^exit$/ - }, - exit_page_id: { - type: 'string', - format: 'uuid', - description: 'The value of the corresponding `page` event.' - // id of the "page" event - }, - exit_first_paint: { - type: 'number', - minimum: 0.001, - description: 'The duration until `first-contentful-paint`, in seconds. Informs CSS performance.' - }, - exit_dom_interactive: { - type: 'number', - minimum: 0.001, - description: 'The duration until `PerformanceNavigationTiming.domInteractive`, in seconds. Informs JavaScript loading performance.' - }, - exit_dom_complete: { - type: 'number', - minimum: 0.001, - description: 'The duration until `PerformanceNavigationTiming.domComplete`, in seconds. Informs JavaScript execution performance.' - }, - exit_visit_duration: { - type: 'number', - minimum: 0.001, - description: 'The duration of exit.timestamp - page.timestamp, in seconds. Informs bounce rate.' - }, - exit_scroll_length: { - type: 'number', - minimum: 0, - maximum: 1, - description: 'The percentage of how far the user scrolled on the page.' - } + } +} + +const exitSchema = { + additionalProperties: false, + required: [ + 'type', + 'context', + 'exit_page_id' + ], + properties: { + context, + type: { + type: 'string', + pattern: '^exit$' + }, + exit_page_id: { + type: 'string', + format: 'uuid', + description: 'The value of the corresponding `page` event.' + // id of the "page" event + }, + exit_first_paint: { + type: 'number', + minimum: 0.001, + description: 'The duration until `first-contentful-paint`, in seconds. Informs CSS performance.' + }, + exit_dom_interactive: { + type: 'number', + minimum: 0.001, + description: 'The duration until `PerformanceNavigationTiming.domInteractive`, in seconds. Informs JavaScript loading performance.' + }, + exit_dom_complete: { + type: 'number', + minimum: 0.001, + description: 'The duration until `PerformanceNavigationTiming.domComplete`, in seconds. Informs JavaScript execution performance.' + }, + exit_visit_duration: { + type: 'number', + minimum: 0.001, + description: 'The duration of exit.timestamp - page.timestamp, in seconds. Informs bounce rate.' + }, + exit_scroll_length: { + type: 'number', + minimum: 0, + maximum: 1, + description: 'The percentage of how far the user scrolled on the page.' } - }, { - // *** type: link *** - required: [ - 'link_url' - ], - properties: { - type: { - type: 'string', - pattern: /^link$/ - }, - link_url: { - type: 'string', - format: 'uri', - description: 'The href of the anchor tag the user clicked, or the page or object they directed their browser to.' - } + } +} + +const linkSchema = { + additionalProperties: false, + required: [ + 'type', + 'context', + 'link_url' + ], + properties: { + context, + type: { + type: 'string', + pattern: '^link$' + }, + link_url: { + type: 'string', + format: 'uri', + description: 'The href of the anchor tag the user clicked, or the page or object they directed their browser to.' } - }, { - // *** type: search *** - required: [ - 'search_query' - ], - properties: { - type: { - type: 'string', - pattern: /^search$/ - }, - search_query: { - type: 'string', - description: 'The actual text content of the query string the user sent to the service.' - }, - search_context: { - type: 'string', - description: 'Any additional search context, such as component searched.' - } + } +} + +const searchSchema = { + additionalProperties: false, + required: [ + 'type', + 'context', + 'search_query' + ], + properties: { + context, + type: { + type: 'string', + pattern: '^search$' + }, + search_query: { + type: 'string', + description: 'The actual text content of the query string the user sent to the service.' + }, + search_context: { + type: 'string', + description: 'Any additional search context, such as component searched.' } - }, { - // *** type: navigate *** - properties: { - type: { - type: 'string', - pattern: /^navigate$/ - }, - navigate_label: { - type: 'string', - description: 'An identifier for where the user is navigating.' - } + } +} + +const navigateSchema = { + additionalProperties: false, + required: [ + 'type', + 'context' + ], + properties: { + context, + type: { + type: 'string', + pattern: '^navigate$' + }, + navigate_label: { + type: 'string', + description: 'An identifier for where the user is navigating.' } - }, { - // *** type: survey *** - properties: { - type: { - type: 'string', - pattern: /^survey$/ - }, - survey_vote: { - type: 'boolean', - description: 'Whether the user found the page helpful.' - }, - survey_comment: { - type: 'string', - description: 'Any textual comments the user wanted to provide.' - }, - survey_email: { - type: 'string', - format: 'email', - description: 'The user\'s email address, if the user provided and consented.' - } + } +} + +const surveySchema = { + additionalProperties: false, + required: [ + 'type', + 'context', + 'survey_vote' + ], + properties: { + context, + type: { + type: 'string', + pattern: '^survey$' + }, + token: { + type: 'string', + description: 'A honeypot.', + maxLength: 0 + }, + survey_vote: { + type: 'boolean', + description: 'Whether the user found the page helpful.' + }, + survey_comment: { + type: 'string', + description: 'Any textual comments the user wanted to provide.' + }, + survey_email: { + type: 'string', + format: 'email', + description: 'The user\'s email address, if the user provided and consented.' } - }, { - // *** type: experiment *** - required: [ - 'experiment_name', - 'experiment_variation' - ], - properties: { - type: { - type: 'string', - pattern: /^experiment$/ - }, - experiment_name: { - type: 'string', - description: 'The test that this event is part of.' - }, - experiment_variation: { - type: 'string', - enum: ['control', 'treatment'], - description: 'The variation this user we bucketed in, such as control or treatment.' - }, - experiment_success: { - type: 'boolean', - default: true, - description: 'Whether or not the user successfully performed the test goal.' - } + } +} + +const experimentSchema = { + additionalProperties: false, + required: [ + 'type', + 'context', + 'experiment_name', + 'experiment_variation' + ], + properties: { + context, + type: { + type: 'string', + pattern: '^experiment$' + }, + experiment_name: { + type: 'string', + description: 'The test that this event is part of.' + }, + experiment_variation: { + type: 'string', + enum: ['control', 'treatment'], + description: 'The variation this user we bucketed in, such as control or treatment.' + }, + experiment_success: { + type: 'boolean', + default: true, + description: 'Whether or not the user successfully performed the test goal.' } - }] + } +} + +module.exports = { + oneOf: [ + pageSchema, + exitSchema, + linkSchema, + searchSchema, + navigateSchema, + surveySchema, + experimentSchema + ] } diff --git a/middleware/events.js b/middleware/events.js index ca2887bbb114..145b63306f07 100644 --- a/middleware/events.js +++ b/middleware/events.js @@ -3,6 +3,7 @@ const Airtable = require('airtable') const { omit } = require('lodash') const Ajv = require('ajv') const schema = require('../lib/schema-event') +const schemaHydro = require('../lib/schema-event-2') const TABLE_NAMES = { HELPFULNESS: 'Helpfulness Survey', @@ -15,7 +16,7 @@ const ajv = new Ajv() const router = express.Router() -router.post('/', async (req, res, next) => { +async function airtablePost (req, res, next) { const { AIRTABLE_API_KEY, AIRTABLE_BASE_KEY } = process.env if (!AIRTABLE_API_KEY || !AIRTABLE_BASE_KEY) { return res.status(501).send({}) @@ -36,6 +37,27 @@ router.post('/', async (req, res, next) => { console.error('unable to POST event', err) return res.status(err.statusCode).send(err) } +} + +router.post('/', async (req, res, next) => { + // All-caps type is an "Airtable" event + if (req.body.type === 'HELPFULNESS' || req.body.type === 'EXPERIMENT') { + return airtablePost(req, res, next) + } + // Remove the condition above when we are no longer sending to Airtable + if (!ajv.validate(schemaHydro, req.body)) { + if (process.env.NODE_ENV === 'development') console.log(ajv.errorsText()) + return res.status(400).json({}) + } + const fields = omit(req.body, OMIT_FIELDS) + try { + const hydroRes = await req.hydro.publish(req.hydro.schemas[req.body.type], fields) + if (!hydroRes.ok) return res.status(500).json({}) + return res.status(201).json(fields) + } catch (err) { + if (process.env.NODE_ENV === 'development') console.log(err) + return res.status(500).json({}) + } }) router.put('/:id', async (req, res, next) => { diff --git a/middleware/index.js b/middleware/index.js index c98847e67731..b19df343ac4e 100644 --- a/middleware/index.js +++ b/middleware/index.js @@ -24,6 +24,7 @@ module.exports = function (app) { app.use(require('./cors')) app.use(require('./csp')) app.use(require('helmet')()) + app.use(require('./req-utils')) app.use(require('./robots')) app.use(require('./cookie-parser')) app.use(require('./csrf')) diff --git a/middleware/req-utils.js b/middleware/req-utils.js new file mode 100644 index 000000000000..d82bb959ed32 --- /dev/null +++ b/middleware/req-utils.js @@ -0,0 +1,6 @@ +const Hydro = require('../lib/hydro') + +module.exports = (req, res, next) => { + req.hydro = new Hydro() + return next() +} diff --git a/tests/rendering/events.js b/tests/rendering/events.js index 0380ca09abe0..7292e79233fc 100644 --- a/tests/rendering/events.js +++ b/tests/rendering/events.js @@ -1,5 +1,6 @@ const request = require('supertest') const Airtable = require('airtable') +const nock = require('nock') const app = require('../../server') jest.mock('airtable') @@ -19,17 +20,33 @@ describe('POST /events', () => { beforeEach(async () => { process.env.AIRTABLE_API_KEY = '$AIRTABLE_API_KEY$' process.env.AIRTABLE_BASE_KEY = '$AIRTABLE_BASE_KEY$' + process.env.HYDRO_SECRET = '$HYDRO_SECRET$' + process.env.HYDRO_ENDPOINT = 'http://example.com/hydro' agent = request.agent(app) const csrfRes = await agent.get('/csrf') csrfToken = csrfRes.body.token + nock('http://example.com') + .post('/hydro') + .reply(200, {}) }) afterEach(() => { delete process.env.AIRTABLE_API_KEY delete process.env.AIRTABLE_BASE_KEY + delete process.env.HYDRO_SECRET + delete process.env.HYDRO_ENDPOINT csrfToken = '' }) + async function checkEvent (data, code) { + return agent + .post('/events') + .send(data) + .set('Accept', 'application/json') + .set('csrf-token', csrfToken) + .expect(code) + } + describe('HELPFULNESS', () => { const example = { type: 'HELPFULNESS', @@ -41,94 +58,43 @@ describe('POST /events', () => { } it('should accept a valid object', () => - agent - .post('/events') - .send(example) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(201) + checkEvent(example, 201) ) it('should reject extra properties', () => - agent - .post('/events') - .send({ ...example, toothpaste: false }) - .set('Accept', 'application/json') - - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, toothpaste: false }, 400) ) it('should not accept if type is missing', () => - agent - .post('/events') - .send({ ...example, type: undefined }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, type: undefined }, 400) ) it('should not accept if url is missing', () => - agent - .post('/events') - .send({ ...example, url: undefined }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, url: undefined }, 400) ) it('should not accept if url is misformatted', () => - agent - .post('/events') - .send({ ...example, url: 'examplecom' }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, url: 'examplecom' }, 400) ) it('should not accept if vote is missing', () => - agent - .post('/events') - .send({ ...example, vote: undefined }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, vote: undefined }, 400) ) it('should not accept if vote is not boolean', () => - agent - .post('/events') - .send({ ...example, vote: 'true' }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, vote: 'true' }, 400) ) it('should not accept if email is misformatted', () => - agent - .post('/events') - .send({ ...example, email: 'testexample.com' }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, email: 'testexample.com' }, 400) ) it('should not accept if comment is not string', () => - agent - .post('/events') - .send({ ...example, comment: [] }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, comment: [] }, 400) ) it('should not accept if category is not an option', () => - agent - .post('/events') - .send({ ...example, category: 'Fabulous' }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, category: 'Fabulous' }, 400) ) }) @@ -142,57 +108,401 @@ describe('POST /events', () => { } it('should accept a valid object', () => - agent - .post('/events') - .send(example) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(201) + checkEvent(example, 201) ) it('should reject extra fields', () => - agent - .post('/events') - .send({ ...example, toothpaste: false }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, toothpaste: false }, 400) ) it('should require a long unique user-id', () => - agent - .post('/events') - .send({ ...example, 'user-id': 'short' }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, 'user-id': 'short' }, 400) ) it('should require a test', () => - agent - .post('/events') - .send({ ...example, test: undefined }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, test: undefined }, 400) ) it('should require a valid group', () => - agent - .post('/events') - .send({ ...example, group: 'revolution' }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(400) + checkEvent({ ...example, group: 'revolution' }, 400) ) it('should default the success field', () => - agent - .post('/events') - .send({ ...example, success: undefined }) - .set('Accept', 'application/json') - .set('csrf-token', csrfToken) - .expect(201) + checkEvent({ ...example, success: undefined }, 201) + ) + }) + + const baseExample = { + context: { + // Primitives + event_id: 'a35d7f88-3f48-4f36-ad89-5e3c8ebc3df7', + user: '703d32a8-ed0f-45f9-8d78-a913d4dc6f19', + version: '1.0.0', + created: '2020-10-02T17:12:18.620Z', + + // Content information + path: '/github/docs/issues', + referrer: 'https://github.com/github/docs', + search: '?q=is%3Aissue+is%3Aopen+example+', + href: 'https://github.com/github/docs/issues?q=is%3Aissue+is%3Aopen+example+', + site_language: 'en', + + // Device information + os: 'linux', + os_version: '18.04', + browser: 'chrome', + browser_version: '85.0.4183.121', + viewport_width: 1418, + viewport_height: 501, + + // Location information + timezone: -7, + user_language: 'en-US' + } + } + + describe('page', () => { + const pageExample = { ...baseExample, type: 'page' } + + it('should record a page event', () => + checkEvent(pageExample, 201) + ) + + it('should require a type', () => + checkEvent(baseExample, 400) + ) + + it('should require an event_id in uuid', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + event_id: 'asdfghjkl' + } + }, 400) + ) + + it('should require a user in uuid', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + user: 'asdfghjkl' + } + }, 400) + ) + + it('should require a version', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + version: undefined + } + }, 400) + ) + + it('should require created timestamp', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + timestamp: 1234 + } + }, 400) + ) + + it('should not allow a honeypot token', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + token: 'zxcv' + } + }, 400) + ) + + it('should path be uri-reference', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + path: ' ' + } + }, 400) + ) + + it('should referrer be uri-reference', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + referrer: ' ' + } + }, 400) + ) + + it('should search a string', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + search: 1234 + } + }, 400) + ) + + it('should href be uri', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + href: '/example' + } + }, 400) + ) + + it('should site_language is a valid option', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + site_language: 'nl' + } + }, 400) + ) + + it('should a valid os option', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + os: 'ubuntu' + } + }, 400) + ) + + it('should os_version a string', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + os_version: 25 + } + }, 400) + ) + + it('should browser a valid option', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + browser: 'opera' + } + }, 400) + ) + + it('should browser_version a string', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + browser_version: 25 + } + }, 400) + ) + + it('should viewport_width a number', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + viewport_width: -500 + } + }, 400) + ) + + it('should viewport_height a number', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + viewport_height: '53px' + } + }, 400) + ) + + it('should timezone in number', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + timezone: 'GMT-0700' + } + }, 400) + ) + + it('should user_language is a string', () => + checkEvent({ + ...pageExample, + context: { + ...pageExample.context, + user_language: true + } + }, 400) + ) + + it('should page_render_duration is a positive number', () => + checkEvent({ + ...pageExample, + page_render_duration: -0.5 + }, 400) + ) + }) + + describe('exit', () => { + const exitExample = { + ...baseExample, + type: 'exit', + exit_page_id: 'c93c2d16-8e07-43d5-bc3c-eacc999c184d', + exit_first_paint: 0.1, + exit_dom_interactive: 0.2, + exit_dom_complete: 0.3, + exit_visit_duration: 5, + exit_scroll_length: 0.5 + } + + it('should record an exit event', () => + checkEvent(exitExample, 201) + ) + + it('should require exit_page_id', () => + checkEvent({ ...exitExample, exit_page_id: undefined }, 400) + ) + + it('should require exit_page_id is a uuid', () => + checkEvent({ ...exitExample, exit_page_id: 'afjdskalj' }, 400) + ) + + it('exit_first_paint is a number', () => + checkEvent({ ...exitExample, exit_first_paint: 'afjdkl' }, 400) + ) + + it('exit_dom_interactive is a number', () => + checkEvent({ ...exitExample, exit_dom_interactive: '202' }, 400) + ) + + it('exit_visit_duration is a number', () => + checkEvent({ ...exitExample, exit_visit_duration: '75' }, 400) + ) + + it('exit_scroll_length is a number between 0 and 1', () => + checkEvent({ ...exitExample, exit_scroll_length: 1.1 }, 400) + ) + }) + + describe('link', () => { + const linkExample = { + ...baseExample, + type: 'link', + link_url: 'https://example.com' + } + + it('should send a link event', () => + checkEvent(linkExample, 201) + ) + + it('link_url is a required uri formatted string', () => + checkEvent({ ...linkExample, link_url: 'foo' }, 400) + ) + }) + + describe('search', () => { + const searchExample = { + ...baseExample, + type: 'search', + search_query: 'github private instances', + search_context: 'private' + } + + it('should record a search event', () => + checkEvent(searchExample, 201) + ) + + it('search_query is required string', () => + checkEvent({ ...searchExample, search_query: undefined }, 400) + ) + + it('search_context is optional string', () => + checkEvent({ ...searchExample, search_context: undefined }, 201) + ) + }) + + describe('navigate', () => { + const navigateExample = { + ...baseExample, + type: 'navigate', + navigate_label: 'drop down' + } + + it('should record a navigate event', () => + checkEvent(navigateExample, 201) + ) + + it('navigate_label is optional string', () => + checkEvent({ ...navigateExample, navigate_label: undefined }, 201) + ) + }) + + describe('survey', () => { + const surveyExample = { + ...baseExample, + type: 'survey', + survey_vote: true, + survey_comment: 'I love this site.', + survey_email: 'daisy@example.com' + } + + it('should record a survey event', () => + checkEvent(surveyExample, 201) + ) + + it('survey_vote is boolean', () => + checkEvent({ ...surveyExample, survey_vote: undefined }, 400) + ) + + it('survey_comment is string', () => { + checkEvent({ ...surveyExample, survey_comment: 1234 }, 400) + }) + + it('survey_email is email', () => { + checkEvent({ ...surveyExample, survey_email: 'daisy' }, 400) + }) + }) + + describe('experiment', () => { + const experimentExample = { + ...baseExample, + type: 'experiment', + experiment_name: 'change-button-copy', + experiment_variation: 'treatment', + experiment_success: true + } + + it('should record an experiment event', () => + checkEvent(experimentExample, 201) + ) + + it('experiment_name is required string', () => + checkEvent({ ...experimentExample, experiment_name: undefined }, 400) + ) + + it('experiment_variation is required string', () => + checkEvent({ ...experimentExample, experiment_variation: undefined }, 400) + ) + + it('experiment_success is optional boolean', () => + checkEvent({ ...experimentExample, experiment_success: undefined }, 201) ) }) }) diff --git a/tests/unit/hydro.js b/tests/unit/hydro.js index 806d6d1a3e06..6fd5955d2f6f 100644 --- a/tests/unit/hydro.js +++ b/tests/unit/hydro.js @@ -11,18 +11,22 @@ describe('hydro', () => { reqheaders: { Authorization: /^Hydro [\d\w]{64}$/, 'Content-Type': 'application/json', - 'X-Hydro-App': 'docs' + 'X-Hydro-App': 'docs-production' } }) - // Respond with a 201 and store the body we sent - .post('/').reply(201, (_, body) => { params = body }) + // Respond with a 200 and store the body we sent + .post('/').reply(200, (_, body) => { params = body }) }) describe('#publish', () => { it('publishes a single event to Hydro', async () => { await hydro.publish('event-name', { pizza: true }) expect(params).toEqual({ - events: [{ schema: 'event-name', value: { pizza: true } }] + events: [{ + schema: 'event-name', + value: JSON.stringify({ pizza: true }), + cluster: 'potomac' + }] }) }) }) @@ -35,10 +39,15 @@ describe('hydro', () => { ]) expect(params).toEqual({ - events: [ - { schema: 'event-name', value: { pizza: true } }, - { schema: 'other-name', value: { salad: false } } - ] + events: [{ + schema: 'event-name', + value: JSON.stringify({ pizza: true }), + cluster: 'potomac' + }, { + schema: 'other-name', + value: JSON.stringify({ salad: false }), + cluster: 'potomac' + }] }) }) })