diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index 3c9173d7e5..918c5b23b4 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -6,6 +6,11 @@ Note that this is not an exhaustive list. Processed profile format upgraders can ## Processed profile format +### Version 51 + +Two new marker schema field format types have been added: `flow-id` and `terminating-flow-id`, with string index values (like `unique-string`). +An optional `isStackBased` boolean field has been added to the marker schema. + ### Version 50 The serialized format can now optionally store sample and counter sample times as time deltas instead of absolute timestamps to reduce the JSON size. The unserialized version is unchanged. @@ -78,6 +83,11 @@ Older versions are not documented in this changelog but can be found in [process ## Gecko profile format +### Version 31 + +Two new marker schema field format types have been added: `flow-id` and `terminating-flow-id`, with string index values (like `unique-string`). +An optional `isStackBased` boolean field has been added to the marker schema. + ### Version 30 A new `sanitized-string` marker schema format type has been added, allowing markers to carry arbitrary strings containing PII that will be sanitized along with URLs and FilePaths. diff --git a/package.json b/package.json index bf862c3fa7..cde0c6b684 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "test": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test jest", "test-all": "run-p --max-parallel 4 flow license-check lint test test-alex test-lockfile", "test-all:ci": "run-p --max-parallel 4 flow:ci license-check lint test test-alex test-lockfile", - "test-build-coverage": "jest --coverage --coverageReporters=html", + "test-build-coverage": "yarn test --coverage --coverageReporters=html", "test-serve-coverage": "ws -d coverage/ -p 4343", "test-coverage": "run-s test-build-coverage test-serve-coverage", "test-alex": "alex ./docs-* *.md", @@ -97,26 +97,26 @@ "reselect": "^4.1.8", "url": "^0.11.4", "weaktuplemap": "^1.0.0", - "workbox-window": "^7.1.0" + "workbox-window": "^7.3.0" }, "devDependencies": { - "@babel/cli": "^7.25.7", - "@babel/core": "^7.25.7", - "@babel/eslint-parser": "^7.25.7", - "@babel/eslint-plugin": "^7.25.7", + "@babel/cli": "^7.25.9", + "@babel/core": "^7.26.0", + "@babel/eslint-parser": "^7.25.9", + "@babel/eslint-plugin": "^7.25.9", "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.25.7", - "@babel/preset-flow": "^7.25.7", - "@babel/preset-react": "^7.25.7", + "@babel/preset-env": "^7.26.0", + "@babel/preset-flow": "^7.25.9", + "@babel/preset-react": "^7.25.9", "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.5.0", + "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.1", "alex": "^11.0.1", "autoprefixer": "^10.4.20", "babel-jest": "^29.7.0", "babel-loader": "^9.2.1", "babel-plugin-module-resolver": "^5.0.2", - "browserslist": "^4.24.0", + "browserslist": "^4.24.2", "caniuse-lite": "^1.0.30001667", "circular-dependency-plugin": "^5.2.1", "codecov": "^3.8.3", @@ -133,9 +133,9 @@ "eslint-plugin-jest": "^28.8.3", "eslint-plugin-jest-dom": "^5.4.0", "eslint-plugin-jest-formatting": "^3.1.0", - "eslint-plugin-react": "^7.37.1", - "eslint-plugin-testing-library": "^6.3.0", - "espree": "^10.2.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-testing-library": "^6.4.0", + "espree": "^10.3.0", "fake-indexeddb": "^6.0.0", "fetch-mock-jest": "^1.5.1", "file-loader": "^6.2.0", @@ -143,7 +143,7 @@ "flow-coverage-report": "^0.8.0", "flow-typed": "^4.0.0", "glob": "^10.4.5", - "html-webpack-plugin": "^5.6.0", + "html-webpack-plugin": "^5.6.3", "husky": "^4.3.8", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -167,7 +167,7 @@ "webpack": "^5.95.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.1.0", - "workbox-webpack-plugin": "^7.1.0", + "workbox-webpack-plugin": "^7.3.0", "yargs": "^17.7.2" }, "jest": { diff --git a/res/img/svg/edit-name-profiler.svg b/res/img/svg/edit-name-profiler.svg index 6403c60c5a..4e693cde60 100644 --- a/res/img/svg/edit-name-profiler.svg +++ b/res/img/svg/edit-name-profiler.svg @@ -1,4 +1,4 @@ - \ No newline at end of file + diff --git a/res/img/svg/extension-outline.svg b/res/img/svg/extension-outline.svg new file mode 100644 index 0000000000..336c92dc7f --- /dev/null +++ b/res/img/svg/extension-outline.svg @@ -0,0 +1,6 @@ + + + + diff --git a/res/img/svg/globe.svg b/res/img/svg/globe.svg new file mode 100644 index 0000000000..fc0a64d4a5 --- /dev/null +++ b/res/img/svg/globe.svg @@ -0,0 +1,6 @@ + + + + diff --git a/res/img/svg/select-thread.svg b/res/img/svg/select-thread.svg index c7f7e66785..177d575c76 100644 --- a/res/img/svg/select-thread.svg +++ b/res/img/svg/select-thread.svg @@ -1,6 +1,6 @@ - + diff --git a/res/img/svg/warning.svg b/res/img/svg/warning.svg index 46743bb3ef..aac508162c 100644 --- a/res/img/svg/warning.svg +++ b/res/img/svg/warning.svg @@ -1,4 +1,4 @@ - \ No newline at end of file + diff --git a/src/actions/icons.js b/src/actions/icons.js index d0b94ffb90..f22730980e 100644 --- a/src/actions/icons.js +++ b/src/actions/icons.js @@ -3,12 +3,19 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // @flow -import type { Action, ThunkAction } from 'firefox-profiler/types'; +import type { + Action, + ThunkAction, + IconWithClassName, +} from 'firefox-profiler/types'; -export function iconHasLoaded(icon: string): Action { +export function iconHasLoaded(iconWithClassName: {| + +icon: string, + +className: string, +|}): Action { return { type: 'ICON_HAS_LOADED', - icon, + iconWithClassName, }; } @@ -20,25 +27,35 @@ export function iconIsInError(icon: string): Action { } const icons: Set = new Set(); +let iconCounter = 0; -type IconRequestResult = 'loaded' | 'error' | 'cached'; +type IconRequestResult = + | {| type: 'error' | 'cached' |} + | {| + type: 'loaded', + iconWithClassName: IconWithClassName, + |}; -function _getIcon(icon: string): Promise { +async function _getIcon(icon: string): Promise { if (icons.has(icon)) { - return Promise.resolve('cached'); + return Promise.resolve({ type: 'cached' }); } icons.add(icon); + // New class name for an icon. They are guaranteed to be unique, that's why + // just increment the icon counter and return that string. + const className = `favicon-${++iconCounter}`; + const result = new Promise((resolve) => { const image = new Image(); image.src = icon; image.referrerPolicy = 'no-referrer'; image.onload = () => { - resolve('loaded'); + resolve({ type: 'loaded', iconWithClassName: { icon, className } }); }; image.onerror = () => { - resolve('error'); + resolve({ type: 'error' }); }; }); @@ -48,9 +65,9 @@ function _getIcon(icon: string): Promise { export function iconStartLoading(icon: string): ThunkAction> { return (dispatch) => { return _getIcon(icon).then((result) => { - switch (result) { + switch (result.type) { case 'loaded': - dispatch(iconHasLoaded(icon)); + dispatch(iconHasLoaded(result.iconWithClassName)); break; case 'error': dispatch(iconIsInError(icon)); @@ -59,8 +76,46 @@ export function iconStartLoading(icon: string): ThunkAction> { // nothing to do break; default: - throw new Error(`Unknown icon load result ${result}`); + throw new Error(`Unknown icon load result ${result.type}`); } }); }; } + +/** + * Batch load the data url icons. + * + * We don't need to check if they are valid images or not, so we can omit doing + * this extra work for these icons. Just add them directly to our cache and state. + */ +export function batchLoadDataUrlIcons( + iconsToAdd: Array +): ThunkAction { + return (dispatch) => { + const newIcons = []; + for (const icon of iconsToAdd) { + if (!icon || icons.has(icon)) { + continue; + } + + icons.add(icon); + + // New class name for an icon. They are guaranteed to be unique, that's why + // just increment the icon counter and return that string. + const className = `favicon-${++iconCounter}`; + newIcons.push({ icon, className }); + } + + dispatch({ + type: 'ICON_BATCH_ADD', + icons: newIcons, + }); + }; +} + +/** + * Only use it in tests! + */ +export function _resetIconCounter() { + iconCounter = 0; +} diff --git a/src/actions/receive-profile.js b/src/actions/receive-profile.js index 5c320962f8..6aaec42260 100644 --- a/src/actions/receive-profile.js +++ b/src/actions/receive-profile.js @@ -69,6 +69,7 @@ import { import { computeActiveTabTracks } from 'firefox-profiler/profile-logic/active-tab'; import { setDataSource } from './profile-view'; import { fatalError } from './errors'; +import { batchLoadDataUrlIcons } from './icons'; import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; import { determineTimelineType, @@ -90,6 +91,7 @@ import type { InnerWindowID, Pid, OriginsTimelineRoot, + PageList, } from 'firefox-profiler/types'; import type { @@ -97,6 +99,7 @@ import type { SymbolicationStepInfo, } from '../profile-logic/symbolication'; import { assertExhaustiveCheck, ensureExists } from '../utils/flow'; +import { bytesToBase64DataUrl } from 'firefox-profiler/utils/base64'; import type { BrowserConnection, BrowserConnectionStatus, @@ -264,9 +267,19 @@ export function finalizeProfileView( ); } + let faviconsPromise = null; + if (browserConnection && pages && pages.length > 0) { + faviconsPromise = retrievePageFaviconsFromBrowser( + dispatch, + pages, + browserConnection + ); + } + // Note we kick off symbolication only for the profiles we know for sure // that they weren't symbolicated. // We can skip the symbolication in tests if needed. + let symbolicationPromise = null; if (!skipSymbolication && profile.meta.symbolicated === false) { const symbolStore = getSymbolStore( dispatch, @@ -276,9 +289,15 @@ export function finalizeProfileView( if (symbolStore) { // Only symbolicate if a symbol store is available. In tests we may not // have access to IndexedDB. - await doSymbolicateProfile(dispatch, profile, symbolStore); + symbolicationPromise = doSymbolicateProfile( + dispatch, + profile, + symbolStore + ); } } + + await Promise.all([faviconsPromise, symbolicationPromise]); }; } @@ -296,9 +315,10 @@ export function finalizeFullProfileView( const hasUrlInfo = maybeSelectedThreadIndexes !== null; const tabToThreadIndexesMap = getTabToThreadIndexesMap(getState()); + const tabFilter = hasUrlInfo ? getTabFilter(getState()) : null; const globalTracks = computeGlobalTracks( profile, - hasUrlInfo ? getTabFilter(getState()) : null, + tabFilter, tabToThreadIndexesMap ); const localTracksByPid = computeLocalTracksByPid( @@ -351,10 +371,13 @@ export function finalizeFullProfileView( // This is the case for the initial profile load. // We also get here if the URL info was ignored, for example if // respecting it would have caused all threads to become hidden. + const includeParentProcessThreads = tabFilter === null; hiddenTracks = computeDefaultHiddenTracks( tracksWithOrder, profile, - getThreadActivityScores(getState()) + getThreadActivityScores(getState()), + // Only include the parent process if there is no tab filter applied. + includeParentProcessThreads ); } @@ -1017,6 +1040,53 @@ export async function doSymbolicateProfile( dispatch(doneSymbolicating()); } +export async function retrievePageFaviconsFromBrowser( + dispatch: Dispatch, + pages: PageList, + browserConnection: BrowserConnection +) { + const newPages = [...pages]; + + const favicons = await browserConnection.getPageFavicons( + newPages.map((p) => p.url) + ); + + if (newPages.length !== favicons.length) { + // It appears that an error occurred since the pages and favicons arrays + // have different lengths. Return early without doing anything. The favicons + // array will be empty if Firefox doesn't support this webchannel request. + return; + } + + // Convert binary favicon data into data urls. + const faviconDataStringPromises: Array> = favicons.map( + (faviconData) => { + if (!faviconData) { + return Promise.resolve(null); + } + return bytesToBase64DataUrl(faviconData.data, faviconData.mimeType); + } + ); + + const faviconDataUrls = await Promise.all(faviconDataStringPromises); + + for (let index = 0; index < favicons.length; index++) { + if (faviconDataUrls[index]) { + newPages[index] = { + ...newPages[index], + favicon: faviconDataUrls[index], + }; + } + } + + // Once we update the pages, we can also start loading the data urls. + dispatch(batchLoadDataUrlIcons(faviconDataUrls)); + dispatch({ + type: 'UPDATE_PAGES', + newPages, + }); +} + // From a BrowserConnectionStatus, this unwraps the included browserConnection // when possible. export function unwrapBrowserConnection( @@ -1811,10 +1881,13 @@ export function changeTabFilter(tabID: TabID | null): ThunkAction { // This is the case for the initial profile load. // We also get here if the URL info was ignored, for example if // respecting it would have caused all threads to become hidden. + const includeParentProcessThreads = tabID === null; hiddenTracks = computeDefaultHiddenTracks( tracksWithOrder, profile, - getThreadActivityScores(getState()) + getThreadActivityScores(getState()), + // Only include the parent process if there is no tab filter applied. + includeParentProcessThreads ); } diff --git a/src/app-logic/browser-connection.js b/src/app-logic/browser-connection.js index 04ede410ad..e96f76c14f 100644 --- a/src/app-logic/browser-connection.js +++ b/src/app-logic/browser-connection.js @@ -11,8 +11,9 @@ import { getSymbolTableViaWebChannel, queryWebChannelVersionViaWebChannel, querySymbolicationApiViaWebChannel, + getPageFaviconsViaWebChannel, } from './web-channel'; -import type { Milliseconds } from 'firefox-profiler/types'; +import type { Milliseconds, FaviconData } from 'firefox-profiler/types'; /** * This file manages the communication between the profiler and the browser. @@ -68,6 +69,8 @@ export interface BrowserConnection { debugName: string, breakpadId: string ): Promise; + + getPageFavicons(pageUrls: Array): Promise>; } /** @@ -81,12 +84,14 @@ class BrowserConnectionImpl implements BrowserConnection { _webChannelSupportsGetProfileAndSymbolication: boolean; _webChannelSupportsGetExternalPowerTracks: boolean; _webChannelSupportsGetExternalMarkers: boolean; + _webChannelSupportsGetPageFavicons: boolean; _geckoProfiler: $GeckoProfiler | void; constructor(webChannelVersion: number) { this._webChannelSupportsGetProfileAndSymbolication = webChannelVersion >= 1; this._webChannelSupportsGetExternalPowerTracks = webChannelVersion >= 2; this._webChannelSupportsGetExternalMarkers = webChannelVersion >= 3; + this._webChannelSupportsGetPageFavicons = webChannelVersion >= 4; } // Only called when we must obtain the profile from the browser, i.e. if we @@ -181,6 +186,17 @@ class BrowserConnectionImpl implements BrowserConnection { 'Cannot obtain a symbol table: have neither WebChannel nor a GeckoProfiler object' ); } + + async getPageFavicons( + pageUrls: Array + ): Promise> { + // This is added in Firefox 134. + if (this._webChannelSupportsGetPageFavicons) { + return getPageFaviconsViaWebChannel(pageUrls); + } + + return []; + } } // Should work with: diff --git a/src/app-logic/constants.js b/src/app-logic/constants.js index 18b460ee79..2b8c1fa2fe 100644 --- a/src/app-logic/constants.js +++ b/src/app-logic/constants.js @@ -9,12 +9,12 @@ import type { MarkerPhase } from 'firefox-profiler/types'; // The current version of the Gecko profile format. // Please don't forget to update the gecko profile format changelog in // `docs-developer/CHANGELOG-formats.md`. -export const GECKO_PROFILE_VERSION = 30; +export const GECKO_PROFILE_VERSION = 31; // The current version of the "processed" profile format. // Please don't forget to update the processed profile format changelog in // `docs-developer/CHANGELOG-formats.md`. -export const PROCESSED_PROFILE_VERSION = 50; +export const PROCESSED_PROFILE_VERSION = 51; // The following are the margin sizes for the left and right of the timeline. Independent // components need to share these values. diff --git a/src/app-logic/web-channel.js b/src/app-logic/web-channel.js index a8d733e56a..22f70dac06 100644 --- a/src/app-logic/web-channel.js +++ b/src/app-logic/web-channel.js @@ -8,6 +8,7 @@ import type { Milliseconds, MixedObject, ExternalMarkersData, + FaviconData, } from 'firefox-profiler/types'; /** @@ -27,7 +28,8 @@ export type Request = | GetExternalMarkersRequest | GetExternalPowerTracksRequest | GetSymbolTableRequest - | QuerySymbolicationApiRequest; + | QuerySymbolicationApiRequest + | GetPageFaviconsRequest; type StatusQueryRequest = {| type: 'STATUS_QUERY' |}; type EnableMenuButtonRequest = {| type: 'ENABLE_MENU_BUTTON' |}; @@ -52,6 +54,10 @@ type QuerySymbolicationApiRequest = {| path: string, requestJson: string, |}; +type GetPageFaviconsRequest = {| + type: 'GET_PAGE_FAVICONS', + pageUrls: Array, +|}; export type MessageFromBrowser = | OutOfBandErrorMessageFromBrowser @@ -82,7 +88,8 @@ export type ResponseFromBrowser = | GetExternalMarkersResponse | GetExternalPowerTracksResponse | GetSymbolTableResponse - | QuerySymbolicationApiResponse; + | QuerySymbolicationApiResponse + | GetPageFaviconsResponse; type StatusQueryResponse = {| menuButtonIsEnabled: boolean, @@ -106,6 +113,11 @@ type StatusQueryResponse = {| // Shipped in Firefox 125. // Adds support for the following message types: // - GET_EXTERNAL_MARKERS + // Version 4: + // Shipped in Firefox 134. + // Adds support for the following message types: + // - GET_PAGE_FAVICONS + version?: number, |}; type EnableMenuButtonResponse = void; @@ -114,6 +126,7 @@ type GetExternalMarkersResponse = ExternalMarkersData; type GetExternalPowerTracksResponse = MixedObject[]; type GetSymbolTableResponse = SymbolTableAsTuple; type QuerySymbolicationApiResponse = string; +type GetPageFaviconsResponse = Array; // Manually declare all pairs of request + response for Flow. /* eslint-disable no-redeclare */ @@ -138,6 +151,9 @@ declare function _sendMessageWithResponse( declare function _sendMessageWithResponse( QuerySymbolicationApiRequest ): Promise; +declare function _sendMessageWithResponse( + GetPageFaviconsRequest +): Promise; /* eslint-enable no-redeclare */ /** @@ -226,6 +242,15 @@ export async function querySymbolicationApiViaWebChannel( }); } +export async function getPageFaviconsViaWebChannel( + pageUrls: Array +): Promise { + return _sendMessageWithResponse({ + type: 'GET_PAGE_FAVICONS', + pageUrls, + }); +} + /** * ----------------------------------------------------------------------------- * diff --git a/src/components/app/ProfileFilterNavigator.js b/src/components/app/ProfileFilterNavigator.js index baf2b6b0d9..f4e1d1bed6 100644 --- a/src/components/app/ProfileFilterNavigator.js +++ b/src/components/app/ProfileFilterNavigator.js @@ -106,8 +106,7 @@ class ProfileFilterNavigatorBarImpl extends React.PureComponent { // profile or when the page information is empty. This could happen for // older profiles and profiles from external importers that don't have // this information. - // eslint-disable-next-line no-constant-condition - if (false && pageDataByTabID && pageDataByTabID.size > 0) { + if (pageDataByTabID && pageDataByTabID.size > 0) { const pageData = tabFilter !== null ? pageDataByTabID.get(tabFilter) : null; diff --git a/src/components/app/ProfileViewer.js b/src/components/app/ProfileViewer.js index 69b0ae1d34..1248f7fc50 100644 --- a/src/components/app/ProfileViewer.js +++ b/src/components/app/ProfileViewer.js @@ -38,7 +38,7 @@ import { BackgroundImageStyleDef } from 'firefox-profiler/components/shared/Styl import classNames from 'classnames'; import { DebugWarning } from 'firefox-profiler/components/app/DebugWarning'; -import type { CssPixels, IconWithClassName } from 'firefox-profiler/types'; +import type { CssPixels, IconsWithClassNames } from 'firefox-profiler/types'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './ProfileViewer.css'; @@ -50,7 +50,7 @@ type StateProps = {| +isUploading: boolean, +isHidingStaleProfile: boolean, +hasSanitizedProfile: boolean, - +icons: IconWithClassName[], + +icons: IconsWithClassNames, +isBottomBoxOpen: boolean, |}; @@ -83,7 +83,7 @@ class ProfileViewerImpl extends PureComponent { profileViewerWrapperBackground: hasSanitizedProfile, })} > - {icons.map(({ className, icon }) => ( + {[...icons].map(([icon, className]) => ( { const { meta } = profile; let title = ''; if (formattedMetaInfoString) { - title += formattedMetaInfoString + SEPARATOR; + title += formattedMetaInfoString; } - title += _formatDateTime( - meta.startTime + (meta.profilingStartTime || 0) - ); + + // Print the startTime only if it's provided. + if (meta.startTime > 0) { + title += + SEPARATOR + + _formatDateTime(meta.startTime + (meta.profilingStartTime || 0)); + } + if (dataSource === 'public') { title += ` (${dataSource})`; } - title += SEPARATOR + PRODUCT; + if (title !== '') { + // Add the separator only if we added some information before. + title += SEPARATOR; + } + title += PRODUCT; // Prepend the name of the file if from a zip file. if (fileNameInZipFilePath) { diff --git a/src/components/calltree/CallTree.css b/src/components/calltree/CallTree.css index 3f5702c346..1e4055fe98 100644 --- a/src/components/calltree/CallTree.css +++ b/src/components/calltree/CallTree.css @@ -18,6 +18,7 @@ .treeViewFixedColumn.icon { display: flex; + overflow: visible; flex-flow: column nowrap; align-items: center; } diff --git a/src/components/shared/TabSelectorMenu.css b/src/components/shared/TabSelectorMenu.css index 5da4b4a96a..92c6ad16b3 100644 --- a/src/components/shared/TabSelectorMenu.css +++ b/src/components/shared/TabSelectorMenu.css @@ -17,3 +17,7 @@ /* Move the checkmark to inline-start instead of right, as it's logically better. */ inset-inline: 8px 0; } + +.tabSelectorMenuItem .nodeIcon { + margin-inline-end: 10px; +} diff --git a/src/components/shared/TabSelectorMenu.js b/src/components/shared/TabSelectorMenu.js index d50c78d764..9d4bfb01b7 100644 --- a/src/components/shared/TabSelectorMenu.js +++ b/src/components/shared/TabSelectorMenu.js @@ -13,6 +13,7 @@ import explicitConnect from 'firefox-profiler/utils/connect'; import { changeTabFilter } from 'firefox-profiler/actions/receive-profile'; import { getTabFilter } from '../../selectors/url-state'; import { getProfileFilterSortedPageData } from 'firefox-profiler/selectors/profile'; +import { Icon } from 'firefox-profiler/components/shared/Icon'; import type { TabID, SortedTabPageData } from 'firefox-profiler/types'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; @@ -71,6 +72,7 @@ class TabSelectorMenuImpl extends React.PureComponent { 'aria-checked': tabFilter === tabID ? 'false' : 'true', }} > + {pageData.hostname} ))} diff --git a/src/profile-logic/gecko-profile-versioning.js b/src/profile-logic/gecko-profile-versioning.js index a0df20f626..869aac1ebf 100644 --- a/src/profile-logic/gecko-profile-versioning.js +++ b/src/profile-logic/gecko-profile-versioning.js @@ -1480,6 +1480,15 @@ const _upgraders = { // marker data with sanitized-string typed data, and no modification is needed in the // frontend to display older formats. }, + [31]: (_) => { + // This version bump added two new form types for new marker schema field: + // "flow-id" and "terminating-flow-id". + // Older frontends will not be able to display these fields. + // Furthermore, the marker schema itself has an optional isStackBased field. + // No upgrade is needed, as older versions of firefox would not generate + // marker data with the new field types data, and no modification is needed in the + // frontend to display older formats. + }, // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. diff --git a/src/profile-logic/import/chrome.js b/src/profile-logic/import/chrome.js index a2bfbcbc63..d3882ba4d6 100644 --- a/src/profile-logic/import/chrome.js +++ b/src/profile-logic/import/chrome.js @@ -24,7 +24,10 @@ import { INTERVAL_END, } from 'firefox-profiler/app-logic/constants'; -import { getOrCreateURIResource } from '../../profile-logic/profile-data'; +import { + getOrCreateURIResource, + getTimeRangeForThread, +} from '../../profile-logic/profile-data'; // Chrome Tracing Event Spec: // https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview @@ -41,7 +44,8 @@ export type TracingEventUnion = | ProcessSortIndexEvent | ThreadSortIndexEvent | ScreenshotEvent - | FallbackEndEvent; + | FallbackEndEvent + | TracingStartedInBrowserEvent; type TracingEvent = {| cat: string, @@ -178,6 +182,11 @@ type ScreenshotEvent = TracingEvent<{| args: { snapshot: string }, |}>; +type TracingStartedInBrowserEvent = TracingEvent<{| + name: 'TracingStartedInBrowser', + ph: 'I', +|}>; + function wrapCpuProfileInEvent(cpuProfile: CpuProfileData): CpuProfileEvent { return { name: 'CpuProfile', @@ -735,6 +744,37 @@ async function processTracingEvents( profile ); + // Figure out the profiling start and end times if they haven't been found yet. + // CpuProfile traces would have already found and updated this, we should do + // it for the other tracing formats only. + if ( + profile.meta.profilingStartTime === undefined && + profile.meta.profilingEndTime === undefined && + eventsByName.has('TracingStartedInBrowser') + ) { + const tracingStartedEvent = ensureExists( + eventsByName.get('TracingStartedInBrowser') + )[0]; + if ( + tracingStartedEvent.ts !== undefined && + Number.isFinite(tracingStartedEvent.ts) + ) { + // We know the start easily, but we have to compute the end time. + let profilingEndTime = -Infinity; + + profile.threads.forEach((thread) => { + const threadRange = getTimeRangeForThread( + thread, + profile.meta.interval + ); + profilingEndTime = Math.max(profilingEndTime, threadRange.end); + }); + + profile.meta.profilingStartTime = tracingStartedEvent.ts / 1000; + profile.meta.profilingEndTime = profilingEndTime; + } + } + profile.threads.sort((threadA, threadB) => { const threadInfoA = threadInfoByThread.get(threadA); const threadInfoB = threadInfoByThread.get(threadB); diff --git a/src/profile-logic/marker-schema.js b/src/profile-logic/marker-schema.js index 9e8cd20f91..2b40be481e 100644 --- a/src/profile-logic/marker-schema.js +++ b/src/profile-logic/marker-schema.js @@ -477,6 +477,8 @@ export function formatFromMarkerSchema( // Make sure a non-empty string is returned here. return String(value) || '(empty)'; case 'unique-string': + case 'flow-id': + case 'terminating-flow-id': return stringTable.getString(value, '(empty)'); case 'duration': case 'time': @@ -671,7 +673,11 @@ export function markerPayloadMatchesSearch( for (const payloadField of markerSchema.data) { if (payloadField.searchable) { let value = data[payloadField.key]; - if (payloadField.format === 'unique-string') { + if ( + payloadField.format === 'unique-string' || + payloadField.format === 'flow-id' || + payloadField.format === 'terminating-flow-id' + ) { value = stringTable.getString(value); } if (value === undefined || value === null || value === '') { diff --git a/src/profile-logic/processed-profile-versioning.js b/src/profile-logic/processed-profile-versioning.js index accd48a8bb..6c9c00b96d 100644 --- a/src/profile-logic/processed-profile-versioning.js +++ b/src/profile-logic/processed-profile-versioning.js @@ -2273,6 +2273,15 @@ const _upgraders = { // The unserialized version is unchanged, and because the upgraders run // after unserialization they see no difference. }, + [51]: (_) => { + // This version bump added two new form types for new marker schema field: + // "flow-id" and "terminating-flow-id". + // Older frontends will not be able to display these fields. + // Furthermore, the marker schema itself has an optional isStackBased field. + // No upgrade is needed, as older versions of firefox would not generate + // marker data with the new field types data, and no modification is needed in the + // frontend to display older formats. + }, // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. }; diff --git a/src/profile-logic/profile-data.js b/src/profile-logic/profile-data.js index 6107bc2a0d..8deaa0ee18 100644 --- a/src/profile-logic/profile-data.js +++ b/src/profile-logic/profile-data.js @@ -31,6 +31,8 @@ import { ensureExists, getFirstItemFromSet, } from 'firefox-profiler/utils/flow'; +import ExtensionFavicon from '../../res/img/svg/extension-outline.svg'; +import DefaultLinkFavicon from '../../res/img/svg/globe.svg'; import type { Profile, @@ -2963,14 +2965,15 @@ export function extractProfileFilterPageData( } // The last page is the one we care about. - const pageUrl = topMostPages[topMostPages.length - 1].url; + const currentPage = topMostPages[topMostPages.length - 1]; + const pageUrl = currentPage.url; if (pageUrl.startsWith('about:')) { // If we only have an `about:*` page, we should return early with a friendly // origin and hostname. Otherwise the try block will always fail. pageDataByTabID.set(tabID, { origin: pageUrl, hostname: pageUrl, - favicon: null, + favicon: DefaultLinkFavicon, }); continue; } @@ -2983,17 +2986,19 @@ export function extractProfileFilterPageData( // The known failing case is when we try to construct a URL with a // moz-extension:// protocol on platforms outside of Firefox. Only Firefox // can parse it properly. Chrome and node will output a URL with no `origin`. + const isExtension = pageUrl.startsWith('moz-extension://'); + const defaultFavicon = isExtension ? ExtensionFavicon : DefaultLinkFavicon; const pageData: ProfileFilterPageData = { origin: '', hostname: '', - favicon: null, + favicon: currentPage.favicon ?? defaultFavicon, }; try { const page = new URL(pageUrl); pageData.hostname = - extensionIDToNameMap && pageUrl.startsWith('moz-extension://') + extensionIDToNameMap && isExtension ? // Get the real extension name if it's an extension. (extensionIDToNameMap.get( 'moz-extension://' + @@ -3005,15 +3010,7 @@ export function extractProfileFilterPageData( ) ?? '') : page.hostname; - // FIXME(Bug 1620546): This is not ideal and we should get the favicon - // either during profile capture or profile pre-process. pageData.origin = page.origin; - const favicon = new URL('/favicon.ico', page.origin); - if (favicon.protocol === 'http:') { - // Upgrade http requests. - favicon.protocol = 'https:'; - } - pageData.favicon = favicon.href; } catch (e) { console.warn( 'Error while extracing the hostname and favicon from the page url', diff --git a/src/profile-logic/sanitize.js b/src/profile-logic/sanitize.js index 7b8afdd562..309088cba0 100644 --- a/src/profile-logic/sanitize.js +++ b/src/profile-logic/sanitize.js @@ -120,6 +120,8 @@ export function sanitizePII( pages = pages.map((page, pageIndex) => ({ ...page, url: removeURLs(page.url, ``), + // Remove the favicon data as it could reveal the url. + favicon: null, })); } } diff --git a/src/profile-logic/tracks.js b/src/profile-logic/tracks.js index 22610eb462..1df2cabcf3 100644 --- a/src/profile-logic/tracks.js +++ b/src/profile-logic/tracks.js @@ -818,14 +818,16 @@ export function tryInitializeHiddenTracksFromUrl( export function computeDefaultHiddenTracks( tracksWithOrder: TracksWithOrder, profile: Profile, - threadActivityScores: Array + threadActivityScores: Array, + includeParentProcessThreads: boolean ): HiddenTracks { return _computeHiddenTracksForVisibleThreads( profile, computeDefaultVisibleThreads( profile, tracksWithOrder, - threadActivityScores + threadActivityScores, + includeParentProcessThreads ), tracksWithOrder ); @@ -1054,7 +1056,8 @@ const IDLE_THRESHOLD_FRACTION = 0.05; export function computeDefaultVisibleThreads( profile: Profile, tracksWithOrder: TracksWithOrder, - threadActivityScores: Array + threadActivityScores: Array, + includeParentProcessThreads: boolean ): Set { const threads = profile.threads; if (threads.length === 0) { @@ -1099,11 +1102,33 @@ export function computeDefaultVisibleThreads( // to the thread with the most "sampleScore" activity. // We keep all threads whose sampleScore is at least 5% of the highest // sampleScore, and also any threads which are otherwise essential. + // We also remove the parent process if that was requested. That's why we + // have to ignore their scores while computing the highest score. const highestSampleScore = Math.max( - ...scores.map(({ score }) => score.sampleScore) + ...scores.map(({ score }) => { + if (score.isInParentProcess && !includeParentProcessThreads) { + // Do not account for the parent process threads if we do not want to + // include them for the visible threads by default. + return 0; + } + return score.sampleScore; + }) ); const thresholdSampleScore = highestSampleScore * IDLE_THRESHOLD_FRACTION; - const finalList = top15.filter(({ score }) => { + const tryToHideList = []; + let finalList = top15.filter((activityScore) => { + const { score } = activityScore; + if (score.isInParentProcess && !includeParentProcessThreads) { + // We try to hide this thread that belongs to the parent process. + // But when we hide all the threads we might encounter that all the + // threads are hidden now. For cases like this we would like to keep a + // tryToHideList, so we can add them back if the track is completely empty. + if (score.sampleScore >= thresholdSampleScore) { + tryToHideList.push(activityScore); + } + return false; + } + if (score.isEssentialFirefoxThread) { return true; // keep. } @@ -1113,6 +1138,12 @@ export function computeDefaultVisibleThreads( return score.sampleScore >= thresholdSampleScore; }); + if (finalList.length === 0) { + // We tried to hide the main process threads, but this resulted us to have + // an empty list. Put them back. + finalList = tryToHideList; + } + return new Set(finalList.map(({ threadIndex }) => threadIndex)); } @@ -1120,6 +1151,9 @@ export type ThreadActivityScore = {| // Whether this thread is one of the essential threads that // should always be kept (unless there's too many of them). isEssentialFirefoxThread: boolean, + // Whether this thread belongs to the parent process. We do not want to show + // them by default if the tab selector is used. + isInParentProcess: boolean, // Whether this thread should be kept even if it looks very idle, // as long as there's a single sample with non-zero activity. isInterestingEvenWithMinimalActivity: boolean, @@ -1146,6 +1180,7 @@ export function computeThreadActivityScore( maxCpuDeltaPerInterval: number | null ): ThreadActivityScore { const isEssentialFirefoxThread = _isEssentialFirefoxThread(thread); + const isInParentProcess = thread.processType === 'default'; const isInterestingEvenWithMinimalActivity = _isFirefoxMediaThreadWhichIsUsuallyIdle(thread); const sampleScore = _computeThreadSampleScore( @@ -1158,6 +1193,7 @@ export function computeThreadActivityScore( : sampleScore; return { isEssentialFirefoxThread, + isInParentProcess, isInterestingEvenWithMinimalActivity, sampleScore, boostedSampleScore, diff --git a/src/reducers/icons.js b/src/reducers/icons.js index 9cf2806c70..1c77ae3c69 100644 --- a/src/reducers/icons.js +++ b/src/reducers/icons.js @@ -3,12 +3,22 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // @flow -import type { Reducer } from 'firefox-profiler/types'; +import type { Reducer, IconsWithClassNames } from 'firefox-profiler/types'; -const favicons: Reducer> = (state = new Set(), action) => { +const favicons: Reducer = (state = new Map(), action) => { switch (action.type) { - case 'ICON_HAS_LOADED': - return new Set([...state, action.icon]); + case 'ICON_HAS_LOADED': { + const { icon, className } = action.iconWithClassName; + return new Map([...state.entries(), [icon, className]]); + } + case 'ICON_BATCH_ADD': { + const newState = new Map([...state.entries()]); + for (const { icon, className } of action.icons) { + newState.set(icon, className); + } + + return newState; + } case 'ICON_IN_ERROR': // nothing to do default: return state; diff --git a/src/reducers/profile-view.js b/src/reducers/profile-view.js index 5a10ca65b7..46116902b4 100644 --- a/src/reducers/profile-view.js +++ b/src/reducers/profile-view.js @@ -72,6 +72,16 @@ const profile: Reducer = (state = null, action) => { }, }; } + case 'UPDATE_PAGES': { + if (state === null) { + throw new Error( + `We tried to update the pages information for a non-existent profile.` + ); + } + + const { newPages } = action; + return { ...state, pages: newPages }; + } default: return state; } diff --git a/src/selectors/icons.js b/src/selectors/icons.js index 435c83d128..05ffdf75a7 100644 --- a/src/selectors/icons.js +++ b/src/selectors/icons.js @@ -3,18 +3,18 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // @flow -import { createSelector } from 'reselect'; import type { - IconWithClassName, - IconState, + IconsWithClassNames, Selector, DangerousSelectorWithArguments, } from 'firefox-profiler/types'; /** * A simple selector into the icon state. + * It returns a map that matches icon to the icon class name. */ -export const getIcons: Selector = (state) => state.icons; +export const getIconsWithClassNames: Selector = (state) => + state.icons; /** * In order to load icons without multiple requests, icons are created through @@ -26,21 +26,9 @@ export const getIconClassName: DangerousSelectorWithArguments< string, string | null, > = (state, icon) => { - const icons = getIcons(state); - return icon !== null && icons.has(icon) ? _classNameFromUrl(icon) : ''; + if (icon === null) { + return ''; + } + const icons = getIconsWithClassNames(state); + return icons.get(icon) ?? ''; }; - -/** - * This functions returns an object with both the icon URL and the class name. - */ -export const getIconsWithClassNames: Selector = - createSelector(getIcons, (icons) => - [...icons].map((icon) => ({ icon, className: _classNameFromUrl(icon) })) - ); - -/** - * Transforms a URL into a valid CSS class name. - */ -function _classNameFromUrl(url): string { - return url.replace(/[/:.+>< ~()#,]/g, '_'); -} diff --git a/src/symbolicator-cli/webpack.config.js b/src/symbolicator-cli/webpack.config.js index bb6b052c46..3418ba7a8f 100644 --- a/src/symbolicator-cli/webpack.config.js +++ b/src/symbolicator-cli/webpack.config.js @@ -19,6 +19,10 @@ module.exports = { use: ['babel-loader'], include: includes, }, + { + test: /\.svg$/, + type: 'asset/resource', + }, ], }, experiments: { diff --git a/src/test/components/TabSelectorMenu.test.js b/src/test/components/TabSelectorMenu.test.js index ecc3b8682b..1157c8e423 100644 --- a/src/test/components/TabSelectorMenu.test.js +++ b/src/test/components/TabSelectorMenu.test.js @@ -18,12 +18,15 @@ import { import { storeWithProfile } from '../fixtures/stores'; import { fireFullClick } from '../fixtures/utils'; import { getTabFilter } from '../../selectors/url-state'; +import { ensureExists } from 'firefox-profiler/utils/flow'; describe('app/TabSelectorMenu', () => { function setup() { const { profile, ...extraPageData } = addActiveTabInformationToProfile( getProfileWithNiceTracks() ); + ensureExists(profile.pages)[3].favicon = + 'data:image/png;base64,test-png-favicon-data-for-profiler.firefox.com'; // This is needed for the thread activity score calculation. profile.meta.sampleUnits = { @@ -131,7 +134,7 @@ describe('app/TabSelectorMenu', () => { // Note that the first thread will be visible too, because it's the parent // process which we always include. expect(getHumanReadableTracks(getState())).toEqual([ - 'show [thread GeckoMain default]', + 'hide [thread GeckoMain default]', 'show [thread GeckoMain tab] SELECTED', ' - show [thread DOM Worker]', ' - show [thread Style]', diff --git a/src/test/components/TrackContextMenu.test.js b/src/test/components/TrackContextMenu.test.js index 001bd3b5c6..b41d6aca11 100644 --- a/src/test/components/TrackContextMenu.test.js +++ b/src/test/components/TrackContextMenu.test.js @@ -515,8 +515,8 @@ describe('timeline/TrackContextMenu', function () { expect(getHumanReadableTracks(getState())).toEqual([ 'show [thread GeckoMain default] SELECTED', 'hide [thread GeckoMain tab]', - ' - show [thread DOM Worker]', - ' - show [thread Style]', + ' - hide [thread DOM Worker]', + ' - hide [thread Style]', ]); }); diff --git a/src/test/components/WindowTitle.test.js b/src/test/components/WindowTitle.test.js index c529a2f331..3cc42e31d9 100644 --- a/src/test/components/WindowTitle.test.js +++ b/src/test/components/WindowTitle.test.js @@ -34,11 +34,61 @@ describe('WindowTitle', () => { ); + expect(document.title).toBe('Firefox – Firefox Profiler'); + }); + + it('shows the profiler startTime in the window title if it is available', () => { + const profile = getEmptyProfile(); + profile.threads.push(getEmptyThread()); + Object.assign(profile.meta, { + startTime: new Date('5 Nov 2024 13:00 UTC').getTime(), + }); + const store = storeWithProfile(profile); + store.dispatch(setDataSource('from-url')); + render( + + + + ); + + expect(document.title).toBe( + 'Firefox – 11/5/2024, 1:00:00 PM UTC – Firefox Profiler' + ); + }); + + it('shows the profiler startTime with public annotation in the window title if it is available', () => { + const profile = getEmptyProfile(); + profile.threads.push(getEmptyThread()); + Object.assign(profile.meta, { + startTime: new Date('5 Nov 2024 13:00 UTC').getTime(), + }); + const store = storeWithProfile(profile); + store.dispatch(setDataSource('public')); + render( + + + + ); + expect(document.title).toBe( - 'Firefox – 1/1/1970, 12:00:00 AM UTC – Firefox Profiler' + 'Firefox – 11/5/2024, 1:00:00 PM UTC (public) – Firefox Profiler' ); }); + it('shows the public annotation without startTime in the window title', () => { + const profile = getEmptyProfile(); + profile.threads.push(getEmptyThread()); + const store = storeWithProfile(profile); + store.dispatch(setDataSource('public')); + render( + + + + ); + + expect(document.title).toBe('Firefox (public) – Firefox Profiler'); + }); + it('shows platform details in the window title if it is available', () => { const profile = getEmptyProfile(); profile.threads.push(getEmptyThread()); @@ -55,8 +105,28 @@ describe('WindowTitle', () => { ); + expect(document.title).toBe('Firefox – macOS 10.14 – Firefox Profiler'); + }); + + it('shows platform details with the start time in the window title if it is available', () => { + const profile = getEmptyProfile(); + profile.threads.push(getEmptyThread()); + Object.assign(profile.meta, { + oscpu: 'Intel Mac OS X 10.14', + platform: 'Macintosh', + toolkit: 'cocoa', + startTime: new Date('5 Nov 2024 13:00 UTC').getTime(), + }); + const store = storeWithProfile(profile); + store.dispatch(setDataSource('from-url')); + render( + + + + ); + expect(document.title).toBe( - 'Firefox – macOS 10.14 – 1/1/1970, 12:00:00 AM UTC – Firefox Profiler' + 'Firefox – macOS 10.14 – 11/5/2024, 1:00:00 PM UTC – Firefox Profiler' ); }); @@ -102,7 +172,7 @@ describe('WindowTitle', () => { ); - expect(document.title).toBe('1/1/1970, 12:00:00 AM UTC – Firefox Profiler'); + expect(document.title).toBe('Firefox Profiler'); }); it('shows the correct title for uploaded recordings', () => { @@ -173,7 +243,7 @@ describe('WindowTitle', () => { ); expect(document.title).toBe( - 'bar/profile1.json – Firefox – 1/1/1970, 12:00:00 AM UTC – Firefox Profiler' + 'bar/profile1.json – Firefox – Firefox Profiler' ); }); }); diff --git a/src/test/components/__snapshots__/FilterNavigatorBar.test.js.snap b/src/test/components/__snapshots__/FilterNavigatorBar.test.js.snap index 4b996c5b3f..212d55d210 100644 --- a/src/test/components/__snapshots__/FilterNavigatorBar.test.js.snap +++ b/src/test/components/__snapshots__/FilterNavigatorBar.test.js.snap @@ -10,7 +10,12 @@ exports[`app/ProfileFilterNavigator renders ProfileFilterNavigator properly 1`] - Full Range (⁨51ms⁩) + @@ -27,7 +32,11 @@ exports[`app/ProfileFilterNavigator renders ProfileFilterNavigator properly 2`] class="filterNavigatorBarItemContent" type="button" > - Full Range (⁨51ms⁩) + + Full Range (⁨51ms⁩) +
  • - Full Range (⁨51ms⁩) + + Full Range (⁨51ms⁩) +