diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d66ae435bf..85d6f6b9d7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 34899349a4..36f8497103 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -34,6 +34,7 @@ const noopStartRum = (): ReturnType => ({ stopDurationVital: () => undefined, addDurationVital: () => undefined, stop: () => undefined, + setUser: () => undefined, }) const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } const FAKE_WORKER = {} as DeflateWorker diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 000064db0c..96e379f036 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -377,6 +377,8 @@ export function makeRumPublicApi( return buildCommonContext(globalContextManager, userContextManager, recorderApi) } + let setUser: (user: User) => void + let strategy = createPreStartStrategy( options, getCommonContext, @@ -405,6 +407,7 @@ export function makeRumPublicApi( customVitalsState ) + setUser = startRumResult.setUser recorderApi.onRumStart( startRumResult.lifeCycle, configuration, @@ -510,6 +513,7 @@ export function makeRumPublicApi( if (checkUser(newUser)) { userContextManager.setContext(sanitizeUser(newUser as Context)) } + setUser(newUser) addTelemetryUsage({ feature: 'set-user' }) }), @@ -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' }) }), diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 1c6a96b87a..d43a536909 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -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 @@ -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) @@ -194,6 +197,7 @@ export function startRum( ) return { + setUser, addAction, addError, addTiming, diff --git a/packages/rum-core/src/domain/voc/survey.ts b/packages/rum-core/src/domain/voc/survey.ts new file mode 100644 index 0000000000..a5d10bfa60 --- /dev/null +++ b/packages/rum-core/src/domain/voc/survey.ts @@ -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() + + 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 +} diff --git a/packages/rum-core/src/domain/voc/trigger.ts b/packages/rum-core/src/domain/voc/trigger.ts new file mode 100644 index 0000000000..656ba40911 --- /dev/null +++ b/packages/rum-core/src/domain/voc/trigger.ts @@ -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 [] + } +}