Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[REPLAY-898] Recording Frustration signals (dead, error & rage clicks) for session replay #1632

Merged
merged 26 commits into from
Jul 21, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7386512
Recording dead and error clicks for session replay
N-Boutaib Jul 7, 2022
2fc12b1
Add record type to incremental snapshot with mouseInteraction source
N-Boutaib Jul 7, 2022
57c1048
Fix linter errors
N-Boutaib Jul 7, 2022
04f317c
Solve ci issues
N-Boutaib Jul 7, 2022
a18b53f
Fix prettier errors
N-Boutaib Jul 8, 2022
2ab1f9b
Change segment start date to the timestamp of the earliest record & R…
N-Boutaib Jul 8, 2022
7cdc8f4
Adding more tests for segment collection
N-Boutaib Jul 8, 2022
b3a36d6
Merge branch 'main' into najib.boutaib/sr-898-add-frustration-records
N-Boutaib Jul 11, 2022
88432be
Record rage clicks for session replay
N-Boutaib Jul 11, 2022
cd1b477
Fix linter
N-Boutaib Jul 11, 2022
be88297
Fix format job
N-Boutaib Jul 11, 2022
a8d0e86
Fix and add more tests
N-Boutaib Jul 11, 2022
55b36f8
Review and e2e tests
N-Boutaib Jul 13, 2022
ace2ae6
Merge branch 'main' into najib.boutaib/sr-898-add-frustration-records
N-Boutaib Jul 13, 2022
9762e5d
Update PR after review
N-Boutaib Jul 19, 2022
b8824d0
Merge branch 'main' into najib.boutaib/sr-898-add-frustration-records
N-Boutaib Jul 19, 2022
a21fa3f
Merge branch 'main' into najib.boutaib/sr-898-add-frustration-records
N-Boutaib Jul 19, 2022
6354391
Fix typo
N-Boutaib Jul 19, 2022
5f56ac9
Adding deprected attribute with its value
N-Boutaib Jul 19, 2022
a8b5ff2
Fix Failing test
N-Boutaib Jul 19, 2022
924521e
Updating PR & adding more unit tests
N-Boutaib Jul 20, 2022
911d63b
Remove useless statement in e2e tests
N-Boutaib Jul 20, 2022
0ee3479
Merge branch 'main' into najib.boutaib/sr-898-add-frustration-records
N-Boutaib Jul 20, 2022
031b2d3
Remove IE unsupported statement
N-Boutaib Jul 20, 2022
17ab8be
Merge branch 'main' into najib.boutaib/sr-898-add-frustration-records
N-Boutaib Jul 20, 2022
f12c747
Merge branch 'main' into najib.boutaib/sr-898-add-frustration-records
N-Boutaib Jul 21, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe('actionCollection', () => {
})
expect(rawRumEvents[0].domainContext).toEqual({
event,
eventsSequence: undefined,
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function processAction(
customerContext,
rawRumEvent: actionEvent,
startTime: action.startClocks.relative,
domainContext: isAutoAction(action) ? { event: action.event } : {},
domainContext: isAutoAction(action) ? { event: action.event, eventsSequence: action.eventsSequence } : {},
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('trackClickActions', () => {
frustrationTypes: [],
target: undefined,
position: undefined,
eventsSequence: undefined,
},
])
})
Expand Down Expand Up @@ -326,6 +327,19 @@ describe('trackClickActions', () => {
expect(events[0].duration).toBe((MAX_DURATION_BETWEEN_CLICKS + 2 * actionDuration) as Duration)
})

it('should contain original events from of rage sequence', () => {
const { domMutationObservable, clock } = setupBuilder.build()
const actionDuration = 5
emulateClickWithActivity(domMutationObservable, clock, undefined, actionDuration)
emulateClickWithActivity(domMutationObservable, clock, undefined, actionDuration)
emulateClickWithActivity(domMutationObservable, clock, undefined, actionDuration)

clock.tick(EXPIRE_DELAY)
expect(events.length).toBe(1)
expect(events[0].frustrationTypes).toEqual([FrustrationType.RAGE_CLICK])
expect(events[0].eventsSequence?.length).toBe(3)
})

it('aggregates frustrationTypes from all clicks', () => {
const { lifeCycle, domMutationObservable, clock } = setupBuilder.build()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface ClickAction {
counts: ActionCounts
event: MouseEvent & { target: Element }
frustrationTypes: FrustrationType[]
eventsSequence?: Event[]
}

export interface ActionContexts {
Expand Down Expand Up @@ -247,7 +248,7 @@ function newClick(

clone: () => newClick(lifeCycle, history, getUserActivity, base),

validate: () => {
validate: (eventsSequence?: Event[]) => {
stop()
if (status !== ClickStatus.STOPPED) {
return
Expand All @@ -267,6 +268,7 @@ function newClick(
errorCount,
longTaskCount,
},
eventsSequence,
},
base
)
Expand All @@ -286,7 +288,7 @@ export function finalizeClicks(clicks: Click[], rageClick: Click) {
if (isRage) {
clicks.forEach((click) => click.discard())
rageClick.stop(timeStampNow())
rageClick.validate()
rageClick.validate(clicks.map((click) => click.event))
} else {
rageClick.discard()
clicks.forEach((click) => click.validate())
Expand Down
1 change: 1 addition & 0 deletions packages/rum-core/src/domainContext.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface RumViewEventDomainContext {

export interface RumActionEventDomainContext {
event?: Event
eventsSequence?: Event[]
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved
}

export interface RumFetchResourceEventDomainContext {
Expand Down
2 changes: 1 addition & 1 deletion packages/rum-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export {
RumViewEventDomainContext,
RumEventDomainContext,
} from './domainContext.types'
export { CommonContext, ReplayStats } from './rawRumEvent.types'
export { CommonContext, ReplayStats, ActionType, RumEventType, FrustrationType } from './rawRumEvent.types'
export { startRum } from './boot/startRum'
export { LifeCycle, LifeCycleEventType } from './domain/lifeCycle'
export { ViewCreatedEvent } from './domain/rumEventsCollection/view/trackViews'
Expand Down
1 change: 1 addition & 0 deletions packages/rum/src/boot/startRecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function startRecording(
} = record({
emit: addRecord,
defaultPrivacyLevel: configuration.defaultPrivacyLevel,
lifeCycle,
})

const { unsubscribe: unsubscribeViewEnded } = lifeCycle.subscribe(LifeCycleEventType.VIEW_ENDED, () => {
Expand Down
66 changes: 61 additions & 5 deletions packages/rum/src/domain/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import {
addEventListener,
noop,
} from '@datadog/browser-core'
import { initViewportObservable } from '@datadog/browser-rum-core'
import type { LifeCycle } from '@datadog/browser-rum-core'
import {
FrustrationType,
initViewportObservable,
ActionType,
RumEventType,
LifeCycleEventType,
} from '@datadog/browser-rum-core'
import { NodePrivacyLevel } from '../../constants'
import type {
InputState,
Expand All @@ -23,11 +30,14 @@ import type {
MediaInteraction,
FocusRecord,
VisualViewportRecord,
FrustrationRecord,
IncrementalSnapshotRecord,
MouseInteractionData,
} from '../../types'
import { IncrementalSource, MediaInteractionType, MouseInteractionType } from '../../types'
import { RecordType, IncrementalSource, MediaInteractionType, MouseInteractionType } from '../../types'
import { getNodePrivacyLevel, shouldMaskNode } from './privacy'
import { getElementInputValue, getSerializedNodeId, hasSerializedNode } from './serializationUtils'
import { forEach, isTouchEvent } from './utils'
import { assembleIncrementalSnapshot, forEach, getFrustrationFromAction, isTouchEvent } from './utils'
import type { MutationController } from './mutationObserver'
import { startMutationObserver } from './mutationObserver'

Expand All @@ -37,6 +47,16 @@ const MOUSE_MOVE_OBSERVER_THRESHOLD = 50
const SCROLL_OBSERVER_THRESHOLD = 100
const VISUAL_VIEWPORT_OBSERVER_THRESHOLD = 200

const recordIds = new WeakMap<Event, number>()
let nextId = 1

function getRecordIdForEvent(event: Event): number {
if (!recordIds.has(event)) {
recordIds.set(event, nextId++)
}
return recordIds.get(event)!
}

type ListenerHandler = () => void

type MousemoveCallBack = (
Expand All @@ -46,7 +66,7 @@ type MousemoveCallBack = (

export type MutationCallBack = (m: MutationPayload) => void

type MouseInteractionCallBack = (d: MouseInteraction) => void
type MouseInteractionCallBack = (record: IncrementalSnapshotRecord) => void

type ScrollCallback = (p: ScrollPosition) => void

Expand All @@ -62,7 +82,10 @@ type FocusCallback = (data: FocusRecord['data']) => void

type VisualViewportResizeCallback = (data: VisualViewportRecord['data']) => void

type FrustrationCallback = (record: FrustrationRecord) => void

interface ObserverParam {
lifeCycle: LifeCycle
defaultPrivacyLevel: DefaultPrivacyLevel
mutationController: MutationController
mutationCb: MutationCallBack
Expand All @@ -75,6 +98,7 @@ interface ObserverParam {
mediaInteractionCb: MediaInteractionCallback
styleSheetRuleCb: StyleSheetRuleCallback
focusCb: FocusCallback
frustrationCb: FrustrationCallback
}

export function initObservers(o: ObserverParam): ListenerHandler {
Expand All @@ -88,6 +112,7 @@ export function initObservers(o: ObserverParam): ListenerHandler {
const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb)
const focusHandler = initFocusObserver(o.focusCb)
const visualViewportResizeHandler = initVisualViewportResizeObserver(o.visualViewportResizeCb)
const frustartionHandler = initFrustrationObserver(o.lifeCycle, o.frustrationCb)
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved

return () => {
mutationHandler()
Expand All @@ -100,6 +125,7 @@ export function initObservers(o: ObserverParam): ListenerHandler {
styleSheetObserver()
focusHandler()
visualViewportResizeHandler()
frustartionHandler()
}
}

Expand Down Expand Up @@ -175,7 +201,12 @@ function initMouseInteractionObserver(
position.x = visualViewportX
position.y = visualViewportY
}
cb(position)

const record = assign(
{ id: getRecordIdForEvent(event) },
assembleIncrementalSnapshot<MouseInteractionData>(IncrementalSource.MouseInteraction, position)
)
cb(record)
}
return addEventListeners(document, Object.keys(eventTypeToMouseInteraction) as DOM_EVENT[], handler, {
capture: true,
Expand Down Expand Up @@ -399,3 +430,28 @@ function initVisualViewportResizeObserver(cb: VisualViewportResizeCallback): Lis
cancelThrottle()
}
}

function initFrustrationObserver(lifeCycle: LifeCycle, frustrationCb: FrustrationCallback): ListenerHandler {
return lifeCycle.subscribe(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, (data) => {
if (
data.rawRumEvent.type === RumEventType.ACTION &&
data.rawRumEvent.action.type === ActionType.CLICK &&
data.rawRumEvent.action.frustration?.type &&
'event' in data.domainContext &&
data.domainContext.event
) {
const frustrationType = getFrustrationFromAction(data.rawRumEvent.action.frustration.type)
frustrationCb({
timestamp: data.rawRumEvent.date,
type: RecordType.FrustrationRecord,
data: {
frustrationType,
recordIds:
frustrationType === FrustrationType.RAGE_CLICK && data.domainContext.eventsSequence
? data.domainContext.eventsSequence.map((e) => getRecordIdForEvent(e))
: [getRecordIdForEvent(data.domainContext.event)],
},
})
}
}).unsubscribe
}
2 changes: 2 additions & 0 deletions packages/rum/src/domain/record/record.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DefaultPrivacyLevel, isIE } from '@datadog/browser-core'
import { LifeCycle } from '@datadog/browser-rum-core'
import type { Clock } from '../../../../core/test/specHelper'
import { createNewEvent } from '../../../../core/test/specHelper'
import { collectAsyncCalls, recordsPerFullSnapshot } from '../../../test/utils'
Expand Down Expand Up @@ -198,6 +199,7 @@ describe('record', () => {
recordApi = record({
emit: emitSpy,
defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW,
lifeCycle: new LifeCycle(),
})
}

Expand Down
29 changes: 7 additions & 22 deletions packages/rum/src/domain/record/record.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { assign, timeStampNow } from '@datadog/browser-core'
import { timeStampNow } from '@datadog/browser-core'
import type { DefaultPrivacyLevel, TimeStamp } from '@datadog/browser-core'
import type { LifeCycle } from '@datadog/browser-rum-core'
import { getViewportDimension } from '@datadog/browser-rum-core'
import type {
IncrementalSnapshotRecord,
IncrementalData,
InputData,
MediaInteractionData,
MouseInteractionData,
MousemoveData,
MutationData,
ScrollData,
Expand All @@ -20,10 +18,12 @@ import { initObservers } from './observer'

import { MutationController } from './mutationObserver'
import { getVisualViewport, getScrollX, getScrollY } from './viewports'
import { assembleIncrementalSnapshot } from './utils'

export interface RecordOptions {
emit?: (record: Record) => void
defaultPrivacyLevel: DefaultPrivacyLevel
lifeCycle: LifeCycle
}

export interface RecordAPI {
Expand Down Expand Up @@ -86,19 +86,20 @@ export function record(options: RecordOptions): RecordAPI {
takeFullSnapshot()

const stopObservers = initObservers({
lifeCycle: options.lifeCycle,
mutationController,
defaultPrivacyLevel: options.defaultPrivacyLevel,
inputCb: (v) => emit(assembleIncrementalSnapshot<InputData>(IncrementalSource.Input, v)),
mediaInteractionCb: (p) =>
emit(assembleIncrementalSnapshot<MediaInteractionData>(IncrementalSource.MediaInteraction, p)),
mouseInteractionCb: (d) =>
emit(assembleIncrementalSnapshot<MouseInteractionData>(IncrementalSource.MouseInteraction, d)),
mouseInteractionCb: (mouseInteractionRecord) => emit(mouseInteractionRecord),
mousemoveCb: (positions, source) => emit(assembleIncrementalSnapshot<MousemoveData>(source, { positions })),
mutationCb: (m) => emit(assembleIncrementalSnapshot<MutationData>(IncrementalSource.Mutation, m)),
scrollCb: (p) => emit(assembleIncrementalSnapshot<ScrollData>(IncrementalSource.Scroll, p)),
styleSheetRuleCb: (r) => emit(assembleIncrementalSnapshot<StyleSheetRuleData>(IncrementalSource.StyleSheetRule, r)),
viewportResizeCb: (d) => emit(assembleIncrementalSnapshot<ViewportResizeData>(IncrementalSource.ViewportResize, d)),

frustrationCb: (frustrationRecord) => emit(frustrationRecord),
focusCb: (data) =>
emit({
data,
Expand All @@ -120,19 +121,3 @@ export function record(options: RecordOptions): RecordAPI {
flushMutations: () => mutationController.flush(),
}
}

function assembleIncrementalSnapshot<Data extends IncrementalData>(
source: Data['source'],
data: Omit<Data, 'source'>
): IncrementalSnapshotRecord {
return {
data: assign(
{
source,
},
data
) as Data,
type: RecordType.IncrementalSnapshot,
timestamp: timeStampNow(),
}
}
29 changes: 29 additions & 0 deletions packages/rum/src/domain/record/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { assign, includes, timeStampNow } from '@datadog/browser-core'
import { FrustrationType } from '@datadog/browser-rum-core'
import type { IncrementalData, IncrementalSnapshotRecord } from '../../types'
import { RecordType } from '../../types'

export function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent {
return Boolean((event as TouchEvent).changedTouches)
}
Expand All @@ -8,3 +13,27 @@ export function forEach<List extends { [index: number]: any }>(
) {
Array.prototype.forEach.call(list, callback as any)
}

export function getFrustrationFromAction(frustrations: FrustrationType[]): FrustrationType {
return includes(frustrations, FrustrationType.RAGE_CLICK)
? FrustrationType.RAGE_CLICK
: includes(frustrations, FrustrationType.ERROR_CLICK)
? FrustrationType.ERROR_CLICK
: FrustrationType.DEAD_CLICK
}
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved

export function assembleIncrementalSnapshot<Data extends IncrementalData>(
source: Data['source'],
data: Omit<Data, 'source'>
): IncrementalSnapshotRecord {
return {
data: assign(
{
source,
},
data
) as Data,
type: RecordType.IncrementalSnapshot,
timestamp: timeStampNow(),
}
}
14 changes: 11 additions & 3 deletions packages/rum/src/domain/segmentCollection/segment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,19 @@ describe('Segment', () => {
it('increments records_count', () => {
expect(segment.metadata.records_count).toBe(2)
})
it('increases end timestamp', () => {
it('does not change start timestamp when receiving a later record', () => {
expect(segment.metadata.start).toBe(10)
})
it('should change the start timestamp when receiving an earlier record', () => {
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved
segment.addRecord({ type: RecordType.ViewEnd, timestamp: 5 as TimeStamp })
expect(segment.metadata.start).toBe(5)
})
it('increases end timestamp when receiving a later record', () => {
expect(segment.metadata.end).toBe(15)
})
it('does not change start timestamp', () => {
expect(segment.metadata.start).toBe(10)
it('should not change the end timestamp when receiving an earlier record', () => {
segment.addRecord({ type: RecordType.ViewEnd, timestamp: 5 as TimeStamp })
expect(segment.metadata.end).toBe(15)
})
})

Expand Down
7 changes: 6 additions & 1 deletion packages/rum/src/domain/segmentCollection/segment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ export class Segment {
}

addRecord(record: Record): void {
this.metadata.end = record.timestamp
if (record.timestamp < this.metadata.start) {
this.metadata.start = record.timestamp
}
if (this.metadata.end < record.timestamp) {
this.metadata.end = record.timestamp
}
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved
this.metadata.records_count += 1
replayStats.addRecord(this.metadata.view.id)
this.metadata.has_full_snapshot ||= record.type === RecordType.FullSnapshot
Expand Down
Loading