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) {