From acb7cd88db9efbfaaea161dc475ac4f3e07cd508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Qu=C3=A8ze?= Date: Sun, 14 Dec 2025 16:32:32 +0100 Subject: [PATCH] Add experimental sampling interval tracks per process Add a new experimental feature to visualize sampling intervals within each process. This helps identify when samples were missed, delayed, or when there are variations in sampling frequency. The feature is similar to experimental.enableProcessCPUTracks() and can be enabled via the console with: experimental.enableSamplingIntervalTracks() Implementation details: - Add 'sampling-interval' LocalTrack type - Create TrackSamplingInterval and TrackSamplingIntervalGraph components - Implement canvas rendering with max-min decimation optimization - Add hover tooltips showing actual vs expected sampling intervals - Include hover dot indicator for currently inspected sample --- src/actions/app.ts | 54 ++ src/actions/profile-view.ts | 18 + src/components/timeline/LocalTrack.tsx | 7 + .../timeline/TrackSamplingInterval.css | 44 ++ .../timeline/TrackSamplingInterval.tsx | 78 +++ .../timeline/TrackSamplingIntervalGraph.tsx | 570 ++++++++++++++++++ src/profile-logic/tracks.ts | 43 +- src/reducers/app.ts | 15 + src/reducers/profile-view.ts | 1 + src/reducers/url-state.ts | 1 + src/selectors/app.tsx | 7 + src/test/fixtures/profiles/tracks.ts | 2 + src/types/actions.ts | 5 + src/types/profile-derived.ts | 1 + src/types/state.ts | 1 + src/utils/window-console.ts | 14 + 16 files changed, 859 insertions(+), 2 deletions(-) create mode 100644 src/components/timeline/TrackSamplingInterval.css create mode 100644 src/components/timeline/TrackSamplingInterval.tsx create mode 100644 src/components/timeline/TrackSamplingIntervalGraph.tsx diff --git a/src/actions/app.ts b/src/actions/app.ts index 6302233259..03eca60245 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -12,6 +12,7 @@ import { getIsEventDelayTracksEnabled, getIsExperimentalCPUGraphsEnabled, getIsExperimentalProcessCPUTracksEnabled, + getIsExperimentalSamplingIntervalTracksEnabled, } from 'firefox-profiler/selectors/app'; import { getLocalTracksByPid, @@ -30,6 +31,7 @@ import { addEventDelayTracksForThreads, initializeLocalTrackOrderByPid, addProcessCPUTracksForProcess, + addSamplingIntervalTracksForProcess, } from 'firefox-profiler/profile-logic/tracks'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { @@ -369,6 +371,58 @@ export function enableExperimentalProcessCPUTracks(): ThunkAction { }; } +/* + * This action enables the sampling interval tracks. They are hidden by default + * and are useful for visualizing the actual sampling intervals per process. + * There is no UI that triggers this action in the profiler interface. Instead, + * users have to enable this from the developer console by writing this line: + * `experimental.enableSamplingIntervalTracks()` + */ +export function enableExperimentalSamplingIntervalTracks(): ThunkAction { + return (dispatch, getState) => { + if (getIsExperimentalSamplingIntervalTracksEnabled(getState())) { + console.error( + 'Tried to enable the sampling interval tracks, but they are already enabled.' + ); + return false; + } + + const profile = getProfile(getState()); + + // Check if there are any threads with samples + const hasThreadsWithSamples = profile.threads.some( + (thread) => thread.samples.length > 0 + ); + + if (!hasThreadsWithSamples) { + console.error( + 'Tried to enable the sampling interval tracks, but this profile does not have any threads with samples.' + ); + return false; + } + + const oldLocalTracks = getLocalTracksByPid(getState()); + const localTracksByPid = addSamplingIntervalTracksForProcess( + profile, + oldLocalTracks + ); + const localTrackOrderByPid = initializeLocalTrackOrderByPid( + getLocalTrackOrderByPid(getState()), + localTracksByPid, + null, + profile + ); + + dispatch({ + type: 'ENABLE_EXPERIMENTAL_SAMPLING_INTERVAL_TRACKS', + localTracksByPid, + localTrackOrderByPid, + }); + + return true; + }; +} + /** * This caches the profile data in the local state for synchronous access. */ diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index e518623d26..4792b1881b 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -15,6 +15,7 @@ import { getLocalTracksByPid, getThreads, getLastNonShiftClick, + getProfile, } from 'firefox-profiler/selectors/profile'; import { getThreadSelectors, @@ -359,6 +360,23 @@ function getInformationFromTrackReference( relatedTab: null, }; } + case 'sampling-interval': { + // Find the first thread for this PID to use as related thread. + // If no thread is found, use the first thread in the profile as fallback. + const profile = getProfile(state); + const pidThread = profile.threads.find( + (thread: { pid: Pid }) => thread.pid === localTrack.pid + ); + const relatedThreadIndex = pidThread + ? profile.threads.indexOf(pidThread) + : 0; + return { + ...commonLocalProperties, + threadIndex: null, + relatedThreadIndex, + relatedTab: null, + }; + } default: throw assertExhaustiveCheck(localTrack, `Unhandled LocalTrack type.`); } diff --git a/src/components/timeline/LocalTrack.tsx b/src/components/timeline/LocalTrack.tsx index f43e2b991f..61cc7bcd78 100644 --- a/src/components/timeline/LocalTrack.tsx +++ b/src/components/timeline/LocalTrack.tsx @@ -31,6 +31,7 @@ import { TrackBandwidth } from './TrackBandwidth'; import { TrackIPC } from './TrackIPC'; import { TrackProcessCPU } from './TrackProcessCPU'; import { TrackPower } from './TrackPower'; +import { TrackSamplingInterval } from './TrackSamplingInterval'; import { getTrackSelectionModifiers } from 'firefox-profiler/utils'; import type { TrackReference, @@ -118,6 +119,8 @@ class LocalTrackComponent extends PureComponent { return ; case 'power': return ; + case 'sampling-interval': + return ; case 'marker': return ( ; + +type State = {}; + +export class TrackSamplingIntervalImpl extends React.PureComponent< + Props, + State +> { + override render() { + const { pid } = this.props; + return ( +
+ +
+ ); + } +} + +export const TrackSamplingInterval = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state) => { + const { start, end } = getCommittedRange(state); + return { + rangeStart: start, + rangeEnd: end, + }; + }, + mapDispatchToProps: { updatePreviewSelection }, + component: TrackSamplingIntervalImpl, +}); diff --git a/src/components/timeline/TrackSamplingIntervalGraph.tsx b/src/components/timeline/TrackSamplingIntervalGraph.tsx new file mode 100644 index 0000000000..f5931ab001 --- /dev/null +++ b/src/components/timeline/TrackSamplingIntervalGraph.tsx @@ -0,0 +1,570 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { withSize } from 'firefox-profiler/components/shared/WithSize'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { formatMilliseconds } from 'firefox-profiler/utils/format-numbers'; +import { bisectionRight } from 'firefox-profiler/utils/bisect'; +import { + getCommittedRange, + getProfile, + getProfileInterval, +} from 'firefox-profiler/selectors/profile'; +import { BLUE_50 } from 'photon-colors'; +import { Tooltip } from 'firefox-profiler/components/tooltip/Tooltip'; +import { EmptyThreadIndicator } from './EmptyThreadIndicator'; + +import type { + Pid, + Profile, + Thread, + Milliseconds, + CssPixels, +} from 'firefox-profiler/types'; + +import type { SizeProps } from 'firefox-profiler/components/shared/WithSize'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './TrackSamplingInterval.css'; + +type SamplingData = { + time: Milliseconds[]; + interval: Milliseconds[]; +}; + +/** + * Collect all sampling intervals from all threads in a process + */ +function collectSamplingIntervalsForPid( + profile: Profile, + pid: Pid +): SamplingData { + const allTimes: Array<{ time: Milliseconds; threadIndex: number }> = []; + + // Collect all sample times from all threads in this process + for ( + let threadIndex = 0; + threadIndex < profile.threads.length; + threadIndex++ + ) { + const thread = profile.threads[threadIndex]; + + if (thread.pid !== pid || thread.samples.length === 0) { + continue; + } + + // Get absolute times from either the time column or timeDeltas + let sampleTimes: Milliseconds[]; + if (thread.samples.time) { + sampleTimes = thread.samples.time; + } else if (thread.samples.timeDeltas) { + // Convert timeDeltas to absolute times + sampleTimes = new Array(thread.samples.timeDeltas.length); + let currentTime = 0; + for (let i = 0; i < thread.samples.timeDeltas.length; i++) { + currentTime += thread.samples.timeDeltas[i]; + sampleTimes[i] = currentTime; + } + } else { + // No time data available + continue; + } + + for (let i = 0; i < sampleTimes.length; i++) { + allTimes.push({ + time: sampleTimes[i], + threadIndex, + }); + } + } + + // Sort by time + allTimes.sort((a, b) => a.time - b.time); + + // Calculate intervals + const times: Milliseconds[] = []; + const intervals: Milliseconds[] = []; + + for (let i = 0; i < allTimes.length; i++) { + times.push(allTimes[i].time); + if (i > 0) { + intervals.push(allTimes[i].time - allTimes[i - 1].time); + } else { + // First sample uses the profile's default interval + intervals.push(profile.meta.interval); + } + } + + return { time: times, interval: intervals }; +} + +type CanvasProps = { + readonly rangeStart: Milliseconds; + readonly rangeEnd: Milliseconds; + readonly samplingData: SamplingData; + readonly minInterval: Milliseconds; + readonly maxInterval: Milliseconds; + readonly profileInterval: Milliseconds; + readonly width: CssPixels; + readonly height: CssPixels; + readonly lineWidth: CssPixels; +}; + +class TrackSamplingIntervalCanvas extends React.PureComponent { + _canvas: null | HTMLCanvasElement = null; + _requestedAnimationFrame: boolean = false; + + drawCanvas(canvas: HTMLCanvasElement): void { + const { + rangeStart, + rangeEnd, + samplingData, + height, + width, + lineWidth, + minInterval, + maxInterval, + } = this.props; + if (width === 0) { + return; + } + + const ctx = canvas.getContext('2d')!; + const devicePixelRatio = window.devicePixelRatio; + const deviceWidth = width * devicePixelRatio; + const deviceHeight = height * devicePixelRatio; + const deviceLineWidth = lineWidth * devicePixelRatio; + const deviceLineHalfWidth = deviceLineWidth * 0.5; + const innerDeviceHeight = deviceHeight - deviceLineWidth; + const rangeLength = rangeEnd - rangeStart; + const millisecondWidth = deviceWidth / rangeLength; + + // Resize and clear the canvas. + canvas.width = Math.round(deviceWidth); + canvas.height = Math.round(deviceHeight); + ctx.clearRect(0, 0, deviceWidth, deviceHeight); + + if (samplingData.time.length === 0) { + return; + } + + // Find sample range within visible time range + const startIndex = bisectionRight(samplingData.time, rangeStart); + const endIndex = bisectionRight(samplingData.time, rangeEnd); + + if (startIndex >= endIndex) { + return; + } + + ctx.lineWidth = deviceLineWidth; + ctx.lineJoin = 'bevel'; + ctx.strokeStyle = BLUE_50; + ctx.fillStyle = BLUE_50 + '44'; // Blue with transparency + ctx.beginPath(); + + const getX = (i: number) => + Math.round((samplingData.time[i] - rangeStart) * millisecondWidth); + const getY = (intervalValue: Milliseconds) => { + const intervalRange = maxInterval - minInterval; + const normalizedValue = + intervalRange > 0 ? (intervalValue - minInterval) / intervalRange : 0; + return Math.round( + innerDeviceHeight - + innerDeviceHeight * normalizedValue + + deviceLineHalfWidth + ); + }; + + const firstX = getX(startIndex); + let x = firstX; + let y = getY(samplingData.interval[startIndex]); + + // Move to the first point + ctx.moveTo(x, y); + + // Draw the line with optimization for multiple samples per pixel + for (let i = startIndex + 1; i < endIndex; i++) { + const intervalValues = [samplingData.interval[i]]; + x = getX(i); + y = getY(intervalValues[0]); + ctx.lineTo(x, y); + + // If we have multiple samples to draw on the same horizontal pixel, + // we process all of them together with a max-min decimation algorithm + // to save time: + // - We draw the first and last samples to ensure the display is correct + // - For the values in between, we only draw the min and max values, + // to draw a vertical line covering all the other sample values. + while (i + 1 < endIndex && getX(i + 1) === x) { + intervalValues.push(samplingData.interval[++i]); + } + + // Looking for the min and max only makes sense if we have more than 2 samples + if (intervalValues.length > 2) { + const minY = getY(Math.min(...intervalValues)); + if (minY !== y) { + y = minY; + ctx.lineTo(x, y); + } + const maxY = getY(Math.max(...intervalValues)); + if (maxY !== y) { + y = maxY; + ctx.lineTo(x, y); + } + } + + const lastY = getY(intervalValues[intervalValues.length - 1]); + if (lastY !== y) { + y = lastY; + ctx.lineTo(x, y); + } + } + + // Stroke the line + ctx.stroke(); + + // Fill to bottom + ctx.lineTo(x, deviceHeight); + ctx.lineTo(firstX, deviceHeight); + ctx.fill(); + } + + _scheduleDraw() { + if (!this._requestedAnimationFrame) { + this._requestedAnimationFrame = true; + window.requestAnimationFrame(() => { + this._requestedAnimationFrame = false; + const canvas = this._canvas; + if (canvas) { + this.drawCanvas(canvas); + } + }); + } + } + + _takeCanvasRef = (canvas: HTMLCanvasElement | null) => { + this._canvas = canvas; + }; + + override componentDidMount() { + this._scheduleDraw(); + } + + override componentDidUpdate() { + this._scheduleDraw(); + } + + override render() { + return ( + + ); + } +} + +type OwnProps = { + readonly pid: Pid; + readonly lineWidth: CssPixels; + readonly graphHeight: CssPixels; +}; + +type StateProps = { + readonly rangeStart: Milliseconds; + readonly rangeEnd: Milliseconds; + readonly samplingData: SamplingData; + readonly minInterval: Milliseconds; + readonly maxInterval: Milliseconds; + readonly profileInterval: Milliseconds; + readonly profile: Profile; +}; + +type DispatchProps = {}; + +type Props = SizeProps & ConnectedProps; + +type State = { + hoveredSample: null | number; + mouseX: CssPixels; + mouseY: CssPixels; +}; + +class TrackSamplingIntervalGraphImpl extends React.PureComponent { + override state = { + hoveredSample: null, + mouseX: 0, + mouseY: 0, + }; + + _onMouseLeave = () => { + this.setState({ hoveredSample: null }); + }; + + _onMouseMove = (event: React.MouseEvent) => { + const { pageX: mouseX, pageY: mouseY } = event; + const { left } = event.currentTarget.getBoundingClientRect(); + const { width, rangeStart, rangeEnd, samplingData } = this.props; + const rangeLength = rangeEnd - rangeStart; + const timeAtMouse = rangeStart + ((mouseX - left) / width) * rangeLength; + + if (samplingData.time.length === 0) { + this.setState({ hoveredSample: null }); + return; + } + + if ( + timeAtMouse < samplingData.time[0] || + timeAtMouse > samplingData.time[samplingData.time.length - 1] + ) { + this.setState({ hoveredSample: null }); + return; + } + + // Find the closest sample + const bisectionIndex = bisectionRight(samplingData.time, timeAtMouse); + let hoveredSample; + + if (bisectionIndex > 0 && bisectionIndex < samplingData.time.length) { + const leftDistance = timeAtMouse - samplingData.time[bisectionIndex - 1]; + const rightDistance = samplingData.time[bisectionIndex] - timeAtMouse; + if (leftDistance < rightDistance) { + hoveredSample = bisectionIndex - 1; + } else { + hoveredSample = bisectionIndex; + } + + // If there are samples before or after hoveredSample that fall + // horizontally on the same pixel, move hoveredSample to the sample + // with the highest interval value. + const mouseAtTime = (t: number) => + Math.round(((t - rangeStart) / rangeLength) * width + left); + for ( + let currentIndex = hoveredSample - 1; + currentIndex >= 0 && + mouseAtTime(samplingData.time[currentIndex]) === mouseX; + --currentIndex + ) { + if ( + samplingData.interval[currentIndex] > + samplingData.interval[hoveredSample] + ) { + hoveredSample = currentIndex; + } + } + for ( + let currentIndex = hoveredSample + 1; + currentIndex < samplingData.time.length && + mouseAtTime(samplingData.time[currentIndex]) === mouseX; + ++currentIndex + ) { + if ( + samplingData.interval[currentIndex] > + samplingData.interval[hoveredSample] + ) { + hoveredSample = currentIndex; + } + } + } else if (bisectionIndex >= samplingData.time.length) { + hoveredSample = samplingData.time.length - 1; + } else { + hoveredSample = bisectionIndex; + } + + this.setState({ + mouseX, + mouseY, + hoveredSample, + }); + }; + + _renderTooltip(sampleIndex: number): React.ReactNode { + const { samplingData, profileInterval, rangeStart, rangeEnd } = this.props; + const { mouseX, mouseY } = this.state; + + const interval = samplingData.interval[sampleIndex]; + const time = samplingData.time[sampleIndex]; + + if (time < rangeStart || time > rangeEnd) { + return null; + } + + return ( + +
+
+ Sampling Interval: +
+
+ {formatMilliseconds(interval, 3, 1)} +
+
+ Expected Interval: +
+
+ {formatMilliseconds(profileInterval, 3, 1)} +
+
+
+ ); + } + + /** + * Create a div that is a dot on top of the graph representing the current + * sample being hovered. + */ + _renderDot(sampleIndex: number): React.ReactNode { + const { + samplingData, + rangeStart, + rangeEnd, + graphHeight, + width, + lineWidth, + minInterval, + maxInterval, + } = this.props; + + const sampleTime = samplingData.time[sampleIndex]; + const intervalValue = samplingData.interval[sampleIndex]; + + if (sampleTime < rangeStart || sampleTime > rangeEnd) { + return null; + } + + const rangeLength = rangeEnd - rangeStart; + const left = (width * (sampleTime - rangeStart)) / rangeLength; + + const intervalRange = maxInterval - minInterval; + const normalizedValue = + intervalRange > 0 ? (intervalValue - minInterval) / intervalRange : 0; + const innerTrackHeight = graphHeight - lineWidth / 2; + const top = + innerTrackHeight - normalizedValue * innerTrackHeight + lineWidth / 2; + + return ( +
+ ); + } + + override render() { + const { samplingData, profileInterval } = this.props; + const { hoveredSample } = this.state; + + if (samplingData.time.length === 0) { + return ( + + ); + } + + const { + rangeStart, + rangeEnd, + minInterval, + maxInterval, + lineWidth, + graphHeight, + width, + } = this.props; + + return ( +
+ + {hoveredSample === null ? null : ( + <> + {this._renderDot(hoveredSample)} + {this._renderTooltip(hoveredSample)} + + )} +
+ ); + } +} + +const SizedTrackSamplingIntervalGraphImpl = withSize( + TrackSamplingIntervalGraphImpl +); + +export const TrackSamplingIntervalGraph = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state, ownProps) => { + const { pid } = ownProps; + const { start, end } = getCommittedRange(state); + const profile = getProfile(state); + const profileInterval = getProfileInterval(state); + const samplingData = collectSamplingIntervalsForPid(profile, pid); + + // Calculate min/max intervals for normalization based on visible range + let minInterval = 0; + let maxInterval = profileInterval * 3; // fallback + if (samplingData.time.length > 0) { + // Find the range of intervals visible in the committed range + const startIndex = bisectionRight(samplingData.time, start); + const endIndex = bisectionRight(samplingData.time, end); + + if (startIndex < endIndex) { + const visibleIntervals = samplingData.interval.slice( + startIndex, + endIndex + ); + const minVisibleInterval = Math.min(...visibleIntervals); + const maxVisibleInterval = Math.max(...visibleIntervals); + + // Add 10% padding to avoid having lines at the very edges + const range = maxVisibleInterval - minVisibleInterval; + const padding = range * 0.1; + minInterval = Math.max(0, minVisibleInterval - padding); + maxInterval = maxVisibleInterval + padding; + } + } + + return { + rangeStart: start, + rangeEnd: end, + samplingData, + minInterval, + maxInterval, + profileInterval, + profile, + }; + }, + mapDispatchToProps: {}, + component: SizedTrackSamplingIntervalGraphImpl, +}); diff --git a/src/profile-logic/tracks.ts b/src/profile-logic/tracks.ts index 44277e17b5..df455dad99 100644 --- a/src/profile-logic/tracks.ts +++ b/src/profile-logic/tracks.ts @@ -63,6 +63,7 @@ const LOCAL_TRACK_INDEX_ORDER = { power: 6, marker: 7, bandwidth: 8, + 'sampling-interval': 9, }; const LOCAL_TRACK_DISPLAY_ORDER = { network: 0, @@ -77,7 +78,8 @@ const LOCAL_TRACK_DISPLAY_ORDER = { thread: 5, 'event-delay': 6, 'process-cpu': 7, - marker: 8, + 'sampling-interval': 8, + marker: 9, }; const GLOBAL_TRACK_INDEX_ORDER = { @@ -503,6 +505,37 @@ export function addProcessCPUTracksForProcess( return newLocalTracksByPid; } +/** + * Take global tracks and add the experimental sampling interval tracks. Return the new + * localTracksByPid map. This creates one track per process that shows the sampling + * intervals for all threads in that process. + */ +export function addSamplingIntervalTracksForProcess( + profile: Profile, + localTracksByPid: Map +): Map { + const newLocalTracksByPid = new Map(localTracksByPid); + const pidsWithSamples = new Set(); + + // Find all PIDs that have threads with samples + for (const thread of profile.threads) { + if (thread.samples.length > 0) { + pidsWithSamples.add(thread.pid); + } + } + + // Add a sampling-interval track for each PID that has samples + for (const pid of pidsWithSamples) { + let localTracks = newLocalTracksByPid.get(pid) ?? []; + + // Do not mutate the current state. + localTracks = [...localTracks, { type: 'sampling-interval', pid }]; + newLocalTracksByPid.set(pid, localTracks); + } + + return newLocalTracksByPid; +} + /** * Take a profile and figure out what GlobalTracks it contains. */ @@ -1134,6 +1167,8 @@ export function getLocalTrackName( return 'Process CPU'; case 'power': return counters[localTrack.counterIndex].name; + case 'sampling-interval': + return 'Sampling Intervals'; case 'marker': return shared.stringArray[localTrack.markerName]; default: @@ -1583,7 +1618,8 @@ export function getSearchFilteredLocalTracksByPid( case 'ipc': case 'event-delay': case 'power': - case 'process-cpu': { + case 'process-cpu': + case 'sampling-interval': { const { type } = localTrack; if (searchRegExp.test(type)) { searchFilteredLocalTracks.add(trackIndex); @@ -1784,6 +1820,9 @@ function _isLocalTrackVisible( case 'power': // Keep non-thread local tracks visible. return true; + case 'sampling-interval': + // Sampling interval tracks are experimental and shown only when enabled. + return true; case 'ipc': // IPC tracks are not always useful to the users. So we are making them hidden // by default to reduce the noise. diff --git a/src/reducers/app.ts b/src/reducers/app.ts index 5c3653dfd9..f6b39f67f6 100644 --- a/src/reducers/app.ts +++ b/src/reducers/app.ts @@ -138,6 +138,7 @@ const panelLayoutGeneration: Reducer = (state = 0, action) => { case 'TOGGLE_RESOURCES_PANEL': case 'ENABLE_EXPERIMENTAL_CPU_GRAPHS': case 'ENABLE_EXPERIMENTAL_PROCESS_CPU_TRACKS': + case 'ENABLE_EXPERIMENTAL_SAMPLING_INTERVAL_TRACKS': case 'CHANGE_TAB_FILTER': // Committed range changes: (fallthrough) case 'COMMIT_RANGE': @@ -288,6 +289,19 @@ const processCPUTracks: Reducer = (state = false, action) => { } }; +/* + * This reducer holds the state for whether the sampling interval tracks are enabled. + * This feature is experimental and allows visualization of sampling intervals per process. + */ +const samplingIntervalTracks: Reducer = (state = false, action) => { + switch (action.type) { + case 'ENABLE_EXPERIMENTAL_SAMPLING_INTERVAL_TRACKS': + return true; + default: + return state; + } +}; + /** * This keeps the information about the upload for the current profile, if any. * This is retrieved from the IndexedDB for published profiles information in @@ -319,6 +333,7 @@ const experimental: Reducer = combineReducers({ eventDelayTracks, cpuGraphs, processCPUTracks, + samplingIntervalTracks, }); const browserConnectionStatus: Reducer = ( diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 6039d1f810..f54803a315 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -111,6 +111,7 @@ const localTracksByPid: Reducer> = ( case 'VIEW_FULL_PROFILE': case 'ENABLE_EVENT_DELAY_TRACKS': case 'ENABLE_EXPERIMENTAL_PROCESS_CPU_TRACKS': + case 'ENABLE_EXPERIMENTAL_SAMPLING_INTERVAL_TRACKS': case 'CHANGE_TAB_FILTER': return action.localTracksByPid; default: diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index bebe69468d..3ab5ad1c1d 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -488,6 +488,7 @@ const localTrackOrderByPid: Reducer> = ( case 'VIEW_FULL_PROFILE': case 'ENABLE_EVENT_DELAY_TRACKS': case 'ENABLE_EXPERIMENTAL_PROCESS_CPU_TRACKS': + case 'ENABLE_EXPERIMENTAL_SAMPLING_INTERVAL_TRACKS': case 'CHANGE_TAB_FILTER': return action.localTrackOrderByPid; case 'CHANGE_LOCAL_TRACK_ORDER': { diff --git a/src/selectors/app.tsx b/src/selectors/app.tsx index 65cb1d043e..4a4537f799 100644 --- a/src/selectors/app.tsx +++ b/src/selectors/app.tsx @@ -80,6 +80,10 @@ export const getIsExperimentalProcessCPUTracksEnabled: Selector = ( state ) => getExperimental(state).processCPUTracks; +export const getIsExperimentalSamplingIntervalTracksEnabled: Selector< + boolean +> = (state) => getExperimental(state).samplingIntervalTracks; + export const getIsDragAndDropDragging: Selector = (state) => getApp(state).isDragAndDropDragging; export const getIsDragAndDropOverlayRegistered: Selector = (state) => @@ -204,6 +208,9 @@ export const getTimelineHeight: Selector = createSelector( case 'marker': height += TRACK_MARKER_HEIGHT + border; break; + case 'sampling-interval': + height += TRACK_PROCESS_CPU_HEIGHT + border; + break; default: throw assertExhaustiveCheck(localTrack); } diff --git a/src/test/fixtures/profiles/tracks.ts b/src/test/fixtures/profiles/tracks.ts index ae23c71340..a06189ec63 100644 --- a/src/test/fixtures/profiles/tracks.ts +++ b/src/test/fixtures/profiles/tracks.ts @@ -106,6 +106,8 @@ export function getHumanReadableTracks(state: State): string[] { .getCounter(state).name; } else if (track.type === 'marker') { trackName = stringArray[track.markerName]; + } else if (track.type === 'sampling-interval') { + trackName = 'Sampling Intervals'; } else { trackName = threads[track.threadIndex].name; } diff --git a/src/types/actions.ts b/src/types/actions.ts index 940b018500..b1635a530d 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -334,6 +334,11 @@ type ProfileAction = readonly localTracksByPid: Map; readonly localTrackOrderByPid: Map; } + | { + readonly type: 'ENABLE_EXPERIMENTAL_SAMPLING_INTERVAL_TRACKS'; + readonly localTracksByPid: Map; + readonly localTrackOrderByPid: Map; + } | { readonly type: 'UPDATE_BOTTOM_BOX'; readonly libIndex: IndexIntoLibs | null; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 3ee00c355b..8271c6098d 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -628,6 +628,7 @@ export type LocalTrack = | { readonly type: 'event-delay'; readonly threadIndex: ThreadIndex } | { readonly type: 'process-cpu'; readonly counterIndex: CounterIndex } | { readonly type: 'power'; readonly counterIndex: CounterIndex } + | { readonly type: 'sampling-interval'; readonly pid: Pid } | { readonly type: 'marker'; readonly threadIndex: ThreadIndex; diff --git a/src/types/state.ts b/src/types/state.ts index 54c83a32bd..4e68952267 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -171,6 +171,7 @@ export type ExperimentalFlags = { readonly eventDelayTracks: boolean; readonly cpuGraphs: boolean; readonly processCPUTracks: boolean; + readonly samplingIntervalTracks: boolean; }; export type AppState = { diff --git a/src/utils/window-console.ts b/src/utils/window-console.ts index 09a49f2691..ab22580d9a 100644 --- a/src/utils/window-console.ts +++ b/src/utils/window-console.ts @@ -34,6 +34,7 @@ export type ExtraPropertiesOnWindowForConsole = { enableEventDelayTracks(): void; enableCPUGraphs(): void; enableProcessCPUTracks(): void; + enableSamplingIntervalTracks(): void; }; togglePseudoLocalization: (pseudoStrategy?: string) => void; toggleTimelineType: (timelineType?: string) => void; @@ -144,6 +145,19 @@ export function addDataToWindowObject( `); } }, + + enableSamplingIntervalTracks() { + const areExperimentalSamplingIntervalTracksEnabled = dispatch( + actions.enableExperimentalSamplingIntervalTracks() + ); + if (areExperimentalSamplingIntervalTracksEnabled) { + console.log(stripIndent` + ✅ The sampling interval tracks are now enabled and should be displayed in the timeline. + 👉 Note that this is an experimental feature that might still have bugs. + 💡 As an experimental feature their presence isn't persisted as a URL parameter like the other things. + `); + } + }, }; target.togglePseudoLocalization = function (pseudoStrategy?: string) {