зеркало из https://github.com/github/docs.git
Коммит
f6b8362bc6
|
@ -120,32 +120,32 @@ publishing {
|
|||
|
||||
##### Example using Kotlin DSL for multiple packages in the same repository
|
||||
|
||||
```shell
|
||||
plugins {
|
||||
`maven-publish` apply false
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply(plugin = "maven-publish")
|
||||
configure<PublishingExtension> {
|
||||
repositories {
|
||||
maven {
|
||||
name = "GitHubPackages"
|
||||
url = uri("https://{% if currentVersion == "free-pro-team@latest" %}maven.pkg.github.com{% else %}<em>REGISTRY-URL</em>{% endif %}/<em>OWNER</em>/<em>REPOSITORY</em>")
|
||||
credentials {
|
||||
username = project.findProperty("gpr.user") as String? ?: System.getenv("<em>USERNAME</em>")
|
||||
password = project.findProperty("gpr.key") as String? ?: System.getenv("<em>TOKEN</em>")
|
||||
}
|
||||
}
|
||||
}
|
||||
publications {
|
||||
register<MavenPublication>("gpr") {
|
||||
from(components["java"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
```shell
|
||||
plugins {
|
||||
`maven-publish` apply false
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply(plugin = "maven-publish")
|
||||
configure<PublishingExtension> {
|
||||
repositories {
|
||||
maven {
|
||||
name = "GitHubPackages"
|
||||
url = uri("https://{% if currentVersion == "free-pro-team@latest" %}maven.pkg.github.com{% else %}<em>REGISTRY-URL</em>{% endif %}/<em>OWNER</em>/<em>REPOSITORY</em>")
|
||||
credentials {
|
||||
username = project.findProperty("gpr.user") as String? ?: System.getenv("<em>USERNAME</em>")
|
||||
password = project.findProperty("gpr.key") as String? ?: System.getenv("<em>TOKEN</em>")
|
||||
}
|
||||
}
|
||||
}
|
||||
publications {
|
||||
register<MavenPublication>("gpr") {
|
||||
from(components["java"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Authenticating with the `GITHUB_TOKEN`
|
||||
|
||||
|
@ -176,13 +176,13 @@ You can install a package by adding the package as a dependency to your project.
|
|||
Example using Grady Groovy:
|
||||
```shell
|
||||
dependencies {
|
||||
implementation 'com.example:package'
|
||||
implementation 'com.example:package'
|
||||
}
|
||||
```
|
||||
Example using Kotlin DSL:
|
||||
```shell
|
||||
dependencies {
|
||||
implementation("com.example:package")
|
||||
implementation("com.example:package")
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -191,13 +191,13 @@ You can install a package by adding the package as a dependency to your project.
|
|||
Example using Grady Groovy:
|
||||
```shell
|
||||
plugins {
|
||||
id 'maven'
|
||||
id 'maven'
|
||||
}
|
||||
```
|
||||
Example using Kotlin DSL:
|
||||
```shell
|
||||
plugins {
|
||||
`maven`
|
||||
`maven`
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
@ -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' })
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
|
|
23
lib/hydro.js
23
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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
// *** 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.'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
// *** 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.'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
// *** 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.'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
// *** type: navigate ***
|
||||
properties: {
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
// *** 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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const Hydro = require('../lib/hydro')
|
||||
|
||||
module.exports = (req, res, next) => {
|
||||
req.hydro = new Hydro()
|
||||
return next()
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
}]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Загрузка…
Ссылка в новой задаче