1
0
mirror of synced 2025-12-21 02:46:50 -05:00
Files
docs/components/lib/events.ts
Kevin Heis 83804c7114 Create search result event (#22307)
* Create search result event

* Update Search.tsx

* actually send it

* remove comment

* Apply suggestions from code review

Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>

* add 'required' to schema

Co-authored-by: Peter Bengtsson <mail@peterbe.com>
Co-authored-by: Peter Bengtsson <peterbe@github.com>
2021-10-21 00:37:39 +00:00

273 lines
7.6 KiB
TypeScript

/* eslint-disable camelcase */
import { v4 as uuidv4 } from 'uuid'
import Cookies from 'js-cookie'
import getCsrf from './get-csrf'
import parseUserAgent from './user-agent'
const COOKIE_NAME = '_docs-events'
const startVisitTime = Date.now()
let cookieValue: string | undefined
let pageEventId: string | undefined
let maxScrollY = 0
let pauseScrolling = false
let sentExit = false
function resetPageParams() {
maxScrollY = 0
pauseScrolling = false
sentExit = false
}
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 enum EventType {
page = 'page',
exit = 'exit',
link = 'link',
search = 'search',
searchResult = 'searchResult',
navigate = 'navigate',
survey = 'survey',
experiment = 'experiment',
preference = 'preference',
clipboard = 'clipboard',
print = 'print',
}
type SendEventProps = {
type: EventType
version?: string
exit_render_duration?: number
exit_first_paint?: number
exit_dom_interactive?: number
exit_dom_complete?: number
exit_visit_duration?: number
exit_scroll_length?: number
link_url?: string
search_query?: string
search_context?: string
search_result_query?: string
search_result_index?: number
search_result_total?: number
search_result_rank?: number
search_result_url?: string
navigate_label?: string
survey_token?: string // Honeypot, doesn't exist in schema
survey_vote?: boolean
survey_comment?: string
survey_email?: string
experiment_name?: string
experiment_variation?: string
experiment_success?: boolean
clipboard_operation?: string
preference_name?: string
preference_value?: string
}
function getMetaContent(name: string) {
const metaTag = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement
return metaTag?.content
}
export function sendEvent({ type, version = '1.0.0', ...props }: SendEventProps) {
const body = {
_csrf: getCsrf(),
type,
context: {
// Primitives
event_id: uuidv4(),
user: getUserEventsId(),
version,
created: new Date().toISOString(),
page_event_id: pageEventId,
// Content information
path: location.pathname,
hostname: location.hostname,
referrer: document.referrer,
search: location.search,
href: location.href,
path_language: getMetaContent('path-language'),
path_version: getMetaContent('path-version'),
path_product: getMetaContent('path-product'),
path_article: getMetaContent('path-article'),
page_document_type: getMetaContent('page-document-type'),
page_type: getMetaContent('page-type'),
status: Number(getMetaContent('status') || 0),
// Device information
// os, os_version, browser, browser_version:
...parseUserAgent(),
viewport_width: document.documentElement.clientWidth,
viewport_height: document.documentElement.clientHeight,
// Location information
timezone: new Date().getTimezoneOffset() / -60,
user_language: navigator.language,
// Preference information
application_preference: Cookies.get('toolPreferred'),
color_mode_preference: getColorModePreference(),
os_preference: Cookies.get('osPreferred'),
},
...props,
}
// Only send the beacon if the feature is not disabled in the user's browser
if (navigator?.sendBeacon) {
const blob = new Blob([JSON.stringify(body)], { type: 'application/json' })
navigator.sendBeacon('/events', blob)
}
return body
}
function getColorModePreference() {
// color mode is set as attributes on <body>, we'll use that information
// along with media query checking rather than parsing the cookie value
// set by github.com
let color_mode_preference = document.querySelector('body')?.dataset.colorMode
if (color_mode_preference === 'auto') {
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
color_mode_preference += ':light'
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
color_mode_preference += ':dark'
}
}
return color_mode_preference
}
function getPerformance() {
const paint = performance
?.getEntriesByType('paint')
?.find(({ name }) => name === 'first-contentful-paint')
const nav = performance?.getEntriesByType('navigation')?.[0] as
| PerformanceNavigationTiming
| undefined
return {
firstContentfulPaint: paint ? paint.startTime / 1000 : undefined,
domInteractive: nav ? nav.domInteractive / 1000 : undefined,
domComplete: nav ? nav.domComplete / 1000 : undefined,
render: nav ? (nav.responseEnd - nav.requestStart) / 1000 : undefined,
}
}
function trackScroll() {
// Throttle the calculations to no more than five per second
if (pauseScrolling) return
pauseScrolling = true
setTimeout(() => {
pauseScrolling = false
}, 200)
// Update maximum scroll position reached
const scrollPixels = window.scrollY + window.innerHeight
const scrollPosition = scrollPixels / document.documentElement.scrollHeight
if (scrollPosition > maxScrollY) maxScrollY = scrollPosition
}
function sendPage() {
const pageEvent = sendEvent({ type: EventType.page })
pageEventId = pageEvent?.context?.event_id
}
function sendExit() {
if (sentExit) return
sentExit = true
const { render, firstContentfulPaint, domInteractive, domComplete } = getPerformance()
return sendEvent({
type: EventType.exit,
exit_render_duration: render,
exit_first_paint: firstContentfulPaint,
exit_dom_interactive: domInteractive,
exit_dom_complete: domComplete,
exit_visit_duration: (Date.now() - startVisitTime) / 1000,
exit_scroll_length: maxScrollY,
})
}
function initPageAndExitEvent() {
sendPage() // Initial page hit
// Regular page exits
window.addEventListener('scroll', trackScroll)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
sendExit()
}
})
// Client-side routing
const pushState = history.pushState
history.pushState = function (state, title, url) {
// Don't trigger page events on query string or hash changes
const newPath = url?.toString().replace(location.origin, '').split('?')[0]
const shouldSendEvents = newPath !== location.pathname
if (shouldSendEvents) {
sendExit()
}
const result = pushState.call(history, state, title, url)
if (shouldSendEvents) {
sendPage()
resetPageParams()
}
return result
}
}
function initClipboardEvent() {
;['copy', 'cut', 'paste'].forEach((verb) => {
document.documentElement.addEventListener(verb, () => {
sendEvent({ type: EventType.clipboard, clipboard_operation: verb })
})
})
}
function initLinkEvent() {
document.documentElement.addEventListener('click', (evt) => {
const target = evt.target as HTMLElement
const link = target.closest('a[href^="http"]') as HTMLAnchorElement
if (!link) return
sendEvent({
type: EventType.link,
link_url: link.href,
})
})
}
function initPrintEvent() {
window.addEventListener('beforeprint', () => {
sendEvent({ type: EventType.print })
})
}
export default function initializeEvents() {
initPageAndExitEvent() // must come first
initLinkEvent()
initClipboardEvent()
initPrintEvent()
// survey event in ./survey.js
// experiment event in ./experiment.js
// search and search_result event in ./search.js
// redirect event in middleware/record-redirect.js
// preference event in ./display-tool-specific-content.js
}