Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/rum/src/domain/record/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { takeFullSnapshot, takeNodeSnapshot } from './internalApi'
export { record } from './record'
export type { SerializationMetric, SerializationStats } from './serialization'
export { createSerializationStats, aggregateSerializationStats } from './serialization'
Expand Down
122 changes: 122 additions & 0 deletions packages/rum/src/domain/record/internalApi.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { NodeType, RecordType } from '../../types'
import { appendElement } from '../../../../rum-core/test'
import { takeFullSnapshot, takeNodeSnapshot } from './internalApi'

describe('takeFullSnapshot', () => {
it('should produce Meta, Focus, and FullSnapshot records', () => {
expect(takeFullSnapshot()).toEqual(
jasmine.arrayContaining([
{
data: {
height: jasmine.any(Number),
href: window.location.href,
width: jasmine.any(Number),
},
type: RecordType.Meta,
timestamp: jasmine.any(Number),
},
{
data: {
has_focus: document.hasFocus(),
},
type: RecordType.Focus,
timestamp: jasmine.any(Number),
},
{
data: {
node: jasmine.any(Object),
initialOffset: {
left: jasmine.any(Number),
top: jasmine.any(Number),
},
},
type: RecordType.FullSnapshot,
timestamp: jasmine.any(Number),
},
])
)
})

it('should produce VisualViewport records when supported', () => {
if (!window.visualViewport) {
pending('visualViewport not supported')
}

expect(takeFullSnapshot()).toEqual(
jasmine.arrayContaining([
{
data: jasmine.any(Object),
type: RecordType.VisualViewport,
timestamp: jasmine.any(Number),
},
])
)
})
})

describe('takeNodeSnapshot', () => {
it('should serialize nodes', () => {
const node = appendElement('<div>Hello <b>world</b></div>', document.body)
expect(takeNodeSnapshot(node)).toEqual({
type: NodeType.Element,
id: 0,
tagName: 'div',
isSVG: undefined,
attributes: {},
childNodes: [
{
type: NodeType.Text,
id: 1,
textContent: 'Hello ',
},
{
type: NodeType.Element,
id: 2,
tagName: 'b',
isSVG: undefined,
attributes: {},
childNodes: [
{
type: NodeType.Text,
id: 3,
textContent: 'world',
},
],
},
],
})
})

it('should serialize shadow hosts', () => {
const node = appendElement('<div>Hello</div>', document.body)
const shadowRoot = node.attachShadow({ mode: 'open' })
shadowRoot.appendChild(document.createTextNode('world'))
expect(takeNodeSnapshot(node)).toEqual({
type: NodeType.Element,
id: 0,
tagName: 'div',
isSVG: undefined,
attributes: {},
childNodes: [
{
type: NodeType.Text,
id: 1,
textContent: 'Hello',
},
{
type: NodeType.DocumentFragment,
id: 2,
isShadowRoot: true,
adoptedStyleSheets: undefined,
childNodes: [
{
type: NodeType.Text,
id: 3,
textContent: 'world',
},
],
},
],
})
})
})
88 changes: 88 additions & 0 deletions packages/rum/src/domain/record/internalApi.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

I’m a bit confused. Why is there so much logic tied to internalAPI? FMU, internal.ts was meant to expose the underlying APIs as-is, not to add internal-specific code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the downside of dependency injection; we need to configure all the dependencies for this code! All of the logic is just calls to factory functions, or direct construction of "noop" versions of dependencies in cases where we don't need the full versions.

Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { noop, timeStampNow } from '@datadog/browser-core'
import type { RumConfiguration } from '@datadog/browser-rum-core'
import { getNodePrivacyLevel, NodePrivacyLevel } from '@datadog/browser-rum-core'
import type { BrowserRecord, SerializedNodeWithId } from '../../types'
import { takeFullSnapshot as doTakeFullSnapshot } from './startFullSnapshots'
import type { ShadowRootsController } from './shadowRootsController'
import type { RecordingScope } from './recordingScope'
import { createRecordingScope } from './recordingScope'
import { createElementsScrollPositions } from './elementsScrollPositions'
import { createEventIds } from './eventIds'
import { createNodeIds } from './nodeIds'
import type { EmitRecordCallback } from './record.types'
import type { SerializationTransaction } from './serialization'
import { SerializationKind, serializeInTransaction, serializeNode } from './serialization'

/**
* Take a full snapshot of the document, generating the same records that the browser SDK
* would generate.
*
* This is an internal API function. Be sure to update Datadog-internal callers if you
* change its signature or behavior.
*/
export function takeFullSnapshot({
configuration,
}: { configuration?: Partial<RumConfiguration> } = {}): BrowserRecord[] {
const records: BrowserRecord[] = []
const emitRecord: EmitRecordCallback = (record: BrowserRecord) => {
records.push(record)
}

doTakeFullSnapshot(
timeStampNow(),
SerializationKind.INITIAL_FULL_SNAPSHOT,
emitRecord,
Copy link
Contributor

Choose a reason for hiding this comment

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

We must be sure that doTakeFullSnapshot will always be synchronous for this to work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed! In the near term, for better or worse, I think we'll be stuck with that limitation anyway, because if we yield, the DOM might mutate out from under us. (It'd be interesting to investigate a full snapshot algorithm that could tolerate that, though...)

noop,
createTemporaryRecordingScope(configuration)
)

return records
}

/**
* Take a snapshot of a DOM node, generating the serialized representation that the
* browser SDK would generate.
*
* This is an internal API function. Be sure to update Datadog-internal callers if you
* change its signature or behavior.
*/
export function takeNodeSnapshot(
node: Node,
{ configuration }: { configuration?: Partial<RumConfiguration> } = {}
): SerializedNodeWithId | null {
let serializedNode: SerializedNodeWithId | null = null

serializeInTransaction(
SerializationKind.INITIAL_FULL_SNAPSHOT,
noop,
noop,
createTemporaryRecordingScope(configuration),
(transaction: SerializationTransaction): void => {
const privacyLevel = getNodePrivacyLevel(node, transaction.scope.configuration.defaultPrivacyLevel)
if (privacyLevel === NodePrivacyLevel.HIDDEN || privacyLevel === NodePrivacyLevel.IGNORE) {
return
}
serializedNode = serializeNode(node, privacyLevel, transaction)
}
)

return serializedNode
}

function createTemporaryRecordingScope(configuration?: Partial<RumConfiguration>): RecordingScope {
return createRecordingScope(
{
defaultPrivacyLevel: NodePrivacyLevel.ALLOW,
...configuration,
} as RumConfiguration,
createElementsScrollPositions(),
createEventIds(),
createNodeIds(),
{
addShadowRoot: noop,
removeShadowRoot: noop,
flush: noop,
stop: noop,
} as ShadowRootsController
)
}
102 changes: 57 additions & 45 deletions packages/rum/src/domain/record/startFullSnapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,57 +16,69 @@ export function startFullSnapshots(
flushMutations: () => void,
scope: RecordingScope
) {
const takeFullSnapshot = (timestamp: TimeStamp, kind: SerializationKind) => {
serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction): void => {
const { width, height } = getViewportDimension()
transaction.add({
data: {
height,
href: window.location.href,
width,
},
type: RecordType.Meta,
timestamp,
})

transaction.add({
data: {
has_focus: document.hasFocus(),
},
type: RecordType.Focus,
timestamp,
})

transaction.add({
data: {
node: serializeDocument(document, transaction),
initialOffset: {
left: getScrollX(),
top: getScrollY(),
},
},
type: RecordType.FullSnapshot,
timestamp,
})

if (window.visualViewport) {
transaction.add({
data: getVisualViewport(window.visualViewport),
type: RecordType.VisualViewport,
timestamp,
})
}
})
}

takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT)
takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope)

const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => {
flushMutations()
takeFullSnapshot(view.startClocks.timeStamp, SerializationKind.SUBSEQUENT_FULL_SNAPSHOT)
takeFullSnapshot(
view.startClocks.timeStamp,
SerializationKind.SUBSEQUENT_FULL_SNAPSHOT,
emitRecord,
emitStats,
scope
)
})

return {
stop: unsubscribe,
}
}

export function takeFullSnapshot(
timestamp: TimeStamp,
kind: SerializationKind,
emitRecord: EmitRecordCallback,
emitStats: EmitStatsCallback,
scope: RecordingScope
): void {
serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction): void => {
const { width, height } = getViewportDimension()
transaction.add({
data: {
height,
href: window.location.href,
width,
},
type: RecordType.Meta,
timestamp,
})

transaction.add({
data: {
has_focus: document.hasFocus(),
},
type: RecordType.Focus,
timestamp,
})

transaction.add({
data: {
node: serializeDocument(document, transaction),
initialOffset: {
left: getScrollX(),
top: getScrollY(),
},
},
type: RecordType.FullSnapshot,
timestamp,
})

if (window.visualViewport) {
transaction.add({
data: getVisualViewport(window.visualViewport),
type: RecordType.VisualViewport,
timestamp,
})
}
})
}
2 changes: 1 addition & 1 deletion packages/rum/src/entries/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ export {

export * from '../types'

export { serializeNode, serializeNode as serializeNodeWithId } from '../domain/record'
export { takeFullSnapshot, takeNodeSnapshot, serializeNode as serializeNodeWithId } from '../domain/record'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

serializeNode() was just added in a previous PR in this PR stack, so nothing's using it yet. takeNodeSnapshot() is a superior alternative, so I've removed serializeNode() in favor of the new function. I plan to remove serializeNodeWithId(), too, once we've switched all callers over to the new APIs.