Skip to content

Commit

Permalink
Add survey collection
Browse files Browse the repository at this point in the history
  • Loading branch information
amortemousque committed Dec 4, 2024
1 parent 4ffe796 commit bdf1f5b
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export {
setCookie,
deleteCookie,
resetInitCookies,
getCurrentSite,
} from './browser/cookie'
export { CookieStore, WeakRef, WeakRefConstructor } from './browser/browser.types'
export { initXhrObservable, XhrCompleteContext, XhrStartContext } from './browser/xhrObservable'
Expand Down
1 change: 1 addition & 0 deletions packages/rum-core/src/boot/rumPublicApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const noopStartRum = (): ReturnType<StartRum> => ({
stopDurationVital: () => undefined,
addDurationVital: () => undefined,
stop: () => undefined,
setUser: () => undefined,
})
const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' }
const FAKE_WORKER = {} as DeflateWorker
Expand Down
7 changes: 7 additions & 0 deletions packages/rum-core/src/boot/rumPublicApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ export function makeRumPublicApi(
return buildCommonContext(globalContextManager, userContextManager, recorderApi)
}

let setUser: (user: User) => void

let strategy = createPreStartStrategy(
options,
getCommonContext,
Expand Down Expand Up @@ -405,6 +407,7 @@ export function makeRumPublicApi(
customVitalsState
)

setUser = startRumResult.setUser
recorderApi.onRumStart(
startRumResult.lifeCycle,
configuration,
Expand Down Expand Up @@ -510,6 +513,7 @@ export function makeRumPublicApi(
if (checkUser(newUser)) {
userContextManager.setContext(sanitizeUser(newUser as Context))
}
setUser(newUser)
addTelemetryUsage({ feature: 'set-user' })
}),

Expand All @@ -518,6 +522,9 @@ export function makeRumPublicApi(
setUserProperty: monitor((key, property) => {
const sanitizedProperty = sanitizeUser({ [key]: property })[key]
userContextManager.setContextProperty(key, sanitizedProperty)

setUser(userContextManager.getContext())

addTelemetryUsage({ feature: 'set-user' })
}),

Expand Down
4 changes: 4 additions & 0 deletions packages/rum-core/src/boot/startRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type { CustomVitalsState } from '../domain/vital/vitalCollection'
import { startVitalCollection } from '../domain/vital/vitalCollection'
import { startCiVisibilityContext } from '../domain/contexts/ciVisibilityContext'
import { startLongAnimationFrameCollection } from '../domain/longAnimationFrame/longAnimationFrameCollection'
import { startSurveyCollection } from '../domain/voc/survey'
import type { RecorderApi } from './rumPublicApi'

export type StartRum = typeof startRum
Expand Down Expand Up @@ -124,6 +125,8 @@ export function startRum(
startRumEventBridge(lifeCycle)
}

const { setUser } = startSurveyCollection(lifeCycle)

const domMutationObservable = createDOMMutationObservable()
const locationChangeObservable = createLocationChangeObservable(configuration, location)
const pageStateHistory = startPageStateHistory(configuration)
Expand Down Expand Up @@ -194,6 +197,7 @@ export function startRum(
)

return {
setUser,
addAction,
addError,
addTiming,
Expand Down
123 changes: 123 additions & 0 deletions packages/rum-core/src/domain/voc/survey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Context, User } from '@datadog/browser-core'
import { addEventListener, display, setTimeout } from '@datadog/browser-core'
import type { RumPublicApi } from '../../boot/rumPublicApi'
import { LifeCycleEventType, type LifeCycle } from '../lifeCycle'
import { RumEventType } from '../../rawRumEvent.types'
import type { VocConfig } from './trigger'
import { initTriggers } from './trigger'

interface BrowserWindow {
DD_RUM?: RumPublicApi
}

export function startSurveyCollection(lifeCycle: LifeCycle) {
const { getByAction, getByUserEmail } = initTriggers()
const triggeredSurveys = new Set<VocConfig>()

function openSurveys(triggers: VocConfig[]) {
for (const trigger of triggers) {
if (!triggeredSurveys.has(trigger)) {
openSurvey(trigger, savedSurvey)
triggeredSurveys.add(trigger)
}
}
}

function savedSurvey(payload: Context) {
;(window as BrowserWindow).DD_RUM?.addAction('voc', payload)
}

lifeCycle.subscribe(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, (event) => {
if (event.rawRumEvent.type !== RumEventType.ACTION) {
return
}

openSurveys(getByAction(event.rawRumEvent.action.target.name))
})

return {
setUser: (user: User) => {
if (user.email) {
openSurveys(getByUserEmail(user.email))
}
},
}
}

function openSurvey(vocConfig: VocConfig, onSavedSurvey: (payload: any) => void) {
const iframe = createIframe()
iframe.onload = () => showIframe(iframe)
iframe.src = `http://localhost:8080/form.html?vocConfig=${encodeURIComponent(JSON.stringify(vocConfig))}`
document.body.appendChild(iframe)

// Add listener for close message from the iframe
addEventListener({ allowUntrustedEvents: true }, window, 'message', (event) => {
switch (event.data?.type) {
case 'dd-rum-close-survey':
closeIframe(iframe)
break
case 'dd-rum-survey-response':
onSavedSurvey(event.data.payload)
closeIframe(iframe)
break
case 'iframe-resize': {
const { width, height } = event.data.payload
iframe.style.width = `${width}px`
iframe.style.height = `${height}px`
break
}
}
})

showIframe(iframe)
}

function closeIframe(iframe: HTMLIFrameElement) {
if (!iframe) {
return
}
iframe.style.opacity = '0'
iframe.style.transform = 'translateX(320px)'

addEventListener(
{ allowUntrustedEvents: true },
iframe,
'transitionend',
() => {
iframe.remove()
},
{ once: true }
)
}

function showIframe(iframe: HTMLIFrameElement) {
if (!iframe) {
display.warn('Iframe not initialized. Call injectIframe() first.')
return
}
iframe.style.display = 'block'
setTimeout(() => {
iframe.style.opacity = '1'
}, 0) // Allow the display change to take effect
}

function createIframe() {
const iframe = document.createElement('iframe')
iframe.id = 'dd-rum-iframe'
Object.assign(iframe.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '300px',
height: '292px',
border: 'none',
display: 'none',
opacity: '0',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
transition: 'opacity 0.3s ease, transform 0.5s ease',
borderRadius: '10px',
zIndex: '10000',
})

return iframe
}
48 changes: 48 additions & 0 deletions packages/rum-core/src/domain/voc/trigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { display, getCookie } from '@datadog/browser-core'

interface VocConfigBase {
name: string
description: string
type: 'free-text' | 'scale'
triggerActionName: string
trackedUserEmails: string[]
excludedUserEmails?: string[]
sampleRate?: number
}

interface VocConfigFreeText extends VocConfigBase {
type: 'free-text'
question: string
}

interface VocConfigScale extends VocConfigBase {
type: 'scale'
question: string
range: { min: { label: string; value: number }; max: { label: string; value: number } }
}

export type VocConfig = VocConfigFreeText | VocConfigScale

export function initTriggers() {
const triggers = getTriggers()
return {
getByAction: (actionName: string) => triggers.filter((trigger) => trigger.triggerActionName === actionName),
getByUserEmail: (email: string) => triggers.filter((trigger) => trigger.trackedUserEmails.includes(email)),
}
}

export function getTriggers(): VocConfig[] {
const config = getCookie('_dd_s_voc')
if (!config) {
return []
}

try {
// Decode the cookie value and parse it as JSON
const cookieValue = decodeURIComponent(config)
return JSON.parse(cookieValue) as VocConfig[]
} catch (error) {
display.error('Failed to parse cookie _dd_s_voc as JSON:', error)
return []
}
}

0 comments on commit bdf1f5b

Please sign in to comment.