Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
42c483f
⚗ feat: add new flag for the feature
HugoGarrido Feb 10, 2026
6fc488f
⚗ feat: update types for inp supbarts
HugoGarrido Feb 10, 2026
190734d
⚗ feat: add map to group entries for subpart computation
HugoGarrido Feb 10, 2026
f03e852
⚗ feat: compute inp subparts
HugoGarrido Feb 10, 2026
5bb7635
⚡️ fix: clear map on stop callback
HugoGarrido Feb 10, 2026
f65b697
⚗ feat: report inp subparts to view collection
HugoGarrido Feb 10, 2026
41283a4
✅ feat: add inp subparts specific tests
HugoGarrido Feb 10, 2026
8bdf5db
✅ fix tests in domain/view
HugoGarrido Feb 10, 2026
1fbf5a1
🎨 fix: format code
HugoGarrido Feb 10, 2026
106e82d
👌 fix: gate groupEntriesByRenderTime behind the feature flag
HugoGarrido Feb 10, 2026
ecc7e42
👌 fix: always use safe inp value for subparts
HugoGarrido Feb 10, 2026
4f1ce8b
👌 fix: only rely on group that should exists to compute subparts
HugoGarrido Feb 11, 2026
2c2ad12
👌 fix: stop storing entries in group since not used
HugoGarrido Feb 11, 2026
5189c30
👌 fix: prevents 0 value being falsy in checks for interactionId
HugoGarrido Feb 11, 2026
fbec2d2
👌 fix: prevent storing too much none needed entries groups
HugoGarrido Feb 23, 2026
820aca8
👌 fix: not only rely on duration to compute new interaction
HugoGarrido Feb 23, 2026
45c4a9b
Merge branch 'main' into hugo.garridoysaez/feat/support-inp-subparts
HugoGarrido Feb 23, 2026
b19ca0e
fix: typecheck
HugoGarrido Feb 23, 2026
7fd8fcd
fix: format
HugoGarrido Feb 23, 2026
b6fa330
👌extract currentInp to group state variables together
bcaudan Feb 25, 2026
73a5852
👌extract subPartsTracker
bcaudan Feb 25, 2026
f2100d1
📝add a bit of doc on main tracking methods
bcaudan Feb 25, 2026
5d1dc2e
⬆️update rum-events-format
bcaudan Feb 25, 2026
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/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',
INP_SUBPARTS = 'inp_subparts',
}

const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Expand Down
1 change: 1 addition & 0 deletions packages/rum-core/src/domain/view/viewCollection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ describe('viewCollection', () => {
duration: (10 * 1e6) as ServerDuration,
timestamp: (100 * 1e6) as ServerDuration,
target_selector: undefined,
sub_parts: undefined,
},
lcp: {
timestamp: (10 * 1e6) as ServerDuration,
Expand Down
7 changes: 7 additions & 0 deletions packages/rum-core/src/domain/view/viewCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ function computeViewPerformanceData(
duration: toServerDuration(interactionToNextPaint.value),
timestamp: toServerDuration(interactionToNextPaint.time),
target_selector: interactionToNextPaint.targetSelector,
sub_parts: interactionToNextPaint.subParts
? {
input_delay: toServerDuration(interactionToNextPaint.subParts.inputDelay),
processing_duration: toServerDuration(interactionToNextPaint.subParts.processingDuration),
presentation_delay: toServerDuration(interactionToNextPaint.subParts.presentationDelay),
}
: undefined,
},
lcp: largestContentfulPaint && {
timestamp: toServerDuration(largestContentfulPaint.value),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Duration, RelativeTime } from '@datadog/browser-core'
import { elapsed, relativeNow } from '@datadog/browser-core'
import { elapsed, relativeNow, ExperimentalFeature, addExperimentalFeatures } from '@datadog/browser-core'
import { registerCleanupTask } from '@datadog/browser-core/test'
import {
appendElement,
Expand Down Expand Up @@ -106,6 +106,7 @@ describe('trackInteractionToNextPaint', () => {
value: 100 as Duration,
targetSelector: undefined,
time: 1 as RelativeTime,
subParts: undefined,
})
})

Expand All @@ -121,6 +122,7 @@ describe('trackInteractionToNextPaint', () => {
value: MAX_INP_VALUE,
targetSelector: undefined,
time: 1 as RelativeTime,
subParts: undefined,
})
})

Expand All @@ -137,6 +139,7 @@ describe('trackInteractionToNextPaint', () => {
value: 98 as Duration,
targetSelector: undefined,
time: 98 as RelativeTime,
subParts: undefined,
})
})

Expand All @@ -158,6 +161,7 @@ describe('trackInteractionToNextPaint', () => {
value: 40 as Duration,
targetSelector: undefined,
time: 1 as RelativeTime,
subParts: undefined,
})
})

Expand All @@ -175,6 +179,7 @@ describe('trackInteractionToNextPaint', () => {
value: 100 as Duration,
targetSelector: undefined,
time: 100 as RelativeTime,
subParts: undefined,
})
})

Expand Down Expand Up @@ -265,6 +270,227 @@ describe('trackInteractionToNextPaint', () => {
expect(getInteractionSelector(startTime)).toBeUndefined()
})
})

describe('INP subparts', () => {
beforeEach(() => {
addExperimentalFeatures([ExperimentalFeature.INP_SUBPARTS])
})

it('should not include subparts when INP is 0', () => {
startINPTracking()
interactionCountMock.setInteractionCount(1 as Duration)

expect(getInteractionToNextPaint()).toEqual({ value: 0 as Duration })
})
;[
{
description: 'should calculate INP subparts correctly',
interaction: {
startTime: 1000,
processingStart: 1050,
processingEnd: 1200,
duration: 250,
},
expectedSubParts: {
inputDelay: 50, // 1050 - 1000
processingDuration: 150, // 1200 - 1050
presentationDelay: 50, // 1250 - 1200
},
},
{
description: 'should return undefined subparts when processingStart is missing',
interaction: {
startTime: 1000,
processingStart: undefined,
processingEnd: 1200,
duration: 250,
},
expectedSubParts: undefined,
},
{
description: 'should return undefined subparts when processingEnd is missing',
interaction: {
startTime: 1000,
processingStart: 1050,
processingEnd: undefined,
duration: 250,
},
expectedSubParts: undefined,
},
{
description: 'should clamp processingEnd to nextPaintTime',
interaction: {
startTime: 1000,
processingStart: 1050,
processingEnd: 1300, // Exceeds nextPaintTime (1000 + 250 = 1250)
duration: 250,
},
expectedSubParts: {
inputDelay: 50, // 1050 - 1000
processingDuration: 200, // 1250 - 1050 (clamped)
presentationDelay: 0, // 1250 - 1250
},
},
].forEach(({ description, interaction, expectedSubParts }) => {
it(description, () => {
startINPTracking()

newInteraction({
interactionId: 1,
startTime: interaction.startTime as RelativeTime,
processingStart: interaction.processingStart as RelativeTime | undefined,
processingEnd: interaction.processingEnd as RelativeTime | undefined,
duration: interaction.duration as Duration,
})

const inp = getInteractionToNextPaint()

if (expectedSubParts === undefined) {
expect(inp?.subParts).toBeUndefined()
} else {
expect(inp?.subParts).toEqual({
inputDelay: expectedSubParts.inputDelay as Duration,
processingDuration: expectedSubParts.processingDuration as Duration,
presentationDelay: expectedSubParts.presentationDelay as Duration,
})
// Validate: subparts sum equals INP duration
const subPartsSum =
expectedSubParts.inputDelay + expectedSubParts.processingDuration + expectedSubParts.presentationDelay
expect(subPartsSum).toBe(interaction.duration)
}
})
})

it('should handle entry grouping with same interactionId by using group max processingEnd', () => {
startINPTracking()

newInteraction({
interactionId: 1,
startTime: 1000 as RelativeTime,
processingStart: 1050 as RelativeTime,
processingEnd: 1100 as RelativeTime,
duration: 250 as Duration,
})

newInteraction({
interactionId: 1,
startTime: 1005 as RelativeTime,
processingStart: 1060 as RelativeTime,
processingEnd: 1200 as RelativeTime,
duration: 245 as Duration,
})

const inp = getInteractionToNextPaint()
expect(inp?.value).toBe(250 as Duration)
expect(inp?.subParts).toEqual({
inputDelay: 50 as Duration,
processingDuration: 150 as Duration,
presentationDelay: 50 as Duration,
})
})

it('should group entries within 8ms renderTime window', () => {
startINPTracking()

newInteraction({
interactionId: 1,
startTime: 1000 as RelativeTime,
processingStart: 1050 as RelativeTime,
processingEnd: 1150 as RelativeTime,
duration: 250 as Duration,
})

// within 8ms renderTime window
newInteraction({
interactionId: 2,
startTime: 1010 as RelativeTime,
processingStart: 1055 as RelativeTime,
processingEnd: 1240 as RelativeTime,
duration: 245 as Duration,
})

// outside of 8ms renderTime window
newInteraction({
interactionId: 2,
startTime: 1020 as RelativeTime,
processingStart: 1070 as RelativeTime,
processingEnd: 1200 as RelativeTime,
duration: 190 as Duration,
})

const inp = getInteractionToNextPaint()
expect(inp?.value).toBe(250 as Duration)
expect(inp?.subParts).toEqual({
inputDelay: 50 as Duration,
processingDuration: 190 as Duration,
presentationDelay: 10 as Duration,
})
})

it('should keep correct subparts for p98 after interactions causing pruning', () => {
startINPTracking()

// The interaction that will remain the p98 throughout
newInteraction({
interactionId: 1,
startTime: 1000 as RelativeTime,
processingStart: 1050 as RelativeTime,
processingEnd: 1200 as RelativeTime,
duration: 1100 as Duration,
})

// Add more than MAX_INTERACTION_ENTRIES (10) shorter interactions to fill longestInteractions
// and trigger eviction and pruning of their groups
for (let i = 2; i <= 12; i++) {
newInteraction({
interactionId: i,
startTime: (i * 2000) as RelativeTime,
processingStart: (i * 2000 + 10) as RelativeTime,
processingEnd: (i * 2000 + 20) as RelativeTime,
duration: i as Duration,
})
}

const inp = getInteractionToNextPaint()
expect(inp?.value).toBe(1100 as Duration)
expect(inp?.subParts).toEqual({
inputDelay: 50 as Duration,
processingDuration: 150 as Duration,
presentationDelay: 900 as Duration,
})
})

it('should update subparts when INP changes to a different interaction', () => {
startINPTracking()

newInteraction({
interactionId: 1,
startTime: 1000 as RelativeTime,
processingStart: 1050 as RelativeTime,
processingEnd: 1150 as RelativeTime,
duration: 200 as Duration,
})

let inp = getInteractionToNextPaint()
expect(inp?.subParts?.inputDelay).toBe(50 as Duration)

newInteraction({
interactionId: 2,
startTime: 2000 as RelativeTime,
processingStart: 2100 as RelativeTime,
processingEnd: 2350 as RelativeTime,
duration: 400 as Duration,
})

inp = getInteractionToNextPaint()
expect(inp?.value).toBe(400 as Duration)
expect(inp?.subParts).toEqual({
inputDelay: 100 as Duration,
processingDuration: 250 as Duration,
presentationDelay: 50 as Duration,
})
})
})
})

describe('trackViewInteractionCount', () => {
Expand Down
Loading