Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -31,6 +31,7 @@ const RUM_EVENT_TYPE_COLOR = {
error: 'red',
long_task: 'yellow',
view: 'blue',
view_update: 'blue',
resource: 'cyan',
telemetry: 'teal',
vital: 'orange',
Expand Down Expand Up @@ -109,7 +110,7 @@ export const EventRow = React.memo(
case 'date':
return (
<Cell key="date" noWrap>
{formatDate(event.date)}
{formatDate(event.date!)}
</Cell>
)
case 'description':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function startEventCollection(strategy: EventCollectionStrategy, onEvents
function compareEvents(a: SdkEvent, b: SdkEvent) {
// Sort events chronologically
if (a.date !== b.date) {
return b.date - a.date
return (b.date ?? 0) - (a.date ?? 0)
}

// If two events have the same date, make sure to display View events last. This ensures that View
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export enum ExperimentalFeature {
USE_CHANGE_RECORDS = 'use_change_records',
SOURCE_CODE_CONTEXT = 'source_code_context',
LCP_SUBPARTS = 'lcp_subparts',
PARTIAL_VIEW_UPDATES = 'partial_view_updates',
}

const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Expand Down
16 changes: 16 additions & 0 deletions packages/rum-core/src/domain/assembly.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,22 @@ describe('rum assembly', () => {
})
})

it('should not allow dismissing view_update events', () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
partialConfiguration: {
beforeSend: () => false,
},
})

const displaySpy = spyOn(display, 'warn')
notifyRawRumEvent(lifeCycle, {
rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE),
})

expect(serverRumEvents.length).toBe(1)
expect(displaySpy).toHaveBeenCalledWith("Can't dismiss view events using beforeSend!")
})

it('should not dismiss when true is returned', () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
partialConfiguration: {
Expand Down
9 changes: 8 additions & 1 deletion packages/rum-core/src/domain/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export function startRumAssembly(
...VIEW_MODIFIABLE_FIELD_PATHS,
...ROOT_MODIFIABLE_FIELD_PATHS,
},
[RumEventType.VIEW_UPDATE]: {
'view.performance.lcp.resource_url': 'string',
...USER_CUSTOMIZABLE_FIELD_PATHS,
...VIEW_MODIFIABLE_FIELD_PATHS,
...ROOT_MODIFIABLE_FIELD_PATHS,
},
[RumEventType.ERROR]: {
'error.message': 'string',
'error.stack': 'string',
Expand Down Expand Up @@ -129,7 +135,8 @@ function shouldSend(
const result = limitModification(event, modifiableFieldPathsByEvent[event.type], (event) =>
beforeSend(event, domainContext)
)
if (result === false && event.type !== RumEventType.VIEW) {
const eventType = event.type as RumEventType
if (result === false && eventType !== RumEventType.VIEW && eventType !== RumEventType.VIEW_UPDATE) {
return false
}
if (result === false) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('featureFlagContexts', () => {
})

describe('assemble hook', () => {
it('should add feature flag evaluations on VIEW and ERROR by default ', () => {
it('should add feature flag evaluations on VIEW, VIEW_UPDATE, and ERROR by default ', () => {
lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, {
startClocks: relativeToClocks(0 as RelativeTime),
} as ViewCreatedEvent)
Expand All @@ -39,6 +39,10 @@ describe('featureFlagContexts', () => {
eventType: 'view',
startTime: 0 as RelativeTime,
} as AssembleHookParams)
const defaultViewUpdateAttributes = hooks.triggerHook(HookNames.Assemble, {
eventType: 'view_update' as any,
startTime: 0 as RelativeTime,
} as AssembleHookParams)
const defaultErrorAttributes = hooks.triggerHook(HookNames.Assemble, {
eventType: 'error',
startTime: 0 as RelativeTime,
Expand All @@ -51,6 +55,14 @@ describe('featureFlagContexts', () => {
},
})

expect(defaultViewUpdateAttributes).toEqual(
jasmine.objectContaining({
feature_flags: {
feature: 'foo',
},
})
)

expect(defaultErrorAttributes).toEqual({
type: 'error',
feature_flags: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function startFeatureFlagContexts(
hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => {
const trackFeatureFlagsForEvents = (configuration.trackFeatureFlagsForEvents as RumEventType[]).concat([
RumEventType.VIEW,
RumEventType.VIEW_UPDATE,
RumEventType.ERROR,
])
if (!trackFeatureFlagsForEvents.includes(eventType as RumEventType)) {
Expand Down
4 changes: 4 additions & 0 deletions packages/rum-core/src/domain/contexts/sourceCodeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export function startSourceCodeContext(hooks: Hooks) {
hooks.register(HookNames.Assemble, ({ domainContext, rawRumEvent }): DefaultRumEventAttributes | SKIPPED => {
buildContextByFile()

if (rawRumEvent.type === 'view_update') {
return SKIPPED
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is view_update explicitly skipped here, but not view?

view_update gets an explicit guard because these events are emitted by SDK-internal timers and observers — user code never triggers them, so handlingStack is never set on their domain context. Without this guard, they'd go through getSourceUrl() only to hit computeStackTrace({ stack: undefined }) and return SKIPPED anyway. The explicit guard makes the intent clear and short-circuits pointless work.

view events don't need the same guard because they can have a meaningful handlingStack — specifically when a user explicitly calls datadogRum.startView(), which captures the call stack. In that case getSourceUrl() can resolve a real file URL and the view event legitimately receives source code context. When no handlingStack is present (the common case), it naturally returns SKIPPED via the contextByFile.get(url) miss at the end.


const url = getSourceUrl(domainContext, rawRumEvent)

if (url) {
Expand Down
141 changes: 140 additions & 1 deletion packages/rum-core/src/domain/view/viewCollection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { DISCARDED, HookNames, Observable } from '@datadog/browser-core'
import {
DISCARDED,
HookNames,
Observable,
resetExperimentalFeatures,
addExperimentalFeatures,
ExperimentalFeature,
} from '@datadog/browser-core'
import type { Duration, RelativeTime, ServerDuration, TimeStamp } from '@datadog/browser-core'
import { mockClock, registerCleanupTask } from '@datadog/browser-core/test'
import type { RecorderApi } from '../../boot/rumPublicApi'
Expand Down Expand Up @@ -305,3 +312,135 @@ describe('viewCollection', () => {
})
})
})

describe('partial view updates', () => {
let lifeCycle: LifeCycle
let hooks: Hooks
let rawRumEvents: Array<RawRumEventCollectedData<RawRumEvent>>

function setupViewCollection(partialConfiguration: Partial<RumConfiguration> = {}) {
lifeCycle = new LifeCycle()
hooks = createHooks()
const viewHistory = mockViewHistory()
const domMutationObservable = new Observable<RumMutationRecord[]>()
const windowOpenObservable = new Observable<void>()
const locationChangeObservable = new Observable<LocationChange>()
mockClock()

const collectionResult = startViewCollection(
lifeCycle,
hooks,
mockRumConfiguration(partialConfiguration),
domMutationObservable,
windowOpenObservable,
locationChangeObservable,
noopRecorderApi,
viewHistory
)

rawRumEvents = collectAndValidateRawRumEvents(lifeCycle)

registerCleanupTask(() => {
collectionResult.stop()
viewHistory.stop()
})
return collectionResult
}

beforeEach(() => {
addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES])
registerCleanupTask(resetExperimentalFeatures)
})

it('should emit full VIEW event on first update for a view', () => {
setupViewCollection()
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)

expect(rawRumEvents.length).toBe(1)
expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
})

it('should emit VIEW_UPDATE on subsequent updates for the same view', () => {
setupViewCollection()
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
...VIEW,
documentVersion: 4,
eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
})

expect(rawRumEvents.length).toBe(2)
expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
expect(rawRumEvents[1].rawRumEvent.type).toBe(RumEventType.VIEW_UPDATE)
})

it('should include only changed fields in VIEW_UPDATE', () => {
setupViewCollection()
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
...VIEW,
documentVersion: 4,
eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
})

const updateEvent = rawRumEvents[1].rawRumEvent as any
expect(updateEvent.type).toBe(RumEventType.VIEW_UPDATE)
expect(updateEvent.view.error).toEqual({ count: 15 })
expect(updateEvent._dd.document_version).toBe(4)
// Unchanged fields should not be present
expect(updateEvent.view.action).toBeUndefined()
expect(updateEvent.view.resource).toBeUndefined()
})

it('should not emit event when no fields changed', () => {
setupViewCollection()
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW) // identical

expect(rawRumEvents.length).toBe(1) // only the initial VIEW
})

it('should emit full VIEW event when view.id changes', () => {
setupViewCollection()
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
...VIEW,
id: 'new-view-id',
documentVersion: 1,
})

expect(rawRumEvents.length).toBe(2)
expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
expect(rawRumEvents[1].rawRumEvent.type).toBe(RumEventType.VIEW)
})

it('should emit only VIEW events when feature flag is OFF', () => {
resetExperimentalFeatures() // Ensure OFF
setupViewCollection()
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
...VIEW,
documentVersion: 4,
eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
})

expect(rawRumEvents.length).toBe(2)
expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
expect(rawRumEvents[1].rawRumEvent.type).toBe(RumEventType.VIEW)
})

it('should have monotonically increasing document_version across view and view_update', () => {
setupViewCollection()
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...VIEW, documentVersion: 1 })
lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
...VIEW,
documentVersion: 2,
eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
})

const firstEvent = rawRumEvents[0].rawRumEvent as RawRumViewEvent
const secondEvent = rawRumEvents[1].rawRumEvent as any
expect(firstEvent._dd.document_version).toBe(1)
expect(secondEvent._dd.document_version).toBe(2)
})
})
66 changes: 62 additions & 4 deletions packages/rum-core/src/domain/view/viewCollection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { Duration, ServerDuration, Observable } from '@datadog/browser-core'
import { getTimeZone, DISCARDED, HookNames, isEmptyObject, mapValues, toServerDuration } from '@datadog/browser-core'
import {
getTimeZone,
DISCARDED,
HookNames,
isEmptyObject,
mapValues,
toServerDuration,
isExperimentalFeatureEnabled,
ExperimentalFeature,
} from '@datadog/browser-core'
import { discardNegativeDuration } from '../discardNegativeDuration'
import type { RecorderApi } from '../../boot/rumPublicApi'
import type { RawRumViewEvent, ViewPerformanceData } from '../../rawRumEvent.types'
Expand All @@ -15,6 +24,7 @@ import { trackViews } from './trackViews'
import type { ViewEvent, ViewOptions } from './trackViews'
import type { CommonViewMetrics } from './viewMetrics/trackCommonViewMetrics'
import type { InitialViewMetrics } from './viewMetrics/trackInitialViewMetrics'
import { computeViewDiff, createViewDiffTracker } from './viewDiff'

export function startViewCollection(
lifeCycle: LifeCycle,
Expand All @@ -27,9 +37,57 @@ export function startViewCollection(
viewHistory: ViewHistory,
initialViewOptions?: ViewOptions
) {
lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) =>
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processViewUpdate(view, configuration, recorderApi))
)
if (isExperimentalFeatureEnabled(ExperimentalFeature.PARTIAL_VIEW_UPDATES)) {
const diffTracker = createViewDiffTracker()
let currentViewId: string | undefined

lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) => {
const rawEventData = processViewUpdate(view, configuration, recorderApi)

// New view: reset tracker, emit full view event
if (view.id !== currentViewId) {
currentViewId = view.id
diffTracker.reset()
diffTracker.recordSentState(rawEventData.rawRumEvent)
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, rawEventData)
return
}

// Subsequent update: compute diff
const lastSent = diffTracker.getLastSentState()
if (!lastSent) {
// Safety: should not happen, but fall back to full view
diffTracker.recordSentState(rawEventData.rawRumEvent)
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, rawEventData)
return
}

try {
const viewUpdateEvent = computeViewDiff(rawEventData.rawRumEvent, lastSent)

if (viewUpdateEvent) {
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, {
rawRumEvent: viewUpdateEvent,
startTime: rawEventData.startTime,
duration: rawEventData.duration,
domainContext: rawEventData.domainContext,
})
}
// Empty diff: no event emitted (skip)
} catch {
// Diff computation failed: fall back to full view event
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, rawEventData)
}

// Always update tracker with current state
diffTracker.recordSentState(rawEventData.rawRumEvent)
})
} else {
// Feature flag OFF: existing behavior unchanged
lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) =>
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processViewUpdate(view, configuration, recorderApi))
)
}

hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | DISCARDED => {
const view = viewHistory.findView(startTime)
Expand Down
Loading