diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 682b6c8d4d..1a674f4a01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,21 @@ jobs: with: fail_ci_if_error: false + cli-tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js and install dependencies + uses: ./.github/actions/setup-node-and-install + + - name: Run CLI tests + run: yarn test-cli + build-prod: runs-on: ${{ matrix.os }} strategy: diff --git a/.gitignore b/.gitignore index a3bd91ed88..82de1fd4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ webpack.local-config.js *.orig *.rej .idea/ +.profiler-cli-dev/ diff --git a/.prettierignore b/.prettierignore index 93d9f5be03..f4266c7c45 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ src/profile-logic/import/proto src/types/libdef/npm +profiler-cli/dist docs-user/js docs-user/css src/test/fixtures/upgrades diff --git a/bin/output-fixing-commands.js b/bin/output-fixing-commands.js index 22204d3dce..10bfb1d787 100644 --- a/bin/output-fixing-commands.js +++ b/bin/output-fixing-commands.js @@ -13,6 +13,7 @@ const fixingCommands = { 'lint-css': 'lint-fix-css', 'prettier-run': 'prettier-fix', test: 'test -u', + 'test-cli': 'test-cli -u', }; const command = process.argv.slice(2); diff --git a/docs-developer/deploying.md b/docs-developer/deploying.md index 33975e5f1c..543b9408d2 100644 --- a/docs-developer/deploying.md +++ b/docs-developer/deploying.md @@ -97,3 +97,61 @@ To deploy on nginx (without support for direct upload from the Firefox UI), run and point nginx at the `dist` directory, which needs to be at the root of the webserver. Additionally, a `error_page 404 =200 /index.html;` directive needs to be added so that unknown URLs respond with index.html. For a more production-ready configuration, have a look at the netlify [`_headers`](/res/_headers) file. + +# Publishing profiler-cli to npm + +The [`@firefox-devtools/profiler-cli`](https://www.npmjs.com/package/@firefox-devtools/profiler-cli) +package is published to npm from this repository. It provides a command-line +interface for querying Firefox Profiler profiles — see +[`profiler-cli/README.md`](../profiler-cli/README.md) for usage. + +## Prerequisites + +- Be logged in to npm (`npm login`) with publish access to the `@firefox-devtools` scope. +- Make sure the working tree is clean and you are on the commit you want to publish. +- Run `yarn test-all` (or at least `yarn test-cli`) to confirm the CLI still builds and passes tests. + +## Bump the version + +Edit the `version` field in [`profiler-cli/package.json`](../profiler-cli/package.json), +then land the version bump on `main` before publishing. + +## Publish + +From the repository root: + +``` +yarn publish-profiler-cli +``` + +[`scripts/publish-profiler-cli.mjs`](../scripts/publish-profiler-cli.mjs) will: + +1. Run `yarn build-profiler-cli` to produce `profiler-cli/dist/profiler-cli.js` (a + single self-contained bundle with no runtime dependencies). +2. Run `npm publish profiler-cli/`, picking `--tag alpha` when the version + contains `-` (e.g. `0.1.0-alpha.5`) and `--tag latest` otherwise. +3. Trigger the `prepublishOnly` hook in `profiler-cli/package.json`, which runs + [`scripts/verify-profiler-cli-build.mjs`](../scripts/verify-profiler-cli-build.mjs) + to confirm the bundle exists and embeds the current `package.json` version — + this guards against publishing a stale build. + +Extra arguments are forwarded to `npm publish`. For example: + +``` +# Build and verify, but do not actually publish. +yarn publish-profiler-cli --dry-run + +# Override the automatic dist-tag. +yarn publish-profiler-cli --tag alpha +``` + +## Verify the release + +After publishing, confirm the new version is listed on +[npm](https://www.npmjs.com/package/@firefox-devtools/profiler-cli) and installs +cleanly: + +``` +npm install -g @firefox-devtools/profiler-cli@latest +profiler-cli --version +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 5bb495b649..2c0d9543d0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ export default defineConfig( ignores: [ 'src/profile-logic/import/proto/**', 'src/types/libdef/npm/**', + 'profiler-cli/dist/**', 'res/**', 'dist/**', 'node-tools-dist/**', @@ -253,7 +254,7 @@ export default defineConfig( // Test files override { - files: ['src/test/**/*'], + files: ['src/test/**/*', 'profiler-cli/src/test/**/*'], languageOptions: { globals: { ...globals.jest, diff --git a/jest.config.js b/jest.config.js index 67cbc9e4ca..3b89ea2c4e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,21 +2,14 @@ * 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/. */ -module.exports = { - testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], - moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], - - // Use custom resolver that respects the "browser" field in package.json - resolver: './jest-resolver.js', - +// Shared config for projects that need a browser-like (jsdom) environment. +// CLI unit tests use the same environment because they import browser-side +// fixtures to construct profile data. +const browserEnvConfig = { testEnvironment: './src/test/custom-environment', setupFilesAfterEnv: ['jest-extended/all', './src/test/setup.ts'], - - collectCoverageFrom: [ - 'src/**/*.{js,jsx,ts,tsx}', - '!**/node_modules/**', - '!src/types/libdef/**', - ], + moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], + resolver: './jest-resolver.js', transform: { '\\.([jt]sx?|mjs)$': 'babel-jest', @@ -43,5 +36,61 @@ module.exports = { escapeString: true, printBasicPrototype: true, }, - verbose: false, +}; + +const allProjects = [ + // ======================================================================== + // Browser Tests (React/browser environment) + // ======================================================================== + { + ...browserEnvConfig, + displayName: 'browser', + testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], + + collectCoverageFrom: [ + 'src/**/*.{js,jsx,ts,tsx}', + '!**/node_modules/**', + '!src/types/libdef/**', + ], + }, + + // ======================================================================== + // CLI Unit Tests (browser/jsdom environment - imports browser-side fixtures) + // ======================================================================== + { + ...browserEnvConfig, + displayName: 'cli', + testMatch: ['/profiler-cli/src/test/unit/**/*.test.ts'], + }, + + // ======================================================================== + // CLI Integration Tests (Node.js environment - spawns real processes) + // ======================================================================== + { + displayName: 'cli-integration', + testMatch: ['/profiler-cli/src/test/integration/**/*.test.ts'], + + testEnvironment: 'node', + + setupFilesAfterEnv: ['./profiler-cli/src/test/integration/setup.ts'], + + // Integration tests can be slow (loading profiles, spawning processes) + testTimeout: 30000, + + moduleFileExtensions: ['ts', 'js'], + + transform: { + '\\.([jt]sx?|mjs)$': 'babel-jest', + }, + }, +]; + +// Filter projects by JEST_PROJECTS env var (comma-separated displayNames). +// Preferred over --selectProjects because that CLI flag is variadic and +// swallows positional args like `yarn test process-profile.ts`. +const filter = process.env.JEST_PROJECTS; +module.exports = { + projects: filter + ? allProjects.filter((p) => filter.split(',').includes(p.displayName)) + : allProjects, }; diff --git a/package.json b/package.json index 8259763f14..18bade9fb1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,11 @@ "build-sw": "workbox generateSW workbox-config.js", "build-prod:quiet": "yarn build-prod", "build-node-tools": "cross-env NODE_ENV=production node scripts/build-node-tools.mjs", + "build-profile-query": "cross-env NODE_ENV=production node scripts/build-profile-query.mjs", + "build-profile-query:quiet": "yarn build-profile-query", + "build-profiler-cli": "cross-env NODE_ENV=production node scripts/build-profiler-cli.mjs", + "build-profiler-cli:quiet": "yarn build-profiler-cli", + "publish-profiler-cli": "node scripts/publish-profiler-cli.mjs", "lint": "node bin/output-fixing-commands.js run-p lint-js lint-css prettier-run", "lint-fix": "run-p lint-fix-js lint-fix-css prettier-fix", "lint-js": "node bin/output-fixing-commands.js eslint . --report-unused-disable-directives --cache --cache-strategy content", @@ -42,15 +47,16 @@ "start-examples": "ws -d examples/ -s index.html -p 4244", "start-docs": "ws -d docs-user/ -p 3000", "start-photon": "node scripts/run-photon-dev-server.mjs", - "test": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test jest", + "test": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_PROJECTS=browser jest", "test-node": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_ENVIRONMENT=node jest", - "test-all": "run-p --max-parallel 4 ts license-check lint test test-alex test-lockfile", + "test-all": "run-p --max-parallel 4 ts license-check lint test test-alex test-lockfile && yarn test-cli", "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-* CODE_OF_CONDUCT.md CONTRIBUTING.md README.md", "test-lockfile": "lockfile-lint --path yarn.lock --allowed-hosts yarn --validate-https", "test-debug": "cross-env LC_ALL=C TZ=UTC NODE_ENV=test node --inspect-brk node_modules/.bin/jest --runInBand", + "test-cli": "yarn build-profiler-cli && node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_PROJECTS=cli,cli-integration jest", "postinstall": "patch-package" }, "license": "MPL-2.0", @@ -75,6 +81,7 @@ "array-range": "^1.0.1", "clamp": "^1.0.1", "classnames": "^2.5.1", + "commander": "^14.0.3", "common-tags": "^1.8.2", "copy-to-clipboard": "^4.0.2", "core-js": "^3.49.0", diff --git a/profiler-cli/CONTRIBUTING.md b/profiler-cli/CONTRIBUTING.md new file mode 100644 index 0000000000..f8f82b54ad --- /dev/null +++ b/profiler-cli/CONTRIBUTING.md @@ -0,0 +1,238 @@ +# Contributing to Profiler CLI + +## Architecture + +**Two-process model:** + +- **Daemon process**: Long-running background process that loads a profile via `ProfileQuerier` and keeps it in memory +- **Client process**: Short-lived process that sends commands to the daemon and prints results + +**IPC:** Unix domain sockets (named pipes on Windows) with line-delimited JSON messages + +**Session storage:** `~/.profiler-cli/` (or `$PROFILER_CLI_SESSION_DIR` for development) + +`ProfileQuerier` lives in `src/profile-query/` in the main profiler repo. The CLI daemon is just an IPC wrapper around it — query logic belongs in `src/profile-query/`, not in `daemon.ts`. + +## Build & Distribution + +This package uses a **bundled distribution approach**: + +- **Source code**: Lives in `profiler-cli/src/` within the firefox-devtools/profiler monorepo +- **Dependencies**: Defined in the root `package.json` (react, redux, protobufjs, etc.) +- **Build process**: The CLI build writes a single self-contained executable to `profiler-cli/dist/profiler-cli.js` with zero runtime dependencies +- **Published artifact**: `profiler-cli/dist/profiler-cli.js` is published to npm as `@firefox-devtools/profiler-cli` +- **Package.json**: Contains only npm metadata — it does NOT list dependencies since they're pre-bundled + +This means: + +- Users who install via npm get a self-contained binary that just works +- Developers working on the CLI use the root package.json dependencies +- The `package.json` in this directory is for npm publishing only, not for development + +To publish: + +```bash +# From repository root +yarn build-profiler-cli +cd profiler-cli +npm publish +``` + +## Development Workflow + +**Environment variable isolation:** + +```bash +export PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev" # Use local directory instead of ~/.profiler-cli +profiler-cli load profile.json # or: ./dist/profiler-cli.js load profile.json +``` + +All test scripts automatically set `PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev"` to avoid polluting global state. + +**Build:** + +```bash +yarn build-profiler-cli # Creates ./dist/profiler-cli.js +``` + +**Unit tests:** + +```bash +yarn test profile-query +``` + +**CLI integration tests:** + +```bash +yarn test-cli +``` + +## Implementation Details + +**Daemon startup (client.ts):** + +Two-phase startup: + +1. Spawn detached Node.js process with `--daemon` flag +2. **Phase 1** — Poll every 50ms (max 500ms) until the session validates (metadata written, process running, socket exists) +3. **Phase 2** — Poll every 100ms (max 60s, or `$PROFILER_CLI_LOAD_TIMEOUT_MS`) via status messages until the profile finishes loading; fail immediately if a load error is returned +4. Return session ID when profile is ready + +**IPC protocol (protocol.ts):** + +See `src/protocol.ts` for the authoritative `ClientMessage`, `ClientCommand`, and `ServerResponse` type definitions. + +**Session validation (session.ts):** + +- Check PID is running (`process.kill(pid, 0)`) +- Check socket file exists (Unix only — named pipes on Windows are not filesystem files) +- Auto-cleanup stale sessions + +**Current session pointer:** + +- `current.txt` is a plain-text file containing the active session ID +- Resolved to the full socket path in `getCurrentSocketPath()` when needed + +**Session metadata example:** + +```json +{ + "id": "abc123xyz", + "socketPath": "/Users/user/.profiler-cli/abc123xyz.sock", + "logPath": "/Users/user/.profiler-cli/abc123xyz.log", + "pid": 12345, + "profilePath": "/path/to/profile.json", + "createdAt": "2025-10-31T10:00:00.000Z", + "buildHash": "abc123" +} +``` + +On Windows, `socketPath` is a named pipe: `\\.\pipe\profiler-cli--`, +where `` is derived from the session directory path to avoid cross-directory collisions. + +## Build Configuration + +- esbuild bundles the CLI for Node.js +- A build banner adds the `#!/usr/bin/env node` shebang +- The banner also sets `globalThis.self = globalThis` for browser-oriented shared code +- `__BUILD_HASH__` is injected at build time +- `gecko-profiler-demangle` is left external to keep the CLI lean +- Postbuild: `chmod +x dist/profiler-cli.js` + +## Adding New Commands + +Each command group lives in its own file under `commands/`. To add a new command, modify the following files. The example below adds a hypothetical `profiler-cli allocation info` command. + +### Step 1: Define types in `protocol.ts` + +Add to the `ClientCommand` union, define a result type, and add it to `CommandResult`: + +```typescript +// In ClientCommand: +| { command: 'allocation'; subcommand: 'info'; thread?: string } + +// New result type: +export type AllocationInfoResult = { + type: 'allocation-info'; + totalBytes: number; + // ... other fields +}; + +// In CommandResult: +| WithContext +``` + +### Step 2: Create `commands/allocation.ts` + +```typescript +import { Command } from 'commander'; +import { sendCommand } from '../client'; +import { addGlobalOptions } from './shared'; +import { formatOutput } from '../output'; + +export function registerAllocationCommand( + program: Command, + sessionDir: string +): void { + const allocation = program + .command('allocation') + .description('Allocation commands'); + + addGlobalOptions( + allocation + .command('info') + .description('Show allocation summary') + .option('--thread ', 'Thread to query') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'allocation', subcommand: 'info', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} +``` + +### Step 3: Handle the command in `daemon.ts` + +Add a case to `processMessage()`: + +```typescript +case 'allocation': + switch (command.subcommand) { + case 'info': + return this.querier!.allocationInfo(command.thread); + default: + throw assertExhaustiveCheck(command); + } +``` + +### Step 4: Implement the ProfileQuerier method in `src/profile-query/index.ts` + +Return a structured result type wrapped in `WithContext`, not a plain string: + +```typescript +async allocationInfo(threadHandle?: string): Promise> { + // ... + return { type: 'allocation-info', context: this._getContext(), totalBytes: ... }; +} +``` + +### Step 5: Add a formatter in `formatters.ts` and wire it into `output.ts` + +```typescript +// formatters.ts +export function formatAllocationInfoResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context)]; + lines.push(`Total allocated: ${result.totalBytes} bytes`); + return lines.join('\n'); +} + +// output.ts — add a case to the formatOutput switch +case 'allocation-info': + return formatAllocationInfoResult(result); +``` + +### Step 6: Register the command and update docs + +```typescript +// index.ts — add alongside the other register* calls +registerAllocationCommand(program, SESSION_DIR); +``` + +Then: + +- Add the command to `README.md` +- Remove it from "Known Gaps" below if it was previously stubbed out + +## Known Gaps + +These commands are parsed and routed but throw "unimplemented" in the daemon: + +- `profile threads` +- `marker select` +- `sample info`, `sample select` +- `function select` diff --git a/profiler-cli/LICENSE b/profiler-cli/LICENSE new file mode 100644 index 0000000000..a612ad9813 --- /dev/null +++ b/profiler-cli/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/profiler-cli/README.md b/profiler-cli/README.md new file mode 100644 index 0000000000..d2916d04a2 --- /dev/null +++ b/profiler-cli/README.md @@ -0,0 +1,125 @@ +# Firefox Profiler CLI + +A command-line interface for querying Firefox Profiler profiles with persistent daemon sessions. + +> **Alpha release** — this package is in early development. Commands and options may change between versions. + +## Installation + +```bash +npm install -g @firefox-devtools/profiler-cli@latest +``` + +Requires Node.js >= 24. + +## Quick Start + +```bash +profiler-cli load profile.json # Load a profile (file or https:// URL) +profiler-cli profile info # Show profile summary +profiler-cli thread info # List threads +profiler-cli thread select t-0 # Select a thread +profiler-cli thread samples # Show hot functions +profiler-cli stop # Stop the daemon +``` + +`profiler-cli` is also available as `pq` for shorter invocations (e.g. `pq thread samples`). + +Run `profiler-cli guide` for a detailed usage guide with patterns and tips. +Run `profiler-cli --help` for the full options reference. + +## Commands + +```bash +profiler-cli load # Start daemon and load profile (file or http/https URL) +profiler-cli profile info # Print profile summary [--all] [--search ] +profiler-cli profile logs # Print Log markers in MOZ_LOG format [--thread] [--module] [--level] [--search] [--limit] +profiler-cli thread info # Print detailed thread information +profiler-cli thread select # Select a thread (e.g., t-0, t-1) +profiler-cli thread samples # Show hot functions list for current thread +profiler-cli thread samples-top-down # Show top-down call tree (where CPU time is spent) +profiler-cli thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) +profiler-cli thread markers # List markers with aggregated statistics [--list for flat per-marker view] +profiler-cli thread functions # List all functions with CPU percentages +profiler-cli thread network # Show network requests with timing phases [--search] [--min-duration] [--max-duration] [--limit] +profiler-cli thread page-load # Show page load summary (navigation timing, resources, CPU, jank) +profiler-cli marker info # Show detailed marker information (e.g., m-1234) +profiler-cli marker stack # Show full stack trace for a marker +profiler-cli function expand # Show full untruncated function name (e.g., f-123) +profiler-cli function info # Show detailed function information +profiler-cli function annotate # Show annotated source/assembly with timing data [--mode src|asm|all] [--context 2|file|N] [--symbol-server ] +profiler-cli zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) +profiler-cli zoom pop # Pop the most recent zoom range +profiler-cli zoom clear # Clear all zoom ranges (return to full profile) +profiler-cli filter push # Push a sticky sample filter (see filter flags below) +profiler-cli filter pop [N] # Pop the last N filters (default: 1) +profiler-cli filter list # List active filters for current thread +profiler-cli filter clear # Remove all filters for current thread +profiler-cli status # Show session status (selected thread, zoom ranges, filters) +profiler-cli stop # Stop current daemon +profiler-cli stop # Stop a specific session +profiler-cli stop --all # Stop all sessions +profiler-cli session list # List all running daemon sessions (* marks current) +profiler-cli session use # Switch the current session +``` + +### Multiple sessions + +```bash +profiler-cli load --session +profiler-cli profile info --session +``` + +### Thread selection + +```bash +profiler-cli thread select t-93 # Select thread t-93 +profiler-cli thread samples # View samples for selected thread +profiler-cli thread info --thread t-0 # View info for specific thread without selecting +``` + +## Options + +| Flag | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--thread ` | Specify thread (e.g., `t-0`) | +| `--search ` | Filter results by substring. For samples commands, comma-separates multiple terms that all must match (AND); `\|` is literal, not OR. | +| `--include-idle` | Include idle samples (excluded by default in samples commands) | +| `--json` | Output as JSON (for use with `jq`, etc.) | +| `--limit ` | Limit number of results shown | +| `--max-lines ` | Limit call tree nodes for `samples-top-down`/`samples-bottom-up` (default: 100) | +| `--scoring ` | Call tree scoring: `exponential-0.95`, `exponential-0.9` (default), `exponential-0.8`, `harmonic-0.1`, `harmonic-0.5`, `harmonic-1.0`, `percentage-only` | +| `--navigation ` | Select which navigation to show in `thread page-load` (1-based, default: last completed) | +| `--jank-limit ` | Max jank periods to show in `thread page-load` (default: 10, 0 = show all) | +| `--list` | Show a flat chronological list of individual markers (for `thread markers`) | +| `--all` | Show all threads in `profile info` (overrides default top-5 limit) | +| `--session ` | Use a specific session instead of the current one | + +## Sample Filter Flags + +These work ephemerally on `thread samples` / `thread functions`, and as persistent filters via `filter push`. + +| Flag | Description | +| ---------------------------------- | ------------------------------------------------------------------ | +| `--excludes-function ` | Drop samples containing this function | +| `--merge ` | Remove functions from stacks (collapse them out) | +| `--root-at ` | Re-root stacks at this function | +| `--includes-function ` | Keep only samples containing any of these functions | +| `--includes-prefix ` | Keep only samples whose stack starts with this root-first sequence | +| `--includes-suffix ` | Keep only samples whose leaf frame is this function | +| `--during-marker --search ` | Keep only samples that fall during matching markers | +| `--outside-marker --search ` | Keep only samples that fall outside matching markers | + +For `filter push`, exactly one flag per push. For ephemeral use, multiple flags may be combined and applied left-to-right; the same flag may also be repeated (e.g. `--merge f-1 --merge f-2`). + +## Session Storage + +Sessions are stored in `~/.profiler-cli/` (or `$PROFILER_CLI_SESSION_DIR` to override). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture, build instructions, and how to add new commands. + +## License + +[MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt new file mode 100644 index 0000000000..bc1cca6bfb --- /dev/null +++ b/profiler-cli/guide.txt @@ -0,0 +1,415 @@ +profiler-cli: Usage Guide + +profiler-cli (Profiler CLI) queries Firefox performance profiles loaded into a persistent +daemon. Load a profile once, then query it interactively without reloading. + + +QUICK START + + profiler-cli load profile.json.gz + profiler-cli profile info Find threads; note handles (e.g. t-0, t-1) + profiler-cli thread select t-0 + profiler-cli thread samples Hot functions by self time + profiler-cli thread samples-top-down Full top-down call tree + + profiler-cli uses a daemon model: "profiler-cli load" starts a background daemon that + persists your selected thread, zoom ranges, and filter stacks. Stop it with "profiler-cli stop". + + The rest of this guide covers filtering, zooming, markers, and advanced analysis. + + +CORE WORKFLOW + + Step 1: Load a profile + profiler-cli load profile.json.gz + profiler-cli load https://share.firefox.dev/XXXXXX From a profiler.firefox.com / share URL + profiler-cli load profile.json.gz --session run-a Recommended for scripting or concurrent work + + Step 2: Explore the profile + profiler-cli profile info Overview: processes, threads, time range, CPU activity + profiler-cli profile info --all Show all processes and threads (not just top 5) + profiler-cli profile info --search GeckoMain Filter by process/thread name, pid, or tid + profiler-cli profile logs Print all Log markers in MOZ_LOG format (across all threads) + + Step 3: Select a thread to analyze + profiler-cli thread select t-0 Select a specific thread (persists for future commands) + + Step 4: Analyze CPU usage + profiler-cli thread samples Hot functions list (self time, sorted by impact) + profiler-cli thread samples-top-down Top-down call tree (where does CPU time originate) + profiler-cli thread samples-bottom-up Bottom-up call tree (what calls the hot functions) + profiler-cli thread functions All functions with self/total CPU percentages + + All samples commands exclude idle by default so percentages reflect active CPU time. + Use --include-idle to include idle samples (e.g. to see what fraction of wall time is idle). + + Use --search to focus the call tree on paths containing a specific function: + profiler-cli thread samples-top-down --search GC + profiler-cli thread samples-bottom-up --search "JS::Compile" + profiler-cli thread samples --search malloc + + Search syntax for samples commands: + - Matches any frame in the call stack (case-insensitive substring) + - Comma joins multiple terms with AND: both must appear somewhere in the stack + profiler-cli thread samples-top-down --search "GC,malloc" paths through both GC and malloc + - "|" IS A LITERAL CHARACTER, NOT OR. There is no OR operator in --search. + ✗ WRONG: --search "foo|bar" matches nothing (no function is named "foo|bar") + ✗ WRONG: --search "foo\|bar" same — the backslash doesn't help + ✓ RIGHT: run two separate commands, one per term: + profiler-cli thread functions --search "foo" + profiler-cli thread functions --search "bar" + + Step 5: Analyze markers (browser events, timers, etc.) + profiler-cli thread markers All markers with aggregated stats + profiler-cli thread markers --category Layout Filter by category + profiler-cli thread markers --search DOMEvent Filter by name substring + profiler-cli thread markers --min-duration 10 Only markers >= 10ms + profiler-cli thread markers --has-stack Only markers with stack traces + profiler-cli thread markers --list Flat chronological list (one row per marker) + profiler-cli thread markers --search X --list List all individual X markers with handles + + profiler-cli thread network First 20 requests with timing phases (default) + profiler-cli thread network --limit 0 All requests (no limit) + profiler-cli thread network --search "api.example" Filter by URL substring + profiler-cli thread network --min-duration 200 Only requests >= 200ms + profiler-cli thread network --max-duration 50 Only requests <= 50ms + profiler-cli thread network --limit 50 Show up to 50 requests + + profiler-cli profile logs All Log markers in MOZ_LOG format (all threads) + profiler-cli profile logs --module nsHttp Filter by module name (substring match) + profiler-cli profile logs --level info Minimum level: error, warn, info, debug, verbose + profiler-cli profile logs --search "connect" Filter by substring in message + profiler-cli profile logs --thread t-0 Restrict to a specific thread + profiler-cli profile logs --limit 200 Show only the first 200 entries + + Step 6: Drill into specifics + profiler-cli marker info m-1234 Full details for a marker (from handles in marker list) + profiler-cli marker stack m-1234 Full stack trace at the time of a marker + profiler-cli function info f-12 Function details (source location, library) + profiler-cli function expand f-12 Show full untruncated function name + profiler-cli function annotate f-12 Annotated source with per-line self/total timing + profiler-cli function annotate f-12 --mode asm Annotated assembly (requires local symbol server) + profiler-cli function annotate f-12 --mode all Both source and assembly + profiler-cli function annotate f-12 --context file Show entire source file instead of snippets + + Step 7: Filter to focus the analysis (see FILTERS section below) + + profiler-cli thread samples --excludes-function f-184 Ephemeral: one command only + profiler-cli filter push --includes-function f-500 Sticky: persists until popped + profiler-cli filter pop Remove last filter + profiler-cli filter clear Remove all filters + + +SESSION STATE + + Three pieces of mutable state persist across commands: + + selected_thread set by "thread select"; required by all thread/* commands + zoom_stack set by zoom push/pop/clear; restricts all queries to a time window + filter_stack[t] set by filter push/pop/clear; per-thread, independent stacks + + Use "profiler-cli status" to inspect all session state at any time. + + +HANDLE SYSTEM + + Handles are short IDs shown in command output that reference specific items: + t-0, t-1 Thread handles (from "profile info") + m-1234 Marker handles (from "thread markers") + f-12 Function handles (from "thread samples", "thread functions") + ts-6 Timestamp handles (named points in time, usable with "zoom push") + + Handle lifetime and stability: + + Handle Populated by Stable across sessions? + ────────────────────────────────────────────────────────────────────────── + t-N profile info Yes, if the same profile is loaded + m-N thread markers No -- rebuilt each time the daemon starts + f-N thread samples, Yes -- direct index into the profile's function + thread functions table; same profile always yields the same f-N + ts-N thread markers No -- position-based, session-scoped + ────────────────────────────────────────────────────────────────────────── + + Function handles (f-N) can be saved and reused across sessions for the same profile. + For all other handles, re-run the command that generates them after each daemon restart. + + +ZOOM: FOCUS ON A TIME RANGE + + Zoom restricts all subsequent queries to a specific time window. Useful for + drilling into a specific jank period, page load phase, or marker duration. + + profiler-cli zoom push 2.7,3.1 Zoom to 2.7s-3.1s (relative to profile start) + profiler-cli zoom push m-158 Zoom to the time range of marker m-158 + profiler-cli zoom push ts-6,ts-12 Zoom to the range between two named timestamps + profiler-cli zoom pop Undo the last zoom + profiler-cli zoom clear Return to full profile view + + After zooming, all thread/marker/sample commands automatically apply the filter. + Use "profiler-cli status" to confirm the active zoom stack (along with thread and filters). + + +FILTERS + + Filters narrow which samples appear in analysis results. They work in two modes: + + EPHEMERAL (one command only) + Add filter flags directly to thread samples/functions commands. They apply + only to that invocation; the sticky filter stack is unchanged. Multiple flags + may be combined on one command and are applied left to right. + + profiler-cli thread samples --excludes-function f-184 + profiler-cli thread samples --merge f-142,f-143 --root-at f-500 --limit 30 + profiler-cli thread functions --includes-function f-500 + + STICKY (persists across commands, per-thread) + Use "filter push" to add a filter to the current thread's stack. Every + subsequent analysis command sees all pushed filters automatically. Exactly + one filter flag per "filter push" command. + + profiler-cli filter push --merge f-142,f-143 # add filter 1 + profiler-cli filter push --includes-function f-500 # add filter 2 + profiler-cli filter list # one row per entry, in push order + profiler-cli filter pop # undo the last push + profiler-cli filter pop 2 # undo the last 2 pushes + profiler-cli filter clear # remove all + + Each "filter push" is one entry, even when the flag takes a comma-separated + list (e.g. "--merge f-1,f-2" is a single entry and a single pop). + + Loading a profiler.firefox.com URL that encodes transforms adds them as + entries too; each URL transform is its own entry, so "filter pop" removes + them one at a time. "filter clear" removes everything. + + Use "profiler-cli status" to inspect the full session state (selected thread, + zoom stack, per-thread filters). + + Sticky + ephemeral compose: sticky filters apply first, then ephemeral flags + layer on top for that one invocation only. + + AVAILABLE FILTER FLAGS + + Inclusion -- keep only samples whose stack matches: + --includes-function f-N,... stack contains any of these funcs (OR) + --includes-prefix f-N,... stack starts with this root-first sequence + --includes-suffix f-N leaf (innermost) frame is f-N + --during-marker --search sample timestamp inside a matching marker + --outside-marker --search sample timestamp outside all matching markers + + Exclusion -- drop matching samples: + --excludes-function f-N,... stack contains any of these funcs + + Stack transforms -- modify stack structure: + --merge f-N,... remove funcs from stacks (A→f-N→B becomes A→B) + --root-at f-N re-root all stacks at f-N (subtree within f-N) + + COMBINING FILTERS + + OR within one push: --includes-function f-1,f-2 keeps samples with f-1 OR f-2. + AND across pushes: two separate "filter push" calls both must pass. + Push order matters: each filter sees the stack as left by the prior filter. + + Example -- only samples containing f-500, during Paint markers, after merging noise: + profiler-cli filter push --merge f-142,f-143 + profiler-cli filter push --includes-function f-500 + profiler-cli filter push --during-marker --search Paint + + +JSON OUTPUT + + Add --json to any command to get structured JSON output, suitable for piping to jq + or processing programmatically. + + profiler-cli thread samples --json | jq '.topFunctionsBySelf[0]' + profiler-cli thread markers --json | jq '[.byType[] | select(.durationStats.max > 50)]' + profiler-cli profile info --json | jq '.processes[].threads[] | {handle: .threadHandle, name}' + + Run "profiler-cli schemas" for the full JSON schema reference. + + +COMMON ANALYSIS PATTERNS + + Investigate a jank/slow period: + profiler-cli thread markers --min-duration 50 Find long-running markers (>50ms = jank) + profiler-cli zoom push m-158 Zoom into that marker's time range + profiler-cli thread samples What was executing during that period? + profiler-cli zoom pop Back to full profile + + Eliminate allocator noise from a call tree: + profiler-cli thread functions --search malloc Find allocator function handles (e.g. f-142) + profiler-cli thread samples --merge f-142 Try ephemerally first + profiler-cli filter push --merge f-142,f-143 Make it sticky for all subsequent commands + profiler-cli thread samples Clean call tree, filters persist + profiler-cli filter clear + + Focus on work inside a specific function: + profiler-cli thread functions --search PresentImpl Note the handle (e.g. f-500) + profiler-cli thread samples --root-at f-500 Ephemeral: subtree rooted at f-500 + profiler-cli filter push --includes-function f-500 Sticky: only samples containing f-500 + profiler-cli filter push --root-at f-500 Sticky: re-root at f-500 + profiler-cli thread samples-top-down Call tree within f-500 only + + Correlate CPU with a specific event type: + profiler-cli thread markers --search Paint Check Paint marker frequency/duration + profiler-cli filter push --during-marker --search Paint + profiler-cli thread samples What runs during Paint? + profiler-cli thread functions Which functions are active during Paint? + profiler-cli filter clear + + Find slow layout or script execution: + profiler-cli thread markers --category Layout --min-duration 5 + profiler-cli thread markers --category JavaScript --min-duration 10 + profiler-cli thread markers --search Reflow --min-duration 5 + + Deep dive on a specific function: + profiler-cli thread samples Find hot function, note its handle (f-12) + profiler-cli function info f-12 See callers, callees, source location + profiler-cli function expand f-12 See full name if truncated + profiler-cli function annotate f-12 Annotated source: per-line self/total timing + profiler-cli function annotate f-12 --context file Full source file with all timings inline + profiler-cli function annotate f-12 --mode asm Annotated assembly (needs local symbol server) + profiler-cli function annotate f-12 --mode all Source + assembly together + + Group markers by event type: + profiler-cli thread markers --search DOMEvent --group-by field:eventType + profiler-cli thread markers --auto-group + profiler-cli thread markers --group-by type,name + + List all occurrences of a specific marker type: + profiler-cli thread markers --search "Histogram::Add" --list + profiler-cli thread markers --search "Paint" --list | head -50 + profiler-cli thread markers --search "GC" --min-duration 5 --list + profiler-cli thread markers --list --limit 100 First 100 markers in chronological order + + Investigate a page load: + profiler-cli thread page-load Overview: milestones, top resources, CPU categories, jank + profiler-cli thread page-load --navigation 2 If multiple navigations, inspect each one separately (1-based) + profiler-cli thread page-load --jank-limit 0 Show all jank periods (default: first 10) + profiler-cli zoom push m- Zoom into a specific jank period (handle from page-load output) + profiler-cli thread samples What was executing during the jank? + profiler-cli zoom pop + Requires page load markers in the selected thread (typically GeckoMain or a content process thread). + Marker handles shown in page-load output can be passed to "marker info" or "zoom push". + + Analyze network activity: + profiler-cli thread network All requests with timing phases + profiler-cli thread network --min-duration 200 Slow requests only (>= 200ms) + profiler-cli thread network --search "api" Filter by URL substring + profiler-cli thread markers --category Network Cross-reference with marker view + + +UNDERSTANDING THE OUTPUT + + Self time vs total time: + Self time Samples where this function was the innermost frame (actually executing) + Total time All samples where this function appeared anywhere in the stack + + Self time is more actionable -- it pinpoints where CPU time is actually spent. + + Samples: + Profiles are sampled at regular intervals (typically 1ms). Each sample is a snapshot + of the call stack. Higher sample counts = more time spent. Counts are relative + within a profile. + + By default, samples commands drop idle samples before computing percentages, so + percentages reflect how the thread spent its active CPU time. Pass --include-idle + to include idle samples and see percentages relative to wall time instead. + + Call tree views: + samples-top-down Root frames at top, drilling down to leaves. + Use to understand the calling structure and where time originates. + samples-bottom-up Hot leaf functions at top with their callers. + Use to understand what calls a frequently-seen function. + +TIPS + + - Start with "profile info"; GeckoMain is usually the main thread -- use + "--search GeckoMain" to find it quickly + - Idle time alone is not a finding: only investigate idle during a period when the + thread should be busy (e.g. inside a zoom on a jank marker), which may indicate + lock contention or blocking on another thread + - --search has no OR operator. "|" and "\|" are literal characters that will match + nothing. "--search foo,bar" is AND (both must appear). To get OR behavior, run two + separate commands: once with "--search foo", once with "--search bar". + - Try filters ephemerally first (as flags on thread commands) before committing + with "filter push" + - "filter push --during-marker --search X" is powerful for correlating CPU work + with specific event types + - Check "profiler-cli status" after any state changes to confirm selected thread, active + zoom, and active filters before running analysis + + +SCRIPTING + + When using profiler-cli in scripts or pipelines: + + Always use a named session for isolation: + profiler-cli load profile.json.gz --session my-analysis + profiler-cli profile info --session my-analysis + profiler-cli thread select t-0 --session my-analysis + + Always use --json for reliable output parsing. Plain text output is for human + reading and may change; the JSON schema is stable. + + Prerequisite chain -- commands depend on prior state: + load → thread select → analysis commands + (run "profile info" to discover thread handles before selecting) + + Extracting handles with jq: + # Find the GeckoMain thread handle + profiler-cli profile info --json | \ + jq -r '.processes[].threads[] | select(.name | test("GeckoMain")) | .threadHandle' + + # Get the top self-time function handle + profiler-cli thread samples --json | jq -r '.topFunctionsBySelf[0].functionHandle' + + # List marker handles for markers longer than 50ms + profiler-cli thread markers --json | \ + jq -r '[.byType[].topMarkers[] | select(.duration > 50) | .handle][]' + + # List all handles for a specific marker type (flat list mode) + profiler-cli thread markers --search "Histogram::Add" --list --json | \ + jq -r '.flatMarkers[].handle' + +SESSION MANAGEMENT + + profiler-cli session list List all running daemon sessions (* marks current) + profiler-cli session use Switch the current session + profiler-cli stop Stop the current session + profiler-cli stop Stop a specific session + profiler-cli stop --all Stop all sessions + profiler-cli load profile.json.gz --session my-session Named session + profiler-cli load profile.json.gz --symbol-server Override symbol server (else ?symbolServer= URL param or Mozilla default) + profiler-cli thread info --session my-session Query a specific session + + +ERROR HANDLING + + "No running session" + Run "profiler-cli load " first. If using a named session, ensure --session matches. + Run "profiler-cli session list" to see what sessions are currently active. + + "Thread not found" + The handle does not exist in this profile. Run "profiler-cli profile info" to see valid + thread handles, then re-run "profiler-cli thread select" with a handle from that list. + + "No thread selected" + Run "profiler-cli thread select " before querying thread data. Use "profiler-cli profile info" + first to get valid handles. + + "Marker not found" + Marker handles (m-N) are session-scoped. If the daemon was restarted, re-run + "profiler-cli thread markers" to get fresh handles for the new session. + + "Function not found" + Function handles (f-N) are stable across sessions for the same profile, but only + valid after the profile is loaded. Verify the correct profile is loaded with + "profiler-cli status" (check the profile path). + + "Session not found" / "No such session" + The session has exited or was stopped. Run "profiler-cli session list" to see active sessions. + Run "profiler-cli load --session " to start a new session with that ID. + + "Profile load failed" + Check that the path is correct and the file is a valid supported profile format. + The daemon log at ~/.profiler-cli/.log contains the full error detail. diff --git a/profiler-cli/package.json b/profiler-cli/package.json new file mode 100644 index 0000000000..1c62db31c4 --- /dev/null +++ b/profiler-cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@firefox-devtools/profiler-cli", + "version": "0.1.0-alpha.4", + "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", + "scripts": { + "prepublishOnly": "node ../scripts/verify-profiler-cli-build.mjs" + }, + "main": "./dist/profiler-cli.js", + "bin": { + "profiler-cli": "dist/profiler-cli.js", + "pq": "dist/profiler-cli.js" + }, + "files": [ + "dist/profiler-cli.js" + ], + "engines": { + "node": ">= 24" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": ">= 24" + } + }, + "keywords": [ + "profiler", + "firefox", + "performance", + "profiling", + "cli", + "performance-analysis" + ], + "author": "Mozilla DevTools", + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/firefox-devtools/profiler.git", + "directory": "profiler-cli" + }, + "homepage": "https://profiler.firefox.com", + "bugs": { + "url": "https://github.com/firefox-devtools/profiler/issues" + } +} diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt new file mode 100644 index 0000000000..2a13b1389f --- /dev/null +++ b/profiler-cli/schemas.txt @@ -0,0 +1,121 @@ +profiler-cli: JSON Output Schemas + +Add --json to any command to get structured JSON output. The schemas below +document the exact fields returned by each command. + +SessionContext (present on all command results): + { + selectedThreadHandle, + selectedThreads: [{ threadIndex, name }], + currentViewRange: { start, startName, end, endName } | null, + rootRange: { start, end } + } + + +profiler-cli profile info --json + { + type: "profile-info", + name, platform, threadCount, processCount, + processes: [{ + pid, name, cpuMs, + threads: [{ threadHandle, threadIndex, name, tid, cpuMs }], + remainingThreads?: { count, combinedCpuMs, maxCpuMs } + }], + remainingProcesses?: { count, combinedCpuMs, maxCpuMs }, + context: SessionContext + } + +profiler-cli thread samples --json + { + type: "thread-samples", + threadHandle, friendlyThreadName, activeOnly?, + topFunctionsBySelf: [{ functionHandle, functionIndex, name, nameWithLibrary, + library?, selfSamples, selfPercentage, + totalSamples, totalPercentage }], + topFunctionsByTotal: [ ...same shape... ], + heaviestStack: { + selfSamples, frameCount, + frames: [{ name, nameWithLibrary, library?, + selfSamples, selfPercentage, totalSamples, totalPercentage }] + }, + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +profiler-cli thread samples-top-down --json + { + type: "thread-samples-top-down", + threadHandle, friendlyThreadName, activeOnly?, + regularCallTree: CallTreeNode, + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +profiler-cli thread samples-bottom-up --json + { + type: "thread-samples-bottom-up", + threadHandle, friendlyThreadName, activeOnly?, + invertedCallTree: CallTreeNode | null, + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +CallTreeNode (recursive): + { + name, nameWithLibrary, library?, + functionHandle?, functionIndex?, + totalSamples, totalPercentage, selfSamples, selfPercentage, + originalDepth, + children: [CallTreeNode], + childrenTruncated?: { count, combinedSamples, combinedPercentage, + maxSamples, maxPercentage, depth } + } + +profiler-cli thread markers --json + { + type: "thread-markers", + threadHandle, friendlyThreadName, + totalMarkerCount, filteredMarkerCount, + byType: [{ + markerName, count, isInterval, + durationStats?: { min, max, avg, median, p95, p99 }, + rateStats?: { markersPerSecond, minGap, avgGap, maxGap }, + topMarkers: [{ handle, label, start, duration?, hasStack? }], + subGroups?: [ ...MarkerGroupData... ] + }], + byCategory: [{ categoryName, categoryIndex, count, percentage }], + customGroups?: [ ...MarkerGroupData... ], + context: SessionContext + } + +profiler-cli thread functions --json + { + type: "thread-functions", + threadHandle, friendlyThreadName, activeOnly?, + totalFunctionCount, filteredFunctionCount, + functions: [{ functionHandle, name, nameWithLibrary, library?, + selfSamples, selfPercentage, totalSamples, totalPercentage, + fullSelfPercentage?, fullTotalPercentage? }], + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +profiler-cli marker info --json + { + type: "marker-info", + threadHandle, friendlyThreadName, markerHandle, markerIndex, name, + category: { index, name }, + start, end, duration?, + fields?: [{ key, label, value, formattedValue }], + stack?: { frames: [{ name, nameWithLibrary }], truncated } + } + +profiler-cli status --json + { + type: "status", + selectedThreadHandle, + selectedThreads: [{ threadIndex, name }], + viewRanges: [{ start, startName, end, endName }], + rootRange: { start, end }, + filterStacks: [{ threadHandle, filters: FilterEntry[] }] + } diff --git a/profiler-cli/src/client.ts b/profiler-cli/src/client.ts new file mode 100644 index 0000000000..b1c3c17661 --- /dev/null +++ b/profiler-cli/src/client.ts @@ -0,0 +1,401 @@ +/* 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/. */ + +/** + * Client for communicating with the profiler-cli daemon. + */ + +import * as net from 'net'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as child_process from 'child_process'; +import type { + ClientCommand, + ClientMessage, + ServerResponse, + CommandResult, +} from './protocol'; +import { + cleanupSession, + generateSessionId, + getCurrentSessionId, + getCurrentSocketPath, + getSocketPath, + isProcessRunning, + loadSessionMetadata, + validateSession, + waitForProcessExit, +} from './session'; +import { BUILD_HASH } from './constants'; + +type BuildMismatchShutdownResult = 'stopped' | 'already-dead' | 'still-running'; + +async function sendMessageToSocket( + socketPath: string, + message: ClientMessage, + timeoutMs: number = 30000 +): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(socketPath); + let buffer = ''; + + socket.on('connect', () => { + socket.write(JSON.stringify(message) + '\n'); + }); + + socket.on('data', (data) => { + buffer += data.toString(); + + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex !== -1) { + const line = buffer.substring(0, newlineIndex); + try { + const response = JSON.parse(line) as ServerResponse; + socket.end(); + resolve(response); + } catch (error) { + socket.destroy(); + reject(new Error(`Failed to parse response: ${error}`)); + } + } + }); + + socket.on('error', (error) => { + reject(new Error(`Socket error: ${error.message}`)); + }); + + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + + socket.setTimeout(timeoutMs); + }); +} + +async function attemptShutdownOnBuildMismatch( + sessionDir: string, + sessionId: string, + socketPath: string, + pid: number +): Promise { + if (process.platform !== 'win32' && !fs.existsSync(socketPath)) { + if (!isProcessRunning(pid)) { + cleanupSession(sessionDir, sessionId); + return 'already-dead'; + } + return 'still-running'; + } + + try { + const response = await sendMessageToSocket( + socketPath, + { type: 'shutdown' }, + 2000 + ); + + if (response.type !== 'success') { + console.error( + `Failed to stop mismatched daemon for session ${sessionId}: unexpected response ${response.type}` + ); + return isProcessRunning(pid) ? 'still-running' : 'already-dead'; + } + + const exited = await waitForProcessExit(pid); + if (!exited) { + console.error( + `Mismatched daemon for session ${sessionId} acknowledged shutdown but did not exit within timeout` + ); + return 'still-running'; + } + + cleanupSession(sessionDir, sessionId); + return 'stopped'; + } catch (error) { + if (!isProcessRunning(pid)) { + cleanupSession(sessionDir, sessionId); + return 'already-dead'; + } + + console.error( + `Failed to stop mismatched daemon for session ${sessionId}: ${error}` + ); + return 'still-running'; + } +} + +/** + * Send a message to the daemon and return the raw response. + */ +async function sendRawMessage( + sessionDir: string, + message: ClientMessage, + sessionId?: string +): Promise { + const resolvedSessionId = sessionId || getCurrentSessionId(sessionDir); + + if (!resolvedSessionId) { + throw new Error('No active session. Run "profiler-cli load " first.'); + } + + // Validate the session + if (!validateSession(sessionDir, resolvedSessionId)) { + cleanupSession(sessionDir, resolvedSessionId); + throw new Error( + `Session ${resolvedSessionId} is not running or is invalid.` + ); + } + + // Check build hash matches + const metadata = loadSessionMetadata(sessionDir, resolvedSessionId); + if (metadata && metadata.buildHash !== BUILD_HASH) { + const shutdownResult = await attemptShutdownOnBuildMismatch( + sessionDir, + resolvedSessionId, + metadata.socketPath, + metadata.pid + ); + + const shutdownMessage = + shutdownResult === 'stopped' || shutdownResult === 'already-dead' + ? 'The daemon is no longer running.' + : 'The daemon may still be running; stop it before reusing this session id.'; + + throw new Error( + `Session ${resolvedSessionId} was built with a different version (daemon: ${metadata.buildHash}, client: ${BUILD_HASH}). ${shutdownMessage} Please run "profiler-cli load " again.` + ); + } + + const socketPath = sessionId + ? getSocketPath(sessionDir, sessionId) + : getCurrentSocketPath(sessionDir); + + if (!socketPath) { + throw new Error(`Socket not found for session ${resolvedSessionId}`); + } + + return sendMessageToSocket(socketPath, message); +} + +/** + * Send a message to the daemon and return the result. + * Only works for messages that return success responses. + * Result can be either a string (legacy) or a structured CommandResult. + */ +export async function sendMessage( + sessionDir: string, + message: ClientMessage, + sessionId?: string +): Promise { + const response = await sendRawMessage(sessionDir, message, sessionId); + + if (response.type === 'success') { + return response.result; + } else if (response.type === 'error') { + throw new Error(response.error); + } else { + throw new Error(`Unexpected response type: ${response.type}`); + } +} + +/** + * Send a status check to the daemon and return the response. + */ +async function sendStatusMessage( + sessionDir: string, + sessionId?: string +): Promise { + return sendRawMessage(sessionDir, { type: 'status' }, sessionId); +} + +/** + * Send a command to the daemon. + * Result can be either a string (legacy) or a structured CommandResult. + */ +export async function sendCommand( + sessionDir: string, + command: ClientCommand, + sessionId?: string +): Promise { + return sendMessage(sessionDir, { type: 'command', command }, sessionId); +} + +/** + * Start a new daemon for the given profile. + * Uses a two-phase approach: + * 1. Wait for daemon to be validated (short 500ms timeout) + * 2. Wait for profile to load via status checks (longer 60s timeout) + */ +export async function startNewDaemon( + sessionDir: string, + profilePath: string, + sessionId?: string, + symbolServerUrl?: string +): Promise { + // Check if this is a URL + const isUrl = + profilePath.startsWith('http://') || profilePath.startsWith('https://'); + + // Resolve the absolute path (only for file paths, not URLs) + const absolutePath = isUrl ? profilePath : path.resolve(profilePath); + + // Check if file exists (skip this check for URLs) + if (!isUrl && !fs.existsSync(absolutePath)) { + throw new Error(`Profile file not found: ${absolutePath}`); + } + + // Generate a session ID upfront if not provided, so we know exactly which + // session to wait for (avoids race condition with existing sessions) + const targetSessionId = sessionId || generateSessionId(); + + if (sessionId) { + const existingSession = validateSession(sessionDir, targetSessionId); + if (existingSession) { + throw new Error( + `Session ${targetSessionId} is already running. Stop it first or choose a different session id.` + ); + } + + if (loadSessionMetadata(sessionDir, targetSessionId)) { + cleanupSession(sessionDir, targetSessionId); + } + } + + // Get the path to the current script (profiler-cli.js) + const scriptPath = process.argv[1]; + + const daemonArgs = [ + scriptPath, + '--daemon', + absolutePath, + '--session', + targetSessionId, + ]; + if (symbolServerUrl) { + daemonArgs.push('--symbol-server', symbolServerUrl); + } + + // Spawn the daemon process (detached from parent) + const child = child_process.spawn( + process.execPath, // node + daemonArgs, + { + detached: true, + stdio: 'ignore', // Don't pipe stdin/stdout/stderr + env: { ...process.env, PROFILER_CLI_SESSION_DIR: sessionDir }, // Pass sessionDir via env + } + ); + + // Unref so parent can exit + child.unref(); + + // Phase 1: Wait for daemon to be validated (short timeout) + const daemonStartMaxAttempts = 10; // 10 * 50ms = 500ms + let attempts = 0; + + while (attempts < daemonStartMaxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 50)); + attempts++; + + // Validate the session (checks metadata exists, process running, socket exists) + if (validateSession(sessionDir, targetSessionId)) { + // Daemon is validated and running + break; + } + } + + // Check if daemon started successfully after polling + if (!validateSession(sessionDir, targetSessionId)) { + throw new Error( + `Failed to start daemon: session not validated after ${daemonStartMaxAttempts * 50}ms` + ); + } + + // Phase 2: Wait for profile to load by checking status (longer timeout). + // Override with PROFILER_CLI_LOAD_TIMEOUT_MS env var for large profiles. + const loadTimeoutMs = process.env.PROFILER_CLI_LOAD_TIMEOUT_MS + ? parseInt(process.env.PROFILER_CLI_LOAD_TIMEOUT_MS, 10) + : 60_000; + const profileLoadMaxAttempts = Math.ceil(loadTimeoutMs / 100); + attempts = 0; + let printedSymbolicating = false; + + while (attempts < profileLoadMaxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 100)); + attempts++; + + try { + const response = await sendStatusMessage(sessionDir, targetSessionId); + + switch (response.type) { + case 'ready': + // Profile loaded successfully + return targetSessionId; + + case 'loading': + // Still loading, keep waiting + continue; + + case 'symbolicating': + if (!printedSymbolicating) { + console.log('Symbolicating...'); + printedSymbolicating = true; + } + continue; + + case 'error': + // Profile load failed, fail immediately + throw new Error(response.error); + + default: + // Unexpected response type + throw new Error( + `Unexpected response type: ${(response as any).type}` + ); + } + } catch (error) { + // Socket connection errors - daemon might still be setting up + // Keep retrying unless it's an explicit error response + if ( + error instanceof Error && + error.message.startsWith('Profile load failed') + ) { + throw error; + } + continue; + } + } + + // If we got here, profile load timed out + throw new Error( + `Profile load timeout after ${loadTimeoutMs}ms (set PROFILER_CLI_LOAD_TIMEOUT_MS to override)` + ); +} + +/** + * Stop a running daemon. + */ +export async function stopDaemon( + sessionDir: string, + sessionId?: string +): Promise { + const resolvedSessionId = sessionId || getCurrentSessionId(sessionDir); + + if (!resolvedSessionId) { + throw new Error('No active session to stop.'); + } + + // Send shutdown command + try { + await sendMessage(sessionDir, { type: 'shutdown' }, resolvedSessionId); + } catch (error) { + // If the daemon is already dead, that's fine + console.error(`Note: ${error}`); + } + + // Wait a bit for cleanup + await new Promise((resolve) => setTimeout(resolve, 500)); + + console.log(`Session ${resolvedSessionId} stopped`); +} diff --git a/profiler-cli/src/commands/filter.ts b/profiler-cli/src/commands/filter.ts new file mode 100644 index 0000000000..6c17ae725c --- /dev/null +++ b/profiler-cli/src/commands/filter.ts @@ -0,0 +1,110 @@ +/* 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/. */ + +/** + * `profiler-cli filter` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { parseFilterSpec } from '../utils/parse'; +import { + addGlobalOptions, + addSampleFilterOptions, + parseIntArg, + wasExplicit, +} from './shared'; + +export function registerFilterCommand( + program: Command, + sessionDir: string +): void { + const filter = program + .command('filter') + .description('Manage sticky sample filters'); + + addSampleFilterOptions( + addGlobalOptions( + filter + .command('push') + .description('Push a sticky sample filter') + .option('--thread ', 'Thread handle') + ) + ).action(async (opts) => { + const spec = parseFilterSpec({ + excludesFunction: opts.excludesFunction, + merge: opts.merge, + rootAt: opts.rootAt, + includesFunction: opts.includesFunction, + includesPrefix: opts.includesPrefix, + includesSuffix: opts.includesSuffix, + duringMarker: opts.duringMarker, + outsideMarker: opts.outsideMarker, + search: opts.search, + }); + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'push', thread: opts.thread, spec }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + filter + .command('pop [count]') + .description('Pop the last N filters (default: 1)') + .option('--count ', 'Number of filters to pop') + .option('--thread ', 'Thread handle') + ).action(async (countArg: string | undefined, opts) => { + const raw = countArg ?? opts.count ?? '1'; + const count = parseIntArg( + 'count', + String(raw), + 1, + 'Error: count must be a positive integer' + ); + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'pop', thread: opts.thread, count }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + filter + .command('list', { isDefault: true }) + .description('List active filters for current thread') + .option('--thread ', 'Thread handle') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'list', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + + if (!wasExplicit('filter', 'list')) { + console.log( + '\nOther subcommands: profiler-cli filter [options]' + ); + } + }); + + addGlobalOptions( + filter + .command('clear') + .description('Remove all filters for current thread') + .option('--thread ', 'Thread handle') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'clear', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/commands/function.ts b/profiler-cli/src/commands/function.ts new file mode 100644 index 0000000000..63c9442592 --- /dev/null +++ b/profiler-cli/src/commands/function.ts @@ -0,0 +1,88 @@ +/* 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/. */ + +/** + * `profiler-cli function` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerFunctionCommand( + program: Command, + sessionDir: string +): void { + const fn = program.command('function').description('Function-level commands'); + + addGlobalOptions( + fn + .command('expand [handle]') + .description('Show full untruncated function name (e.g. f-123)') + .option('--function ', 'Function handle') + ).action(async (handleArg: string | undefined, opts) => { + const funcHandle = handleArg ?? opts.function; + const result = await sendCommand( + sessionDir, + { command: 'function', subcommand: 'expand', function: funcHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + fn + .command('info [handle]') + .description('Show detailed function information (e.g. f-123)') + .option('--function ', 'Function handle') + ).action(async (handleArg: string | undefined, opts) => { + const funcHandle = handleArg ?? opts.function; + const result = await sendCommand( + sessionDir, + { command: 'function', subcommand: 'info', function: funcHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + fn + .command('annotate [handle]') + .description( + 'Show annotated source/assembly with timing data (e.g. f-123)' + ) + .option('--function ', 'Function handle') + .option( + '--mode ', + 'Annotation mode: src, asm, or all (default: src)', + 'src' + ) + .option( + '--symbol-server ', + 'Symbol server URL for asm mode (default: http://localhost:3000)', + 'http://localhost:3000' + ) + .option( + '--context ', + 'Source context: number of lines around annotated lines, or "file" for the whole file (default: 2)', + '2' + ) + ).action(async (handleArg: string | undefined, opts) => { + const funcHandle = handleArg ?? opts.function; + const result = await sendCommand( + sessionDir, + { + command: 'function', + subcommand: 'annotate', + function: funcHandle, + annotateMode: opts.mode, + symbolServerUrl: opts.symbolServer, + annotateContext: opts.context, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/commands/marker.ts b/profiler-cli/src/commands/marker.ts new file mode 100644 index 0000000000..d84c93e6dc --- /dev/null +++ b/profiler-cli/src/commands/marker.ts @@ -0,0 +1,49 @@ +/* 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/. */ + +/** + * `profiler-cli marker` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerMarkerCommand( + program: Command, + sessionDir: string +): void { + const marker = program.command('marker').description('Marker-level commands'); + + addGlobalOptions( + marker + .command('info [handle]') + .description('Show detailed marker information (e.g. m-1234)') + .option('--marker ', 'Marker handle') + ).action(async (handleArg: string | undefined, opts) => { + const markerHandle = handleArg ?? opts.marker; + const result = await sendCommand( + sessionDir, + { command: 'marker', subcommand: 'info', marker: markerHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + marker + .command('stack [handle]') + .description('Show full stack trace for a marker (e.g. m-1234)') + .option('--marker ', 'Marker handle') + ).action(async (handleArg: string | undefined, opts) => { + const markerHandle = handleArg ?? opts.marker; + const result = await sendCommand( + sessionDir, + { command: 'marker', subcommand: 'stack', marker: markerHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/commands/profile.ts b/profiler-cli/src/commands/profile.ts new file mode 100644 index 0000000000..582080324c --- /dev/null +++ b/profiler-cli/src/commands/profile.ts @@ -0,0 +1,98 @@ +/* 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/. */ + +/** + * `profiler-cli profile` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions, parseIntArg } from './shared'; + +export function registerProfileCommand( + program: Command, + sessionDir: string +): void { + const profile = program + .command('profile') + .description('Profile-level commands'); + + addGlobalOptions( + profile + .command('info') + .description('Print profile summary (processes, threads, CPU activity)') + .option( + '--all', + 'Show all processes and threads (overrides default top-5 limit)' + ) + .option('--search ', 'Filter by substring') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { + command: 'profile', + subcommand: 'info', + all: opts.all, + search: opts.search, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + const VALID_LOG_LEVELS = ['error', 'warn', 'info', 'debug', 'verbose']; + + addGlobalOptions( + profile + .command('logs') + .description('Print Log markers in MOZ_LOG format') + .option('--thread ', 'Filter to a specific thread (e.g. t-0)') + .option('--module ', 'Filter by module name (substring match)') + .option( + '--level ', + `Minimum log level: ${VALID_LOG_LEVELS.join(', ')}` + ) + .option('--search ', 'Filter by substring in message') + .option('--limit ', 'Limit to first N entries') + ).action(async (opts) => { + if (opts.level !== undefined && !VALID_LOG_LEVELS.includes(opts.level)) { + console.error( + `Error: --level must be one of: ${VALID_LOG_LEVELS.join(', ')}` + ); + process.exit(1); + } + + let limit: number | undefined; + if (opts.limit !== undefined) { + limit = parseIntArg('--limit', opts.limit, 1); + } + + const hasFilters = + opts.thread !== undefined || + opts.module !== undefined || + opts.level !== undefined || + opts.search !== undefined || + limit !== undefined; + + const result = await sendCommand( + sessionDir, + { + command: 'profile', + subcommand: 'logs', + logFilters: hasFilters + ? { + thread: opts.thread, + module: opts.module, + level: opts.level, + search: opts.search, + limit, + } + : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/commands/session.ts b/profiler-cli/src/commands/session.ts new file mode 100644 index 0000000000..6ad3e4f4f4 --- /dev/null +++ b/profiler-cli/src/commands/session.ts @@ -0,0 +1,82 @@ +/* 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/. */ + +/** + * `profiler-cli session` command. + */ + +import type { Command } from 'commander'; +import { wasExplicit } from './shared'; +import { + cleanupSession, + getCurrentSessionId, + listSessions, + setCurrentSession, + validateSession, +} from '../session'; + +export function registerSessionCommand( + program: Command, + sessionDir: string +): void { + const session = program + .command('session') + .description('Manage daemon sessions'); + + session + .command('list', { isDefault: true }) + .description('List all running daemon sessions') + .action(() => { + const sessionIds = listSessions(sessionDir); + let numCleaned = 0; + const runningSessionMetadata = []; + + for (const sessionId of sessionIds) { + const metadata = validateSession(sessionDir, sessionId); + if (metadata === null) { + cleanupSession(sessionDir, sessionId); + numCleaned++; + continue; + } + runningSessionMetadata.push(metadata); + } + + if (numCleaned !== 0) { + console.log(`Cleaned up ${numCleaned} stale sessions.`); + console.log(); + } + + runningSessionMetadata.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + const currentSessionId = getCurrentSessionId(sessionDir); + console.log(`Found ${runningSessionMetadata.length} running sessions:`); + for (const metadata of runningSessionMetadata) { + const isCurrent = metadata.id === currentSessionId; + const marker = isCurrent ? '* ' : ' '; + console.log( + `${marker}${metadata.id}, created at ${metadata.createdAt} [daemon pid: ${metadata.pid}]` + ); + } + + if (!wasExplicit('session', 'list')) { + console.log('\nOther subcommands: profiler-cli session use '); + } + }); + + session + .command('use ') + .description('Switch the current session') + .action((sessionId: string) => { + const metadata = validateSession(sessionDir, sessionId); + if (metadata === null) { + console.error(`Error: session "${sessionId}" not found or not running`); + process.exit(1); + } + setCurrentSession(sessionDir, sessionId); + console.log(`Switched to session ${sessionId}`); + }); +} diff --git a/profiler-cli/src/commands/shared.ts b/profiler-cli/src/commands/shared.ts new file mode 100644 index 0000000000..38079ced9e --- /dev/null +++ b/profiler-cli/src/commands/shared.ts @@ -0,0 +1,137 @@ +/* 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/. */ + +/** + * Shared option helpers for profiler-cli commands. + */ + +import type { Command } from 'commander'; +import { Option } from 'commander'; +import { collectStrings } from '../utils/parse'; + +/** + * Parse a string as an integer and exit with an error if it is not a valid + * integer >= min. Pass a custom `msg` to override the default error message. + */ +export function parseIntArg( + flagName: string, + value: string, + min: number, + msg?: string +): number { + const v = parseInt(value, 10); + if (isNaN(v) || v < min) { + console.error( + msg ?? + `Error: ${flagName} must be a ${min <= 0 ? 'non-negative' : 'positive'} integer` + ); + process.exit(1); + } + return v; +} + +/** + * Parse a string as a float in [min, max] and exit with an error on failure. + */ +export function parseFloatArg( + flagName: string, + value: string, + min: number, + max: number = Infinity, + msg?: string +): number { + const v = parseFloat(value); + if (isNaN(v) || v < min || v > max) { + console.error( + msg ?? + `Error: ${flagName} must be a number between ${min} and ${max === Infinity ? '∞' : max}` + ); + process.exit(1); + } + return v; +} + +/** + * Returns true if the given subcommand was explicitly typed by the user. + * Used to decide whether to print a "other subcommands" hint after a default action. + * + * e.g. `profiler-cli session` -> wasExplicit('session', 'list') === false + * `profiler-cli session list` -> wasExplicit('session', 'list') === true + */ +export function wasExplicit(parent: string, subcommand: string): boolean { + const args = process.argv; + const idx = args.lastIndexOf(parent); + return idx !== -1 && args[idx + 1] === subcommand; +} + +/** + * Add --session and --json options to a command. + */ +export function addGlobalOptions(cmd: Command): Command { + return cmd + .option( + '--session ', + 'Use a specific session (default: current session)' + ) + .option('--json', 'Output results as JSON'); +} + +/** + * Add all ephemeral sample filter options to a command. + * Used by `thread samples`, `thread samples-top-down`, `thread samples-bottom-up`, + * `thread functions`, and `filter push`. + */ +export function addSampleFilterOptions(cmd: Command): Command { + return cmd + .addOption( + new Option( + '--excludes-function ', + 'Drop samples containing this function' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option('--merge ', 'Merge (remove) functions from stacks') + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option('--root-at ', 'Re-root stacks at this function') + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--includes-function ', + 'Keep only samples whose stack contains any of these functions' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--includes-prefix ', + 'Keep only samples whose stack starts with this root-first sequence' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--includes-suffix ', + 'Keep only samples whose leaf frame is this function' + ) + .argParser(collectStrings) + .default([]) + ) + .option( + '--during-marker', + 'Keep only samples during matching markers (requires --search)' + ) + .option( + '--outside-marker', + 'Keep only samples outside matching markers (requires --search)' + ); +} diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts new file mode 100644 index 0000000000..4ae326e4b1 --- /dev/null +++ b/profiler-cli/src/commands/thread.ts @@ -0,0 +1,474 @@ +/* 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/. */ + +/** + * `profiler-cli thread` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { parseEphemeralFilters } from '../utils/parse'; +import { + addGlobalOptions, + addSampleFilterOptions, + parseIntArg, + parseFloatArg, +} from './shared'; +import type { + CallTreeScoringStrategy, + MarkerFilterOptions, + FunctionFilterOptions, +} from '../protocol'; + +const VALID_SCORING_STRATEGIES: CallTreeScoringStrategy[] = [ + 'exponential-0.95', + 'exponential-0.9', + 'exponential-0.8', + 'harmonic-0.1', + 'harmonic-0.5', + 'harmonic-1.0', + 'percentage-only', +]; + +function addSamplesOptions(cmd: Command): Command { + return addSampleFilterOptions( + addGlobalOptions(cmd) + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--include-idle', 'Include idle samples in percentages') + .option( + '--search ', + 'Keep samples containing this substring in any frame. Comma-separates multiple terms, all must match (AND).' + ) + .option('--limit ', 'Limit the number of results shown') + ); +} + +function addCallTreeOptions(cmd: Command): Command { + return addSamplesOptions(cmd) + .option('--max-lines ', 'Maximum nodes in call tree (default: 100)') + .option( + '--scoring ', + `Call tree scoring strategy: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); +} + +function parseCallTreeOptions(opts: { + maxLines?: unknown; + scoring?: string; +}): + | { maxNodes?: number; scoringStrategy?: CallTreeScoringStrategy } + | undefined { + if (opts.maxLines === undefined && opts.scoring === undefined) { + return undefined; + } + const result: { + maxNodes?: number; + scoringStrategy?: CallTreeScoringStrategy; + } = {}; + if (opts.maxLines !== undefined) { + result.maxNodes = parseIntArg('--max-lines', String(opts.maxLines), 1); + } + if (opts.scoring !== undefined) { + if (!(VALID_SCORING_STRATEGIES as string[]).includes(opts.scoring)) { + console.error( + `Error: --scoring must be one of: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); + process.exit(1); + } + result.scoringStrategy = opts.scoring as CallTreeScoringStrategy; + } + return result; +} + +export function registerThreadCommand( + program: Command, + sessionDir: string +): void { + const thread = program.command('thread').description('Thread-level commands'); + + // thread info + addGlobalOptions( + thread + .command('info') + .description('Print detailed thread information') + .option('--thread ', 'Thread handle (e.g. t-0)') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'thread', subcommand: 'info', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread select + addGlobalOptions( + thread + .command('select [handle]') + .description('Select a thread (e.g. t-0, t-1)') + .option('--thread ', 'Thread handle') + ).action(async (handleArg: string | undefined, opts) => { + const threadHandle = handleArg ?? opts.thread; + const result = await sendCommand( + sessionDir, + { command: 'thread', subcommand: 'select', thread: threadHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread samples + addSamplesOptions( + thread + .command('samples') + .description('Show hot functions list for a thread') + ).action(async (opts) => { + const sampleFilters = parseEphemeralFilters(opts); + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread samples-top-down + addCallTreeOptions( + thread + .command('samples-top-down') + .description('Show top-down call tree (where CPU time is spent)') + ).action(async (opts) => { + const sampleFilters = parseEphemeralFilters(opts); + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-top-down', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions: parseCallTreeOptions(opts), + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread samples-bottom-up + addCallTreeOptions( + thread + .command('samples-bottom-up') + .description('Show bottom-up call tree (what calls hot functions)') + ).action(async (opts) => { + const sampleFilters = parseEphemeralFilters(opts); + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-bottom-up', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions: parseCallTreeOptions(opts), + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread markers + addGlobalOptions( + thread + .command('markers') + .description('List markers with aggregated statistics') + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--search ', 'Filter by substring') + .option( + '--category ', + 'Filter by category name (case-insensitive substring match)' + ) + .option( + '--min-duration ', + 'Filter by minimum duration in milliseconds' + ) + .option( + '--max-duration ', + 'Filter by maximum duration in milliseconds' + ) + .option('--has-stack', 'Show only markers with stack traces') + .option('--limit ', 'Limit the number of results shown') + .option( + '--group-by ', + 'Group by custom keys (e.g. "type,name" or "type,field:eventType")' + ) + .option( + '--auto-group', + 'Automatically determine grouping based on field variance' + ) + .option( + '--top-n ', + 'Number of top markers to include per group in JSON output (default: 5)' + ) + .option('--list', 'Show a flat chronological list of individual markers') + ).action(async (opts) => { + let markerFilters: MarkerFilterOptions | undefined; + + if ( + opts.search !== undefined || + opts.minDuration !== undefined || + opts.maxDuration !== undefined || + opts.category !== undefined || + opts.hasStack || + opts.limit !== undefined || + opts.groupBy !== undefined || + opts.autoGroup || + opts.topN !== undefined || + opts.list + ) { + markerFilters = {}; + if (opts.search !== undefined) { + markerFilters.searchString = opts.search; + } + if (opts.category !== undefined) { + markerFilters.category = opts.category; + } + if (opts.hasStack) { + markerFilters.hasStack = true; + } + if (opts.autoGroup) { + markerFilters.autoGroup = true; + } + if (opts.groupBy !== undefined) { + markerFilters.groupBy = opts.groupBy; + } + if (opts.list) { + markerFilters.list = true; + } + + if (opts.minDuration !== undefined) { + markerFilters.minDuration = parseFloatArg( + '--min-duration', + opts.minDuration, + 0, + Infinity, + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + } + if (opts.maxDuration !== undefined) { + markerFilters.maxDuration = parseFloatArg( + '--max-duration', + opts.maxDuration, + 0, + Infinity, + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + } + if (opts.limit !== undefined) { + markerFilters.limit = parseIntArg('--limit', opts.limit, 1); + } + if (opts.topN !== undefined) { + markerFilters.topN = parseIntArg('--top-n', opts.topN, 1); + } + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'markers', + thread: opts.thread, + markerFilters, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread network + addGlobalOptions( + thread + .command('network') + .description('Show network requests with timing phases') + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--search ', 'Filter by URL substring') + .option( + '--min-duration ', + 'Filter by minimum total request duration in milliseconds' + ) + .option( + '--max-duration ', + 'Filter by maximum total request duration in milliseconds' + ) + .option('--limit ', 'Max requests to show (default: 20, 0 = show all)') + ).action(async (opts) => { + const networkFilters: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + } = {}; + + if (opts.search !== undefined) { + networkFilters.searchString = opts.search; + } + if (opts.minDuration !== undefined) { + networkFilters.minDuration = parseFloatArg( + '--min-duration', + opts.minDuration, + 0, + Infinity, + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + } + if (opts.maxDuration !== undefined) { + networkFilters.maxDuration = parseFloatArg( + '--max-duration', + opts.maxDuration, + 0, + Infinity, + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + } + if (opts.limit !== undefined) { + networkFilters.limit = parseIntArg( + '--limit', + opts.limit, + 0, + 'Error: --limit must be a non-negative integer (0 = show all)' + ); + } else { + networkFilters.limit = 20; + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'network', + thread: opts.thread, + networkFilters, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread page-load + addGlobalOptions( + thread + .command('page-load') + .description( + 'Show page load summary: navigation timing, resources, CPU categories, and jank' + ) + .option('--thread ', 'Thread handle (e.g. t-0)') + .option( + '--navigation ', + 'Select which navigation to show (1-based, default: last completed)' + ) + .option( + '--jank-limit ', + 'Max jank periods to show (default: 10, 0 = show all)' + ) + ).action(async (opts) => { + const pageLoadOptions: { navigationIndex?: number; jankLimit?: number } = + {}; + + if (opts.navigation !== undefined) { + pageLoadOptions.navigationIndex = parseIntArg( + '--navigation', + opts.navigation, + 1, + 'Error: --navigation must be a positive integer (1-based index)' + ); + } + if (opts.jankLimit !== undefined) { + pageLoadOptions.jankLimit = parseIntArg( + '--jank-limit', + opts.jankLimit, + 0, + 'Error: --jank-limit must be a non-negative integer (0 = show all)' + ); + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'page-load', + thread: opts.thread, + pageLoadOptions, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread functions + addSampleFilterOptions( + addGlobalOptions( + thread + .command('functions') + .description('List all functions with CPU percentages') + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--search ', 'Filter by substring') + .option( + '--min-self ', + 'Filter by minimum self time percentage' + ) + .option('--limit ', 'Limit the number of results shown') + .option('--include-idle', 'Include idle samples in percentages') + ) + ).action(async (opts) => { + let functionFilters: FunctionFilterOptions | undefined; + + if ( + opts.search !== undefined || + opts.minSelf !== undefined || + opts.limit !== undefined + ) { + functionFilters = {}; + if (opts.search !== undefined) { + functionFilters.searchString = opts.search; + } + if (opts.minSelf !== undefined) { + functionFilters.minSelf = parseFloatArg( + '--min-self', + opts.minSelf, + 0, + 100, + 'Error: --min-self must be a number between 0 and 100 (percentage)' + ); + } + if (opts.limit !== undefined) { + functionFilters.limit = parseIntArg('--limit', opts.limit, 1); + } + } + + const sampleFilters = parseEphemeralFilters(opts); + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'functions', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + functionFilters, + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/commands/zoom.ts b/profiler-cli/src/commands/zoom.ts new file mode 100644 index 0000000000..26232030cb --- /dev/null +++ b/profiler-cli/src/commands/zoom.ts @@ -0,0 +1,58 @@ +/* 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/. */ + +/** + * `profiler-cli zoom` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerZoomCommand( + program: Command, + sessionDir: string +): void { + const zoom = program.command('zoom').description('Manage zoom ranges'); + + addGlobalOptions( + zoom + .command('push ') + .description( + 'Push a zoom range (e.g. 2.7,3.1 in seconds, 2700ms,3100ms in milliseconds, 10%,20% as percentage, or m-158 for a marker)' + ) + ).action(async (range: string, opts) => { + const result = await sendCommand( + sessionDir, + { command: 'zoom', subcommand: 'push', range }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + zoom.command('pop').description('Pop the most recent zoom range') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'zoom', subcommand: 'pop' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + zoom + .command('clear') + .description('Clear all zoom ranges (return to full profile)') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'zoom', subcommand: 'clear' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/constants.ts b/profiler-cli/src/constants.ts new file mode 100644 index 0000000000..3050cbaaac --- /dev/null +++ b/profiler-cli/src/constants.ts @@ -0,0 +1,22 @@ +/* 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/. */ + +/** + * Build-time constants injected by the build script. + */ + +// These globals are defined via esbuild's define option. +declare const __BUILD_HASH__: string; +declare const __VERSION__: string; + +/** + * Unique hash for this build, used to detect version mismatches + * between client and daemon. + */ +export const BUILD_HASH = __BUILD_HASH__; + +/** + * Package version from profiler-cli/package.json, injected at build time. + */ +export const VERSION = __VERSION__; diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts new file mode 100644 index 0000000000..de3a5ea562 --- /dev/null +++ b/profiler-cli/src/daemon.ts @@ -0,0 +1,470 @@ +/* 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/. */ + +/** + * Daemon process for profiler-cli. + * Loads a profile and listens for commands on a Unix socket (or named pipe on Windows). + */ + +import * as net from 'net'; +import * as fs from 'fs'; +import { ProfileQuerier } from '../../src/profile-query'; +import type { LoadPhase } from '../../src/profile-query/loader'; +import type { + ClientCommand, + ClientMessage, + ServerResponse, + SessionMetadata, + CommandResult, +} from './protocol'; +import { + generateSessionId, + getSocketPath, + getLogPath, + saveSessionMetadata, + setCurrentSession, + cleanupSession, + ensureSessionDir, +} from './session'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { BUILD_HASH } from './constants'; + +export class Daemon { + private querier: ProfileQuerier | null = null; + private server: net.Server | null = null; + private sessionDir: string; + private sessionId: string; + private socketPath: string; + private logPath: string; + private logStream: fs.WriteStream; + private profilePath: string; + private symbolServerUrl?: string; + private loadPhase: LoadPhase = 'fetching'; + private profileLoadError: Error | null = null; + + constructor( + sessionDir: string, + profilePath: string, + sessionId?: string, + symbolServerUrl?: string + ) { + this.sessionDir = sessionDir; + this.profilePath = profilePath; + this.sessionId = sessionId || generateSessionId(); + this.symbolServerUrl = symbolServerUrl; + this.socketPath = getSocketPath(sessionDir, this.sessionId); + this.logPath = getLogPath(sessionDir, this.sessionId); + this.logStream = fs.createWriteStream(this.logPath, { flags: 'a' }); + + // Redirect console to log file + this.redirectConsole(); + + // Handle shutdown signals + process.on('SIGINT', () => this.shutdown('SIGINT')); + process.on('SIGTERM', () => this.shutdown('SIGTERM')); + } + + private redirectConsole(): void { + // The daemon is spawned with stdio: 'ignore', so forwarding to the + // original console functions would just discard the output. Write + // exclusively to the log stream. + const write = (level: string, args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write( + `[${level}] ${new Date().toISOString()} ${message}\n` + ); + }; + console.log = (...args: any[]) => write('LOG', args); + console.error = (...args: any[]) => write('ERROR', args); + console.warn = (...args: any[]) => write('WARN', args); + } + + async start(): Promise { + try { + console.log(`Starting daemon for session ${this.sessionId}`); + console.log(`Profile path: ${this.profilePath}`); + console.log(`Socket path: ${this.socketPath}`); + console.log(`Log path: ${this.logPath}`); + + // Ensure session directory exists + ensureSessionDir(this.sessionDir); + + // Create Unix socket server BEFORE loading the profile + this.server = net.createServer((socket) => this.handleConnection(socket)); + + // Remove stale socket if it exists (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && fs.existsSync(this.socketPath)) { + fs.unlinkSync(this.socketPath); + } + + this.server.listen(this.socketPath, () => { + console.log(`Daemon listening on ${this.socketPath}`); + + // Save session metadata immediately + const metadata: SessionMetadata = { + id: this.sessionId, + socketPath: this.socketPath, + logPath: this.logPath, + pid: process.pid, + profilePath: this.profilePath, + createdAt: new Date().toISOString(), + buildHash: BUILD_HASH, + }; + saveSessionMetadata(this.sessionDir, metadata); + setCurrentSession(this.sessionDir, this.sessionId); + + console.log('Daemon ready (socket listening)'); + + // Start loading the profile in the background + this.loadProfileAsync(); + }); + + this.server.on('error', (error) => { + console.error(`Server error: ${error}`); + this.shutdown('error'); + }); + } catch (error) { + console.error(`Failed to start daemon: ${error}`); + process.exit(1); + } + } + + private async loadProfileAsync(): Promise { + this.loadPhase = 'fetching'; + try { + console.log('Loading profile...'); + const skipSymbolication = process.env.PROFILER_CLI_NO_SYMBOLICATE === '1'; + this.querier = await ProfileQuerier.load(this.profilePath, { + explicitSymbolServerUrl: this.symbolServerUrl, + skipSymbolication, + onPhaseChange: (phase) => { + this.loadPhase = phase; + if (phase === 'symbolicating') { + console.log('Symbolicating profile...'); + } + }, + }); + this.loadPhase = 'ready'; + console.log('Profile loaded successfully'); + } catch (error) { + console.error(`Failed to load profile: ${error}`); + this.profileLoadError = + error instanceof Error ? error : new Error(String(error)); + } + } + + private handleConnection(socket: net.Socket): void { + console.log('Client connected'); + + let buffer = ''; + // Serialize commands on this connection so concurrent messages cannot + // race on shared Redux state (e.g. _withEphemeralFilters). + let inFlight: Promise = Promise.resolve(); + + socket.on('data', (data) => { + buffer += data.toString(); + + // Process complete lines + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.substring(0, newlineIndex); + buffer = buffer.substring(newlineIndex + 1); + + if (line.trim()) { + inFlight = inFlight.then(() => this.handleMessage(line, socket)); + } + } + }); + + socket.on('error', (error) => { + console.error(`Socket error: ${error}`); + }); + + socket.on('end', () => { + console.log('Client disconnected'); + }); + } + + private async handleMessage(line: string, socket: net.Socket): Promise { + try { + const message = JSON.parse(line) as ClientMessage; + console.log(`Received message: ${message.type}`); + const response = await this.processMessage(message); + socket.write(JSON.stringify(response) + '\n'); + } catch (error) { + const errorResponse: ServerResponse = { + type: 'error', + error: error instanceof Error ? error.message : String(error), + }; + socket.write(JSON.stringify(errorResponse) + '\n'); + } + } + + private async processMessage( + message: ClientMessage + ): Promise { + switch (message.type) { + case 'status': { + // Return current daemon state + if (this.profileLoadError) { + return { + type: 'error', + error: `Profile load failed: ${this.profileLoadError.message}`, + }; + } + switch (this.loadPhase) { + case 'fetching': + case 'processing': + return { type: 'loading' }; + case 'symbolicating': + return { type: 'symbolicating' }; + case 'ready': + if (this.querier) { + return { type: 'ready' }; + } + return { type: 'error', error: 'Profile not loaded' }; + default: + return { type: 'error', error: 'Profile not loaded' }; + } + } + + case 'shutdown': { + console.log('Shutdown command received'); + // Send response before shutting down + const response: ServerResponse = { + type: 'success', + result: 'Shutting down', + }; + setImmediate(() => this.shutdown('command')); + return response; + } + + case 'command': { + // Commands require profile to be loaded + if (this.profileLoadError) { + return { + type: 'error', + error: `Profile load failed: ${this.profileLoadError.message}`, + }; + } + if (this.loadPhase !== 'ready' || !this.querier) { + return { + type: 'error', + error: 'Profile still loading, try again shortly', + }; + } + + const result = await this.processCommand(message.command); + return { + type: 'success', + result, + }; + } + + default: { + return { + type: 'error', + error: `Unknown message type: ${(message as any).type}`, + }; + } + } + } + + private async processCommand( + command: ClientCommand + ): Promise { + switch (command.command) { + case 'profile': + switch (command.subcommand) { + case 'info': + return this.querier!.profileInfo(command.all, command.search); + case 'threads': + throw new Error('unimplemented'); + case 'logs': + return this.querier!.profileLogs(command.logFilters); + default: + throw assertExhaustiveCheck(command); + } + case 'thread': + switch (command.subcommand) { + case 'info': + return this.querier!.threadInfo(command.thread); + case 'select': + if (!command.thread) { + throw new Error('thread handle required for thread select'); + } + return this.querier!.threadSelect(command.thread); + case 'samples': + return this.querier!.threadSamples( + command.thread, + command.includeIdle, + command.search, + command.sampleFilters + ); + case 'samples-top-down': + return this.querier!.threadSamplesTopDown( + command.thread, + command.callTreeOptions, + command.includeIdle, + command.search, + command.sampleFilters + ); + case 'samples-bottom-up': + return this.querier!.threadSamplesBottomUp( + command.thread, + command.callTreeOptions, + command.includeIdle, + command.search, + command.sampleFilters + ); + case 'markers': + return this.querier!.threadMarkers( + command.thread, + command.markerFilters + ); + case 'functions': + return this.querier!.threadFunctions( + command.thread, + command.functionFilters, + command.includeIdle, + command.sampleFilters + ); + case 'network': + return this.querier!.threadNetwork( + command.thread, + command.networkFilters + ); + case 'page-load': + return this.querier!.threadPageLoad( + command.thread, + command.pageLoadOptions + ); + default: + throw assertExhaustiveCheck(command); + } + case 'marker': + switch (command.subcommand) { + case 'info': + if (!command.marker) { + throw new Error('marker handle required for marker info'); + } + return this.querier!.markerInfo(command.marker); + case 'stack': + if (!command.marker) { + throw new Error('marker handle required for marker stack'); + } + return this.querier!.markerStack(command.marker); + case 'select': + throw new Error('unimplemented'); + default: + throw assertExhaustiveCheck(command); + } + case 'sample': + switch (command.subcommand) { + case 'info': + throw new Error('unimplemented'); + case 'select': + throw new Error('unimplemented'); + default: + throw assertExhaustiveCheck(command); + } + case 'function': + switch (command.subcommand) { + case 'info': + if (!command.function) { + throw new Error('function handle required for function info'); + } + return this.querier!.functionInfo(command.function); + case 'expand': + if (!command.function) { + throw new Error('function handle required for function expand'); + } + return this.querier!.functionExpand(command.function); + case 'select': + throw new Error('unimplemented'); + case 'annotate': + if (!command.function) { + throw new Error('function handle required for function annotate'); + } + return this.querier!.functionAnnotate( + command.function, + command.annotateMode ?? 'src', + command.symbolServerUrl ?? 'http://localhost:3000', + command.annotateContext ?? '2' + ); + default: + throw assertExhaustiveCheck(command); + } + case 'zoom': + switch (command.subcommand) { + case 'push': + if (!command.range) { + throw new Error('range parameter is required for zoom push'); + } + return this.querier!.pushViewRange(command.range); + case 'pop': + return this.querier!.popViewRange(); + case 'clear': + return this.querier!.clearViewRange(); + default: + throw assertExhaustiveCheck(command); + } + case 'filter': + switch (command.subcommand) { + case 'push': + if (!command.spec) { + throw new Error('spec is required for filter push'); + } + return this.querier!.filterPush(command.spec, command.thread); + case 'pop': + return this.querier!.filterPop(command.count ?? 1, command.thread); + case 'list': + return this.querier!.filterList(command.thread); + case 'clear': + return this.querier!.filterClear(command.thread); + default: + throw assertExhaustiveCheck(command); + } + case 'status': + return this.querier!.getStatus(); + default: + throw assertExhaustiveCheck(command); + } + } + + private shutdown(reason: string): void { + console.log(`Shutting down daemon (reason: ${reason})`); + + if (this.server) { + this.server.close(); + } + + cleanupSession(this.sessionDir, this.sessionId); + + if (this.logStream) { + this.logStream.end(); + } + + console.log('Daemon stopped'); + process.exit(0); + } +} + +/** + * Start the daemon (called from CLI). + */ +export async function startDaemon( + sessionDir: string, + profilePath: string, + sessionId?: string, + symbolServerUrl?: string +): Promise { + const daemon = new Daemon( + sessionDir, + profilePath, + sessionId, + symbolServerUrl + ); + await daemon.start(); +} diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts new file mode 100644 index 0000000000..53838b8595 --- /dev/null +++ b/profiler-cli/src/formatters.ts @@ -0,0 +1,1516 @@ +/* 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/. */ + +/** + * Text formatters for CommandResult types. + * These functions convert structured JSON results into human-readable text output. + */ + +import type { + StatusResult, + SessionContext, + WithContext, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + ViewRangeResult, + FilterStackResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadFunctionsResult, + ThreadNetworkResult, + ThreadPageLoadResult, + NetworkPhaseTimings, + MarkerGroupData, + CallTreeNode, + FilterEntry, + SampleFilterSpec, + ProfileLogsResult, + ThreadSelectResult, +} from './protocol'; +import { truncateFunctionName } from '../../src/profile-query/function-list'; +import { describeSpec } from '../../src/profile-query/filter-stack'; +import { formatTimestamp as formatDuration } from 'firefox-profiler/utils/format-numbers'; + +// Maximum display width for function names in call-tree and sample views. +const FUNC_NAME_WIDTH = 120; + +/** + * Format a SessionContext as a compact header line. + * Shows current thread selection, zoom range, and full profile duration. + */ +export function formatContextHeader( + context: SessionContext, + activeFilters?: FilterEntry[], + ephemeralFilters?: SampleFilterSpec[] +): string { + // Thread info + let threadInfo = 'No thread selected'; + if (context.selectedThreadHandle && context.selectedThreads.length > 0) { + if (context.selectedThreads.length === 1) { + const thread = context.selectedThreads[0]; + threadInfo = `${context.selectedThreadHandle} (${thread.name})`; + } else { + const names = context.selectedThreads + .map((t: { name: string }) => t.name) + .join(', '); + threadInfo = `${context.selectedThreadHandle} (${names})`; + } + } + + // View range info + const rootDuration = context.rootRange.end - context.rootRange.start; + + let viewInfo = 'Full profile'; + if (context.currentViewRange) { + const range = context.currentViewRange; + const rangeDuration = range.end - range.start; + viewInfo = `${range.startName}→${range.endName} (${formatDuration(rangeDuration)})`; + } + + const fullInfo = formatDuration(rootDuration); + + const totalFilterCount = + (activeFilters?.length ?? 0) + (ephemeralFilters?.length ?? 0); + const filterInfo = + totalFilterCount > 0 ? ` | Filters: ${totalFilterCount}` : ''; + return `[Thread: ${threadInfo} | View: ${viewInfo} | Full: ${fullInfo}${filterInfo}]`; +} + +/** + * Format a StatusResult as plain text. + */ +export function formatStatusResult(result: StatusResult): string { + let threadInfo = 'No thread selected'; + if (result.selectedThreadHandle && result.selectedThreads.length > 0) { + if (result.selectedThreads.length === 1) { + const thread = result.selectedThreads[0]; + threadInfo = `${result.selectedThreadHandle} (${thread.name})`; + } else { + const names = result.selectedThreads.map((t) => t.name).join(', '); + threadInfo = `${result.selectedThreadHandle} (${names})`; + } + } + + let rangesInfo = 'Full profile'; + if (result.viewRanges.length > 0) { + const rangeStrs = result.viewRanges.map((range) => { + return `${range.startName} to ${range.endName}`; + }); + rangesInfo = rangeStrs.join(' > '); + } + + const filterLines: string[] = []; + for (const stack of result.filterStacks) { + if (stack.filters.length === 0) { + continue; + } + filterLines.push(` Filters for ${stack.threadHandle}:`); + for (const f of stack.filters) { + filterLines.push(` ${f.index}. ${f.description}`); + } + } + const filterSection = + filterLines.length > 0 + ? '\n' + filterLines.join('\n') + : '\n Filters: none'; + + return `\ +Session Status: + Selected thread: ${threadInfo} + View range: ${rangesInfo}${filterSection}`; +} + +/** + * Format a FilterStackResult as plain text. + */ +export function formatFilterStackResult(result: FilterStackResult): string { + const lines: string[] = []; + if (result.message) { + lines.push(result.message); + } + if (result.filters.length === 0) { + lines.push(`No active filters for ${result.threadHandle}`); + } else { + lines.push(`Filters for ${result.threadHandle} (applied in order):`); + for (const f of result.filters) { + lines.push(` ${f.index}. ${f.description}`); + } + } + return lines.join('\n'); +} + +/** + * Format a FunctionExpandResult as plain text. + */ +export function formatFunctionExpandResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + return `${contextHeader} + +Function ${result.functionHandle}: +${result.fullName}`; +} + +/** + * Format a FunctionInfoResult as plain text. + */ +export function formatFunctionInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Function ${result.functionHandle}: + Full name: ${result.fullName} + Short name: ${result.name} + Is JS: ${result.isJS} + Relevant for JS: ${result.relevantForJS}`; + + if (result.resource) { + output += `\n Resource: ${result.resource.name}`; + } + + if (result.library) { + output += `\n Library: ${result.library.name}`; + output += `\n Library path: ${result.library.path}`; + if (result.library.debugName) { + output += `\n Debug name: ${result.library.debugName}`; + } + if (result.library.debugPath) { + output += `\n Debug path: ${result.library.debugPath}`; + } + if (result.library.breakpadId) { + output += `\n Breakpad ID: ${result.library.breakpadId}`; + } + } + + return output; +} + +/** + * Format a ViewRangeResult as plain text. + */ +export function formatViewRangeResult(result: ViewRangeResult): string { + // Start with the basic message + let output = result.message; + + // For 'push' action, add enhanced information if available + if (result.action === 'push' && result.duration !== undefined) { + output += ` (duration: ${formatDuration(result.duration)})`; + + // If this is a marker zoom, show marker details + if (result.markerInfo) { + output += `\n Zoomed to: Marker ${result.markerInfo.markerHandle} - ${result.markerInfo.markerName}`; + output += `\n Thread: ${result.markerInfo.threadHandle} (${result.markerInfo.threadName})`; + } + + // Show zoom depth if available + if (result.zoomDepth !== undefined) { + output += `\n Zoom depth: ${result.zoomDepth}${result.zoomDepth > 1 ? ' (use "profiler-cli zoom pop" to go back)' : ''}`; + } + } + + if (result.warning) { + output += `\nWarning: ${result.warning}`; + } + + return output; +} + +/** + * Format a ThreadInfoResult as plain text. + */ +export function formatThreadInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const endedAtStr = result.endedAtName || 'still alive at end of recording'; + + let output = `${contextHeader} + +Name: ${result.friendlyName} +TID: ${result.tid} +Created at: ${result.createdAtName} +Ended at: ${endedAtStr} + +This thread contains ${result.sampleCount} samples and ${result.markerCount} markers. + +CPU activity over time:`; + + if (result.cpuActivity && result.cpuActivity.length > 0) { + for (const activity of result.cpuActivity) { + const indent = ' '.repeat(activity.depthLevel); + const duration = activity.endTime - activity.startTime; + const percentage = + duration > 0 ? Math.round((activity.cpuMs / duration) * 100) : 0; + output += `\n${indent}- ${percentage}% for ${activity.cpuMs.toFixed(1)}ms: [${activity.startTimeName} → ${activity.endTimeName}] (${activity.startTimeStr} - ${activity.endTimeStr})`; + } + } else { + output += '\nNo significant activity.'; + } + + return output; +} + +/** + * Format a MarkerStackResult as plain text. + */ +export function formatMarkerStackResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Stack trace for marker ${result.markerHandle}: ${result.markerName}\n`; + output += `Thread: ${result.threadHandle} (${result.friendlyThreadName})`; + + if (!result.stack || result.stack.frames.length === 0) { + return output + '\n\n(This marker has no stack trace)'; + } + + if (result.stack.capturedAt !== undefined) { + const rootStart = result.context.rootRange.start; + output += `\nCaptured at: ${formatDuration(result.stack.capturedAt - rootStart)}\n`; + } + + for (let i = 0; i < result.stack.frames.length; i++) { + const frame = result.stack.frames[i]; + output += `\n [${i + 1}] ${frame.nameWithLibrary}`; + } + + if (result.stack.truncated) { + output += '\n ... (truncated)'; + } + + return output; +} + +/** + * Format a MarkerInfoResult as plain text. + */ +export function formatMarkerInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Marker ${result.markerHandle}: ${result.name}`; + if (result.tooltipLabel) { + output += ` - ${result.tooltipLabel}`; + } + output += '\n\n'; + + // Basic info + output += `Type: ${result.markerType ?? 'None'}\n`; + output += `Category: ${result.category.name}\n`; + + // Time and duration (relative to profile root start) + const rootStart = result.context.rootRange.start; + const startStr = formatDuration(result.start - rootStart); + if (result.end !== null) { + const endStr = formatDuration(result.end - rootStart); + const durationStr = formatDuration(result.duration!); + output += `Time: ${startStr} - ${endStr} (${durationStr})\n`; + } else { + output += `Time: ${startStr} (instant)\n`; + } + + output += `Thread: ${result.threadHandle} (${result.friendlyThreadName})\n`; + + // Marker data fields + if (result.fields && result.fields.length > 0) { + output += '\nFields:\n'; + for (const field of result.fields) { + output += ` ${field.label}: ${field.formattedValue}\n`; + } + } + + // Schema description + if (result.schema?.description) { + output += '\nDescription:\n'; + output += ` ${result.schema.description}\n`; + } + + // Stack trace (truncated to 20 frames) + if (result.stack && result.stack.frames.length > 0) { + output += '\nStack trace:\n'; + if (result.stack.capturedAt !== undefined) { + output += ` Captured at: ${formatDuration(result.stack.capturedAt - rootStart)}\n`; + } + + for (let i = 0; i < result.stack.frames.length; i++) { + const frame = result.stack.frames[i]; + output += ` [${i + 1}] ${frame.nameWithLibrary}\n`; + } + + if (result.stack.truncated) { + output += `\nUse 'profiler-cli marker stack ${result.markerHandle}' for the full stack trace.\n`; + } + } + + return output; +} + +/** + * Format a ProfileInfoResult as plain text. + */ +export function formatProfileInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Name: ${result.name}\n`; + output += `Platform: ${result.platform}\n\n`; + output += `This profile contains ${result.threadCount} threads across ${result.processCount} processes.\n`; + + if (result.processes.length === 0) { + output += '\n(CPU time information not available)'; + return output; + } + + let processesHeading: string; + if (result.searchQuery !== undefined) { + processesHeading = `Processes and threads matching '${result.searchQuery}':`; + } else if (result.showAll) { + processesHeading = 'All processes and threads by CPU usage:'; + } else { + processesHeading = 'Top processes and threads by CPU usage:'; + } + output += `\n${processesHeading}\n`; + + for (const process of result.processes) { + // Format process timing information + let timingInfo = ''; + if (process.startTime !== undefined && process.startTimeName) { + if (process.endTime !== null && process.endTimeName !== null) { + timingInfo = ` [${process.startTimeName} → ${process.endTimeName}]`; + } else { + timingInfo = ` [${process.startTimeName} → end]`; + } + } + + const etld1Suffix = process.etld1 ? ` [${process.etld1}]` : ''; + output += ` p-${process.processIndex}: ${process.name}${etld1Suffix} [pid ${process.pid}]${timingInfo} - ${process.cpuMs.toFixed(3)}ms\n`; + + for (const thread of process.threads) { + output += ` ${thread.threadHandle}: ${thread.name} [tid ${thread.tid}] - ${thread.cpuMs.toFixed(3)}ms\n`; + } + + if (process.remainingThreads) { + output += ` + ${process.remainingThreads.count} more threads with combined CPU time ${process.remainingThreads.combinedCpuMs.toFixed(3)}ms and max CPU time ${process.remainingThreads.maxCpuMs.toFixed(3)}ms (use --all to see all)\n`; + } + } + + if (result.remainingProcesses) { + output += ` + ${result.remainingProcesses.count} more processes with combined CPU time ${result.remainingProcesses.combinedCpuMs.toFixed(3)}ms and max CPU time ${result.remainingProcesses.maxCpuMs.toFixed(3)}ms (use --all to see all)\n`; + } + + output += '\nCPU activity over time:\n'; + + if (result.cpuActivity && result.cpuActivity.length > 0) { + for (const activity of result.cpuActivity) { + const indent = ' '.repeat(activity.depthLevel); + const duration = activity.endTime - activity.startTime; + const percentage = + duration > 0 ? Math.round((activity.cpuMs / duration) * 100) : 0; + output += `${indent}- ${percentage}% for ${activity.cpuMs.toFixed(1)}ms: [${activity.startTimeName} → ${activity.endTimeName}] (${activity.startTimeStr} - ${activity.endTimeStr})\n`; + } + } else { + output += 'No significant activity.\n'; + } + + return output; +} + +/** + * Helper function to format a call tree node recursively. + * + * This formatter uses a "stack fragment" approach for single-child chains: + * - Root-level nodes always indent their children with tree symbols + * - Single-child continuations are rendered without tree symbols (as stack fragments) + * - Only nodes with multiple children use tree symbols to show branching + */ +function formatCallTreeNode( + node: CallTreeNode, + baseIndent: string, + useTreeSymbol: boolean, + isLastSibling: boolean, + depth: number, + lines: string[] +): void { + const totalPct = node.totalPercentage.toFixed(1); + const selfPct = node.selfPercentage.toFixed(1); + const displayName = truncateFunctionName( + node.nameWithLibrary, + FUNC_NAME_WIDTH + ); + + // Build the line prefix + let linePrefix: string; + if (useTreeSymbol) { + const symbol = isLastSibling ? '└─ ' : '├─ '; + linePrefix = baseIndent + symbol; + } else { + linePrefix = baseIndent; + } + + // Add function handle prefix if available + const handlePrefix = node.functionHandle ? `${node.functionHandle}. ` : ''; + + lines.push( + `${linePrefix}${handlePrefix}${displayName} [total: ${totalPct}%, self: ${selfPct}%]` + ); + + // Handle children and truncation + const hasChildren = node.children && node.children.length > 0; + const hasTruncatedChildren = node.childrenTruncated; + + if (hasChildren || hasTruncatedChildren) { + // Calculate the base indent for children + let childBaseIndent: string; + if (useTreeSymbol) { + // We used a tree symbol, so children need appropriate spine continuation + const spine = isLastSibling ? ' ' : '│ '; + childBaseIndent = baseIndent + spine; + } else { + // We didn't use a tree symbol (stack fragment), children keep the same base indent + childBaseIndent = baseIndent; + } + + if (hasChildren) { + const hasMultipleChildren = + node.children.length > 1 || !!hasTruncatedChildren; + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const isLast = i === node.children.length - 1 && !hasTruncatedChildren; + + // Children use tree symbols if: + // - There are multiple children (branching), OR + // - We're at root level (depth 0) - root children always get tree symbols + const childUsesTreeSymbol = hasMultipleChildren || depth === 0; + + formatCallTreeNode( + child, + childBaseIndent, + childUsesTreeSymbol, + isLast, + depth + 1, + lines + ); + } + } + + // Show combined elision info if children were omitted or depth limit reached + // Combine both types of elision into a single marker + if (hasTruncatedChildren) { + const truncPrefix = childBaseIndent + '└─ '; + const truncInfo = node.childrenTruncated!; + const combinedPct = truncInfo.combinedPercentage.toFixed(1); + const maxPct = truncInfo.maxPercentage.toFixed(1); + lines.push( + `${truncPrefix}... (${truncInfo.count} more children: combined ${combinedPct}%, max ${maxPct}%)` + ); + } + } +} + +/** + * Helper function to format a call tree. + */ +function formatCallTree( + tree: CallTreeNode, + title: string, + emptyMessage?: string +): string { + const lines: string[] = [`${title} Call Tree:`]; + + // The root node is virtual, so format its children + if (tree.children && tree.children.length > 0) { + for (let i = 0; i < tree.children.length; i++) { + const child = tree.children[i]; + const isLast = i === tree.children.length - 1; + // Root-level nodes don't use tree symbols (they are the starting points) + formatCallTreeNode(child, '', false, isLast, 0, lines); + } + } else if (emptyMessage) { + lines.push(emptyMessage); + } + + return lines.join('\n'); +} + +function formatSamplesPreamble(result: { + context: SessionContext; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + activeOnly?: boolean; + search?: string; + friendlyThreadName: string; +}): string { + const contextHeader = formatContextHeader( + result.context, + result.activeFilters, + result.ephemeralFilters + ); + const activeOnlyNote = result.activeOnly + ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' + : ''; + const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; + const filtersParts: string[] = [ + ...(result.activeFilters?.map((f) => `[${f.index}] ${f.description}`) ?? + []), + ...(result.ephemeralFilters?.map((f) => `[~] ${describeSpec(f)}`) ?? []), + ]; + const filtersNote = + filtersParts.length > 0 ? `Filters: ${filtersParts.join(', ')}\n\n` : ''; + return `${contextHeader}\n\nThread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; +} + +/** + * Format a ThreadSamplesResult as plain text. + */ +export function formatThreadSamplesResult( + result: WithContext +): string { + let output = formatSamplesPreamble(result); + + if (result.search && result.topFunctionsByTotal.length === 0) { + output += + `No samples matched --search "${result.search}".\n` + + 'Tip: --search keeps samples with a matching frame anywhere in the stack.\n' + + ' Use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + + ' "|" is treated as a literal character, not OR.\n'; + return output; + } + + // Top functions by total time + output += 'Top Functions (by total time):\n'; + output += + ' (For a call tree starting from these functions, use: profiler-cli thread samples-top-down)\n\n'; + for (const func of result.topFunctionsByTotal) { + const totalCount = Math.round(func.totalSamples); + const totalPct = func.totalPercentage.toFixed(1); + const displayName = truncateFunctionName( + func.nameWithLibrary, + FUNC_NAME_WIDTH + ); + output += ` ${func.functionHandle}. ${displayName} - total: ${totalCount} (${totalPct}%)\n`; + } + + output += '\n'; + + // Top functions by self time + output += 'Top Functions (by self time):\n'; + output += + ' (For a call tree showing what calls these functions, use: profiler-cli thread samples-bottom-up)\n\n'; + for (const func of result.topFunctionsBySelf) { + const selfCount = Math.round(func.selfSamples); + const selfPct = func.selfPercentage.toFixed(1); + const displayName = truncateFunctionName( + func.nameWithLibrary, + FUNC_NAME_WIDTH + ); + output += ` ${func.functionHandle}. ${displayName} - self: ${selfCount} (${selfPct}%)\n`; + } + + output += '\n'; + + // Heaviest stack + const stack = result.heaviestStack; + output += `Heaviest stack (${stack.selfSamples.toFixed(1)} samples, ${stack.frameCount} frames):\n`; + + if (stack.frames.length === 0) { + output += ' (empty)\n'; + } else if (stack.frameCount <= 200) { + // Show all frames + for (let i = 0; i < stack.frames.length; i++) { + const frame = stack.frames[i]; + const displayName = truncateFunctionName( + frame.nameWithLibrary, + FUNC_NAME_WIDTH + ); + const totalCount = Math.round(frame.totalSamples); + const totalPct = frame.totalPercentage.toFixed(1); + const selfCount = Math.round(frame.selfSamples); + const selfPct = frame.selfPercentage.toFixed(1); + output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + } + } else { + // Show first 100 + for (let i = 0; i < 100; i++) { + const frame = stack.frames[i]; + const displayName = truncateFunctionName( + frame.nameWithLibrary, + FUNC_NAME_WIDTH + ); + const totalCount = Math.round(frame.totalSamples); + const totalPct = frame.totalPercentage.toFixed(1); + const selfCount = Math.round(frame.selfSamples); + const selfPct = frame.selfPercentage.toFixed(1); + output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + } + + // Show placeholder for skipped frames + const skippedCount = stack.frameCount - 200; + output += ` ... (${skippedCount} frames skipped)\n`; + + // Show last 100 + for (let i = stack.frameCount - 100; i < stack.frameCount; i++) { + const frame = stack.frames[i]; + const displayName = truncateFunctionName( + frame.nameWithLibrary, + FUNC_NAME_WIDTH + ); + const totalCount = Math.round(frame.totalSamples); + const totalPct = frame.totalPercentage.toFixed(1); + const selfCount = Math.round(frame.selfSamples); + const selfPct = frame.selfPercentage.toFixed(1); + output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + } + } + + return output; +} + +/** + * Format a ThreadSamplesTopDownResult as plain text. + */ +export function formatThreadSamplesTopDownResult( + result: WithContext +): string { + let output = formatSamplesPreamble(result); + + // Top-down call tree + const topDownEmpty = result.search + ? `No samples matched --search "${result.search}".\n` + + 'Tip: use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + + ' "|" is treated as a literal character, not OR.' + : undefined; + output += formatCallTree(result.regularCallTree, 'Top-Down', topDownEmpty); + + return output; +} + +/** + * Format a ThreadSamplesBottomUpResult as plain text. + */ +export function formatThreadSamplesBottomUpResult( + result: WithContext +): string { + let output = formatSamplesPreamble(result); + + // Bottom-up call tree (inverted tree shows callers) + if (result.invertedCallTree) { + const bottomUpEmpty = result.search + ? `No samples matched --search "${result.search}".\n` + + 'Tip: use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + + ' "|" is treated as a literal character, not OR.' + : undefined; + output += formatCallTree( + result.invertedCallTree, + 'Bottom-Up', + bottomUpEmpty + ); + } else { + output += 'Bottom-Up Call Tree:\n (unable to create bottom-up tree)'; + } + + return output; +} + +/** + * Format a ThreadMarkersResult as plain text. + */ +export function formatThreadMarkersResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const lines: string[] = [contextHeader, '']; + + // Check if filters are active + const hasFilters = result.filters !== undefined; + const filterSuffix = + hasFilters && result.filteredMarkerCount !== result.totalMarkerCount + ? ` (filtered from ${result.totalMarkerCount})` + : ''; + + lines.push( + `Markers in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${result.filteredMarkerCount} markers${filterSuffix}` + ); + lines.push('Legend: ✓ = has stack trace, ✗ = no stack trace\n'); + + if (result.filteredMarkerCount === 0) { + if (hasFilters) { + lines.push('No markers match the specified filters.'); + } else { + lines.push('No markers in this thread.'); + } + return lines.join('\n'); + } + + // Flat list mode: one row per marker in chronological order + if (result.flatMarkers) { + const rootStart = result.context.rootRange.start; + for (const m of result.flatMarkers) { + const stackIndicator = m.hasStack ? '✓' : '✗'; + const startStr = `t=${formatDuration(m.start - rootStart)}`; + const durationStr = + m.duration !== undefined ? formatDuration(m.duration) : 'instant'; + const labelSuffix = m.label !== m.name ? ` ${m.label}` : ''; + lines.push( + ` ${m.handle.padEnd(8)} ${m.name.padEnd(30)} ${startStr.padEnd(14)} ${durationStr.padEnd(10)} ${stackIndicator}${labelSuffix}` + ); + } + return lines.join('\n'); + } + + // Handle custom grouping if present + if (result.customGroups && result.customGroups.length > 0) { + formatMarkerGroupsForDisplay(lines, result.customGroups, 0); + } else { + // Default aggregation by marker name + const W_STAT_NAME = 25; + const W_STAT_COUNT = 5; + lines.push('By Name (top 15):'); + const topTypes = result.byType.slice(0, 15); + for (const stats of topTypes) { + let line = ` ${stats.markerName.padEnd(W_STAT_NAME)} ${stats.count.toString().padStart(W_STAT_COUNT)} markers`; + + if (stats.durationStats) { + const { min, avg, max } = stats.durationStats; + line += ` (interval: min=${formatDuration(min)}, avg=${formatDuration(avg)}, max=${formatDuration(max)})`; + } else { + line += ' (instant)'; + } + + lines.push(line); + + // Show top markers with handles (for easy inspection) + if (!stats.subGroups && stats.topMarkers.length > 0) { + const handleList = stats.topMarkers + .slice(0, 3) + .map((m) => { + const stackIndicator = m.hasStack ? '✓' : '✗'; + const handleWithIndicator = `${m.handle} ${stackIndicator}`; + if (m.duration !== undefined) { + return `${handleWithIndicator} (${formatDuration(m.duration)})`; + } + return handleWithIndicator; + }) + .join(', '); + lines.push(` Examples: ${handleList}`); + } + + // Show sub-groups if present (from auto-grouping) + if (stats.subGroups && stats.subGroups.length > 0) { + if (stats.subGroupKey) { + lines.push(` Grouped by ${stats.subGroupKey}:`); + } + formatMarkerGroupsForDisplay(lines, stats.subGroups, 2); + } + } + + if (result.byType.length > 15) { + lines.push(` ... (${result.byType.length - 15} more marker names)`); + } + + lines.push(''); + + // Aggregate by category + lines.push('By Category:'); + for (const stats of result.byCategory) { + lines.push( + ` ${stats.categoryName.padEnd(W_STAT_NAME)} ${stats.count.toString().padStart(W_STAT_COUNT)} markers (${stats.percentage.toFixed(1)}%)` + ); + } + + lines.push(''); + + // Frequency analysis for top markers + lines.push('Frequency Analysis:'); + const topRateTypes = result.byType + .filter((s) => s.rateStats && s.rateStats.markersPerSecond > 0) + .slice(0, 5); + + for (const stats of topRateTypes) { + if (!stats.rateStats) { + continue; + } + const { markersPerSecond, minGap, avgGap, maxGap } = stats.rateStats; + lines.push( + ` ${stats.markerName}: ${markersPerSecond.toFixed(1)} markers/sec (interval: min=${formatDuration(minGap)}, avg=${formatDuration(avgGap)}, max=${formatDuration(maxGap)})` + ); + } + + lines.push(''); + } + + lines.push( + 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , --auto-group, or --top-n to filter/group markers, or m- handles to inspect individual markers or zoom into their time range (profiler-cli zoom push m-).' + ); + + return lines.join('\n'); +} + +/** + * Helper function to format marker groups hierarchically. + */ +function formatMarkerGroupsForDisplay( + lines: string[], + groups: MarkerGroupData[], + baseIndent: number +): void { + for (const group of groups) { + const indent = ' '.repeat(baseIndent); + let line = `${indent}${group.groupName}: ${group.count} markers`; + + if (group.durationStats) { + const { avg, max } = group.durationStats; + line += ` (avg=${formatDuration(avg)}, max=${formatDuration(max)})`; + } + + lines.push(line); + + // Show top markers if no sub-groups + if (!group.subGroups && group.topMarkers.length > 0) { + const handleList = group.topMarkers + .slice(0, 3) + .map((m) => { + const stackIndicator = m.hasStack ? '✓' : '✗'; + const handleWithIndicator = `${m.handle} ${stackIndicator}`; + if (m.duration !== undefined) { + return `${handleWithIndicator} (${formatDuration(m.duration)})`; + } + return handleWithIndicator; + }) + .join(', '); + lines.push(`${indent} Examples: ${handleList}`); + } + + // Recursively format sub-groups + if (group.subGroups && group.subGroups.length > 0) { + formatMarkerGroupsForDisplay(lines, group.subGroups, baseIndent + 1); + } + } +} + +/** + * Format a ThreadFunctionsResult as plain text. + */ +export function formatThreadFunctionsResult( + result: WithContext +): string { + const contextHeader = formatContextHeader( + result.context, + result.activeFilters, + result.ephemeralFilters + ); + const lines: string[] = [contextHeader, '']; + + // Check if filters are active + const hasFilters = result.filters !== undefined; + const filterSuffix = + hasFilters && result.filteredFunctionCount !== result.totalFunctionCount + ? ` (filtered from ${result.totalFunctionCount})` + : ''; + + lines.push( + `Functions in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${result.filteredFunctionCount} functions${filterSuffix}\n` + ); + + if (result.activeOnly) { + lines.push( + 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n' + ); + } + + if (result.filteredFunctionCount === 0) { + if (hasFilters) { + lines.push('No functions match the specified filters.'); + if (result.filters?.searchString) { + lines.push( + 'Tip: --search matches as a substring of the full function name (including library prefix).' + ); + } + } else { + lines.push('No functions in this thread.'); + } + return lines.join('\n'); + } + + // Show active filters if any + const filterParts: string[] = []; + if (hasFilters && result.filters) { + if (result.filters.searchString) { + filterParts.push(`search: "${result.filters.searchString}"`); + } + if (result.filters.minSelf !== undefined) { + filterParts.push(`min-self: ${result.filters.minSelf}%`); + } + if (result.filters.limit !== undefined) { + filterParts.push(`limit: ${result.filters.limit}`); + } + } + if (result.activeFilters) { + for (const f of result.activeFilters) { + filterParts.push(`[${f.index}] ${f.description}`); + } + } + if (result.ephemeralFilters) { + for (const f of result.ephemeralFilters) { + filterParts.push(`[~] ${describeSpec(f)}`); + } + } + if (filterParts.length > 0) { + lines.push(`Filters: ${filterParts.join(', ')}\n`); + } + + // List functions sorted by self time + lines.push('Functions (by self time):'); + for (const func of result.functions) { + const selfCount = Math.round(func.selfSamples); + const totalCount = Math.round(func.totalSamples); + const displayName = truncateFunctionName( + func.nameWithLibrary, + FUNC_NAME_WIDTH + ); + + // Format percentages: show dual percentages when zoomed + let selfPctStr: string; + let totalPctStr: string; + if ( + func.fullSelfPercentage !== undefined && + func.fullTotalPercentage !== undefined + ) { + // Zoomed: show both view and full percentages + selfPctStr = `${func.selfPercentage.toFixed(1)}% of view, ${func.fullSelfPercentage.toFixed(1)}% of full`; + totalPctStr = `${func.totalPercentage.toFixed(1)}% of view, ${func.fullTotalPercentage.toFixed(1)}% of full`; + } else { + // Not zoomed: show single percentage + selfPctStr = `${func.selfPercentage.toFixed(1)}%`; + totalPctStr = `${func.totalPercentage.toFixed(1)}%`; + } + + lines.push( + ` ${func.functionHandle}. ${displayName} - self: ${selfCount} (${selfPctStr}), total: ${totalCount} (${totalPctStr})` + ); + } + + if (result.filteredFunctionCount > result.functions.length) { + const omittedCount = result.filteredFunctionCount - result.functions.length; + lines.push(`\n ... (${omittedCount} more functions omitted)`); + } + + lines.push(''); + lines.push( + 'Use --search , --min-self , or --limit to filter functions, or f- handles to inspect individual functions.' + ); + + return lines.join('\n'); +} + +function formatNetworkPhases(phases: NetworkPhaseTimings): string { + const parts: string[] = []; + if (phases.dns !== undefined) { + parts.push(`DNS=${formatDuration(phases.dns)}`); + } + if (phases.tcp !== undefined) { + parts.push(`TCP=${formatDuration(phases.tcp)}`); + } + if (phases.tls !== undefined) { + parts.push(`TLS=${formatDuration(phases.tls)}`); + } + if (phases.ttfb !== undefined) { + parts.push(`TTFB=${formatDuration(phases.ttfb)}`); + } + if (phases.download !== undefined) { + parts.push(`DL=${formatDuration(phases.download)}`); + } + if (phases.mainThread !== undefined) { + parts.push(`wait=${formatDuration(phases.mainThread)}`); + } + return parts.join(' '); +} + +export function formatThreadNetworkResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context), '']; + + const filterSuffix = + result.filters !== undefined && + result.filteredRequestCount !== result.totalRequestCount + ? ` (filtered from ${result.totalRequestCount})` + : ''; + + const truncated = result.requests.length < result.filteredRequestCount; + const countStr = truncated + ? `${result.requests.length} of ${result.filteredRequestCount} requests` + : `${result.filteredRequestCount} requests`; + + lines.push( + `Network requests in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${countStr}${filterSuffix}` + ); + lines.push(''); + + // Summary + const s = result.summary; + lines.push('Summary:'); + lines.push( + ` Cache: ${s.cacheHit} hit, ${s.cacheMiss} miss, ${s.cacheUnknown} unknown` + ); + + const pt = s.phaseTotals; + const hasPhaseTotals = + pt.dns !== undefined || + pt.tcp !== undefined || + pt.tls !== undefined || + pt.ttfb !== undefined || + pt.download !== undefined || + pt.mainThread !== undefined; + + if (hasPhaseTotals) { + lines.push(' Phase totals:'); + if (pt.dns !== undefined) { + lines.push(` DNS: ${formatDuration(pt.dns)}`); + } + if (pt.tcp !== undefined) { + lines.push(` TCP connect: ${formatDuration(pt.tcp)}`); + } + if (pt.tls !== undefined) { + lines.push(` TLS: ${formatDuration(pt.tls)}`); + } + if (pt.ttfb !== undefined) { + lines.push(` TTFB: ${formatDuration(pt.ttfb)}`); + } + if (pt.download !== undefined) { + lines.push(` Download: ${formatDuration(pt.download)}`); + } + if (pt.mainThread !== undefined) { + lines.push(` Main thread wait: ${formatDuration(pt.mainThread)}`); + } + } + + lines.push(''); + + if (result.requests.length === 0) { + lines.push('No network requests match the specified filters.'); + return lines.join('\n'); + } + + for (const req of result.requests) { + const url = req.url.length > 100 ? req.url.slice(0, 97) + '...' : req.url; + const status = + req.httpStatus !== undefined ? String(req.httpStatus) : '???'; + const version = req.httpVersion !== undefined ? ` ${req.httpVersion}` : ''; + const cache = + req.cacheStatus !== undefined ? ` cache=${req.cacheStatus}` : ''; + const size = + req.transferSizeKB !== undefined + ? ` size=${req.transferSizeKB.toFixed(1)}KB` + : ''; + + lines.push(` ${url}`); + lines.push( + ` ${status}${version}${cache}${size} duration=${formatDuration(req.duration)}` + ); + + const phaseStr = formatNetworkPhases(req.phases); + if (phaseStr) { + lines.push(` Phases: ${phaseStr}`); + } + + lines.push(''); + } + + if (truncated) { + lines.push( + `Use --limit 0 to show all requests, or --limit to set a different limit.` + ); + } else { + lines.push( + 'Use --search , --min-duration , --max-duration , or --limit to filter.' + ); + } + + return lines.join('\n'); +} + +export function formatFunctionAnnotateResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const out: string[] = []; + const RULER = '─'.repeat(80); + + out.push(contextHeader, ''); + out.push(`Function ${result.functionHandle}: ${result.name}`); + out.push(`Thread: ${result.friendlyThreadName} (${result.threadHandle})`, ''); + out.push( + `Self time: ${Math.round(result.totalSelfSamples)} samples, ` + + `Total time: ${Math.round(result.totalTotalSamples)} samples` + ); + out.push(`Mode: ${result.mode}`); + + for (const w of result.warnings) { + out.push('', `Warning: ${w}`); + } + + // Source annotation + const src = result.srcAnnotation; + if (src) { + const fileSuffix = + src.totalFileLines !== null ? ` (${src.totalFileLines} lines)` : ''; + out.push('', `Source file: ${src.filename}${fileSuffix}`); + out.push( + ` ${Math.round(src.samplesWithLineInfo)} of ${Math.round(src.samplesWithFunction)} ` + + `samples have line number information` + ); + out.push(` Showing: ${src.contextMode}`, ''); + + const W_LINE = 5; + const W_SELF = 6; + const W_TOTAL = 7; + + out.push( + `${'Line'.padStart(W_LINE)} ${'Self'.padStart(W_SELF)} ${'Total'.padStart(W_TOTAL)} Source` + ); + out.push(RULER); + + const showGaps = src.contextMode !== 'full file'; + let prevLine: number | null = null; + for (const line of src.lines) { + if (showGaps && prevLine !== null && line.lineNumber > prevLine + 1) { + out.push(' '.repeat(W_LINE + 2) + '...'); + } + prevLine = line.lineNumber; + + const selfStr = + line.selfSamples > 0 + ? String(Math.round(line.selfSamples)).padStart(W_SELF) + : ' '.repeat(W_SELF); + const totalStr = + line.totalSamples > 0 + ? String(Math.round(line.totalSamples)).padStart(W_TOTAL) + : ' '.repeat(W_TOTAL); + const srcText = line.sourceText !== null ? ` ${line.sourceText}` : ''; + out.push( + `${String(line.lineNumber).padStart(W_LINE)} ${selfStr} ${totalStr}${srcText}` + ); + } + } + + // Assembly annotations + for (const asm of result.asmAnnotations) { + out.push('', `Compilation ${asm.compilationIndex}:`); + out.push(` Name: ${asm.symbolName}`); + out.push(` Address: 0x${asm.symbolAddress.toString(16)}`); + if (asm.functionSize !== null) { + out.push(` Function size: ${asm.functionSize} bytes`); + } + out.push(` Native symbols: ${asm.nativeSymbolCount}`); + + if (asm.fetchError !== null) { + out.push(` (Assembly unavailable: ${asm.fetchError})`); + continue; + } + + out.push(''); + out.push( + ` ${'Address'.padEnd(18)}${'Self'.padStart(6)} ${'Total'.padStart(7)} Instruction` + ); + out.push(' ' + '─'.repeat(70)); + + for (const instr of asm.instructions) { + const addrStr = `0x${instr.address.toString(16)}`.padEnd(18); + const selfStr = + instr.selfSamples > 0 + ? String(Math.round(instr.selfSamples)).padStart(6) + : ' '.repeat(6); + const totalStr = + instr.totalSamples > 0 + ? String(Math.round(instr.totalSamples)).padStart(7) + : ' '.repeat(7); + out.push(` ${addrStr}${selfStr} ${totalStr} ${instr.decodedString}`); + } + } + + if ( + result.srcAnnotation && + result.srcAnnotation.contextMode !== 'full file' + ) { + out.push( + '', + `Tip: use --context file to show the full source file, or --context for more context lines.` + ); + } + + return out.join('\n'); +} + +export function formatProfileLogsResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context), '']; + + const { filters } = result; + const isFiltered = + filters !== undefined && + (filters.thread !== undefined || + filters.module !== undefined || + filters.level !== undefined || + filters.search !== undefined || + filters.limit !== undefined); + + const shown = result.entries.length; + const total = result.totalCount; + + if (total === 0) { + lines.push( + isFiltered + ? 'No log entries match the specified filters.' + : 'No Log markers found in this profile.' + ); + return lines.join('\n'); + } + + if (isFiltered && shown < total) { + lines.push(`Showing ${shown} of ${total} log entries (filtered/limited)`); + } else if (isFiltered) { + lines.push(`${total} log entries (filtered)`); + } else { + lines.push(`${total} log entries`); + } + lines.push(''); + + for (const entry of result.entries) { + lines.push(entry); + } + + return lines.join('\n'); +} + +export function formatThreadPageLoadResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context), '']; + + if (result.navigationTotal === 0) { + lines.push( + 'No page load markers found in this thread.', + 'Try a different thread or check that the profile includes a web page load.' + ); + return lines.join('\n'); + } + + const navLabel = + result.navigationTotal > 1 + ? ` [Navigation ${result.navigationIndex} of ${result.navigationTotal}]` + : ''; + + lines.push( + `Page Load Summary — ${result.friendlyThreadName} (${result.threadHandle})${navLabel}` + ); + lines.push(''); + + if (result.url) { + lines.push(` URL: ${result.url}`); + lines.push(''); + } + + // ── Navigation Timing ────────────────────────────────────────────────────── + + lines.push('──── Navigation Timing ────'); + lines.push(''); + + const milestones = result.milestones; + + if (milestones.length === 0) { + lines.push(' No navigation timing data available.'); + } else { + const TIMELINE_WIDTH = 60; + // Axis max = largest non-TTFI milestone. TTFI is shown with ▶ if it + // exceeds this, since it's post-load and can dwarf everything else. + const nonTtfiMilestones = milestones.filter((m) => m.name !== 'TTFI'); + const axisMax = + nonTtfiMilestones.length > 0 + ? Math.max(...nonTtfiMilestones.map((m) => m.timeMs)) + : milestones[milestones.length - 1].timeMs; + + // Label column: name (right-aligned) + space + handle (left-aligned) + const maxLabelLen = Math.max(...milestones.map((m) => m.name.length)); + const labelWidth = Math.max(maxLabelLen, 3); + const maxHandleLen = Math.max( + ...milestones.map((m) => m.markerHandle.length) + ); + // Total prefix width before the bar: labelWidth + 1 (space) + maxHandleLen + 2 (gap) + const prefixWidth = labelWidth + 1 + maxHandleLen + 2; + + // Time header line + const startLabel = '0ms'; + const endLabel = `${Math.round(axisMax)}ms`; + const padding = TIMELINE_WIDTH - startLabel.length - endLabel.length; + lines.push( + ` ${' '.repeat(prefixWidth)}${startLabel}${' '.repeat(Math.max(0, padding))}${endLabel}` + ); + + // Axis line + lines.push(` ${' '.repeat(prefixWidth)}${'─'.repeat(TIMELINE_WIDTH)}`); + + // One row per milestone + for (const m of milestones) { + const label = m.name.padStart(labelWidth); + const handle = m.markerHandle.padEnd(maxHandleLen); + let bar: string; + if (m.timeMs > axisMax) { + bar = '─'.repeat(TIMELINE_WIDTH) + '▶'; + } else { + const pos = + axisMax > 0 + ? Math.round((m.timeMs / axisMax) * TIMELINE_WIDTH) + : TIMELINE_WIDTH; + // Clamp to TIMELINE_WIDTH - 1 so │ always fits within the axis width + const drawPos = Math.min(pos, TIMELINE_WIDTH - 1); + bar = '─'.repeat(Math.max(0, drawPos)) + '│'; + } + lines.push(` ${label} ${handle} ${bar} ${formatDuration(m.timeMs)}`); + } + } + + lines.push(''); + + // ── Resources ───────────────────────────────────────────────────────────── + + lines.push(`──── Resources (${result.resourceCount} requests) ────`); + lines.push(''); + + if (result.resourceCount === 0) { + lines.push(' No network requests recorded during page load.'); + } else { + if (result.resourceAvgMs !== null) { + lines.push(` Avg duration: ${formatDuration(result.resourceAvgMs)}`); + } + if (result.resourceMaxMs !== null) { + lines.push(` Max duration: ${formatDuration(result.resourceMaxMs)}`); + } + lines.push(''); + + if (result.resourcesByType.length > 0) { + const W_RTYPE = 8; + const W_RCOUNT = 4; + const W_PCT = 5; + lines.push(' By type:'); + for (const t of result.resourcesByType) { + const countStr = String(t.count).padStart(W_RCOUNT); + const pctStr = t.percentage.toFixed(1).padStart(W_PCT); + lines.push(` ${t.type.padEnd(W_RTYPE)} ${countStr} (${pctStr}%)`); + } + lines.push(''); + } + + if (result.topResources.length > 0) { + const W_NUM = 3; + const W_DUR = 7; + const W_FILE = 50; + lines.push(' Top 10 longest:'); + result.topResources.forEach((r, idx) => { + const num = String(idx + 1).padStart(W_NUM); + const dur = formatDuration(r.durationMs).padStart(W_DUR); + const file = r.filename.padEnd(W_FILE); + lines.push( + ` ${num}. ${dur} ${file} ${r.resourceType} ${r.markerHandle}` + ); + }); + } + } + + lines.push(''); + + // ── CPU Categories ───────────────────────────────────────────────────────── + + lines.push(`──── CPU Categories (${result.totalSamples} samples) ────`); + lines.push(''); + + if (result.categories.length === 0) { + lines.push(' No sample data available during page load.'); + } else { + const BAR_WIDTH = 28; + const maxCount = result.categories[0].count; + const maxNameLen = Math.max(...result.categories.map((c) => c.name.length)); + + for (const cat of result.categories) { + const barLen = + maxCount > 0 ? Math.round((cat.count / maxCount) * BAR_WIDTH) : 0; + const bar = '█'.repeat(barLen).padEnd(BAR_WIDTH); + const name = cat.name.padEnd(maxNameLen); + const countStr = String(cat.count).padStart(6); + const pctStr = cat.percentage.toFixed(1).padStart(5); + lines.push(` ${name} ${bar} ${countStr} ${pctStr}%`); + } + } + + lines.push(''); + + // ── Jank ────────────────────────────────────────────────────────────────── + + lines.push(`──── Jank (${result.jankTotal} periods) ────`); + lines.push(''); + + if (result.jankTotal === 0) { + lines.push(' No jank detected during page load.'); + } else { + const shown = result.jankPeriods.length; + result.jankPeriods.forEach((jank, idx) => { + lines.push( + ` Jank ${idx + 1} (${jank.markerHandle}) at ${formatDuration(jank.startMs)} ${formatDuration(jank.durationMs)} duration [${jank.startHandle} → ${jank.endHandle}]` + ); + + if (jank.topFunctions.length > 0) { + lines.push(' Top functions:'); + for (const fn of jank.topFunctions) { + const name = truncateFunctionName(fn.name, 60); + lines.push(` ${name.padEnd(60)} ${fn.sampleCount} samples`); + } + } + + if (jank.categories.length > 0) { + const catStr = jank.categories + .map((c) => `${c.name}: ${c.count}`) + .join(' '); + lines.push(` Categories: ${catStr}`); + } + + lines.push(''); + }); + + if (shown < result.jankTotal) { + lines.push( + ` Showing ${shown} of ${result.jankTotal} jank periods. Use --jank-limit or --jank-limit 0 to show more.` + ); + } + } + + return lines.join('\n'); +} + +export function formatThreadSelectResult( + result: WithContext +): string { + const count = result.threadNames.length; + const names = result.threadNames.join(', '); + if (count === 1) { + return `Selected thread: ${result.threadHandle} (${names})`; + } + return `Selected ${count} threads: ${result.threadHandle} (${names})`; +} diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts new file mode 100644 index 0000000000..5ee26fa4eb --- /dev/null +++ b/profiler-cli/src/index.ts @@ -0,0 +1,192 @@ +/* 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/. */ + +/** + * CLI entry point for profiler-cli (Profiler CLI). + * + * Usage: + * profiler-cli load [--session ] Start a new daemon and load a profile + * profiler-cli profile info [--session ] Print profile summary + * profiler-cli thread info [--thread ] Print thread information + * profiler-cli thread samples [--thread ] Show thread call tree and top functions + * profiler-cli stop [] [--all] Stop the daemon + * profiler-cli session list List all running sessions + * profiler-cli session use Switch the current session + * + * Build: + * yarn build-profiler-cli + * + * Run: + * profiler-cli (if profiler-cli is in PATH) + * ./profiler-cli/dist/profiler-cli.js (direct invocation) + */ + +import * as path from 'path'; +import * as os from 'os'; +import { Command } from 'commander'; +import guideText from '../guide.txt'; +import schemasText from '../schemas.txt'; +import { startDaemon } from './daemon'; +import { startNewDaemon, stopDaemon, sendCommand } from './client'; +import { listSessions } from './session'; +import { formatOutput } from './output'; +import { addGlobalOptions } from './commands/shared'; +import { VERSION } from './constants'; +import { registerProfileCommand } from './commands/profile'; +import { registerThreadCommand } from './commands/thread'; +import { registerMarkerCommand } from './commands/marker'; +import { registerFunctionCommand } from './commands/function'; +import { registerZoomCommand } from './commands/zoom'; +import { registerFilterCommand } from './commands/filter'; +import { registerSessionCommand } from './commands/session'; + +// Read session directory from environment (only place this is read) +const SESSION_DIR = + process.env.PROFILER_CLI_SESSION_DIR || + path.join(os.homedir(), '.profiler-cli'); + +async function main(): Promise { + const rawArgs = process.argv.slice(2); + + // Daemon escape hatch: spawned internally by startNewDaemon(), never shown in --help + if (rawArgs.includes('--daemon')) { + const daemonIdx = rawArgs.indexOf('--daemon'); + const profilePath = rawArgs.find( + (a, i) => i > daemonIdx && !a.startsWith('-') + ); + const sessionIdx = rawArgs.indexOf('--session'); + const sessionId = sessionIdx !== -1 ? rawArgs[sessionIdx + 1] : undefined; + const symbolServerIdx = rawArgs.indexOf('--symbol-server'); + const symbolServerUrl = + symbolServerIdx !== -1 ? rawArgs[symbolServerIdx + 1] : undefined; + if (!profilePath) { + console.error('Error: Profile path required for daemon mode'); + process.exit(1); + } + await startDaemon(SESSION_DIR, profilePath, sessionId, symbolServerUrl); + return; + } + + const program = new Command(); + program + .name('profiler-cli') + .description('Profiler CLI — query Firefox profiles from the terminal') + .version(VERSION, '-V, --version', 'Print the version number') + .helpOption('-h, --help', 'Show help') + .addHelpCommand('help [command]', 'Show help for a command') + .addHelpText( + 'after', + ` +Examples: + profiler-cli load profile.json.gz + profiler-cli profile info + profiler-cli thread info + profiler-cli thread samples + profiler-cli thread functions --search GC --min-self 1 + profiler-cli thread markers --search DOMEvent --category Graphics + profiler-cli zoom push 2.7,3.1 + profiler-cli filter push --excludes-function f-184 + profiler-cli status + profiler-cli stop --all` + ); + + // Unknown commands + program.on('command:*', (operands: string[]) => { + console.error(`Error: Unknown command '${operands[0]}'\n`); + program.outputHelp(); + process.exit(1); + }); + + // profiler-cli load + addGlobalOptions( + program + .command('load ') + .description('Load a profile and start a daemon session') + .option( + '--symbol-server ', + 'Symbol server URL for symbolication (overrides URL param and default Mozilla server)' + ) + ).action(async (profilePath: string, opts) => { + console.log(`Loading profile from ${profilePath}...`); + const sessionId = await startNewDaemon( + SESSION_DIR, + profilePath, + opts.session, + opts.symbolServer + ); + console.log(`Session started: ${sessionId}`); + }); + + // profiler-cli status + addGlobalOptions( + program + .command('status') + .description( + 'Show session status (selected thread, zoom ranges, filters)' + ) + ).action(async (opts) => { + const result = await sendCommand( + SESSION_DIR, + { command: 'status' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // profiler-cli stop [id] + addGlobalOptions( + program + .command('stop [id]') + .description( + 'Stop the current session, a specific session, or all with --all' + ) + .option('--all', 'Stop all running sessions') + ).action(async (idArg: string | undefined, opts) => { + if (opts.all) { + const sessionIds = listSessions(SESSION_DIR); + await Promise.all( + sessionIds.map((id: string) => stopDaemon(SESSION_DIR, id)) + ); + } else { + const sessionId = idArg ?? opts.session; + await stopDaemon(SESSION_DIR, sessionId); + } + }); + + // profiler-cli guide + program + .command('guide') + .description('Show detailed usage guide (commands, patterns, tips)') + .action(() => { + console.log(guideText); + }); + + // profiler-cli schemas + program + .command('schemas') + .description('Show JSON output schemas for all commands') + .action(() => { + console.log(schemasText); + }); + + registerProfileCommand(program, SESSION_DIR); + registerThreadCommand(program, SESSION_DIR); + registerMarkerCommand(program, SESSION_DIR); + registerFunctionCommand(program, SESSION_DIR); + registerZoomCommand(program, SESSION_DIR); + registerFilterCommand(program, SESSION_DIR); + registerSessionCommand(program, SESSION_DIR); + + try { + await program.parseAsync(process.argv); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } +} + +main().catch((error) => { + console.error(`Fatal error: ${error}`); + process.exit(1); +}); diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts new file mode 100644 index 0000000000..36d8fc6345 --- /dev/null +++ b/profiler-cli/src/output.ts @@ -0,0 +1,94 @@ +/* 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/. */ + +/** + * Output formatting for profiler-cli commands. + */ + +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import type { CommandResult } from './protocol'; +import { + formatStatusResult, + formatFunctionExpandResult, + formatFunctionInfoResult, + formatFunctionAnnotateResult, + formatViewRangeResult, + formatFilterStackResult, + formatThreadInfoResult, + formatMarkerStackResult, + formatMarkerInfoResult, + formatProfileInfoResult, + formatThreadSamplesResult, + formatThreadSamplesTopDownResult, + formatThreadSamplesBottomUpResult, + formatThreadMarkersResult, + formatThreadFunctionsResult, + formatThreadNetworkResult, + formatProfileLogsResult, + formatThreadPageLoadResult, + formatThreadSelectResult, +} from './formatters'; + +/** + * Format a command result for output. + * If jsonFlag is true, outputs JSON. Otherwise outputs as plain text. + */ +export function formatOutput( + result: string | CommandResult, + jsonFlag: boolean +): string { + if (jsonFlag) { + if (typeof result === 'string') { + return JSON.stringify({ type: 'text', result }, null, 2); + } + return JSON.stringify(result, null, 2); + } + + if (typeof result === 'string') { + return result; + } + + switch (result.type) { + case 'status': + return formatStatusResult(result); + case 'filter-stack': + return formatFilterStackResult(result); + case 'function-expand': + return formatFunctionExpandResult(result); + case 'function-info': + return formatFunctionInfoResult(result); + case 'function-annotate': + return formatFunctionAnnotateResult(result); + case 'view-range': + return formatViewRangeResult(result); + case 'thread-info': + return formatThreadInfoResult(result); + case 'marker-stack': + return formatMarkerStackResult(result); + case 'marker-info': + return formatMarkerInfoResult(result); + case 'profile-info': + return formatProfileInfoResult(result); + case 'thread-samples': + return formatThreadSamplesResult(result); + case 'thread-samples-top-down': + return formatThreadSamplesTopDownResult(result); + case 'thread-samples-bottom-up': + return formatThreadSamplesBottomUpResult(result); + case 'thread-markers': + return formatThreadMarkersResult(result); + case 'thread-functions': + return formatThreadFunctionsResult(result); + case 'thread-network': + return formatThreadNetworkResult(result); + case 'profile-logs': + return formatProfileLogsResult(result); + case 'thread-page-load': + return formatThreadPageLoadResult(result); + case 'thread-select': + return formatThreadSelectResult(result); + default: + throw assertExhaustiveCheck(result); + } +} diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts new file mode 100644 index 0000000000..df510dff91 --- /dev/null +++ b/profiler-cli/src/protocol.ts @@ -0,0 +1,207 @@ +/* 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/. */ + +/** + * Protocol for communication between profiler-cli client and daemon. + * Messages are sent as line-delimited JSON over Unix domain sockets. + */ + +// Re-export shared types from profile-query +export type { + MarkerFilterOptions, + FlatMarkerItem, + FunctionFilterOptions, + SampleFilterSpec, + FilterEntry, + FilterStackResult, + SessionContext, + WithContext, + StatusResult, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + ViewRangeResult, + ThreadInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + CallTreeNode, + CallTreeScoringStrategy, + ThreadMarkersResult, + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, + ThreadFunctionsResult, + ThreadPageLoadResult, + NavigationMilestone, + PageLoadResourceEntry, + PageLoadCategoryEntry, + JankPeriod, + JankFunction, + DurationStats, + RateStats, + MarkerGroupData, + MarkerInfoResult, + MarkerStackResult, + StackTraceData, + ProfileInfoResult, + ProfileLogsResult, + ThreadSelectResult, +} from '../../src/profile-query/types'; +export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; + +// Import types for use in type definitions +import type { + MarkerFilterOptions, + FunctionFilterOptions, + SampleFilterSpec, + WithContext, + StatusResult, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + ViewRangeResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadNetworkResult, + ThreadFunctionsResult, + ThreadPageLoadResult, + FilterStackResult, + ProfileLogsResult, + ThreadSelectResult, +} from '../../src/profile-query/types'; +import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; + +export type ClientMessage = + | { type: 'command'; command: ClientCommand } + | { type: 'shutdown' } + | { type: 'status' }; + +export type ClientCommand = + | { + command: 'profile'; + subcommand: 'info' | 'threads'; + all?: boolean; + search?: string; + } + | { + command: 'profile'; + subcommand: 'logs'; + logFilters?: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + }; + } + | { + command: 'thread'; + subcommand: + | 'info' + | 'select' + | 'samples' + | 'samples-top-down' + | 'samples-bottom-up' + | 'markers' + | 'functions' + | 'network' + | 'page-load'; + thread?: string; + includeIdle?: boolean; + search?: string; + markerFilters?: MarkerFilterOptions; + functionFilters?: FunctionFilterOptions; + callTreeOptions?: CallTreeCollectionOptions; + networkFilters?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + }; + pageLoadOptions?: { + navigationIndex?: number; + jankLimit?: number; + }; + /** Ephemeral sample filters applied only to this command invocation */ + sampleFilters?: SampleFilterSpec[]; + } + | { + command: 'marker'; + subcommand: 'info' | 'select' | 'stack'; + marker?: string; + } + | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } + | { + command: 'function'; + subcommand: 'info' | 'select' | 'expand' | 'annotate'; + function?: string; + annotateMode?: AnnotateMode; + symbolServerUrl?: string; + /** "file", "function", or a number of context lines (e.g. "2") */ + annotateContext?: string; + } + | { + command: 'zoom'; + subcommand: 'push' | 'pop' | 'clear'; + range?: string; + } + | { + command: 'filter'; + subcommand: 'push' | 'pop' | 'list' | 'clear'; + thread?: string; + spec?: SampleFilterSpec; + count?: number; + } + | { command: 'status' }; + +export type ServerResponse = + | { type: 'success'; result: string | CommandResult } + | { type: 'error'; error: string } + | { type: 'loading' } + | { type: 'symbolicating' } + | { type: 'ready' }; + +/** + * CommandResult is a union of all possible structured result types. + * Commands can return either a string (legacy) or a structured result. + */ +export type CommandResult = + | StatusResult + | WithContext + | WithContext + | ViewRangeResult + | FilterStackResult + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext; + +export interface SessionMetadata { + id: string; + socketPath: string; + logPath: string; + pid: number; + profilePath: string; + createdAt: string; + buildHash: string; +} diff --git a/profiler-cli/src/session.ts b/profiler-cli/src/session.ts new file mode 100644 index 0000000000..bb746db959 --- /dev/null +++ b/profiler-cli/src/session.ts @@ -0,0 +1,239 @@ +/* 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/. */ + +/** + * Session management for profiler-cli daemon. + * Handles session files, socket paths, and current session tracking. + * + * All functions take an explicit sessionDir parameter for testability + * and to avoid global state. The CLI entry point reads PROFILER_CLI_SESSION_DIR + * once and passes it through. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import type { SessionMetadata } from './protocol'; + +/** + * Ensure the session directory exists. + */ +export function ensureSessionDir(sessionDir: string): void { + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + } +} + +/** + * Generate a new session ID. + */ +export function generateSessionId(): string { + return Math.random().toString(36).substring(2, 15); +} + +/** + * Get a stable namespace for a session directory. + */ +export function getSessionDirNamespace(sessionDir: string): string { + const resolvedSessionDir = path.resolve(sessionDir).toLowerCase(); + return crypto + .createHash('sha256') + .update(resolvedSessionDir) + .digest('hex') + .slice(0, 12); +} + +/** + * Get the socket path for a session. + * On Windows, returns a named pipe path. On Unix, returns a .sock file path. + */ +export function getSocketPath(sessionDir: string, sessionId: string): string { + if (process.platform === 'win32') { + const sessionDirNamespace = getSessionDirNamespace(sessionDir); + return `\\\\.\\pipe\\profiler-cli-${sessionDirNamespace}-${sessionId}`; + } + return path.join(sessionDir, `${sessionId}.sock`); +} + +/** + * Get the log path for a session. + */ +export function getLogPath(sessionDir: string, sessionId: string): string { + return path.join(sessionDir, `${sessionId}.log`); +} + +/** + * Get the metadata file path for a session. + */ +export function getMetadataPath(sessionDir: string, sessionId: string): string { + return path.join(sessionDir, `${sessionId}.json`); +} + +/** + * Save session metadata to disk. + */ +export function saveSessionMetadata( + sessionDir: string, + metadata: SessionMetadata +): void { + ensureSessionDir(sessionDir); + const metadataPath = getMetadataPath(sessionDir, metadata.id); + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +} + +/** + * Load session metadata from disk. + */ +export function loadSessionMetadata( + sessionDir: string, + sessionId: string +): SessionMetadata | null { + const metadataPath = getMetadataPath(sessionDir, sessionId); + if (!fs.existsSync(metadataPath)) { + return null; + } + try { + const data = fs.readFileSync(metadataPath, 'utf-8'); + return JSON.parse(data) as SessionMetadata; + } catch (_error) { + return null; + } +} + +/** + * Set the current session by writing to a text file. + */ +export function setCurrentSession(sessionDir: string, sessionId: string): void { + ensureSessionDir(sessionDir); + + const currentSessionFile = path.join(sessionDir, 'current.txt'); + fs.writeFileSync(currentSessionFile, sessionId, 'utf-8'); +} + +/** + * Get the current session ID by reading from a text file. + */ +export function getCurrentSessionId(sessionDir: string): string | null { + const currentSessionFile = path.join(sessionDir, 'current.txt'); + + try { + return fs.readFileSync(currentSessionFile, 'utf-8').trim(); + } catch (error: any) { + if (error && error.code === 'ENOENT') { + return null; + } + throw error; + } +} + +/** + * Get the socket path for the current session. + */ +export function getCurrentSocketPath(sessionDir: string): string | null { + const sessionId = getCurrentSessionId(sessionDir); + + if (!sessionId) { + return null; + } + + return getSocketPath(sessionDir, sessionId); +} + +/** + * Check if a process is running. + */ +export function isProcessRunning(pid: number): boolean { + try { + // Sending signal 0 checks if process exists without killing it + process.kill(pid, 0); + return true; + } catch (_error) { + return false; + } +} + +/** + * Wait for a process to exit. + */ +export async function waitForProcessExit( + pid: number, + timeoutMs: number = 5000, + pollIntervalMs: number = 50 +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (!isProcessRunning(pid)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return !isProcessRunning(pid); +} + +/** + * Clean up a session's files. + */ +export function cleanupSession(sessionDir: string, sessionId: string): void { + const socketPath = getSocketPath(sessionDir, sessionId); + const metadataPath = getMetadataPath(sessionDir, sessionId); + const currentSessionFile = path.join(sessionDir, 'current.txt'); + // Note: We intentionally don't delete the log file for debugging purposes + // const logPath = getLogPath(sessionDir, sessionId); + + // Remove socket file (Unix only — named pipes on Windows are not filesystem files) + // Use force: true to silently ignore ENOENT — client and daemon may both call + // cleanupSession concurrently during version-mismatch shutdown, so the file + // may already be gone by the time the second caller tries to unlink it. + if (process.platform !== 'win32') { + fs.rmSync(socketPath, { force: true }); + } + + // Remove metadata file + fs.rmSync(metadataPath, { force: true }); + + // Remove current session file if it points to this session + const currentSessionId = getCurrentSessionId(sessionDir); + if (currentSessionId === sessionId) { + fs.rmSync(currentSessionFile, { force: true }); + } +} + +/** + * Validate that a session is healthy (process running, socket exists). + * If not, clean up stale files. + */ +export function validateSession( + sessionDir: string, + sessionId: string +): SessionMetadata | null { + const metadata = loadSessionMetadata(sessionDir, sessionId); + if (!metadata) { + return null; + } + + // Check if process is still running + if (!isProcessRunning(metadata.pid)) { + return null; + } + + // Check if socket exists (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && !fs.existsSync(metadata.socketPath)) { + return null; + } + + return metadata; +} + +/** + * List all session IDs. + */ +export function listSessions(sessionDir: string): string[] { + ensureSessionDir(sessionDir); + const files = fs.readdirSync(sessionDir); + return files + .filter((f) => f.endsWith('.json')) + .map((f) => path.basename(f, '.json')); +} diff --git a/profiler-cli/src/test/integration/basic.test.ts b/profiler-cli/src/test/integration/basic.test.ts new file mode 100644 index 0000000000..8427403d7c --- /dev/null +++ b/profiler-cli/src/test/integration/basic.test.ts @@ -0,0 +1,362 @@ +/* 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/. */ + +/** + * Basic CLI functionality tests. + */ + +import { readdir, readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + cli, + cliFail, + type CliTestContext, +} from './utils'; + +describe('profiler-cli basic functionality', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('load creates a session', async () => { + const result = await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Loading profile from'); + expect(result.stdout).toContain('Session started:'); + + // Extract session ID + expect(typeof result.stdout).toBe('string'); + const match = (result.stdout as string).match(/Session started: (\w+)/); + expect(match).toBeTruthy(); + const sessionId = match![1]; + + // Verify session files exist + const files = await readdir(ctx.sessionDir); + // Named pipes on Windows are not filesystem files, so no .sock file is created + const expectedFiles = [ + `${sessionId}.json`, + ...(process.platform !== 'win32' ? [`${sessionId}.sock`] : []), + ]; + expect(files).toEqual(expect.arrayContaining(expectedFiles)); + expect(files).toContain('current.txt'); + }); + + it('profile info works after load', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cli(ctx, ['profile', 'info']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('This profile contains'); + }); + + it('thread select works immediately after load', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cli(ctx, ['thread', 'select', 't-0']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Selected thread'); + expect(result.stdout).toContain('t-0'); + }); + + it('stop cleans up session', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['stop']); + + // Verify socket is removed (the main cleanup requirement) + const files = await readdir(ctx.sessionDir); + expect(files.filter((f) => f.endsWith('.sock'))).toHaveLength(0); + }); + + it('load fails for missing file', async () => { + const result = await cliFail(ctx, ['load', '/nonexistent/file.json']); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('not found'); + }); + + it('profile info fails without active session', async () => { + const result = await cliFail(ctx, ['profile', 'info']); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('No active session'); + }); + + it('multiple profile info calls work (daemon stays running)', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + // First call + const result1 = await cli(ctx, ['profile', 'info']); + expect(result1.exitCode).toBe(0); + + // Second call - should still work (daemon running) + const result2 = await cli(ctx, ['profile', 'info']); + expect(result2.exitCode).toBe(0); + expect(result2.stdout).toEqual(result1.stdout); + }); + + it('numeric zero marker filters are preserved instead of being ignored', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const minDurationResult = await cli(ctx, [ + 'thread', + 'markers', + '--json', + '--min-duration', + '0', + ]); + expect(minDurationResult.stdout).toContain('"minDuration": 0'); + + const maxDurationResult = await cli(ctx, [ + 'thread', + 'markers', + '--json', + '--max-duration', + '0', + ]); + expect(maxDurationResult.stdout).toContain('"maxDuration": 0'); + }); + + it('numeric zero function filters are preserved instead of being ignored', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cli(ctx, [ + 'thread', + 'functions', + '--json', + '--min-self', + '0', + ]); + + expect(result.stdout).toContain('"minSelf": 0'); + }); + + it('sticky filters are isolated per thread and reported in status', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['thread', 'select', 't-0']); + + await cli(ctx, ['filter', 'push', '--merge', 'f-1,f-2']); + + const filterListResult = await cli(ctx, ['filter', 'list', '--json']); + const filterList = JSON.parse(filterListResult.stdout) as { + type: string; + threadHandle: string; + filters: Array<{ + index: number; + transforms: Array<{ type: string; funcIndex?: number }>; + description: string; + }>; + }; + + expect(filterList.type).toBe('filter-stack'); + expect(filterList.threadHandle).toBe('t-0'); + // Multi-func push collapses into one entry backed by multiple transforms. + expect(filterList.filters).toHaveLength(1); + expect(filterList.filters[0].transforms).toEqual([ + { type: 'merge-function', funcIndex: 1 }, + { type: 'merge-function', funcIndex: 2 }, + ]); + expect(filterList.filters[0].description).toBe('merge: f-1, f-2'); + + const statusResult = await cli(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as { + type: string; + filterStacks: Array<{ + threadHandle: string; + filters: Array<{ + transforms: Array<{ type: string; funcIndex?: number }>; + }>; + }>; + }; + + expect(status.type).toBe('status'); + expect(status.filterStacks).toHaveLength(1); + expect(status.filterStacks[0]).toEqual( + expect.objectContaining({ + threadHandle: 't-0', + filters: [ + expect.objectContaining({ + transforms: [ + { type: 'merge-function', funcIndex: 1 }, + { type: 'merge-function', funcIndex: 2 }, + ], + }), + ], + }) + ); + + await cli(ctx, ['thread', 'select', 't-1']); + + const otherThreadFilterListResult = await cli(ctx, [ + 'filter', + 'list', + '--json', + ]); + const otherThreadFilterList = JSON.parse( + otherThreadFilterListResult.stdout + ) as { + threadHandle: string; + filters: unknown[]; + }; + + expect(otherThreadFilterList.threadHandle).toBe('t-1'); + expect(otherThreadFilterList.filters).toHaveLength(0); + + const explicitThreadFilterListResult = await cli(ctx, [ + 'filter', + 'list', + '--thread', + 't-0', + '--json', + ]); + const explicitThreadFilterList = JSON.parse( + explicitThreadFilterListResult.stdout + ) as { + threadHandle: string; + filters: Array<{ + transforms: Array<{ type: string; funcIndex?: number }>; + }>; + }; + + expect(explicitThreadFilterList.threadHandle).toBe('t-0'); + expect(explicitThreadFilterList.filters).toHaveLength(1); + expect(explicitThreadFilterList.filters[0].transforms).toEqual([ + { type: 'merge-function', funcIndex: 1 }, + { type: 'merge-function', funcIndex: 2 }, + ]); + + // One pop removes the whole entry (both underlying transforms). + await cli(ctx, ['filter', 'pop', '--thread', 't-0']); + const afterPopResult = await cli(ctx, [ + 'filter', + 'list', + '--thread', + 't-0', + '--json', + ]); + const afterPop = JSON.parse(afterPopResult.stdout) as { + filters: unknown[]; + }; + expect(afterPop.filters).toHaveLength(0); + }); + + it('ephemeral sample filters do not persist into session state', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const samplesResult = await cli(ctx, [ + 'thread', + 'samples', + '--json', + '--merge', + 'f-1', + ]); + const samples = JSON.parse(samplesResult.stdout) as { + type: string; + ephemeralFilters?: Array<{ type: string; funcIndexes?: number[] }>; + activeFilters?: unknown[]; + }; + + expect(samples.type).toBe('thread-samples'); + expect(samples.ephemeralFilters).toEqual([ + { type: 'merge', funcIndexes: [1] }, + ]); + expect(samples.activeFilters).toBeUndefined(); + + const filterListResult = await cli(ctx, ['filter', 'list', '--json']); + const filterList = JSON.parse(filterListResult.stdout) as { + filters: unknown[]; + }; + expect(filterList.filters).toHaveLength(0); + + const statusResult = await cli(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as { + filterStacks: unknown[]; + }; + expect(status.filterStacks).toHaveLength(0); + }); + + it('max-lines=0 is rejected instead of silently falling back to the default', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cliFail(ctx, [ + 'thread', + 'samples-top-down', + '--max-lines', + '0', + ]); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('--max-lines must be a positive integer'); + }); + + it('build hash mismatch stops the daemon before cleaning up the session', async () => { + const loadResult = await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + expect(typeof loadResult.stdout).toBe('string'); + const match = loadResult.stdout.match(/Session started: (\w+)/); + expect(match).toBeTruthy(); + const sessionId = match![1]; + + const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) as { + buildHash: string; + pid: number; + }; + + await writeFile( + metadataPath, + JSON.stringify({ ...metadata, buildHash: 'intentionally-mismatched' }) + ); + + const result = await cliFail(ctx, ['profile', 'info']); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('was built with a different version'); + expect(output).toContain('The daemon is no longer running'); + + await expectDaemonToExit(metadata.pid); + + const files = await readdir(ctx.sessionDir); + expect(files).not.toContain(`${sessionId}.json`); + expect(files).not.toContain(`${sessionId}.sock`); + }); +}); + +async function expectDaemonToExit(pid: number): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + if (!isProcessRunning(pid)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Daemon process ${pid} did not exit in time`); +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/profiler-cli/src/test/integration/daemon-startup.test.ts b/profiler-cli/src/test/integration/daemon-startup.test.ts new file mode 100644 index 0000000000..0afae28101 --- /dev/null +++ b/profiler-cli/src/test/integration/daemon-startup.test.ts @@ -0,0 +1,124 @@ +/* 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/. */ + +/** + * Tests for two-phase daemon startup behavior. + * Verifies socket creation before profile loading and proper status reporting. + */ + +import { readFile, access } from 'fs/promises'; +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + cli, + cliFail, + type CliTestContext, +} from './utils'; +import { getSocketPath } from '../../session'; + +describe('daemon startup (two-phase)', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('daemon creates socket and metadata before loading profile', async () => { + const startTime = Date.now(); + + const result = await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + const endTime = Date.now(); + const duration = endTime - startTime; + + expect(result.exitCode).toBe(0); + + // Should complete quickly (< 1 second for local file) + // The key improvement is that we don't wait for profile parsing + // before getting success feedback + expect(duration).toBeLessThan(2000); + + // Extract session ID + expect(typeof result.stdout).toBe('string'); + const match = (result.stdout as string).match(/Session started: (\w+)/); + const sessionId = match![1]; + + // Verify metadata file exists and contains correct info + const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); + + expect(metadata.id).toBe(sessionId); + expect(metadata.socketPath).toContain(sessionId); + expect(metadata.pid).toBeNumber(); + expect(metadata.profilePath).toContain('processed-1.json'); + }); + + it('load returns non-zero exit code on profile load failure', async () => { + // Create an invalid JSON file + const invalidProfile = join(ctx.sessionDir, 'invalid.json'); + const { writeFile } = await import('fs/promises'); + await writeFile(invalidProfile, '{ invalid json content', 'utf-8'); + + const result = await cliFail(ctx, ['load', invalidProfile]); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toMatch(/Profile load failed|Failed to|parse|invalid/i); + }); + + it('daemon startup fails fast with short timeout', async () => { + // This test verifies Phase 1 timeout behavior + // We can't easily force a daemon startup failure, but we can + // verify the timeout is reasonable by checking it doesn't wait forever + + const result = await cliFail(ctx, ['load', '/nonexistent/file.json']); + + // Should fail quickly (Phase 1: 500ms for daemon, Phase 2: fails on validation) + expect(result.exitCode).not.toBe(0); + }); + + it('load blocks until profile is fully loaded', async () => { + // Start loading + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + // If load returned, profile should be ready immediately + const result = await cli(ctx, ['profile', 'info']); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('This profile contains'); + }); + + it('validates session before returning (checks process + socket)', async () => { + const result = await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + expect(typeof result.stdout).toBe('string'); + const match = (result.stdout as string).match(/Session started: (\w+)/); + const sessionId = match![1]; + + // Verify both socket and metadata exist (validateSession checks both) + const socketPath = getSocketPath(ctx.sessionDir, sessionId); + const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); + + // Named pipes on Windows are not filesystem files, so treat that case as a no-op. + const socketAccessPromise = + process.platform === 'win32' ? Promise.resolve() : access(socketPath); + await expect(socketAccessPromise).resolves.toBeUndefined(); + await expect(access(metadataPath)).resolves.toBeUndefined(); + + // Process should be running (metadata contains PID) + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); + expect(metadata.pid).toBeNumber(); + expect(metadata.pid).toBeGreaterThan(0); + }); +}); diff --git a/profiler-cli/src/test/integration/sessions.test.ts b/profiler-cli/src/test/integration/sessions.test.ts new file mode 100644 index 0000000000..258d8028ef --- /dev/null +++ b/profiler-cli/src/test/integration/sessions.test.ts @@ -0,0 +1,237 @@ +/* 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/. */ + +/** + * Multi-session tests. + */ + +import { access, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + cli, + cliFail, + type CliTestContext, +} from './utils'; + +describe('profiler-cli multiple concurrent sessions', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('can run multiple sessions with explicit IDs', async () => { + const session1 = 'test-session-1'; + const session2 = 'test-session-2'; + + // Start two sessions + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + session1, + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + session2, + ]); + + // Query session1 explicitly + const result1 = await cli(ctx, ['profile', 'info', '--session', session1]); + expect(result1.stdout).toContain('This profile contains'); + + // Query current session (should be session2, the last loaded) + const result2 = await cli(ctx, ['profile', 'info']); + expect(result2.stdout).toContain('This profile contains'); + + // Stop all sessions (mix of positional arg and --session flag) + await cli(ctx, ['stop', session1]); + await cli(ctx, ['stop', '--session', session2]); + }); + + it('session list shows running sessions and marks the current one', async () => { + // Start two sessions + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // List sessions — session-b was loaded last, so it should be current + const result = await cli(ctx, ['session', 'list']); + + expect(result.stdout).toContain('Found 2 running sessions'); + expect(result.stdout).toContain('session-a'); + expect(result.stdout).toContain('session-b'); + expect(result.stdout).toMatch(/\* session-b/); + + // Clean up + await cli(ctx, ['stop', '--all']); + }); + + it('session use switches the current session', async () => { + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // session-b is current; switch to session-a + const switchResult = await cli(ctx, ['session', 'use', 'session-a']); + expect(switchResult.stdout).toContain('Switched to session session-a'); + + // session list should now mark session-a as current + const listResult = await cli(ctx, ['session', 'list']); + expect(listResult.stdout).toMatch(/\* session-a/); + + await cli(ctx, ['stop', '--all']); + }); + + it('stop --all stops all sessions', async () => { + // Start multiple sessions + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-1', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-2', + ]); + + // Stop all + await cli(ctx, ['stop', '--all']); + + // Verify no sessions + const result = await cli(ctx, ['session', 'list']); + expect(result.stdout).toContain('Found 0 running sessions'); + }); + + it('session use with unknown id fails', async () => { + const result = await cliFail(ctx, ['session', 'use', 'does-not-exist']); + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('does-not-exist'); + }); + + it('session use causes unqualified commands to target the switched session', async () => { + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // Switch to session-a (session-b is current) + await cli(ctx, ['session', 'use', 'session-a']); + + // Unqualified stop should stop session-a + await cli(ctx, ['stop']); + + // session-a is gone; session-b is still running + await cliFail(ctx, ['profile', 'info', '--session', 'session-a']); + const result = await cli(ctx, [ + 'profile', + 'info', + '--session', + 'session-b', + ]); + expect(result.exitCode).toBe(0); + + await cli(ctx, ['stop', '--all']); + }); + + it('reusing a live explicit session id fails without replacing the daemon', async () => { + const sessionId = 'shared-session'; + + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + sessionId, + ]); + + const secondLoad = await cliFail(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + sessionId, + ]); + + expect(secondLoad.exitCode).not.toBe(0); + const output = + String(secondLoad.stdout || '') + String(secondLoad.stderr || ''); + expect(output).toContain(`Session ${sessionId} is already running`); + + const result = await cli(ctx, ['profile', 'info', '--session', sessionId]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('This profile contains'); + }); + + it('session list cleans up stale session metadata files', async () => { + const staleSessionId = 'stale-session'; + const metadataPath = join(ctx.sessionDir, `${staleSessionId}.json`); + const socketPath = join(ctx.sessionDir, `${staleSessionId}.sock`); + const currentPath = join(ctx.sessionDir, 'current.txt'); + + if (process.platform !== 'win32') { + // Named pipes on Windows are not filesystem files + await writeFile(socketPath, '', 'utf-8'); + } + await writeFile(currentPath, staleSessionId, 'utf-8'); + await writeFile( + metadataPath, + JSON.stringify({ + id: staleSessionId, + socketPath, + logPath: join(ctx.sessionDir, `${staleSessionId}.log`), + pid: 999999, + profilePath: '/tmp/does-not-exist.json', + createdAt: '2026-04-11T00:00:00.000Z', + buildHash: 'stale-build', + }), + 'utf-8' + ); + + const result = await cli(ctx, ['session', 'list']); + + expect(result.stdout).toContain('Cleaned up 1 stale sessions.'); + expect(result.stdout).toContain('Found 0 running sessions'); + + await expect(access(metadataPath)).rejects.toThrow(); + await expect(access(socketPath)).rejects.toThrow(); + await expect(access(currentPath)).rejects.toThrow(); + }); +}); diff --git a/profiler-cli/src/test/integration/setup.ts b/profiler-cli/src/test/integration/setup.ts new file mode 100644 index 0000000000..9ba519e3c8 --- /dev/null +++ b/profiler-cli/src/test/integration/setup.ts @@ -0,0 +1,11 @@ +/* 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/. */ + +/** + * Jest setup for CLI integration tests. + * These tests only need jest-extended, not the full browser test setup. + */ + +// Importing this makes jest-extended matchers available everywhere +import 'jest-extended/all'; diff --git a/profiler-cli/src/test/integration/utils.ts b/profiler-cli/src/test/integration/utils.ts new file mode 100644 index 0000000000..971484b6eb --- /dev/null +++ b/profiler-cli/src/test/integration/utils.ts @@ -0,0 +1,188 @@ +/* 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/. */ + +/** + * Utilities for CLI integration tests. + */ + +import { spawn } from 'child_process'; +import { mkdtemp, readdir, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const CLI_BIN = './profiler-cli/dist/profiler-cli.js'; + +/** + * Simple command execution result. + */ +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Execute a command and return stdout, stderr, and exit code. + * Simple replacement for execa that works with Jest without ESM complications. + */ +function exec( + command: string, + args: string[], + options: { + env?: Record; + timeout?: number; + } = {} +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { + env: { ...process.env, ...options.env }, + }); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + let timeoutId: NodeJS.Timeout | undefined; + + if (options.timeout) { + timeoutId = setTimeout(() => { + timedOut = true; + proc.kill('SIGTERM'); + setTimeout(() => proc.kill('SIGKILL'), 1000); + }, options.timeout); + } + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (timedOut) { + reject(new Error(`Command timed out after ${options.timeout}ms`)); + } else { + resolve({ stdout, stderr, exitCode: code ?? 1 }); + } + }); + + proc.on('error', (err) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + reject(err); + }); + }); +} + +/** + * Context for a profiler-cli test session. + */ +export interface CliTestContext { + sessionDir: string; + env: Record; +} + +/** + * Create a test context with isolated session directory. + * Each test should call this in beforeEach() for maximum isolation. + */ +export async function createTestContext(): Promise { + const sessionDir = await mkdtemp(join(tmpdir(), 'profiler-cli-test-')); + return { + sessionDir, + env: { + PROFILER_CLI_SESSION_DIR: sessionDir, + PROFILER_CLI_NO_SYMBOLICATE: '1', + }, + }; +} + +/** + * Kill all daemon processes tracked in the session directory. + */ +async function killSessionDaemons(sessionDir: string): Promise { + let files: string[]; + try { + files = await readdir(sessionDir); + } catch { + return; + } + + const metadataFiles = files.filter((f) => f.endsWith('.json')); + await Promise.all( + metadataFiles.map(async (file) => { + try { + const content = await readFile(join(sessionDir, file), 'utf-8'); + const metadata = JSON.parse(content) as { pid?: number }; + if (metadata.pid) { + try { + process.kill(metadata.pid, 'SIGTERM'); + } catch { + // Process already gone. + } + } + } catch { + // Ignore unreadable/invalid files. + } + }) + ); +} + +/** + * Clean up test context. + * Each test should call this in afterEach() to remove temp directory. + */ +export async function cleanupTestContext(ctx: CliTestContext): Promise { + await killSessionDaemons(ctx.sessionDir); + await rm(ctx.sessionDir, { recursive: true, force: true }); +} + +/** + * Run a profiler-cli command and expect it to succeed. + */ +export async function cli( + ctx: CliTestContext, + args: string[], + options?: { + timeout?: number; + } +): Promise { + const result = await exec(process.execPath, [CLI_BIN, ...args], { + env: ctx.env, + timeout: options?.timeout ?? 30000, + }); + + if (result.exitCode !== 0) { + const error = new Error(`Command failed with exit code ${result.exitCode}`); + Object.assign(error, result); + throw error; + } + + return result; +} + +/** + * Run a profiler-cli command and expect it to fail. + */ +export async function cliFail( + ctx: CliTestContext, + args: string[] +): Promise { + try { + await cli(ctx, args); + throw new Error('Expected command to fail but it succeeded'); + } catch (error) { + if (error instanceof Error && error.message.includes('Expected command')) { + throw error; + } + // Return the error as a result (which has stdout/stderr/exitCode attached) + return error as CommandResult; + } +} diff --git a/profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap b/profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap new file mode 100644 index 0000000000..99ad7965f3 --- /dev/null +++ b/profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap @@ -0,0 +1,318 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`call tree formatting bottom-up view complex nested trees formats a deep call chain inverted 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-6. Idle [total: 50.0%, self: 50.0%] +└─ f-1. Loop [total: 50.0%, self: 0.0%] + f-0. Main [total: 50.0%, self: 0.0%] +f-4. Think [total: 25.0%, self: 25.0%] +└─ f-3. AI [total: 25.0%, self: 0.0%] + f-2. Tick [total: 25.0%, self: 0.0%] + f-1. Loop [total: 25.0%, self: 0.0%] + f-0. Main [total: 25.0%, self: 0.0%] +f-5. Phys [total: 25.0%, self: 25.0%] +└─ f-2. Tick [total: 25.0%, self: 0.0%] + f-1. Loop [total: 25.0%, self: 0.0%] + f-0. Main [total: 25.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view complex nested trees shows which functions call a leaf function 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-2. E [total: 100.0%, self: 100.0%] +└─ f-1. D [total: 100.0%, self: 0.0%] + ├─ f-0. A [total: 33.3%, self: 0.0%] + ├─ f-3. B [total: 33.3%, self: 0.0%] + └─ f-4. C [total: 33.3%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view different scoring strategies exponential-0.9 strategy for bottom-up 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-2. G [total: 20.0%, self: 20.0%] +└─ f-1. D [total: 20.0%, self: 0.0%] + └─ ... (1 more children: combined 20.0%, max 20.0%) +f-3. E [total: 20.0%, self: 20.0%] +└─ f-0. A [total: 20.0%, self: 0.0%] +f-4. F [total: 20.0%, self: 20.0%] +└─ f-0. A [total: 20.0%, self: 0.0%] +f-5. B [total: 20.0%, self: 20.0%] +f-6. C [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting bottom-up view elision bugs each parent node should have at most one elision marker 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +└─ f-1. B [total: 100.0%, self: 0.0%] + f-2. C [total: 100.0%, self: 0.0%] + └─ ... (1 more children: combined 100.0%, max 100.0%)" +`; + +exports[`call tree formatting bottom-up view elision bugs elided children percentages should be relative to parent, not full profile 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. B [total: 50.0%, self: 50.0%] +└─ ... (5 more children: combined 50.0%, max 10.0%) +f-7. D [total: 50.0%, self: 50.0%] +└─ f-6. C [total: 50.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view elision bugs node whose children were never expanded must still show elision marker 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. Root [total: 100.0%, self: 0.0%] +├─ f-1. A [total: 60.0%, self: 0.0%] +│ ├─ f-3. A2 [total: 10.0%, self: 10.0%] +│ └─ ... (5 more children: combined 50.0%, max 10.0%) +├─ f-8. B [total: 20.0%, self: 0.0%] +│ └─ ... (2 more children: combined 20.0%, max 10.0%) +└─ ... (2 more children: combined 20.0%, max 10.0%)" +`; + +exports[`call tree formatting bottom-up view elision bugs sibling nodes with elided children should each show their own elision marker 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B1 [total: 50.0%, self: 0.0%] +│ ├─ f-6. C5 [total: 10.0%, self: 10.0%] +│ └─ ... (4 more children: combined 40.0%, max 10.0%) +└─ f-7. B2 [total: 50.0%, self: 0.0%] + └─ ... (5 more children: combined 50.0%, max 10.0%)" +`; + +exports[`call tree formatting bottom-up view simple trees formats a branching tree inverted 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. D [total: 28.6%, self: 28.6%] +└─ f-0. A [total: 28.6%, self: 0.0%] +f-2. E [total: 28.6%, self: 28.6%] +└─ f-0. A [total: 28.6%, self: 0.0%] +f-3. B [total: 28.6%, self: 28.6%] +f-4. C [total: 14.3%, self: 14.3%]" +`; + +exports[`call tree formatting bottom-up view simple trees formats a simple linear tree inverted 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-3. D [total: 100.0%, self: 100.0%] +└─ f-2. C [total: 100.0%, self: 0.0%] + f-1. B [total: 100.0%, self: 0.0%] + f-0. A [total: 100.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view trees with truncation shows elided callers at multiple levels 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-2. E [total: 25.0%, self: 25.0%] +└─ f-1. B [total: 25.0%, self: 0.0%] + f-0. A [total: 25.0%, self: 0.0%] +f-3. F [total: 25.0%, self: 25.0%] +└─ f-1. B [total: 25.0%, self: 0.0%] + f-0. A [total: 25.0%, self: 0.0%] +f-7. D [total: 25.0%, self: 25.0%] +└─ f-0. A [total: 25.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view trees with truncation shows elided callers with correct percentages 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. B [total: 30.0%, self: 30.0%] +└─ f-0. A [total: 30.0%, self: 0.0%] +f-2. C [total: 20.0%, self: 20.0%] +└─ f-0. A [total: 20.0%, self: 0.0%] +f-3. D [total: 10.0%, self: 10.0%] +└─ ... (1 more children: combined 10.0%, max 10.0%)" +`; + +exports[`call tree formatting top-down view complex nested trees formats a complex tree with mixed branching patterns 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. Main [total: 100.0%, self: 0.0%] +├─ f-2. Loop [total: 90.0%, self: 0.0%] +│ ├─ f-3. Tick [total: 40.0%, self: 0.0%] +│ │ ├─ f-4. AI [total: 20.0%, self: 0.0%] +│ │ │ f-5. Think [total: 20.0%, self: 20.0%] +│ │ └─ f-6. Phys [total: 20.0%, self: 20.0%] +│ ├─ f-7. Idle [total: 20.0%, self: 20.0%] +│ ├─ f-8. Render [total: 10.0%, self: 10.0%] +│ ├─ f-9. Rende [total: 10.0%, self: 0.0%] +│ │ f-10. Layou [total: 10.0%, self: 10.0%] +│ └─ f-11. r Render [total: 10.0%, self: 0.0%] +│ f-12. t Layout [total: 10.0%, self: 10.0%] +└─ f-1. Init [total: 10.0%, self: 10.0%]" +`; + +exports[`call tree formatting top-down view complex nested trees formats a deep nested path with branching 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 80.0%, self: 0.0%] +├─ f-1. C [total: 60.0%, self: 0.0%] +│ ├─ f-2. E [total: 40.0%, self: 0.0%] +│ │ ├─ f-3. G [total: 20.0%, self: 0.0%] +│ │ │ f-4. I [total: 20.0%, self: 20.0%] +│ │ └─ f-5. H [total: 20.0%, self: 20.0%] +│ └─ f-6. F [total: 20.0%, self: 20.0%] +└─ f-7. D [total: 20.0%, self: 20.0%] +f-8. B [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view different scoring strategies exponential-0.9 strategy output 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 60.0%, self: 0.0%] +├─ f-1. D [total: 20.0%, self: 0.0%] +│ f-2. G [total: 20.0%, self: 20.0%] +├─ f-3. E [total: 20.0%, self: 20.0%] +└─ f-4. F [total: 20.0%, self: 20.0%] +f-5. B [total: 20.0%, self: 20.0%] +f-6. C [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view different scoring strategies percentage-only strategy output 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 60.0%, self: 0.0%] +├─ f-1. D [total: 20.0%, self: 0.0%] +│ f-2. G [total: 20.0%, self: 20.0%] +├─ f-3. E [total: 20.0%, self: 20.0%] +└─ f-4. F [total: 20.0%, self: 20.0%] +f-5. B [total: 20.0%, self: 20.0%] +f-6. C [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view ordering and percentages correctly calculates percentages for nested nodes 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 60.0%, self: 10.0%] +│ ├─ f-2. E [total: 30.0%, self: 30.0%] +│ └─ f-3. F [total: 20.0%, self: 20.0%] +├─ f-4. C [total: 20.0%, self: 20.0%] +└─ f-5. D [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view ordering and percentages maintains correct ordering by sample count 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 50.0%, self: 50.0%] +├─ f-2. C [total: 30.0%, self: 30.0%] +└─ f-3. D [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view simple trees formats a branching tree 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 57.1%, self: 0.0%] +├─ f-1. D [total: 28.6%, self: 28.6%] +└─ f-2. E [total: 28.6%, self: 28.6%] +f-3. B [total: 28.6%, self: 28.6%] +f-4. C [total: 14.3%, self: 14.3%]" +`; + +exports[`call tree formatting top-down view simple trees formats a simple linear tree 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +└─ f-1. B [total: 100.0%, self: 0.0%] + f-2. C [total: 100.0%, self: 0.0%] + f-3. D [total: 100.0%, self: 100.0%]" +`; + +exports[`call tree formatting top-down view trees with truncation shows elided children at multiple levels 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 50.0%, self: 0.0%] +│ └─ ... (2 more children: combined 50.0%, max 25.0%) +├─ f-4. C [total: 25.0%, self: 0.0%] +│ └─ ... (2 more children: combined 25.0%, max 12.5%) +└─ f-7. D [total: 25.0%, self: 25.0%]" +`; + +exports[`call tree formatting top-down view trees with truncation shows elided children with correct percentages 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 30.0%, self: 30.0%] +└─ ... (6 more children: combined 70.0%, max 20.0%)" +`; + +exports[`call tree formatting top-down view trees with truncation shows truncation with wide trees (many siblings) 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 8.3%, self: 8.3%] +├─ f-8. I [total: 8.3%, self: 8.3%] +├─ f-9. J [total: 8.3%, self: 8.3%] +├─ f-10. K [total: 8.3%, self: 8.3%] +└─ ... (8 more children: combined 66.7%, max 8.3%)" +`; diff --git a/profiler-cli/src/test/unit/call-tree-formatting.test.ts b/profiler-cli/src/test/unit/call-tree-formatting.test.ts new file mode 100644 index 0000000000..23a5dce71d --- /dev/null +++ b/profiler-cli/src/test/unit/call-tree-formatting.test.ts @@ -0,0 +1,600 @@ +/* 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 { collectCallTree } from 'firefox-profiler/profile-query/formatters/call-tree'; +import type { + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; +import { getProfileFromTextSamples } from 'firefox-profiler/test/fixtures/profiles/processed-profile'; +import { storeWithProfile } from 'firefox-profiler/test/fixtures/stores'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + formatThreadSamplesTopDownResult, + formatThreadSamplesBottomUpResult, +} from '../../formatters'; +import type { CallTreeCollectionOptions } from 'firefox-profiler/profile-query/formatters/call-tree'; +import { + getCallTree, + computeCallTreeTimings, + computeCallNodeSelfAndSummary, +} from 'firefox-profiler/profile-logic/call-tree'; +import { getInvertedCallNodeInfo } from 'firefox-profiler/profile-logic/profile-data'; +import { + getCategories, + getDefaultCategory, +} from 'firefox-profiler/selectors/profile'; + +/** + * Helper to create a mock session context for testing. + */ +function createMockContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'Test Thread' }], + currentViewRange: null, + rootRange: { start: 0, end: 1000 }, + }; +} + +/** + * Helper to build a ThreadSamplesTopDownResult from a profile. + */ +function buildTopDownResult( + profileSamples: string, + options: CallTreeCollectionOptions = {} +): WithContext { + const { profile } = getProfileFromTextSamples(profileSamples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const regularCallTree = collectCallTree(callTree, libs, options); + + return { + type: 'thread-samples-top-down', + threadHandle: 't-0', + friendlyThreadName: 'Test Thread', + regularCallTree, + context: createMockContext(), + }; +} + +/** + * Helper to build a ThreadSamplesBottomUpResult from a profile. + */ +function buildBottomUpResult( + profileSamples: string, + options: CallTreeCollectionOptions = {} +): WithContext { + const { profile } = getProfileFromTextSamples(profileSamples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const libs = profile.libs; + + // Build inverted call tree (bottom-up view) + let collectedInvertedTree = null; + try { + const thread = threadSelectors.getFilteredThread(state); + const callNodeInfo = threadSelectors.getCallNodeInfo(state); + const categories = getCategories(state); + const defaultCategory = getDefaultCategory(state); + const weightType = threadSelectors.getWeightTypeForCallTree(state); + const samples = threadSelectors.getPreviewFilteredCtssSamples(state); + const sampleIndexToCallNodeIndex = + threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( + state + ); + + const callNodeSelfAndSummary = computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + + const invertedCallNodeInfo = getInvertedCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length + ); + + const invertedTimings = computeCallTreeTimings( + invertedCallNodeInfo, + callNodeSelfAndSummary + ); + + const invertedTree = getCallTree( + thread, + invertedCallNodeInfo, + categories, + samples, + invertedTimings, + weightType + ); + + collectedInvertedTree = collectCallTree(invertedTree, libs, options); + } catch (e) { + // Failed to create inverted tree + console.error('Failed to create inverted call tree:', e); + } + + return { + type: 'thread-samples-bottom-up', + threadHandle: 't-0', + friendlyThreadName: 'Test Thread', + invertedCallTree: collectedInvertedTree, + context: createMockContext(), + }; +} + +describe('call tree formatting', function () { + describe('top-down view', function () { + describe('simple trees', function () { + it('formats a simple linear tree', function () { + const result = buildTopDownResult( + ` + A + B + C + D + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('formats a branching tree', function () { + const result = buildTopDownResult( + ` + A A A A B B C + D D E E + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('trees with truncation', function () { + it('shows elided children with correct percentages', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B C C D E F G H + `, + { maxNodes: 2 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows elided children at multiple levels', function () { + const result = buildTopDownResult( + ` + A A A A A A A A + B B B B C C D D + E E F F G H + `, + { maxNodes: 4 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows truncation with wide trees (many siblings)', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A A A + B C D E F G H I J K L M + `, + { maxNodes: 5, maxChildrenPerNode: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('complex nested trees', function () { + it('formats a deep nested path with branching', function () { + const result = buildTopDownResult( + ` + A A A A A A A A B B + C C C C C C D D + E E E E F F + G G H H + I I + `, + { maxNodes: 15 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('formats a complex tree with mixed branching patterns', function () { + const result = buildTopDownResult( + ` + Main Main Main Main Main Main Main Main Main Main + Init Loop Loop Loop Loop Loop Loop Loop Loop Loop + Tick Tick Tick Tick Idle Idle Render Render Render + AI AI Phys Phys Layout Layout + Think Think + `, + { maxNodes: 15 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('ordering and percentages', function () { + it('maintains correct ordering by sample count', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B B B C C C D D + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify ordering in the result structure + const aNode = result.regularCallTree.children[0]; + expect(aNode.children[0].name).toBe('B'); // 5 samples + expect(aNode.children[1].name).toBe('C'); // 3 samples + expect(aNode.children[2].name).toBe('D'); // 2 samples + }); + + it('correctly calculates percentages for nested nodes', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B B B B C C D D + E E E F F + `, + { maxNodes: 20 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify percentages + const aNode = result.regularCallTree.children[0]; + expect(aNode.totalPercentage).toBeCloseTo(100, 0); + + const bNode = aNode.children[0]; + expect(bNode.totalPercentage).toBeCloseTo(60, 0); + + const eNode = bNode.children[0]; + expect(eNode.totalPercentage).toBeCloseTo(30, 0); + }); + }); + + describe('different scoring strategies', function () { + it('exponential-0.9 strategy output', function () { + const result = buildTopDownResult( + ` + A A A A A A B B C C + D D E E F F + G G + `, + { maxNodes: 8, scoringStrategy: 'exponential-0.9' } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('percentage-only strategy output', function () { + const result = buildTopDownResult( + ` + A A A A A A B B C C + D D E E F F + G G + `, + { maxNodes: 8, scoringStrategy: 'percentage-only' } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + }); + + describe('bottom-up view', function () { + describe('simple trees', function () { + it('formats a simple linear tree inverted', function () { + const result = buildBottomUpResult( + ` + A + B + C + D + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('formats a branching tree inverted', function () { + const result = buildBottomUpResult( + ` + A A A A B B C + D D E E + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('trees with truncation', function () { + it('shows elided callers with correct percentages', function () { + const result = buildBottomUpResult( + ` + A A A A A A A A A A + B B B C C D E F G H + `, + { maxNodes: 5 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows elided callers at multiple levels', function () { + const result = buildBottomUpResult( + ` + A A A A A A A A + B B B B C C D D + E E F F G H + `, + { maxNodes: 8 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('complex nested trees', function () { + it('formats a deep call chain inverted', function () { + const result = buildBottomUpResult( + ` + Main Main Main Main Main Main Main Main + Loop Loop Loop Loop Loop Loop Loop Loop + Tick Tick Tick Tick Idle Idle Idle Idle + AI AI Phys Phys + Think Think + `, + { maxNodes: 15 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows which functions call a leaf function', function () { + const result = buildBottomUpResult( + ` + A A B B C C + D D D D D D + E E E E E E + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('different scoring strategies', function () { + it('exponential-0.9 strategy for bottom-up', function () { + const result = buildBottomUpResult( + ` + A A A A A A B B C C + D D E E F F + G G + `, + { maxNodes: 8, scoringStrategy: 'exponential-0.9' } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('elision bugs', function () { + it('elided children percentages should be relative to parent, not full profile', function () { + // Create a tree where B represents 50% of samples (5 out of 10). + // B has multiple callers (A1, A2, A3, A4, A5) that will be truncated. + // The elided caller percentages should be relative to B's total (50%), + // not relative to the full profile (100%). + const result = buildBottomUpResult( + ` + A1 A2 A3 A4 A5 C C C C C + B B B B B D D D D D + `, + { maxNodes: 3 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify the bug: currently elided percentages are calculated relative to full profile + expect(result.invertedCallTree).toBeDefined(); + const bNode = result.invertedCallTree!.children.find( + (n) => n.name === 'B' + ); + expect(bNode).toBeDefined(); + + // B should have truncated children since we have limited nodes + // With the bug, the elided callers show as % of full profile (10 samples) + // After fix, they should show as % of B's total (5 samples = 50% of profile) + // The elided callers combined should be close to 100% of B's total, + // but with the bug they'll show as ~50% (or less depending on which callers were included) + + // For now, the snapshot will capture the buggy behavior + // After fix, we'll update snapshots and add more specific assertions + }); + + it('each parent node should have at most one elision marker', function () { + // Create a tree where a single parent has both depth limit and truncation + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B B B B B B B B + C C C C C C C C C C + D D D D D D D D D D + E E E E E E E E E E + F F F F F F F F F F + `, + { maxNodes: 3, maxDepth: 3 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify that each parent has at most one elision marker + // Count consecutive elision markers (which would indicate duplicates for same parent) + const lines = formatted.split('\n'); + let consecutiveElisionCount = 0; + let maxConsecutiveElisions = 0; + + for (const line of lines) { + if (line.includes('└─ ...')) { + consecutiveElisionCount++; + maxConsecutiveElisions = Math.max( + maxConsecutiveElisions, + consecutiveElisionCount + ); + } else if (line.trim().length > 0) { + consecutiveElisionCount = 0; + } + } + + // Should never have more than 1 consecutive elision marker + expect(maxConsecutiveElisions).toBeLessThanOrEqual(1); + }); + + it('sibling nodes with elided children should each show their own elision marker', function () { + // Create a tree where two sibling nodes each have elided children + // This tests that elision markers are per-parent, not per-indentation-level + const result = buildTopDownResult( + ` + A A A A A A A A A A + B1 B1 B1 B1 B1 B2 B2 B2 B2 B2 + C1 C2 C3 C4 C5 D1 D2 D3 D4 D5 + `, + { maxNodes: 4 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Count how many elision markers appear in the output + const lines = formatted.split('\n'); + const elisionMarkerCount = lines.filter((line) => + line.includes('└─ ...') + ).length; + + // We expect at least 2 elision markers (one for each sibling B1 and B2) + // Both have many children but limited maxNodes, so both should have elisions + expect(elisionMarkerCount).toBeGreaterThanOrEqual(2); + }); + + it('node whose children were never expanded must still show elision marker', function () { + // Reproduce bug where CallWindowProcW has 55.8% total, 0% self, but no elision marker + // This happens when a node is included but hits the budget limit before its children are expanded + const result = buildTopDownResult( + ` + Root Root Root Root Root Root Root Root Root Root + A A A A A A B B C D + A1 A2 A3 A4 A5 A6 B1 B2 + `, + { maxNodes: 4, maxChildrenPerNode: 2 } // Very tight: Root, A, B, C (A never expanded) + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Parse the tree and verify invariant: every node with total > self must show where the time went + const lines = formatted.split('\n'); + const violations: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Match node lines like "├─ f-2. A [total: 50.0%, self: 0.0%]" or "f-2. A [total: 50.0%, self: 0.0%]" + const match = line.match( + /[├└]?─?\s*f-\d+\.\s+(.+?)\s+\[total:\s+([\d.]+)%,\s+self:\s+([\d.]+)%\]/ + ); + if (match) { + const nodeName = match[1]; + const total = parseFloat(match[2]); + const self = parseFloat(match[3]); + + // If total > self, this node has children that account for the difference + if (total > self + 0.01) { + // Check the next line - it must be either a child node or an elision marker + const nextLine = i + 1 < lines.length ? lines[i + 1] : ''; + + // A child line either: + // 1. Starts with more whitespace than current line (deeper nesting) + // 2. Contains tree symbols │, ├─, or └─ + // 3. Contains an elision marker └─ ... + + const currentLeadingSpaces = + line.match(/^(\s*)/)?.[1].length || 0; + const nextLeadingSpaces = + nextLine.match(/^(\s*)/)?.[1].length || 0; + + const hasTreeSymbols = + nextLine.includes('│') || + nextLine.includes('├─') || + nextLine.includes('└─'); + + const isChild = + nextLine.trim().length > 0 && + (nextLeadingSpaces > currentLeadingSpaces || hasTreeSymbols); + + if (!isChild) { + violations.push( + `Node "${nodeName}" has total=${total}%, self=${self}% but no child/elision marker:\n Line ${i + 1}: ${line}\n Next: ${nextLine}` + ); + } + } + } + } + + // Report all violations + if (violations.length > 0) { + throw new Error( + `Found ${violations.length} node(s) missing elision markers:\n\n` + + violations.join('\n\n') + ); + } + }); + }); + }); +}); diff --git a/profiler-cli/src/test/unit/marker-formatting.test.ts b/profiler-cli/src/test/unit/marker-formatting.test.ts new file mode 100644 index 0000000000..283acc8723 --- /dev/null +++ b/profiler-cli/src/test/unit/marker-formatting.test.ts @@ -0,0 +1,150 @@ +/* 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 { formatThreadMarkersResult } from '../../formatters'; +import type { + ThreadMarkersResult, + FlatMarkerItem, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; + +function createContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], + currentViewRange: null, + rootRange: { start: 0, end: 3000 }, + }; +} + +function makeResult( + overrides: Partial = {} +): WithContext { + return { + context: createContext(), + type: 'thread-markers', + threadHandle: 't-0', + friendlyThreadName: 'GeckoMain', + totalMarkerCount: 10, + filteredMarkerCount: 10, + byType: [], + byCategory: [], + ...overrides, + }; +} + +function makeFlat(overrides: Partial = {}): FlatMarkerItem { + return { + handle: 'm-1', + name: 'DOMEvent', + label: 'DOMEvent', + start: 100, + hasStack: false, + category: 'DOM', + ...overrides, + }; +} + +describe('formatThreadMarkersResult flat list mode', function () { + it('renders one line per flat marker', function () { + const result = makeResult({ + filteredMarkerCount: 2, + flatMarkers: [ + makeFlat({ handle: 'm-1', name: 'DOMEvent', label: 'DOMEvent' }), + makeFlat({ handle: 'm-2', name: 'DOMEvent', label: 'DOMEvent' }), + ], + }); + + const output = formatThreadMarkersResult(result); + const markerLines = output + .split('\n') + .filter((l) => l.includes('m-1') || l.includes('m-2')); + expect(markerLines).toHaveLength(2); + }); + + it('shows handle and marker name on each line', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat({ handle: 'm-42', name: 'Paint' })], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('m-42'); + expect(output).toContain('Paint'); + }); + + it('appends label suffix when label differs from name', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [ + makeFlat({ name: 'DOMEvent', label: 'click', handle: 'm-10' }), + ], + }); + + const output = formatThreadMarkersResult(result); + const line = output.split('\n').find((l) => l.includes('m-10'))!; + expect(line).toContain('click'); + }); + + it('does not add label suffix when label equals name', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [ + makeFlat({ name: 'Paint', label: 'Paint', handle: 'm-20' }), + ], + }); + + const output = formatThreadMarkersResult(result); + const line = output.split('\n').find((l) => l.includes('m-20'))!; + // "Paint" appears once (as the name), not twice + expect(line.indexOf('Paint')).toBe(line.lastIndexOf('Paint')); + }); + + it('shows "instant" for markers without duration', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat({ duration: undefined })], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('instant'); + }); + + it('shows formatted duration for interval markers', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat({ duration: 5 })], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('5ms'); + expect(output).not.toContain('instant'); + }); + + it('shows stack indicator', function () { + const result = makeResult({ + filteredMarkerCount: 2, + flatMarkers: [ + makeFlat({ handle: 'm-1', hasStack: true }), + makeFlat({ handle: 'm-2', hasStack: false }), + ], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('✓'); + expect(output).toContain('✗'); + }); + + it('does not show aggregated By Name header in flat list mode', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat()], + }); + + const output = formatThreadMarkersResult(result); + expect(output).not.toContain('By Name'); + expect(output).not.toContain('By Category'); + }); +}); diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts new file mode 100644 index 0000000000..b8b69cae76 --- /dev/null +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -0,0 +1,257 @@ +/* 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 { formatThreadNetworkResult } from '../../formatters'; +import type { + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; + +function createContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], + currentViewRange: null, + rootRange: { start: 0, end: 1000 }, + }; +} + +function makeRequest( + overrides: Partial = {} +): NetworkRequestEntry { + return { + url: 'https://example.com/resource', + startTime: 0, + duration: 100, + phases: {}, + ...overrides, + }; +} + +function makeResult( + overrides: Partial = {} +): WithContext { + return { + context: createContext(), + type: 'thread-network', + threadHandle: 't-0', + friendlyThreadName: 'GeckoMain', + totalRequestCount: 1, + filteredRequestCount: 1, + summary: { + cacheHit: 0, + cacheMiss: 0, + cacheUnknown: 1, + phaseTotals: {}, + }, + requests: [makeRequest()], + ...overrides, + }; +} + +describe('formatThreadNetworkResult', function () { + it('shows thread handle and request count', function () { + const result = makeResult({ + filteredRequestCount: 3, + totalRequestCount: 3, + }); + result.requests = [ + makeRequest({ url: 'https://a.com' }), + makeRequest({ url: 'https://b.com' }), + makeRequest({ url: 'https://c.com' }), + ]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('t-0'); + expect(output).toContain('3 requests'); + }); + + it('shows "(filtered from N)" suffix when filter reduces count', function () { + const result = makeResult({ + totalRequestCount: 10, + filteredRequestCount: 3, + filters: { searchString: 'api' }, + }); + result.requests = [makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('(filtered from 10)'); + }); + + it('shows "X of Y requests" in header when limit truncates results', function () { + const result = makeResult({ + filteredRequestCount: 50, + totalRequestCount: 50, + }); + result.requests = [makeRequest(), makeRequest()]; // only 2 shown of 50 + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('2 of 50 requests'); + }); + + it('shows --limit 0 hint in footer when results are truncated', function () { + const result = makeResult({ + filteredRequestCount: 50, + totalRequestCount: 50, + }); + result.requests = [makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('--limit 0'); + }); + + it('shows normal filter hint in footer when results are not truncated', function () { + const result = makeResult({ + filteredRequestCount: 1, + totalRequestCount: 1, + }); + result.requests = [makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('--limit 0'); + expect(output).toContain('--search'); + }); + + it('does not show filtered suffix when counts are equal', function () { + const result = makeResult({ + totalRequestCount: 2, + filteredRequestCount: 2, + filters: { minDuration: 50 }, + }); + result.requests = [makeRequest(), makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('filtered from'); + }); + + it('shows cache summary counts', function () { + const result = makeResult({ + summary: { + cacheHit: 4, + cacheMiss: 2, + cacheUnknown: 1, + phaseTotals: {}, + }, + }); + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('4 hit'); + expect(output).toContain('2 miss'); + expect(output).toContain('1 unknown'); + }); + + it('shows phase totals section when any phase total is present', function () { + const phaseTotals: NetworkPhaseTimings = { ttfb: 50, download: 30 }; + const result = makeResult({ + summary: { cacheHit: 0, cacheMiss: 1, cacheUnknown: 0, phaseTotals }, + }); + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('Phase totals'); + expect(output).toContain('TTFB'); + expect(output).toContain('Download'); + }); + + it('omits phase totals section when no phases are present', function () { + const result = makeResult({ + summary: { cacheHit: 1, cacheMiss: 0, cacheUnknown: 0, phaseTotals: {} }, + }); + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('Phase totals'); + }); + + it('shows each request URL', function () { + const result = makeResult({ + filteredRequestCount: 2, + totalRequestCount: 2, + }); + result.requests = [ + makeRequest({ url: 'https://api.example.com/data' }), + makeRequest({ url: 'https://static.example.com/img.png' }), + ]; + result.summary.cacheUnknown = 2; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('https://api.example.com/data'); + expect(output).toContain('https://static.example.com/img.png'); + }); + + it('truncates URLs longer than 100 characters', function () { + const longUrl = 'https://example.com/' + 'a'.repeat(90); + const result = makeResult(); + result.requests = [makeRequest({ url: longUrl })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('...'); + expect(output).not.toContain(longUrl); + }); + + it('shows per-request phases when present', function () { + const phases: NetworkPhaseTimings = { dns: 5, ttfb: 30 }; + const result = makeResult(); + result.requests = [makeRequest({ phases })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('Phases:'); + expect(output).toContain('DNS='); + expect(output).toContain('TTFB='); + }); + + it('omits phases line when request has no timing data', function () { + const result = makeResult(); + result.requests = [makeRequest({ phases: {} })]; + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('Phases:'); + }); + + it('shows HTTP status and version when present', function () { + const result = makeResult(); + result.requests = [makeRequest({ httpStatus: 200, httpVersion: 'h2' })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('200'); + expect(output).toContain('h2'); + }); + + it('shows ??? for missing HTTP status', function () { + const result = makeResult(); + result.requests = [makeRequest()]; // no httpStatus + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('???'); + }); + + it('shows "No network requests" message when requests list is empty', function () { + const result = makeResult({ + totalRequestCount: 5, + filteredRequestCount: 0, + filters: { searchString: 'no-match' }, + }); + result.requests = []; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('No network requests'); + }); +}); diff --git a/profiler-cli/src/test/unit/session.test.ts b/profiler-cli/src/test/unit/session.test.ts new file mode 100644 index 0000000000..984b914ecc --- /dev/null +++ b/profiler-cli/src/test/unit/session.test.ts @@ -0,0 +1,445 @@ +/* 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/. */ + +/** + * Unit tests for profiler-cli session management. + * + * These tests cover only the session.ts utility functions. + * Integration tests that spawn daemons and test IPC are in bash scripts: + * - bin/profiler-cli-test: Basic daemon lifecycle + * - bin/profiler-cli-test-multi: Concurrent sessions + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawn } from 'child_process'; +import { + ensureSessionDir, + generateSessionId, + getSessionDirNamespace, + getSocketPath, + getLogPath, + getMetadataPath, + saveSessionMetadata, + loadSessionMetadata, + setCurrentSession, + getCurrentSessionId, + getCurrentSocketPath, + isProcessRunning, + waitForProcessExit, + cleanupSession, + validateSession, + listSessions, +} from '../../session'; +import type { SessionMetadata } from '../../protocol'; + +const TEST_BUILD_HASH = 'test-build-hash'; + +describe('profiler-cli session management', function () { + let testSessionDir: string; + const platformDescriptor = Object.getOwnPropertyDescriptor( + process, + 'platform' + ); + + beforeEach(function () { + // Create a unique temp directory for each test + testSessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'profiler-cli-test-') + ); + }); + + afterEach(function () { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + + // Clean up test directory + if (fs.existsSync(testSessionDir)) { + fs.rmSync(testSessionDir, { recursive: true, force: true }); + } + }); + + describe('ensureSessionDir', function () { + it('creates session directory if it does not exist', function () { + const newDir = path.join(testSessionDir, 'subdir'); + expect(fs.existsSync(newDir)).toBe(false); + + ensureSessionDir(newDir); + + expect(fs.existsSync(newDir)).toBe(true); + expect(fs.statSync(newDir).isDirectory()).toBe(true); + }); + + it('does not fail if directory already exists', function () { + ensureSessionDir(testSessionDir); + + expect(() => ensureSessionDir(testSessionDir)).not.toThrow(); + expect(fs.existsSync(testSessionDir)).toBe(true); + }); + }); + + describe('generateSessionId', function () { + it('returns a non-empty string', function () { + const sessionId = generateSessionId(); + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + }); + + it('returns different IDs on successive calls', function () { + const id1 = generateSessionId(); + const id2 = generateSessionId(); + expect(id1).not.toBe(id2); + }); + }); + + describe('path generation', function () { + it('getSocketPath returns correct Unix path', function () { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + expect(socketPath).toBe(path.join(testSessionDir, 'test123.sock')); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('namespaces Windows pipe paths by session directory', function () { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + const firstSocketPath = getSocketPath( + 'C:\\profiler-cli\\alpha', + 'test123' + ); + const secondSocketPath = getSocketPath( + 'C:\\profiler-cli\\beta', + 'test123' + ); + const thirdSocketPath = getSocketPath( + 'C:\\PROFILER-CLI\\ALPHA', + 'test123' + ); + + expect(firstSocketPath).toMatch( + /^\\\\\.\\pipe\\profiler-cli-[0-9a-f]{12}-test123$/ + ); + expect(secondSocketPath).toMatch( + /^\\\\\.\\pipe\\profiler-cli-[0-9a-f]{12}-test123$/ + ); + expect(firstSocketPath).not.toBe(secondSocketPath); + expect(firstSocketPath).toBe(thirdSocketPath); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('generates a stable namespace from the session directory', function () { + const firstNamespace = getSessionDirNamespace('C:\\profiler-cli\\alpha'); + const secondNamespace = getSessionDirNamespace('C:\\profiler-cli\\beta'); + const thirdNamespace = getSessionDirNamespace('C:\\PROFILER-CLI\\ALPHA'); + + expect(firstNamespace).toMatch(/^[0-9a-f]{12}$/); + expect(firstNamespace).not.toBe(secondNamespace); + expect(firstNamespace).toBe(thirdNamespace); + }); + + it('getLogPath returns correct path', function () { + const sessionId = 'test123'; + const logPath = getLogPath(testSessionDir, sessionId); + expect(logPath).toBe(path.join(testSessionDir, 'test123.log')); + }); + + it('getMetadataPath returns correct path', function () { + const sessionId = 'test123'; + const metadataPath = getMetadataPath(testSessionDir, sessionId); + expect(metadataPath).toBe(path.join(testSessionDir, 'test123.json')); + }); + }); + + describe('metadata serialization', function () { + it('saves and loads metadata correctly', function () { + const metadata: SessionMetadata = { + id: 'test123', + socketPath: getSocketPath(testSessionDir, 'test123'), + logPath: getLogPath(testSessionDir, 'test123'), + pid: 12345, + profilePath: '/path/to/profile.json', + createdAt: '2025-10-31T10:00:00.000Z', + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + + const loaded = loadSessionMetadata(testSessionDir, 'test123'); + expect(loaded).toEqual(metadata); + }); + + it('returns null for non-existent session', function () { + const loaded = loadSessionMetadata(testSessionDir, 'nonexistent'); + expect(loaded).toBeNull(); + }); + + it('returns null for malformed JSON', function () { + const metadataPath = getMetadataPath(testSessionDir, 'bad'); + fs.writeFileSync(metadataPath, 'not valid JSON {'); + + const loaded = loadSessionMetadata(testSessionDir, 'bad'); + expect(loaded).toBeNull(); + }); + }); + + describe('current session tracking', function () { + it('sets and gets current session via symlink', function () { + const sessionId = 'test123'; + setCurrentSession(testSessionDir, sessionId); + + const currentId = getCurrentSessionId(testSessionDir); + expect(currentId).toBe(sessionId); + }); + + it('returns null when no current session exists', function () { + const currentId = getCurrentSessionId(testSessionDir); + expect(currentId).toBeNull(); + }); + + it('replaces existing current session symlink', function () { + // Create first session + setCurrentSession(testSessionDir, 'session1'); + expect(getCurrentSessionId(testSessionDir)).toBe('session1'); + + // Create second session + setCurrentSession(testSessionDir, 'session2'); + expect(getCurrentSessionId(testSessionDir)).toBe('session2'); + }); + + it('getCurrentSocketPath resolves to correct path', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + setCurrentSession(testSessionDir, sessionId); + + const currentPath = getCurrentSocketPath(testSessionDir); + expect(currentPath).toBe(socketPath); + }); + }); + + describe('isProcessRunning', function () { + it('returns true for current process', function () { + expect(isProcessRunning(process.pid)).toBe(true); + }); + + it('returns false for non-existent PID', function () { + expect(isProcessRunning(999999)).toBe(false); + }); + + it('waits for a process to exit', async function () { + const child = spawn(process.execPath, [ + '-e', + 'setTimeout(() => process.exit(0), 100)', + ]); + + const exited = await waitForProcessExit(child.pid!, 2000, 10); + + expect(exited).toBe(true); + }); + + it('times out if a process does not exit', async function () { + const child = spawn(process.execPath, [ + '-e', + 'setTimeout(() => process.exit(0), 5000)', + ]); + + try { + const exited = await waitForProcessExit(child.pid!, 50, 10); + expect(exited).toBe(false); + } finally { + child.kill('SIGTERM'); + await waitForProcessExit(child.pid!, 2000, 10); + } + }); + }); + + describe('cleanupSession', function () { + it('removes socket and metadata files', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + const metadataPath = getMetadataPath(testSessionDir, sessionId); + + fs.writeFileSync(metadataPath, '{}'); + if (process.platform !== 'win32') { + fs.writeFileSync(socketPath, ''); + } + + cleanupSession(testSessionDir, sessionId); + + expect(fs.existsSync(socketPath)).toBe(false); + expect(fs.existsSync(metadataPath)).toBe(false); + }); + + it('preserves log file', function () { + const sessionId = 'test123'; + const logPath = getLogPath(testSessionDir, sessionId); + fs.writeFileSync(logPath, 'log data'); + + cleanupSession(testSessionDir, sessionId); + + expect(fs.existsSync(logPath)).toBe(true); + }); + + it('removes current session symlink if it points to this session', function () { + const sessionId = 'test123'; + setCurrentSession(testSessionDir, sessionId); + + cleanupSession(testSessionDir, sessionId); + + expect(getCurrentSessionId(testSessionDir)).toBeNull(); + }); + + it('does not remove current session symlink if it points to different session', function () { + // Set current session to session1 + setCurrentSession(testSessionDir, 'session1'); + + // Clean up session2 + cleanupSession(testSessionDir, 'session2'); + + // Current session should still be session1 + expect(getCurrentSessionId(testSessionDir)).toBe('session1'); + }); + }); + + describe('validateSession', function () { + it('returns false for non-existent session', function () { + expect(validateSession(testSessionDir, 'nonexistent')).toBe(null); + }); + + it('returns false for session with dead PID', function () { + const sessionId = 'test123'; + const metadata: SessionMetadata = { + id: sessionId, + socketPath: getSocketPath(testSessionDir, sessionId), + logPath: getLogPath(testSessionDir, sessionId), + pid: 999999, // Non-existent PID + profilePath: '/path/to/profile.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + + expect(validateSession(testSessionDir, sessionId)).toBe(null); + }); + + it('returns false for session with missing socket', function () { + if (process.platform === 'win32') { + // Not applicable on Windows: named pipes are self-cleaning and disappear + // automatically when the server stops, so a session can't have a live PID + // but a missing socket. validateSession skips the socket check on Windows + // for this reason. + return; + } + const sessionId = 'test123'; + const metadata: SessionMetadata = { + id: sessionId, + socketPath: getSocketPath(testSessionDir, sessionId), + logPath: getLogPath(testSessionDir, sessionId), + pid: process.pid, // Use current process PID (guaranteed to exist) + profilePath: '/path/to/profile.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + // Intentionally don't create socket file + + expect(validateSession(testSessionDir, sessionId)).toBe(null); + }); + + it('returns true for valid session', function () { + const sessionId = 'test123'; + const metadata: SessionMetadata = { + id: sessionId, + socketPath: getSocketPath(testSessionDir, sessionId), + logPath: getLogPath(testSessionDir, sessionId), + pid: process.pid, // Use current process PID + profilePath: '/path/to/profile.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + if (process.platform !== 'win32') { + fs.writeFileSync(metadata.socketPath, ''); + } + + expect(validateSession(testSessionDir, sessionId)).not.toBe(null); + }); + }); + + describe('listSessions', function () { + it('returns empty array when no sessions exist', function () { + const sessions = listSessions(testSessionDir); + expect(sessions).toEqual([]); + }); + + it('lists all session IDs', function () { + // Create multiple sessions + saveSessionMetadata(testSessionDir, { + id: 'session1', + socketPath: getSocketPath(testSessionDir, 'session1'), + logPath: getLogPath(testSessionDir, 'session1'), + pid: 1, + profilePath: '/test1.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }); + + saveSessionMetadata(testSessionDir, { + id: 'session2', + socketPath: getSocketPath(testSessionDir, 'session2'), + logPath: getLogPath(testSessionDir, 'session2'), + pid: 2, + profilePath: '/test2.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }); + + const sessions = listSessions(testSessionDir); + expect(sessions).toContain('session1'); + expect(sessions).toContain('session2'); + expect(sessions.length).toBe(2); + }); + + it('ignores non-JSON files', function () { + // Create session metadata + saveSessionMetadata(testSessionDir, { + id: 'session1', + socketPath: getSocketPath(testSessionDir, 'session1'), + logPath: getLogPath(testSessionDir, 'session1'), + pid: 1, + profilePath: '/test.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }); + + // Create non-JSON files + fs.writeFileSync(path.join(testSessionDir, 'session1.sock'), ''); + fs.writeFileSync(path.join(testSessionDir, 'session1.log'), ''); + fs.writeFileSync(path.join(testSessionDir, 'random.txt'), ''); + + const sessions = listSessions(testSessionDir); + expect(sessions).toEqual(['session1']); + }); + }); +}); diff --git a/profiler-cli/src/utils/parse.ts b/profiler-cli/src/utils/parse.ts new file mode 100644 index 0000000000..93a4c17260 --- /dev/null +++ b/profiler-cli/src/utils/parse.ts @@ -0,0 +1,207 @@ +/* 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/. */ + +/** + * Shared argument parsing utilities for profiler-cli commands. + */ + +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import type { SampleFilterSpec } from '../protocol'; + +/** + * Accumulator for Commander repeated options (--flag a --flag b -> ['a', 'b']). + */ +export function collectStrings(val: string, prev: string[]): string[] { + return [...prev, val]; +} + +/** + * Parse a comma-separated list of function handles (e.g. "f-1,f-2") into numeric indexes. + */ +export function parseFuncList(value: string): number[] { + return value.split(',').map((s) => { + const m = /^f-(\d+)$/.exec(s.trim()); + if (!m) { + console.error( + `Error: invalid function handle "${s.trim()}" (expected f-)` + ); + process.exit(1); + } + return parseInt(m[1], 10); + }); +} + +/** + * Options bag produced by Commander for commands that support ephemeral sample filters. + * Keys are camelCase because Commander normalises hyphenated option names. + */ +export interface EphemeralFilterOpts { + excludesFunction?: string[]; + merge?: string[]; + rootAt?: string[]; + includesFunction?: string[]; + includesPrefix?: string[]; + includesSuffix?: string[]; + duringMarker?: boolean; + outsideMarker?: boolean; + search?: string; +} + +/** + * Parse zero or more ephemeral SampleFilterSpecs from CLI options. + * Multiple flags are collected in order; each produces one spec. + * The same flag may be repeated (e.g. --merge f-1 --merge f-2) to apply it multiple times. + */ +export function parseEphemeralFilters( + opts: EphemeralFilterOpts +): SampleFilterSpec[] { + const specs: SampleFilterSpec[] = []; + + for (const v of opts.excludesFunction ?? []) { + specs.push({ type: 'excludes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.merge ?? []) { + specs.push({ type: 'merge', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.rootAt ?? []) { + const indexes = parseFuncList(v); + if (indexes.length !== 1) { + console.error('Error: --root-at takes exactly one function handle'); + process.exit(1); + } + specs.push({ type: 'root-at', funcIndex: indexes[0] }); + } + for (const v of opts.includesFunction ?? []) { + specs.push({ type: 'includes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.includesPrefix ?? []) { + specs.push({ type: 'includes-prefix', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.includesSuffix ?? []) { + const indexes = parseFuncList(v); + if (indexes.length !== 1) { + console.error( + 'Error: --includes-suffix takes exactly one function handle' + ); + process.exit(1); + } + specs.push({ type: 'includes-suffix', funcIndex: indexes[0] }); + } + if (opts.duringMarker === true) { + if (!opts.search) { + console.error('Error: --during-marker requires --search '); + process.exit(1); + } + specs.push({ type: 'during-marker', searchString: opts.search }); + } + if (opts.outsideMarker === true) { + if (!opts.search) { + console.error('Error: --outside-marker requires --search '); + process.exit(1); + } + specs.push({ type: 'outside-marker', searchString: opts.search }); + } + + return specs; +} + +/** + * Parse exactly one SampleFilterSpec from CLI options for `profiler-cli filter push`. + * Exactly one filter flag must be provided. + */ +export function parseFilterSpec(opts: EphemeralFilterOpts): SampleFilterSpec { + const valueFlags = [ + 'excludesFunction', + 'merge', + 'rootAt', + 'includesFunction', + 'includesPrefix', + 'includesSuffix', + ] as const; + const markerFlags = ['duringMarker', 'outsideMarker'] as const; + + const activeValueFlags = valueFlags.filter( + (f) => opts[f] !== undefined && (opts[f] as string[]).length > 0 + ); + const activeMarkerFlags = markerFlags.filter((f) => opts[f] === true); + const totalActive = activeValueFlags.length + activeMarkerFlags.length; + + if (totalActive === 0) { + const allFlags = [ + '--excludes-function', + '--merge', + '--root-at', + '--includes-function', + '--includes-prefix', + '--includes-suffix', + '--during-marker', + '--outside-marker', + ]; + console.error('Error: filter push requires one of: ' + allFlags.join(', ')); + process.exit(1); + } + if (totalActive > 1) { + console.error('Error: filter push accepts only one filter flag per push'); + process.exit(1); + } + + if (activeValueFlags.length > 0) { + const flag = activeValueFlags[0]; + const values = opts[flag] as string[]; + // Each repeated flag produces one entry; for filter push there should be exactly one value + const value = values[0]; + + switch (flag) { + case 'excludesFunction': + return { type: 'excludes-function', funcIndexes: parseFuncList(value) }; + case 'merge': + return { type: 'merge', funcIndexes: parseFuncList(value) }; + case 'rootAt': { + const indexes = parseFuncList(value); + if (indexes.length !== 1) { + console.error('Error: --root-at takes exactly one function handle'); + process.exit(1); + } + return { type: 'root-at', funcIndex: indexes[0] }; + } + case 'includesFunction': + return { type: 'includes-function', funcIndexes: parseFuncList(value) }; + case 'includesPrefix': + return { type: 'includes-prefix', funcIndexes: parseFuncList(value) }; + case 'includesSuffix': { + const indexes = parseFuncList(value); + if (indexes.length !== 1) { + console.error( + 'Error: --includes-suffix takes exactly one function handle' + ); + process.exit(1); + } + return { type: 'includes-suffix', funcIndex: indexes[0] }; + } + default: + throw assertExhaustiveCheck(flag); + } + } + + // Marker flags + if (opts.duringMarker === true) { + if (!opts.search) { + console.error('Error: --during-marker requires --search '); + process.exit(1); + } + return { type: 'during-marker', searchString: opts.search }; + } + if (opts.outsideMarker === true) { + if (!opts.search) { + console.error('Error: --outside-marker requires --search '); + process.exit(1); + } + return { type: 'outside-marker', searchString: opts.search }; + } + + // Should not be reachable. + console.error('Error: no valid filter flag found'); + process.exit(1); + throw new Error('unreachable'); +} diff --git a/scripts/build-profile-query.mjs b/scripts/build-profile-query.mjs new file mode 100644 index 0000000000..4427913ecd --- /dev/null +++ b/scripts/build-profile-query.mjs @@ -0,0 +1,19 @@ +/* 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 esbuild from 'esbuild'; +import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; + +const profileQueryConfig = { + ...nodeBaseConfig, + entryPoints: ['src/profile-query/index.ts'], + outfile: 'dist/profile-query.js', + external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'], +}; + +async function build() { + await esbuild.build(profileQueryConfig); + console.log('✅ Profile-query build completed'); +} + +build().catch(console.error); diff --git a/scripts/build-profiler-cli.mjs b/scripts/build-profiler-cli.mjs new file mode 100644 index 0000000000..4cff0ef1d9 --- /dev/null +++ b/scripts/build-profiler-cli.mjs @@ -0,0 +1,36 @@ +/* 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 esbuild from 'esbuild'; +import { chmodSync, readFileSync } from 'fs'; +import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; + +const { version } = JSON.parse( + readFileSync(new URL('../profiler-cli/package.json', import.meta.url), 'utf8') +); + +const BUILD_HASH = Date.now().toString(36); + +const profilerCliConfig = { + ...nodeBaseConfig, + entryPoints: ['profiler-cli/src/index.ts'], + loader: { ...nodeBaseConfig.loader, '.txt': 'text' }, + outfile: 'profiler-cli/dist/profiler-cli.js', + minify: true, + banner: { + js: '#!/usr/bin/env node\n\n// Polyfill browser globals for Node.js\nglobalThis.self = globalThis;', + }, + define: { + __BUILD_HASH__: JSON.stringify(BUILD_HASH), + __VERSION__: JSON.stringify(version), + }, + external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'], +}; + +async function build() { + await esbuild.build(profilerCliConfig); + chmodSync('profiler-cli/dist/profiler-cli.js', 0o755); + console.log('✅ profiler-cli build completed'); +} + +build().catch(console.error); diff --git a/scripts/publish-profiler-cli.mjs b/scripts/publish-profiler-cli.mjs new file mode 100644 index 0000000000..b832776aa3 --- /dev/null +++ b/scripts/publish-profiler-cli.mjs @@ -0,0 +1,32 @@ +/* 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 { readFileSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const pkgUrl = new URL('../profiler-cli/package.json', import.meta.url); +const { version } = JSON.parse(readFileSync(pkgUrl, 'utf8')); + +const forwardedArgs = process.argv.slice(2); +const userSpecifiedTag = forwardedArgs.some( + (a) => a === '--tag' || a.startsWith('--tag=') +); +const isPrerelease = version.includes('-'); +// TODO: switch 'alpha' to 'next' once a stable release exists and we want the +// conventional pre-release channel. +const tagArgs = userSpecifiedTag + ? [] + : ['--tag', isPrerelease ? 'alpha' : 'latest']; + +function run(cmd, args) { + const result = spawnSync(cmd, args, { cwd: repoRoot, stdio: 'inherit' }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +run('yarn', ['test-all']); +run('yarn', ['build-profiler-cli']); +run('npm', ['publish', 'profiler-cli/', ...tagArgs, ...forwardedArgs]); diff --git a/scripts/verify-profiler-cli-build.mjs b/scripts/verify-profiler-cli-build.mjs new file mode 100644 index 0000000000..cd9fe40460 --- /dev/null +++ b/scripts/verify-profiler-cli-build.mjs @@ -0,0 +1,34 @@ +/* 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 { existsSync, readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const pkgUrl = new URL('../profiler-cli/package.json', import.meta.url); +const distUrl = new URL( + '../profiler-cli/dist/profiler-cli.js', + import.meta.url +); +const distPath = fileURLToPath(distUrl); + +if (!existsSync(distUrl)) { + console.error( + `profiler-cli bundle not found at ${distPath}.\n` + + `Run 'yarn build-profiler-cli' from the repo root before publishing.` + ); + process.exit(1); +} + +const { version } = JSON.parse(readFileSync(pkgUrl, 'utf8')); +const bundle = readFileSync(distUrl, 'utf8'); +const needle = JSON.stringify(version); + +if (!bundle.includes(needle)) { + console.error( + `profiler-cli bundle does not embed the current package.json version (${version}).\n` + + `The bundle is stale — rebuild with 'yarn build-profiler-cli' from the repo root.` + ); + process.exit(1); +} + +console.log(`✅ profiler-cli build verified (version ${version})`); diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 7584c1f7be..51ff3dcdb4 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -1860,11 +1860,20 @@ export function popTransformsFromStack( ): ThunkAction { return (dispatch, getState) => { const threadsKey = getSelectedThreadsKey(getState()); - dispatch({ - type: 'POP_TRANSFORMS_FROM_STACK', - threadsKey, - firstPoppedFilterIndex, - }); + dispatch( + popTransformsFromStackForThreads(threadsKey, firstPoppedFilterIndex) + ); + }; +} + +export function popTransformsFromStackForThreads( + threadsKey: ThreadsKey, + firstPoppedFilterIndex: number +): Action { + return { + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex, }; } diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 57495bec52..b955aaf2ac 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -394,6 +394,10 @@ export class CallTree { this._weightType = weightType; } + getTotal(): number { + return this._rootTotalSummary; + } + getRoots() { return this._roots; } diff --git a/src/profile-logic/combined-cpu.ts b/src/profile-logic/combined-cpu.ts new file mode 100644 index 0000000000..a7a7efa163 --- /dev/null +++ b/src/profile-logic/combined-cpu.ts @@ -0,0 +1,134 @@ +/* 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 type { SamplesTable } from 'firefox-profiler/types'; +import { bisectionLeft } from '../utils/bisect'; + +/** + * Represents CPU usage over time for a single thread. + */ +export type CpuRatioTimeSeries = { + time: number[]; + cpuRatio: Float64Array; + maxCpuRatio: number; + length: number; +}; + +/** + * Combines CPU usage data from multiple threads into a single timeline. + * + * This function takes CPU ratio data from multiple threads, each with potentially + * different sampling times, and creates a unified timeline where CPU ratios are + * summed. The result can exceed 1.0 when multiple threads are active simultaneously. + * + * The algorithm: + * 1. Maintains a cursor for each thread tracking the current sample index + * 2. Processes all sample times in ascending order by scanning each thread's + * cursor for the next-lowest time on every step + * 3. For each time point, sums CPU ratios from threads that are active at that time + * 4. A thread is considered active only between its first and last sample times + * + * Note: cpuRatio[i] represents CPU usage between time[i-1] and time[i], so we don't + * extend a thread's CPU usage beyond its last sample time. + * + * @param threadSamples - Array of SamplesTable objects, one per thread + * @param rangeStart - Optional start time to filter samples (inclusive) + * @param rangeEnd - Optional end time to filter samples (exclusive) + * @returns Combined CPU data with unified time array and summed CPU ratios, + * or null if no threads have CPU data + */ +export function combineCPUDataFromThreads( + threadSamples: SamplesTable[], + rangeStart?: number, + rangeEnd?: number +): CpuRatioTimeSeries | null { + // Filter threads that have CPU ratio data. + // We require at least two samples per thread; the first sample's CPU ratio + // is meaningless. threadCPUPercent[1] is the CPU percentage between + // samples.time[0] and samples.time[1]. + const threadsWithCPU: CpuRatioTimeSeries[] = []; + for (const samples of threadSamples) { + if (samples.hasCPUDeltas && samples.time.length >= 2) { + let time = samples.time; + let cpuRatio = Float64Array.from( + samples.threadCPUPercent.subarray(0, samples.length), + (v) => v / 100 + ); + let length = samples.length; + + if (rangeStart !== undefined && rangeEnd !== undefined) { + const startIndex = bisectionLeft(samples.time, rangeStart); + const endIndex = bisectionLeft(samples.time, rangeEnd, startIndex); + + if (startIndex < endIndex) { + time = samples.time.slice(startIndex, endIndex); + cpuRatio = Float64Array.from( + samples.threadCPUPercent.subarray(startIndex, endIndex), + (v) => v / 100 + ); + length = endIndex - startIndex; + } else { + continue; + } + } + + threadsWithCPU.push({ + time, + cpuRatio, + maxCpuRatio: Infinity, + length, + }); + } + } + + if (threadsWithCPU.length === 0) { + return null; + } + + const cursors = new Array(threadsWithCPU.length).fill(0); + const combinedTime: number[] = []; + const combinedCPURatio: number[] = []; + let combinedMaxCpuRatio = 0; + + while (true) { + let sampleTime = Infinity; + for (let threadIdx = 0; threadIdx < threadsWithCPU.length; threadIdx++) { + const cursor = cursors[threadIdx]; + const thread = threadsWithCPU[threadIdx]; + if (cursor < thread.time.length) { + sampleTime = Math.min(sampleTime, thread.time[cursor]); + } + } + + if (sampleTime === Infinity) { + break; + } + + let sumCPURatio = 0; + for (let threadIdx = 0; threadIdx < threadsWithCPU.length; threadIdx++) { + const thread = threadsWithCPU[threadIdx]; + const cursor = cursors[threadIdx]; + if (cursor === thread.time.length) { + continue; + } + if (cursor > 0) { + sumCPURatio += thread.cpuRatio[cursor]; + } + if (thread.time[cursor] === sampleTime) { + cursors[threadIdx]++; + } + } + + combinedTime.push(sampleTime); + combinedCPURatio.push(sumCPURatio); + combinedMaxCpuRatio = Math.max(combinedMaxCpuRatio, sumCPURatio); + } + + return { + time: combinedTime, + cpuRatio: Float64Array.from(combinedCPURatio), + maxCpuRatio: combinedMaxCpuRatio, + length: combinedTime.length, + }; +} diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index 97695c0722..a984f89afa 100644 --- a/src/profile-logic/marker-data.ts +++ b/src/profile-logic/marker-data.ts @@ -41,6 +41,7 @@ import type { MarkerSchemaByName, MarkerDisplayLocation, Tid, + LogMarkerPayload, } from 'firefox-profiler/types'; /** @@ -1583,3 +1584,81 @@ export const stringsToMarkerRegExps = ( fieldMap, }; }; + +// In the new Log marker format, the `level` field is a string table index +// pointing to one of these strings. Map them to the single-letter abbreviations +// used in MOZ_LOG output (E/W/I/D/V). +export const LOG_LEVEL_STRING_TO_LETTER: Record = { + Error: 'E', + Warning: 'W', + Info: 'I', + Debug: 'D', + Verbose: 'V', +}; + +// Maps MOZ_LOG single-letter level abbreviations to a numeric priority +// (lower number = higher severity) for filtering comparisons. +export const LOG_LETTER_TO_LEVEL: Record = { + E: 1, + W: 2, + I: 3, + D: 4, + V: 5, +}; + +/** + * Format an absolute timestamp (ms since epoch) as a MOZ_LOG date string. + * Matches the output format of mozlog: "YYYY-MM-DD HH:MM:SS.mssμs UTC" + */ +export function formatLogTimestamp(absoluteMs: number): string { + function pad(p: string | number, c: number) { + return String(p).padStart(c, '0'); + } + const d = new Date(absoluteMs); + // new Date rounds down milliseconds; recover sub-millisecond precision separately. + // This will be imperfect because of float rounding errors but still better + // than not having them. + const ns = Math.trunc((absoluteMs - Math.trunc(absoluteMs)) * 10 ** 6); + return ( + `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)} ` + + `${pad(d.getUTCHours(), 2)}:${pad(d.getUTCMinutes(), 2)}:${pad(d.getUTCSeconds(), 2)}.` + + `${pad(d.getUTCMilliseconds(), 3)}${pad(ns, 6)} UTC` + ); +} + +/** + * Format a Log marker payload into a MOZ_LOG canonical line. + * + * Returns null if the entry has no message content and should be skipped. + * + * Two payload formats are supported: + * - New format: { message, level } where `level` is a string table index + * resolving to "Error" / "Warning" / "Info" / "Debug" / "Verbose", and the + * module name is taken from the marker's `name` field (also a string table + * index, passed here as `moduleName`). + * - Legacy format: { name, module } where `module` may include a level prefix + * ("D/nsHttp") or just a bare module name ("nsHttp"). + */ +export function formatLogStatement( + timestampStr: string, + processName: string, + pid: number | string, + threadName: string, + data: LogMarkerPayload, + moduleName: string, + stringArray: string[] +): string | null { + if ('message' in data) { + if (!data.message) { + return null; + } + const levelStr = stringArray[data.level] ?? ''; + const levelLetter = LOG_LEVEL_STRING_TO_LETTER[levelStr] ?? 'D'; + return `${timestampStr} - [${processName} ${pid}: ${threadName}]: ${levelLetter}/${moduleName} ${data.message.trim()}`; + } + if (!data.name) { + return null; + } + const prefix = data.module.includes('/') ? '' : 'D/'; + return `${timestampStr} - [${processName} ${pid}: ${threadName}]: ${prefix}${data.module} ${data.name.trim()}`; +} diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 28cfba5169..80e3395805 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -4359,6 +4359,32 @@ export function getNativeSymbolsForCallNode( return set; } +/** + * Return all native symbols whose frames are associated with the given function, + * across all call paths and all occurrences of the function in the call tree. + * + * This is the function-level counterpart to getNativeSymbolsForCallNode, which + * operates on a single call node (one specific path through the call tree). + * Use this when you need assembly coverage for an entire function regardless of + * how it was called, and getNativeSymbolsForCallNode when you need it for a + * specific call site. + */ +export function getNativeSymbolsForFunc( + funcIndex: IndexIntoFuncTable, + frameTable: FrameTable +): Set { + const set = new Set(); + for (let frameIndex = 0; frameIndex < frameTable.func.length; frameIndex++) { + if (frameTable.func[frameIndex] === funcIndex) { + const nativeSymbol = frameTable.nativeSymbol[frameIndex]; + if (nativeSymbol !== null) { + set.add(nativeSymbol); + } + } + } + return set; +} + /** * Return the total of the sample weights per native symbol, by * accumulating the weight from samples which contribute to the diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts index 05ba86b2bc..5bf82499af 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -317,6 +317,15 @@ export function parseTransforms(transformString: string): TransformStack { const filterString = filter.join('-'); const filterType = convertToFullFilterType(shortFilterType); + if (filterType !== 'marker-search') { + // profiler-cli-only filter types are not supported in the frontend. + console.error( + 'A profiler-cli-only filter-samples type was found in the URL and will be ignored.', + filterType + ); + break; + } + transforms.push({ type: 'filter-samples', filterType, @@ -338,6 +347,15 @@ function convertToFullFilterType(shortFilterType: string): FilterSamplesType { switch (shortFilterType) { case 'm': return 'marker-search'; + // profiler-cli-only types: + case 'om': + return 'outside-marker'; + case 'fi': + return 'function-include'; + case 'sp': + return 'stack-prefix'; + case 'ss': + return 'stack-suffix'; default: throw new Error('Unknown filter type.'); } @@ -350,6 +368,15 @@ function convertToShortFilterType(filterType: FilterSamplesType): string { switch (filterType) { case 'marker-search': return 'm'; + // profiler-cli-only types: + case 'outside-marker': + return 'om'; + case 'function-include': + return 'fi'; + case 'stack-prefix': + return 'sp'; + case 'stack-suffix': + return 'ss'; default: throw assertExhaustiveCheck(filterType); } @@ -457,6 +484,14 @@ export function getTransformLabelL10nIds( 'TransformNavigator--drop-samples-outside-of-markers-matching', item: transform.filter, }; + // profiler-cli-only filter types: + case 'outside-marker': + case 'function-include': + case 'stack-prefix': + case 'stack-suffix': + throw new Error( + `getTransformLabelL10nIds: profiler-cli-only filter type "${transform.filterType}" is not supported in the frontend transform navigator.` + ); default: throw assertExhaustiveCheck(transform.filterType); } @@ -1730,70 +1765,169 @@ export function filterSamples( filter: string ): Thread { return timeCode('filterSamples', () => { - // Find the ranges to filter. - function getFilterRanges(): StartEndRange[] { - switch (filterType) { - case 'marker-search': - return _findRangesByMarkerFilter( + const { stackTable, frameTable } = thread; + + switch (filterType) { + case 'function-include': { + // Keep only samples whose stack contains at least one of the given functions. + // The filter string is comma-separated funcIndexes. + if (!filter) { + throw new Error( + 'function-include filter requires a non-empty filter string.' + ); + } + const funcIndexes = new Set(filter.split(',').map(Number)); + // stackHasFunc[i] = 1 if stack i or any of its prefixes contains one of the functions. + const stackHasFunc = new Uint8Array(stackTable.length); + for (let i = 0; i < stackTable.length; i++) { + const prefix = stackTable.prefix[i]; + const f = frameTable.func[stackTable.frame[i]]; + if (funcIndexes.has(f) || (prefix !== null && stackHasFunc[prefix])) { + stackHasFunc[i] = 1; + } + } + return updateThreadStacks(thread, stackTable, (stack) => + stack !== null && !stackHasFunc[stack] ? null : stack + ); + } + + case 'stack-suffix': { + // Keep only samples whose leaf frame (the sample's direct stack) is the given function. + // The filter string is a single funcIndex. + if (!filter) { + throw new Error( + 'stack-suffix filter requires a non-empty filter string.' + ); + } + const targetFunc = Number(filter); + return updateThreadStacks(thread, stackTable, (stack) => { + if (stack === null) { + return null; + } + return frameTable.func[stackTable.frame[stack]] === targetFunc + ? stack + : null; + }); + } + + case 'stack-prefix': { + // Keep only samples whose stack starts with the given root-first sequence of functions. + // The filter string is comma-separated funcIndexes (root frame first). + if (!filter) { + throw new Error( + 'stack-prefix filter requires a non-empty filter string.' + ); + } + const prefixFuncs = filter.split(',').map(Number); + // matchDepth[i]: -1 = no match started; 1..N = number of prefix levels matched so far. + // When matchDepth[i] >= prefixFuncs.length the full prefix is matched and all + // descendants are valid. + const matchDepth = new Int32Array(stackTable.length).fill(-1); + for (let i = 0; i < stackTable.length; i++) { + const prefix = stackTable.prefix[i]; + const f = frameTable.func[stackTable.frame[i]]; + if (prefix === null) { + // Root frame: must match the first element of the prefix. + if (f === prefixFuncs[0]) { + matchDepth[i] = 1; + } + } else { + const pd = matchDepth[prefix]; + if (pd < 0) { + // Parent did not start matching — skip. + } else if (pd >= prefixFuncs.length) { + // Parent already fully matched the prefix — all descendants are valid. + matchDepth[i] = pd; + } else if (f === prefixFuncs[pd]) { + matchDepth[i] = pd + 1; + } + } + } + return updateThreadStacks(thread, stackTable, (stack) => + stack !== null && matchDepth[stack] < prefixFuncs.length + ? null + : stack + ); + } + + case 'marker-search': + case 'outside-marker': { + // Range-based filters: keep samples within (marker-search) or outside + // (outside-marker) the time ranges of matching markers. + const markerRanges = canonicalizeRangeSet( + _findRangesByMarkerFilter( getMarker, markerIndexes, markerSchemaByName, thread.stringTable, categoryList, filter - ); - default: - throw assertExhaustiveCheck(filterType); - } - } - - const ranges = canonicalizeRangeSet(getFilterRanges()); - - function computeFilteredStackColumn( - originalStackColumn: Array, - timeColumn: Milliseconds[] - ): Array { - const newStackColumn = originalStackColumn.slice(); - - // Walk the ranges and samples in order. Both are sorted by time. - // For each range, drop the samples before the range and skip the samples - // inside the range. - let sampleIndex = 0; - const sampleCount = timeColumn.length; - for (const range of ranges) { - const { start: rangeStart, end: rangeEnd } = range; - // Drop samples before the range. - for (; sampleIndex < sampleCount; sampleIndex++) { - if (timeColumn[sampleIndex] >= rangeStart) { - break; + ) + ); + const keepInsideRanges = filterType === 'marker-search'; + + function computeFilteredStackColumn( + originalStackColumn: Array, + timeColumn: Milliseconds[] + ): Array { + const newStackColumn = originalStackColumn.slice(); + let sampleIndex = 0; + const sampleCount = timeColumn.length; + + if (keepInsideRanges) { + // Keep samples INSIDE ranges; drop everything else. + for (const range of markerRanges) { + const { start: rangeStart, end: rangeEnd } = range; + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeStart) { + break; + } + newStackColumn[sampleIndex] = null; + } + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeEnd) { + break; + } + } + } + while (sampleIndex < sampleCount) { + newStackColumn[sampleIndex] = null; + sampleIndex++; + } + } else { + // Keep samples OUTSIDE ranges; drop samples inside each range. + for (const range of markerRanges) { + const { start: rangeStart, end: rangeEnd } = range; + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeStart) { + break; + } + } + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeEnd) { + break; + } + newStackColumn[sampleIndex] = null; + } + } + // Remaining samples after the last range are kept (they are outside all ranges). } - newStackColumn[sampleIndex] = null; - } - // Skip over samples inside the range. - for (; sampleIndex < sampleCount; sampleIndex++) { - if (timeColumn[sampleIndex] >= rangeEnd) { - break; - } + return newStackColumn; } - } - // Drop the remaining samples, i.e. the samples after the last range. - while (sampleIndex < sampleCount) { - newStackColumn[sampleIndex] = null; - sampleIndex++; + return updateThreadStacksByGeneratingNewStackColumns( + thread, + thread.stackTable, + computeFilteredStackColumn, + computeFilteredStackColumn, + (markerData) => markerData + ); } - return newStackColumn; + default: + throw assertExhaustiveCheck(filterType); } - - return updateThreadStacksByGeneratingNewStackColumns( - thread, - thread.stackTable, - computeFilteredStackColumn, - computeFilteredStackColumn, - (markerData) => markerData - ); }); } @@ -2066,9 +2200,54 @@ export function translateTransform( } case 'filter-samples': { switch (transform.filterType) { - case 'marker-search': { - // This transform doesn't contain any data which needs to be translated. + case 'marker-search': + case 'outside-marker': + // These filter by marker name string — no indices to remap. return transform; + case 'stack-suffix': { + // Single funcIndex encoded as a decimal string. + const newFuncIndex = translateFuncIndex( + Number(transform.filter), + translationMaps + ); + if (newFuncIndex === null) { + return null; + } + return { ...transform, filter: String(newFuncIndex) }; + } + case 'stack-prefix': { + // Comma-separated funcIndexes (root-first). The entire prefix is + // invalid if any element is missing after translation. + const translated = []; + for (const raw of transform.filter.split(',')) { + const newFuncIndex = translateFuncIndex( + Number(raw), + translationMaps + ); + if (newFuncIndex === null) { + return null; + } + translated.push(newFuncIndex); + } + return { ...transform, filter: translated.join(',') }; + } + case 'function-include': { + // Comma-separated funcIndexes. Drop missing ones; if all are gone, + // drop the transform. + const translated = []; + for (const raw of transform.filter.split(',')) { + const newFuncIndex = translateFuncIndex( + Number(raw), + translationMaps + ); + if (newFuncIndex !== null) { + translated.push(newFuncIndex); + } + } + if (translated.length === 0) { + return null; + } + return { ...transform, filter: translated.join(',') }; } default: throw assertExhaustiveCheck(transform.filterType); diff --git a/src/profile-query/README.md b/src/profile-query/README.md new file mode 100644 index 0000000000..46463dd553 --- /dev/null +++ b/src/profile-query/README.md @@ -0,0 +1,50 @@ +# Profile Query Library + +A library for programmatically querying the contents of a Firefox Profiler profile. + +## Usage + +### Building + +```bash +yarn build-profile-query +``` + +### Programmatic Usage + +```javascript +// Node.js interactive session +const { ProfileQuerier } = (await import('./dist/profile-query.js')).default; + +// Load from file +const p1 = await ProfileQuerier.load('/path/to/profile.json.gz'); + +// Load from profiler.firefox.com URL +const p2 = await ProfileQuerier.load( + 'https://profiler.firefox.com/from-url/http%3A%2F%2Fexample.com%2Fprofile.json/' +); + +// Load from share URL +const p3 = await ProfileQuerier.load('https://share.firefox.dev/4oLEjCw'); + +// Query the profile +const profileInfo = await p1.profileInfo(); +const threadInfo = await p1.threadInfo(); +const samples = await p1.threadSamples(); +``` + +All query methods return structured result objects (typed as `WithContext<...>` or a specific result type), not plain strings. The `context` field on most results includes the current selected thread and view range. + +## Architecture + +The library is built on top of the Firefox Profiler's Redux store and selectors: + +- **ProfileQuerier**: Main class that wraps a Redux store and provides query methods +- **TimestampManager**: Manages timestamp naming for time range queries +- **ThreadMap**: Maps thread handles (e.g., `t-0`, `t-1`) to thread indexes +- **MarkerMap**: Maps marker handles (e.g., `m-0`, `m-1`) to marker indexes within threads +- **FilterStack**: Manages per-thread stacks of sample filters (backed by Redux transforms) +- **Function handles**: Canonical handles like `f-123` refer to shared `profile.shared.funcTable` indices and are stable across sessions for the same processed profile data +- **Formatters**: Format query results into structured result objects + +All query results are returned as typed result objects containing structured data. The CLI layer in `profiler-cli` is responsible for formatting these into human-readable text. diff --git a/src/profile-query/cpu-activity.ts b/src/profile-query/cpu-activity.ts new file mode 100644 index 0000000000..79351ac47b --- /dev/null +++ b/src/profile-query/cpu-activity.ts @@ -0,0 +1,118 @@ +import type { Slice, SliceTree } from 'firefox-profiler/utils/slice-tree'; +import type { TimestampManager } from './timestamps'; + +export interface CpuActivityEntry { + startTime: number; + startTimeName: string; + startTimeStr: string; + endTime: number; + endTimeName: string; + endTimeStr: string; + cpuMs: number; + depthLevel: number; +} + +/** + * Collect CPU activity slices as structured data. + */ +export function collectSliceTree( + { slices, time }: SliceTree, + tsManager: TimestampManager +): CpuActivityEntry[] { + if (slices.length === 0) { + return []; + } + + const childrenStartPerParent: Array = new Array(slices.length); + const indexAndSumPerSlice: Array<{ i: number; sum: number }> = []; + for (let i = 0; i < slices.length; i++) { + childrenStartPerParent[i] = null; + const { parent, sum } = slices[i]; + indexAndSumPerSlice.push({ i, sum }); + if (parent !== null && childrenStartPerParent[parent] === null) { + childrenStartPerParent[parent] = i; + } + } + indexAndSumPerSlice.sort((a, b) => b.sum - a.sum); + const interestingSliceIndexes = new Set(); + for (const { i } of indexAndSumPerSlice.slice(0, 20)) { + let currentIndex: number | null = i; + while ( + currentIndex !== null && + !interestingSliceIndexes.has(currentIndex) + ) { + interestingSliceIndexes.add(currentIndex); + currentIndex = slices[currentIndex].parent; + } + } + + const result: CpuActivityEntry[] = []; + collectSliceSubtree( + slices, + 0, + null, + childrenStartPerParent, + interestingSliceIndexes, + 0, + time, + result, + tsManager + ); + + return result; +} + +function collectSliceSubtree( + slices: Slice[], + startIndex: number, + parent: number | null, + childrenStartPerParent: Array, + interestingSliceIndexes: Set, + nestingDepth: number, + time: number[], + result: CpuActivityEntry[], + tsManager: TimestampManager +) { + for (let i = startIndex; i < slices.length; i++) { + if (!interestingSliceIndexes.has(i)) { + continue; + } + + const slice = slices[i]; + if (slice.parent !== parent) { + break; + } + + const { start, end, avg } = slice; + const startTime = time[start]; + const endTime = time[end]; + const duration = endTime - startTime; + const cpuMs = duration * avg; + + result.push({ + startTime, + startTimeName: tsManager.nameForTimestamp(startTime), + startTimeStr: tsManager.timestampString(startTime), + endTime, + endTimeName: tsManager.nameForTimestamp(endTime), + endTimeStr: tsManager.timestampString(endTime), + cpuMs, + depthLevel: nestingDepth, + }); + + const childrenStart = childrenStartPerParent[i]; + if (childrenStart !== null) { + collectSliceSubtree( + slices, + childrenStart, + i, + childrenStartPerParent, + interestingSliceIndexes, + nestingDepth + 1, + time, + result, + tsManager + ); + } + } +} diff --git a/src/profile-query/filter-stack.ts b/src/profile-query/filter-stack.ts new file mode 100644 index 0000000000..668f1f89c3 --- /dev/null +++ b/src/profile-query/filter-stack.ts @@ -0,0 +1,223 @@ +/* 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/. */ + +/** + * Filter helpers for profiler-cli. + * + * The CLI's filter stack is just the Redux transform stack for a thread, so + * there is no separate in-memory representation to track. This module provides + * two helpers: + * + * - `pushSpecTransforms` — dispatches the Redux transforms that implement a + * SampleFilterSpec (the CLI's `filter push` DSL). + * - `describeSpec` / `describeTransform` — human-readable descriptions. Specs + * come from `filter push`; transforms come from the raw Redux stack (which + * can include URL-loaded entries that weren't pushed by the CLI). + */ + +import { addTransformToStack } from '../actions/profile-view'; +import type { SampleFilterSpec } from './types'; +import type { Store } from '../types/store'; +import type { ThreadsKey, Transform } from 'firefox-profiler/types'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +/** + * Build a human-readable description for a filter spec. + */ +export function describeSpec(spec: SampleFilterSpec): string { + switch (spec.type) { + case 'excludes-function': + return `excludes function: f-${spec.funcIndexes.join(', f-')}`; + case 'merge': + return `merge: f-${spec.funcIndexes.join(', f-')}`; + case 'root-at': + return `root-at: f-${spec.funcIndex}`; + case 'during-marker': + return `during marker matching: "${spec.searchString}"`; + case 'includes-function': + return `includes function: f-${spec.funcIndexes.join(', f-')}`; + case 'includes-prefix': + return `includes prefix: f-${spec.funcIndexes.join(' → f-')}`; + case 'includes-suffix': + return `includes suffix: f-${spec.funcIndex}`; + case 'outside-marker': + return `outside marker matching: "${spec.searchString}"`; + default: + throw assertExhaustiveCheck(spec); + } +} + +/** + * Build a human-readable description for a single Redux transform. + * + * Where a transform corresponds 1:1 to a CLI filter spec (e.g. `drop-function` + * is what `--excludes-function` pushes), use the CLI wording so `filter list` + * matches what the user typed. For transforms the CLI never produces — only + * URL-loaded or web-app-produced ones — fall back to the transform type. + */ +function describeSingleTransform(transform: Transform): string { + switch (transform.type) { + // CLI-produced transforms: use the original filter-spec wording. + case 'drop-function': + return `excludes function: f-${transform.funcIndex}`; + case 'merge-function': + return `merge: f-${transform.funcIndex}`; + case 'focus-function': + return `root-at: f-${transform.funcIndex}`; + case 'filter-samples': + switch (transform.filterType) { + case 'marker-search': + return `during marker matching: "${transform.filter}"`; + case 'outside-marker': + return `outside marker matching: "${transform.filter}"`; + case 'function-include': + return `includes function: f-${transform.filter.split(',').join(', f-')}`; + case 'stack-prefix': + return `includes prefix: f-${transform.filter.split(',').join(' → f-')}`; + case 'stack-suffix': + return `includes suffix: f-${transform.filter}`; + default: + return `filter-samples (${transform.filterType}): "${transform.filter}"`; + } + + // URL-only / web-app transforms: generic description. + case 'focus-subtree': + return `focus-subtree: ${transform.callNodePath.map((f) => `f-${f}`).join(' → ')}${transform.inverted ? ' (inverted)' : ''}`; + case 'focus-self': + return `focus-self: f-${transform.funcIndex}`; + case 'merge-call-node': + return `merge-call-node: ${transform.callNodePath.map((f) => `f-${f}`).join(' → ')}`; + case 'collapse-resource': + return `collapse-resource: r-${transform.resourceIndex}`; + case 'collapse-direct-recursion': + return `collapse-direct-recursion: f-${transform.funcIndex}`; + case 'collapse-recursion': + return `collapse-recursion: f-${transform.funcIndex}`; + case 'collapse-function-subtree': + return `collapse-function-subtree: f-${transform.funcIndex}`; + case 'focus-category': + return `focus-category: ${transform.category}`; + default: + throw assertExhaustiveCheck(transform); + } +} + +/** + * Build a human-readable description for a filter-stack entry, which may back + * one or more Redux transforms. Multi-transform groups come from CLI specs + * that accept a comma-separated list (e.g. `--merge f-1,f-2` dispatches two + * merge-function transforms); render them with the spec's plural wording so + * the display matches what the user typed. + */ +export function describeTransformGroup(transforms: Transform[]): string { + if (transforms.length === 1) { + return describeSingleTransform(transforms[0]); + } + const allSameType = transforms.every((t) => t.type === transforms[0].type); + if (allSameType && transforms[0].type === 'drop-function') { + const ids = (transforms as { funcIndex: number }[]) + .map((t) => `f-${t.funcIndex}`) + .join(', '); + return `excludes function: ${ids}`; + } + if (allSameType && transforms[0].type === 'merge-function') { + const ids = (transforms as { funcIndex: number }[]) + .map((t) => `f-${t.funcIndex}`) + .join(', '); + return `merge: ${ids}`; + } + // Shouldn't happen in practice — multi-transform groups only come from the + // two CLI specs above. Join single descriptions as a last resort. + return transforms.map(describeSingleTransform).join('; '); +} + +/** + * Dispatch the Redux transforms for a filter spec and return the number pushed. + * Used by `filter push` (sticky) and by ephemeral-filter application. + */ +export function pushSpecTransforms( + store: Store, + threadsKey: ThreadsKey, + spec: SampleFilterSpec +): number { + switch (spec.type) { + case 'excludes-function': { + for (const funcIndex of spec.funcIndexes) { + store.dispatch( + addTransformToStack(threadsKey, { type: 'drop-function', funcIndex }) + ); + } + return spec.funcIndexes.length; + } + case 'merge': { + for (const funcIndex of spec.funcIndexes) { + store.dispatch( + addTransformToStack(threadsKey, { type: 'merge-function', funcIndex }) + ); + } + return spec.funcIndexes.length; + } + case 'root-at': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex: spec.funcIndex, + }) + ); + return 1; + } + case 'during-marker': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'marker-search', + filter: spec.searchString, + }) + ); + return 1; + } + case 'includes-function': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'function-include', + filter: spec.funcIndexes.join(','), + }) + ); + return 1; + } + case 'includes-prefix': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'stack-prefix', + filter: spec.funcIndexes.join(','), + }) + ); + return 1; + } + case 'includes-suffix': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'stack-suffix', + filter: String(spec.funcIndex), + }) + ); + return 1; + } + case 'outside-marker': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'outside-marker', + filter: spec.searchString, + }) + ); + return 1; + } + default: + throw assertExhaustiveCheck(spec); + } +} diff --git a/src/profile-query/formatters/call-tree.ts b/src/profile-query/formatters/call-tree.ts new file mode 100644 index 0000000000..18353efd98 --- /dev/null +++ b/src/profile-query/formatters/call-tree.ts @@ -0,0 +1,326 @@ +/* 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 type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; +import type { IndexIntoCallNodeTable, Lib } from 'firefox-profiler/types'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import type { CallTreeNode, CallTreeScoringStrategy } from '../types'; +import { getFunctionHandle } from '../function-map'; +import { formatFunctionNameWithLibrary } from '../function-list'; + +/** + * Compute inclusion score for a call tree node. + * The score determines priority for node budget selection. + * Property: score(child) ≤ score(parent) for any parent-child pair. + */ +function computeInclusionScore( + totalPercentage: number, + depth: number, + strategy: CallTreeScoringStrategy +): number { + switch (strategy) { + case 'exponential-0.95': + return totalPercentage * Math.pow(0.95, depth); + case 'exponential-0.9': + return totalPercentage * Math.pow(0.9, depth); + case 'exponential-0.8': + return totalPercentage * Math.pow(0.8, depth); + case 'harmonic-0.1': + return totalPercentage / (1 + 0.1 * depth); + case 'harmonic-0.5': + return totalPercentage / (1 + 0.5 * depth); + case 'harmonic-1.0': + return totalPercentage / (1 + depth); + case 'percentage-only': + return totalPercentage; + default: + throw assertExhaustiveCheck(strategy); + } +} + +/** + * Simple max-heap implementation for priority queue. + */ +class MaxHeap { + private items: Array<{ item: T; priority: number }> = []; + + push(item: T, priority: number): void { + this.items.push({ item, priority }); + this._bubbleUp(this.items.length - 1); + } + + popMax(): T | null { + if (this.items.length === 0) { + return null; + } + if (this.items.length === 1) { + return this.items.pop()!.item; + } + + const max = this.items[0].item; + this.items[0] = this.items.pop()!; + this._bubbleDown(0); + return max; + } + + isEmpty(): boolean { + return this.items.length === 0; + } + + size(): number { + return this.items.length; + } + + private _bubbleUp(index: number): void { + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2); + if (this.items[index].priority <= this.items[parentIndex].priority) { + break; + } + // Swap + [this.items[index], this.items[parentIndex]] = [ + this.items[parentIndex], + this.items[index], + ]; + index = parentIndex; + } + } + + private _bubbleDown(index: number): void { + while (true) { + const leftChild = 2 * index + 1; + const rightChild = 2 * index + 2; + let largest = index; + + if ( + leftChild < this.items.length && + this.items[leftChild].priority > this.items[largest].priority + ) { + largest = leftChild; + } + + if ( + rightChild < this.items.length && + this.items[rightChild].priority > this.items[largest].priority + ) { + largest = rightChild; + } + + if (largest === index) { + break; + } + + // Swap + [this.items[index], this.items[largest]] = [ + this.items[largest], + this.items[index], + ]; + index = largest; + } + } +} + +/** + * Internal node used during collection. + */ +type CollectionNode = { + callNodeIndex: IndexIntoCallNodeTable; + depth: number; +}; + +/** + * Options for call tree collection. + */ +export type CallTreeCollectionOptions = { + /** Maximum number of nodes to include. Default: 100 */ + maxNodes?: number; + /** Scoring strategy for node selection. Default: 'exponential-0.9' */ + scoringStrategy?: CallTreeScoringStrategy; + /** Maximum depth to traverse (safety limit). Default: 200 */ + maxDepth?: number; + /** Maximum children to expand per node. Default: 100 */ + maxChildrenPerNode?: number; +}; + +/** + * Collect call tree data using heap-based expansion. + * This works for both top-down and bottom-up (inverted) trees. + */ +export function collectCallTree( + tree: CallTree, + libs: Lib[], + options: CallTreeCollectionOptions = {} +): CallTreeNode { + const maxNodes = options.maxNodes ?? 100; + const scoringStrategy = options.scoringStrategy ?? 'exponential-0.9'; + const maxDepth = options.maxDepth ?? 200; + const maxChildrenPerNode = options.maxChildrenPerNode ?? 100; + + // Map from call node index to our collection node + const includedNodes = new Set(); + const expansionFrontier = new MaxHeap(); + + // Start with root nodes + // For inverted (bottom-up) trees, there can be many roots (all leaf functions). + // Reserve some budget for expanding children by limiting initial roots to ~70% of budget. + const roots = tree.getRoots(); + const maxInitialRoots = Math.min(roots.length, Math.ceil(maxNodes * 0.7)); + for (let i = 0; i < maxInitialRoots; i++) { + const rootIndex = roots[i]; + const nodeData = tree.getNodeData(rootIndex); + const totalPercentage = nodeData.totalRelative * 100; + const score = computeInclusionScore(totalPercentage, 0, scoringStrategy); + + const collectionNode: CollectionNode = { + callNodeIndex: rootIndex, + depth: 0, + }; + + expansionFrontier.push(collectionNode, score); + } + + // Expand nodes in score order until budget reached + while (includedNodes.size < maxNodes) { + const node = expansionFrontier.popMax(); + if (!node) { + break; + } + + // node is the next highest candidate; none of the other nodes in expansionFronteer, or + // any of their descendants, will have a higher score than node. Add it to the included + // set. + includedNodes.add(node.callNodeIndex); + + // Skip children if we've reached max depth + if (node.depth >= maxDepth || !tree.hasChildren(node.callNodeIndex)) { + continue; + } + + const childDepth = node.depth + 1; + + const children = tree.getChildren(node.callNodeIndex); + // Limit children per node to prevent budget explosion + const childrenToExpand = children.slice(0, maxChildrenPerNode); + + for (const childIndex of childrenToExpand) { + const childData = tree.getNodeData(childIndex); + const totalPercentage = childData.totalRelative * 100; + const childScore = computeInclusionScore( + totalPercentage, + childDepth, + scoringStrategy + ); + + const childNode: CollectionNode = { + callNodeIndex: childIndex, + depth: childDepth, + }; + + expansionFrontier.push(childNode, childScore); + } + } + + return buildTreeStructure(tree, includedNodes, libs); +} + +/** + * Build tree structure from the set of included nodes. + */ +function buildTreeStructure( + tree: CallTree, + includedNodes: Set, + libs: Lib[] +): CallTreeNode { + // Get total sample count from the tree for percentage calculations + const totalSampleCount = tree.getTotal(); + + // Create virtual root + const rootNode: CallTreeNode = { + name: '', + nameWithLibrary: '', + totalSamples: totalSampleCount, + totalPercentage: 100, + selfSamples: 0, + selfPercentage: 0, + originalDepth: -1, + children: [], + }; + + const pendingNodes = [rootNode]; + + // Create tree nodes for all included nodes. + // Traverse the tree until we run out of pendingNodes. + while (true) { + const node = pendingNodes.pop(); + if (node === undefined) { + break; + } + + const childrenCallNodeIndexes = + node.callNodeIndex !== undefined + ? tree.getChildren(node.callNodeIndex) + : tree.getRoots(); + const elidedChildren = []; + const childrenDepth = node.originalDepth + 1; + for (const callNodeIndex of childrenCallNodeIndexes) { + if (!includedNodes.has(callNodeIndex)) { + elidedChildren.push(callNodeIndex); + continue; + } + const childNodeData = tree.getNodeData(callNodeIndex); + const funcIndex = tree._callNodeInfo.funcForNode(callNodeIndex); + const totalPercentage = childNodeData.totalRelative * 100; + + // Format function name with library prefix + const nameWithLibrary = formatFunctionNameWithLibrary( + funcIndex, + tree._thread, + libs + ); + + const childNode: CallTreeNode = { + callNodeIndex, + functionHandle: getFunctionHandle(funcIndex), + functionIndex: funcIndex, + name: childNodeData.funcName, + nameWithLibrary, + totalSamples: childNodeData.total, + totalPercentage, + selfSamples: childNodeData.self, + selfPercentage: childNodeData.selfRelative * 100, + originalDepth: childrenDepth, + children: [], + }; + + node.children.push(childNode); + pendingNodes.push(childNode); + } + + // Create elision marker if there are any elided or unexpanded children + if (elidedChildren.length > 0) { + let combinedSamples = 0; + let maxSamples = 0; + + // Stats for elided children that were NOT expanded + for (const childIdx of elidedChildren) { + const childData = tree.getNodeData(childIdx); + combinedSamples += childData.total; + maxSamples = Math.max(maxSamples, childData.total); + } + + const combinedRelative = combinedSamples / totalSampleCount; + const maxRelative = maxSamples / totalSampleCount; + node.childrenTruncated = { + count: elidedChildren.length, + combinedSamples, + combinedPercentage: combinedRelative * 100, + maxSamples, + maxPercentage: maxRelative * 100, + depth: childrenDepth, + }; + } + } + + return rootNode; +} diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts new file mode 100644 index 0000000000..e013493f79 --- /dev/null +++ b/src/profile-query/formatters/marker-info.ts @@ -0,0 +1,1442 @@ +/* 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 { getSelectedThreadIndexes } from 'firefox-profiler/selectors/url-state'; +import { + getProfile, + getCategories, + getMarkerSchemaByName, + getStringTable, +} from 'firefox-profiler/selectors/profile'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + formatFromMarkerSchema, + getLabelGetter, +} from 'firefox-profiler/profile-logic/marker-schema'; +import { changeMarkersSearchString } from '../../actions/profile-view'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { MarkerMap } from '../marker-map'; +import type { + Marker, + MarkerIndex, + CategoryList, + Thread, + Lib, + IndexIntoStackTable, + MarkerSchemaByName, +} from 'firefox-profiler/types'; +import type { StringTable } from 'firefox-profiler/utils/string-table'; +import type { + MarkerStackResult, + MarkerInfoResult, + StackTraceData, + ThreadMarkersResult, + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, + MarkerGroupData, + DurationStats, + RateStats, + MarkerFilterOptions, + FlatMarkerItem, + ProfileLogsResult, +} from '../types'; +import { + isNetworkMarker, + LOG_LEVEL_STRING_TO_LETTER, + LOG_LETTER_TO_LEVEL, + formatLogTimestamp, + formatLogStatement, +} from 'firefox-profiler/profile-logic/marker-data'; +import { formatFunctionNameWithLibrary } from '../function-list'; +import type { + NetworkPayload, + LogMarkerPayload, +} from 'firefox-profiler/types/markers'; + +/** + * Aggregated statistics for a group of markers. + */ +interface MarkerNameStats { + markerName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: Array<{ + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; + }>; + subGroups?: MarkerGroup[]; // Sub-groups for multi-level grouping + subGroupKey?: string; // The key used for sub-grouping (e.g., "eventType" for auto-grouped fields) +} + +/** + * A group of markers with a common grouping key value. + */ +interface MarkerGroup { + groupName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: Array<{ + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; + }>; + subGroups?: MarkerGroup[]; // Recursive sub-grouping +} + +/** + * A grouping key specifies how to group markers. + */ +type GroupingKey = + | 'type' // Group by marker type (data.type) + | 'name' // Group by marker name + | 'category' // Group by category name + | { field: string }; // Group by a specific field value + +/** + * Compute duration statistics for a list of markers. + * Only applies to interval markers (markers with an end time). + * Exported for testing. + */ +export function computeDurationStats( + markers: Marker[] +): DurationStats | undefined { + const durations = markers + .filter((m) => m.end !== null) + .map((m) => m.end! - m.start) + .sort((a, b) => a - b); + + if (durations.length === 0) { + return undefined; + } + + return { + min: durations[0], + max: durations[durations.length - 1], + avg: durations.reduce((a, b) => a + b, 0) / durations.length, + median: durations[Math.floor(durations.length / 2)], + p95: durations[Math.floor(durations.length * 0.95)], + p99: durations[Math.floor(durations.length * 0.99)], + }; +} + +/** + * Compute rate statistics for a list of markers (gaps between markers). + * Exported for testing. + */ +export function computeRateStats(markers: Marker[]): RateStats { + if (markers.length < 2) { + return { + markersPerSecond: 0, + minGap: 0, + avgGap: 0, + maxGap: 0, + }; + } + + const sorted = [...markers].sort((a, b) => a.start - b.start); + const gaps: number[] = []; + + for (let i = 1; i < sorted.length; i++) { + gaps.push(sorted[i].start - sorted[i - 1].start); + } + + const timeRange = sorted[sorted.length - 1].start - sorted[0].start; + // timeRange is in milliseconds, convert to seconds for rate + const markersPerSecond = + timeRange > 0 ? (markers.length / timeRange) * 1000 : 0; + + return { + markersPerSecond, + minGap: Math.min(...gaps), + avgGap: gaps.reduce((a, b) => a + b, 0) / gaps.length, + maxGap: Math.max(...gaps), + }; +} + +/** + * Apply all marker filters to a list of marker indexes. + * Returns the filtered list of marker indexes. + */ +function applyMarkerFilters( + markerIndexes: MarkerIndex[], + markers: Marker[], + categories: CategoryList, + filterOptions: MarkerFilterOptions +): MarkerIndex[] { + let filteredIndexes = markerIndexes; + + const { minDuration, maxDuration, category, hasStack } = filterOptions; + + // Apply duration filtering if specified + if (minDuration !== undefined || maxDuration !== undefined) { + filteredIndexes = filteredIndexes.filter((markerIndex) => { + const marker = markers[markerIndex]; + + // Skip instant markers (they have no duration) + if (marker.end === null) { + return false; + } + + const duration = marker.end - marker.start; + + // Check min duration constraint + if (minDuration !== undefined && duration < minDuration) { + return false; + } + + // Check max duration constraint + if (maxDuration !== undefined && duration > maxDuration) { + return false; + } + + return true; + }); + } + + // Apply category filtering if specified + if (category !== undefined) { + const categoryLower = category.toLowerCase(); + filteredIndexes = filteredIndexes.filter((markerIndex) => { + const marker = markers[markerIndex]; + const categoryName = categories[marker.category]?.name ?? 'Unknown'; + return categoryName.toLowerCase().includes(categoryLower); + }); + } + + // Apply hasStack filtering if specified + if (hasStack) { + filteredIndexes = filteredIndexes.filter((markerIndex) => { + const marker = markers[markerIndex]; + return marker.data && 'cause' in marker.data && marker.data.cause; + }); + } + + return filteredIndexes; +} + +/** + * Create a top markers array from a list of marker items. + * Returns up to 5 top markers, sorted by duration if applicable. + */ +function createTopMarkersArray( + items: Array<{ marker: Marker; index: MarkerIndex }>, + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + maxCount: number = 5 +): Array<{ + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; +}> { + // Partition into interval (sortable by duration) and instant markers, so + // null `end` values never reach the comparator and produce NaN. + const intervalItems = items.filter((item) => item.marker.end !== null); + const instantItems = items.filter((item) => item.marker.end === null); + intervalItems.sort( + (a, b) => b.marker.end! - b.marker.start - (a.marker.end! - a.marker.start) + ); + const sortedItems = [...intervalItems, ...instantItems]; + + return sortedItems.slice(0, maxCount).map((item) => { + const handle = markerMap.handleForMarker(threadIndexes, item.index); + const label = getMarkerLabel(item.index); + const duration = + item.marker.end !== null + ? item.marker.end - item.marker.start + : undefined; + const hasStack = Boolean( + item.marker.data && 'cause' in item.marker.data && item.marker.data.cause + ); + return { + handle, + label: label || item.marker.name, + start: item.marker.start, + duration, + hasStack, + }; + }); +} + +/** + * Parse a groupBy string into an array of grouping keys. + * Examples: + * "type" => ['type'] + * "type,name" => ['type', 'name'] + * "type,field:eventType" => ['type', {field: 'eventType'}] + */ +function parseGroupingKeys(groupBy: string): GroupingKey[] { + return groupBy.split(',').map((key) => { + const trimmed = key.trim(); + if (trimmed.startsWith('field:')) { + return { field: trimmed.substring(6) }; + } + return trimmed as 'type' | 'name' | 'category'; + }); +} + +/** + * Get the grouping value for a marker based on a grouping key. + */ +function getGroupingValue( + marker: Marker, + key: GroupingKey, + categories: CategoryList, + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable +): string { + if (key === 'type') { + return marker.data?.type ?? marker.name; + } else if (key === 'name') { + return marker.name; + } else if (key === 'category') { + return categories[marker.category]?.name ?? 'Unknown'; + } + // Field-based grouping + const fieldValue = (marker.data as any)?.[key.field]; + if (fieldValue === undefined || fieldValue === null) { + return '(no value)'; + } + // For fields whose format stores a string-table index (unique-string / + // flow-id / terminating-flow-id), resolve to the interned string so groups + // show "Error" / "click" / ... instead of integer indices. + const schema = marker.data ? markerSchemaByName[marker.data.type] : undefined; + const field = schema?.fields.find((f) => f.key === key.field); + if ( + field && + (field.format === 'unique-string' || + field.format === 'flow-id' || + field.format === 'terminating-flow-id') && + typeof fieldValue === 'number' + ) { + return stringTable.getString(fieldValue, '(empty)'); + } + return String(fieldValue); +} + +/** + * Analyze field variance for a group of markers to determine if sub-grouping + * would be useful. Returns the best field for grouping based on a scoring + * heuristic, or null if none found. + * + * Schema-driven: iterates the marker schema's declared fields rather than + * probing the first marker's `Object.keys`, so fields absent from the first + * marker still get considered. For fields whose format stores a string-table + * index (`unique-string` / `flow-id` / `terminating-flow-id`), we resolve the + * interned string before computing cardinality — otherwise we'd see variance + * over integer indices. + * + * The schema's `format` tells us which fields are enum-like candidates. High- + * cardinality formats (url, file-path, any time/byte/percent/decimal, list, + * table) are skipped outright; ID-shaped key-name heuristics are unnecessary. + * + * Scoring: + * - 3-20 unique values is the ideal range (score 100), decaying up to 50 + * - Skip fields that appear in < 80% of markers, or with < 3 unique values + * - Small boost if the field is present on every marker + */ +function analyzeFieldVariance( + markers: Marker[], + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable +): { field: string; variance: number } | null { + if (markers.length === 0) { + return null; + } + const schemaName = markers[0].data?.type; + if (!schemaName) { + return null; + } + const schema = markerSchemaByName[schemaName]; + if (!schema) { + return null; + } + + const fieldScores: Array<{ + field: string; + score: number; + uniqueCount: number; + }> = []; + + for (const fieldSchema of schema.fields) { + if (fieldSchema.hidden) { + continue; + } + const fmt = fieldSchema.format; + // Only enum-like formats are useful for auto-grouping. Everything else — + // urls, file paths, any numeric quantity (bytes/time/percent/decimal), + // flow-ids (unique-per-flow by construction), lists, tables — would + // produce either a useless single-value grouping or an ID-like blowup. + const isEnumLike = + fmt === 'string' || + fmt === 'unique-string' || + fmt === 'integer' || + fmt === 'pid' || + fmt === 'tid'; + if (!isEnumLike) { + continue; + } + const needsStringTable = fmt === 'unique-string'; + + const uniqueValues = new Set(); + let validCount = 0; + + for (const marker of markers) { + const raw = (marker.data as any)?.[fieldSchema.key]; + if (raw === undefined || raw === null) { + continue; + } + const resolved = + needsStringTable && typeof raw === 'number' + ? stringTable.getString(raw, '') + : String(raw); + uniqueValues.add(resolved); + validCount++; + } + + if (validCount < markers.length * 0.8) { + continue; + } + const uniqueCount = uniqueValues.size; + if (uniqueCount < 3) { + continue; + } + + let score = 0; + if (uniqueCount <= 20) { + score = 100; + } else if (uniqueCount <= 50) { + score = 100 - (uniqueCount - 20) * 2; + } else { + score = 10; + } + if (validCount === markers.length) { + score += 10; + } + + fieldScores.push({ field: fieldSchema.key, score, uniqueCount }); + } + + if (fieldScores.length === 0) { + return null; + } + + fieldScores.sort((a, b) => b.score - a.score); + return { field: fieldScores[0].field, variance: fieldScores[0].score / 100 }; +} + +/** + * Group markers by a sequence of grouping keys (multi-level grouping). + * Returns a hierarchical structure of groups. + */ +function groupMarkers( + markerGroup: Array<{ marker: Marker; index: MarkerIndex }>, + groupingKeys: GroupingKey[], + categories: CategoryList, + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable, + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + depth: number = 0, + maxTopMarkers: number = 5 +): MarkerGroup[] { + if (groupingKeys.length === 0 || markerGroup.length === 0) { + return []; + } + + const [currentKey, ...remainingKeys] = groupingKeys; + const groups = new Map< + string, + Array<{ marker: Marker; index: MarkerIndex }> + >(); + + // Group by current key + for (const item of markerGroup) { + const groupValue = getGroupingValue( + item.marker, + currentKey, + categories, + markerSchemaByName, + stringTable + ); + if (!groups.has(groupValue)) { + groups.set(groupValue, []); + } + groups.get(groupValue)!.push(item); + } + + const result: MarkerGroup[] = []; + for (const [groupName, items] of groups.entries()) { + const markers = items.map((item) => item.marker); + const hasEnd = markers.some((m) => m.end !== null); + const durationStats = hasEnd ? computeDurationStats(markers) : undefined; + const rateStats = computeRateStats(markers); + + // Get top markers + const topMarkers = createTopMarkersArray( + items, + threadIndexes, + markerMap, + getMarkerLabel, + maxTopMarkers + ); + + // Recursively group by remaining keys (limit depth to 3) + const subGroups = + remainingKeys.length > 0 && depth < 2 + ? groupMarkers( + items, + remainingKeys, + categories, + markerSchemaByName, + stringTable, + threadIndexes, + markerMap, + getMarkerLabel, + depth + 1, + maxTopMarkers + ) + : undefined; + + result.push({ + groupName, + count: markers.length, + isInterval: hasEnd, + durationStats, + rateStats, + topMarkers, + subGroups, + }); + } + + // Sort by count descending + result.sort((a, b) => b.count - a.count); + + return result; +} + +/** + * Aggregate markers by `marker.name` (not by `marker.data.type` — these differ + * when a marker with the same payload type is emitted under different names, + * or when a marker has no payload at all). The output is surfaced as `byType` + * in the JSON schema for historical reasons; callers wanting to group by the + * schema type should use `--group-by type`. + * + * Optionally applies auto-grouping or custom grouping. + */ +function aggregateMarkersByName( + markers: Marker[], + markerIndexes: MarkerIndex[], + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + categories: CategoryList, + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable, + autoGroup: boolean = false, + maxTopMarkers: number = 5 +): MarkerNameStats[] { + // Convert Set to number if needed + const groups = new Map< + string, + Array<{ marker: Marker; index: MarkerIndex }> + >(); + + for (const markerIndex of markerIndexes) { + const marker = markers[markerIndex]; + const markerName = marker.name; + + if (!groups.has(markerName)) { + groups.set(markerName, []); + } + groups.get(markerName)!.push({ marker, index: markerIndex }); + } + + const stats: MarkerNameStats[] = []; + + for (const [markerName, markerGroup] of groups.entries()) { + const markerList = markerGroup.map((g) => g.marker); + const hasEnd = markerList.some((m) => m.end !== null); + const durationStats = hasEnd ? computeDurationStats(markerList) : undefined; + const rateStats = computeRateStats(markerList); + + // Get top N markers by duration (or just first N for instant markers) + const topMarkers = createTopMarkersArray( + markerGroup, + threadIndexes, + markerMap, + getMarkerLabel, + maxTopMarkers + ); + + // Apply auto-grouping if enabled + let subGroups: MarkerGroup[] | undefined; + let subGroupKey: string | undefined; + if (autoGroup && markerList.length > 5) { + const fieldInfo = analyzeFieldVariance( + markerList, + markerSchemaByName, + stringTable + ); + if (fieldInfo) { + // Sub-group by the field with highest variance + subGroups = groupMarkers( + markerGroup, + [{ field: fieldInfo.field }], + categories, + markerSchemaByName, + stringTable, + threadIndexes, + markerMap, + getMarkerLabel, + 1, + maxTopMarkers + ); + subGroupKey = fieldInfo.field; + } + } + + stats.push({ + markerName: markerName, + count: markerList.length, + isInterval: hasEnd, + durationStats, + rateStats, + topMarkers, + subGroups, + subGroupKey, + }); + } + + // Sort by count descending + stats.sort((a, b) => b.count - a.count); + + return stats; +} + +/** + * Aggregate markers by category. Keyed on the raw category index so that two + * categories sharing a name stay separate in the output, and so callers don't + * need an O(n) findIndex lookup to recover the index by name. + */ +function aggregateMarkersByCategory( + markers: Marker[], + markerIndexes: MarkerIndex[], + categories: CategoryList +): Array<{ + categoryIndex: number; + categoryName: string; + count: number; + percentage: number; +}> { + const counts = new Map(); + + for (const markerIndex of markerIndexes) { + const marker = markers[markerIndex]; + counts.set(marker.category, (counts.get(marker.category) ?? 0) + 1); + } + + const total = markerIndexes.length; + return Array.from(counts.entries()) + .map(([categoryIndex, count]) => ({ + categoryIndex, + categoryName: categories[categoryIndex]?.name ?? 'Unknown', + count, + percentage: (count / total) * 100, + })) + .sort((a, b) => b.count - a.count); +} + +/** + * Collect thread markers data in structured format for JSON output. + */ +export function collectThreadMarkers( + store: Store, + threadMap: ThreadMap, + markerMap: MarkerMap, + threadHandle?: string, + filterOptions: MarkerFilterOptions = {} +): ThreadMarkersResult { + // Apply marker search filter if provided + const searchString = filterOptions.searchString || ''; + if (searchString) { + store.dispatch(changeMarkersSearchString(searchString)); + } + + try { + // Get state after potentially dispatching the search action + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const categories = getCategories(state); + const markerSchemaByName = getMarkerSchemaByName(state); + const stringTable = getStringTable(state); + + // Get marker indexes - use search-filtered if search is active, otherwise all markers + const originalCount = + threadSelectors.getFullMarkerListIndexes(state).length; + let filteredIndexes = searchString + ? threadSelectors.getSearchFilteredMarkerIndexes(state) + : threadSelectors.getFullMarkerListIndexes(state); + + // Apply all marker filters + filteredIndexes = applyMarkerFilters( + filteredIndexes, + fullMarkerList, + categories, + filterOptions + ); + + // Get label getter for markers + const getMarkerLabel = getLabelGetter( + (markerIndex: MarkerIndex) => fullMarkerList[markerIndex], + getProfile(state).meta.markerSchema, + markerSchemaByName, + categories, + stringTable, + 'tableLabel' + ); + + // Generate thread handle for display + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + const { groupBy, autoGroup, topN } = filterOptions; + const maxTopMarkers = topN ?? 5; + + // Handle custom grouping if groupBy is specified + let customGroups: MarkerGroupData[] | undefined; + if (groupBy) { + const groupingKeys = parseGroupingKeys(groupBy); + const markerGroups: Array<{ marker: Marker; index: MarkerIndex }> = []; + for (const markerIndex of filteredIndexes) { + markerGroups.push({ + marker: fullMarkerList[markerIndex], + index: markerIndex, + }); + } + + const groups = groupMarkers( + markerGroups, + groupingKeys, + categories, + markerSchemaByName, + stringTable, + threadIndexes, + markerMap, + getMarkerLabel, + 0, + maxTopMarkers + ); + + // Add markerIndex to topMarkers in groups + customGroups = addMarkerIndexToGroups(groups); + } + + // Aggregate by type (with optional auto-grouping) + const nameStats = aggregateMarkersByName( + fullMarkerList, + filteredIndexes, + threadIndexes, + markerMap, + getMarkerLabel, + categories, + markerSchemaByName, + stringTable, + autoGroup || false, + maxTopMarkers + ); + + // Convert nameStats to include markerIndex + const byType = nameStats.map((stats) => ({ + markerName: stats.markerName, + count: stats.count, + isInterval: stats.isInterval, + durationStats: stats.durationStats, + rateStats: stats.rateStats, + topMarkers: stats.topMarkers.map((m) => ({ + handle: m.handle, + label: m.label, + start: m.start, + duration: m.duration, + hasStack: m.hasStack, + })), + subGroups: stats.subGroups + ? addMarkerIndexToGroups(stats.subGroups) + : undefined, + subGroupKey: stats.subGroupKey, + })); + + // Aggregate by category (using filtered indexes) + const categoryStats = aggregateMarkersByCategory( + fullMarkerList, + filteredIndexes, + categories + ); + + const byCategory = categoryStats.map((stats) => ({ + categoryName: stats.categoryName, + categoryIndex: stats.categoryIndex, + count: stats.count, + percentage: stats.percentage, + })); + + // Build filters object (only include if filters were applied) + const { minDuration, maxDuration, category, hasStack, limit } = + filterOptions; + const filters = + searchString || + minDuration !== undefined || + maxDuration !== undefined || + category !== undefined || + hasStack || + limit !== undefined + ? { + searchString: searchString || undefined, + minDuration, + maxDuration, + category, + hasStack, + limit, + } + : undefined; + + let flatMarkers: FlatMarkerItem[] | undefined; + if (filterOptions.list) { + flatMarkers = []; + const listIndexes = + limit !== undefined ? filteredIndexes.slice(0, limit) : filteredIndexes; + for (const markerIndex of listIndexes) { + const marker = fullMarkerList[markerIndex]; + const handle = markerMap.handleForMarker(threadIndexes, markerIndex); + const duration = + marker.end !== null ? marker.end - marker.start : undefined; + const hasStack = Boolean( + marker.data && 'cause' in marker.data && marker.data.cause + ); + const categoryName = categories[marker.category]?.name ?? 'Other'; + const label = getMarkerLabel(markerIndex); + flatMarkers.push({ + handle, + name: marker.name, + label: label || marker.name, + start: marker.start, + duration, + hasStack, + category: categoryName, + }); + } + } + + return { + type: 'thread-markers', + threadHandle: displayThreadHandle, + friendlyThreadName, + totalMarkerCount: originalCount, + filteredMarkerCount: filteredIndexes.length, + filters, + byType, + byCategory, + customGroups, + flatMarkers, + }; + } finally { + // Always clear the search string to avoid affecting other queries + if (searchString) { + store.dispatch(changeMarkersSearchString('')); + } + } +} + +/** + * Helper to add markerIndex to topMarkers in MarkerGroup arrays. + */ +function addMarkerIndexToGroups(groups: MarkerGroup[]): MarkerGroupData[] { + return groups.map((group) => ({ + groupName: group.groupName, + count: group.count, + isInterval: group.isInterval, + durationStats: group.durationStats, + rateStats: group.rateStats, + topMarkers: group.topMarkers.map((m) => ({ + handle: m.handle, + label: m.label, + start: m.start, + duration: m.duration, + hasStack: m.hasStack, + })), + subGroups: group.subGroups + ? addMarkerIndexToGroups(group.subGroups) + : undefined, + })); +} + +/** + * Collect stack trace data in structured format. + */ +function collectStackTrace( + stackIndex: IndexIntoStackTable | null, + thread: Thread, + libs: Lib[], + capturedAt?: number +): StackTraceData | null { + if (stackIndex === null) { + return null; + } + + const { stackTable, frameTable, funcTable, stringTable, resourceTable } = + thread; + const frames: StackTraceData['frames'] = []; + + let currentStackIndex: IndexIntoStackTable | null = stackIndex; + while (currentStackIndex !== null) { + const frameIndex = stackTable.frame[currentStackIndex]; + const funcIndex = frameTable.func[frameIndex]; + const funcName = stringTable.getString(funcTable.name[funcIndex]); + const nameWithLibrary = formatFunctionNameWithLibrary( + funcIndex, + thread, + libs + ); + + let library: string | undefined; + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; + if (libIndex !== null && libs) { + library = libs[libIndex].name; + } + } + + frames.push({ name: funcName, nameWithLibrary, library }); + + currentStackIndex = stackTable.prefix[currentStackIndex]; + } + + return { + frames, + truncated: false, + capturedAt, + }; +} + +/** + * Collect marker stack trace data in structured format. + */ +export function collectMarkerStack( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): MarkerStackResult { + const state = store.getState(); + const { threadIndexes, markerIndex } = + markerMap.markerForHandle(markerHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${markerHandle} not found`); + } + + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const profile = getProfile(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = profile.libs; + + // Check if marker has a stack trace + let stack: StackTraceData | null = null; + if (marker.data && 'cause' in marker.data && marker.data.cause) { + const cause = marker.data.cause; + stack = collectStackTrace(cause.stack, thread, libs, cause.time); + } + + return { + type: 'marker-stack', + markerHandle, + markerIndex, + threadHandle: threadHandleDisplay, + friendlyThreadName, + markerName: marker.name, + stack, + }; +} + +/** + * Collect detailed marker information in structured format. + */ +export function collectMarkerInfo( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): MarkerInfoResult { + const state = store.getState(); + const { threadIndexes, markerIndex } = + markerMap.markerForHandle(markerHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${markerHandle} not found`); + } + + const categories = getCategories(state); + const markerSchemaByName = getMarkerSchemaByName(state); + const stringTable = getStringTable(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + + // Get tooltip label + const getTooltipLabel = getLabelGetter( + (mi: MarkerIndex) => fullMarkerList[mi], + getProfile(state).meta.markerSchema, + markerSchemaByName, + categories, + stringTable, + 'tooltipLabel' + ); + const tooltipLabel = getTooltipLabel(markerIndex); + + // Collect marker fields + let fields: MarkerInfoResult['fields']; + let schemaInfo: MarkerInfoResult['schema']; + + if (marker.data) { + const schema = markerSchemaByName[marker.data.type]; + if (schema && schema.fields.length > 0) { + fields = []; + for (const field of schema.fields) { + if (field.hidden) { + continue; + } + + const value = (marker.data as any)[field.key]; + if (value !== undefined && value !== null) { + const formattedValue = formatFromMarkerSchema( + marker.data.type, + field.format, + value, + stringTable + ); + fields.push({ + key: field.key, + label: field.label || field.key, + value, + formattedValue, + }); + } + } + } + + // Include schema description if available + if (schema?.description) { + schemaInfo = { description: schema.description }; + } + } + + // Collect stack trace if available (truncated to 20 frames) + let stack: StackTraceData | undefined; + if (marker.data && 'cause' in marker.data && marker.data.cause) { + const cause = marker.data.cause; + const profile = getProfile(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = profile.libs; + + const fullStack = collectStackTrace(cause.stack, thread, libs, cause.time); + if (fullStack && fullStack.frames.length > 0) { + // Truncate to 20 frames + const truncated = fullStack.frames.length > 20; + stack = { + frames: fullStack.frames.slice(0, 20), + truncated, + capturedAt: fullStack.capturedAt, + }; + } + } + + return { + type: 'marker-info', + markerHandle, + markerIndex, + threadHandle: threadHandleDisplay, + friendlyThreadName, + name: marker.name, + tooltipLabel: tooltipLabel || undefined, + markerType: marker.data?.type, + category: { + index: marker.category, + name: categories[marker.category]?.name ?? 'Unknown', + }, + start: marker.start, + end: marker.end, + duration: marker.end !== null ? marker.end - marker.start : undefined, + fields, + schema: schemaInfo, + stack, + }; +} + +function buildNetworkPhases(data: NetworkPayload): NetworkPhaseTimings { + const phases: NetworkPhaseTimings = {}; + if ( + data.domainLookupStart !== undefined && + data.domainLookupEnd !== undefined + ) { + phases.dns = data.domainLookupEnd - data.domainLookupStart; + } + if (data.connectStart !== undefined && data.tcpConnectEnd !== undefined) { + phases.tcp = data.tcpConnectEnd - data.connectStart; + } + if ( + data.secureConnectionStart !== undefined && + data.secureConnectionStart > 0 && + data.connectEnd !== undefined + ) { + phases.tls = data.connectEnd - data.secureConnectionStart; + } + if (data.requestStart !== undefined && data.responseStart !== undefined) { + phases.ttfb = data.responseStart - data.requestStart; + } + if (data.responseStart !== undefined && data.responseEnd !== undefined) { + phases.download = data.responseEnd - data.responseStart; + } + if (data.responseEnd !== undefined) { + phases.mainThread = data.endTime - data.responseEnd; + } + return phases; +} + +export function collectThreadNetwork( + store: Store, + threadMap: ThreadMap, + threadHandle?: string, + filterOptions: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + } = {} +): ThreadNetworkResult { + const { searchString, minDuration, maxDuration, limit } = filterOptions; + + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const allMarkerIndexes = threadSelectors.getFullMarkerListIndexes(state); + + // Filter to completed (STOP) network markers only. + // STOP markers are the merged markers that carry full timing data. + const stopIndexes = allMarkerIndexes.filter((i) => { + const m = fullMarkerList[i]; + if (!isNetworkMarker(m)) { + return false; + } + const data = m.data as NetworkPayload; + return data.status === 'STATUS_STOP'; + }); + const totalRequestCount = stopIndexes.length; + + // Apply filters + let filteredIndexes = stopIndexes; + + if (searchString) { + const lowerSearch = searchString.toLowerCase(); + filteredIndexes = filteredIndexes.filter((i) => { + const data = fullMarkerList[i].data as NetworkPayload; + return data.URI.toLowerCase().includes(lowerSearch); + }); + } + + if (minDuration !== undefined || maxDuration !== undefined) { + filteredIndexes = filteredIndexes.filter((i) => { + const data = fullMarkerList[i].data as NetworkPayload; + const duration = data.endTime - data.startTime; + if (minDuration !== undefined && duration < minDuration) { + return false; + } + if (maxDuration !== undefined && duration > maxDuration) { + return false; + } + return true; + }); + } + + const filteredRequestCount = filteredIndexes.length; + + // Accumulate summary stats across all filtered requests (before limit) + const phaseTotals: NetworkPhaseTimings = {}; + let cacheHit = 0; + let cacheMiss = 0; + let cacheUnknown = 0; + + for (const i of filteredIndexes) { + const data = fullMarkerList[i].data as NetworkPayload; + const cache = data.cache; + if (cache === 'Hit' || cache === 'MemoryHit' || cache === 'Prefetched') { + cacheHit++; + } else if ( + cache === 'Miss' || + cache === 'Unresolved' || + cache === 'DiskStorage' || + cache === 'Push' + ) { + cacheMiss++; + } else { + cacheUnknown++; + } + + const phases = buildNetworkPhases(data); + if (phases.dns !== undefined) { + phaseTotals.dns = (phaseTotals.dns ?? 0) + phases.dns; + } + if (phases.tcp !== undefined) { + phaseTotals.tcp = (phaseTotals.tcp ?? 0) + phases.tcp; + } + if (phases.tls !== undefined) { + phaseTotals.tls = (phaseTotals.tls ?? 0) + phases.tls; + } + if (phases.ttfb !== undefined) { + phaseTotals.ttfb = (phaseTotals.ttfb ?? 0) + phases.ttfb; + } + if (phases.download !== undefined) { + phaseTotals.download = (phaseTotals.download ?? 0) + phases.download; + } + if (phases.mainThread !== undefined) { + phaseTotals.mainThread = + (phaseTotals.mainThread ?? 0) + phases.mainThread; + } + } + + // Apply limit after accumulating summary stats. + // limit === 0 means "show all" (no limit). + const limitedIndexes = + limit !== undefined && limit > 0 + ? filteredIndexes.slice(0, limit) + : filteredIndexes; + + // Build per-request entries + const requests: NetworkRequestEntry[] = limitedIndexes.map((i) => { + const data = fullMarkerList[i].data as NetworkPayload; + const duration = data.endTime - data.startTime; + + return { + url: data.URI, + httpStatus: data.responseStatus, + httpVersion: data.httpVersion, + cacheStatus: data.cache, + transferSizeKB: data.count !== undefined ? data.count / 1024 : undefined, + startTime: data.startTime, + duration, + phases: buildNetworkPhases(data), + }; + }); + + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + return { + type: 'thread-network', + threadHandle: displayThreadHandle, + friendlyThreadName, + totalRequestCount, + filteredRequestCount, + filters: + searchString !== undefined || + minDuration !== undefined || + maxDuration !== undefined || + limit !== undefined + ? { searchString, minDuration, maxDuration, limit } + : undefined, + summary: { + cacheHit, + cacheMiss, + cacheUnknown, + phaseTotals, + }, + requests, + }; +} + +export function collectProfileLogs( + store: Store, + threadMap: ThreadMap, + filterOptions: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + } = {} +): ProfileLogsResult { + const { module, level, search, limit } = filterOptions; + const state = store.getState(); + const profile = getProfile(state); + const profileStartTime = profile.meta.startTime; + const stringArray = profile.shared.stringArray; + + // Resolve which thread indexes to include. + const threadIndexes: Set | null = + filterOptions.thread !== undefined + ? new Set(threadMap.threadIndexesForHandle(filterOptions.thread)) + : null; + + // Map level filter string to the numeric threshold. + const LEVEL_NAMES: Record = { + error: 1, + warn: 2, + info: 3, + debug: 4, + verbose: 5, + }; + const maxLevel = + level !== undefined ? (LEVEL_NAMES[level.toLowerCase()] ?? 5) : 5; + + const lowerModule = module?.toLowerCase(); + const lowerSearch = search?.toLowerCase(); + + const entries: string[] = []; + + for ( + let threadIndex = 0; + threadIndex < profile.threads.length; + threadIndex++ + ) { + if (threadIndexes !== null && !threadIndexes.has(threadIndex)) { + continue; + } + const thread = profile.threads[threadIndex]; + const { markers } = thread; + const processName = thread.processName ?? 'Unknown Process'; + const pid = thread.pid; + const threadName = thread.name; + + for (let i = 0; i < markers.length; i++) { + const startTime = markers.startTime[i]; + if (startTime === null) { + continue; + } + + const data = markers.data[i]; + if (data?.type !== 'Log') { + continue; + } + + const logData = data as LogMarkerPayload; + let moduleName: string; + let message: string; + let levelLetter: string; + + if ('message' in logData) { + if (!logData.message) { + continue; + } + moduleName = stringArray[markers.name[i]] ?? ''; + const levelStr = stringArray[logData.level] ?? ''; + levelLetter = LOG_LEVEL_STRING_TO_LETTER[levelStr] ?? 'D'; + message = logData.message.trim(); + } else { + if (!logData.name) { + continue; + } + // Legacy format: data.module is either "D/nsHttp" or just "nsHttp". + const rawModule = logData.module; + const slashIdx = rawModule.indexOf('/'); + if (slashIdx !== -1) { + levelLetter = rawModule.slice(0, slashIdx); + moduleName = rawModule.slice(slashIdx + 1); + } else { + levelLetter = 'D'; + moduleName = rawModule; + } + message = logData.name.trim(); + } + + if ( + lowerModule !== undefined && + !moduleName.toLowerCase().includes(lowerModule) + ) { + continue; + } + + if ((LOG_LETTER_TO_LEVEL[levelLetter] ?? 5) > maxLevel) { + continue; + } + + if ( + lowerSearch !== undefined && + !message.toLowerCase().includes(lowerSearch) + ) { + continue; + } + + const timestampStr = formatLogTimestamp(profileStartTime + startTime); + const formatted = formatLogStatement( + timestampStr, + processName, + pid, + threadName, + logData, + moduleName, + stringArray + ); + if (formatted !== null) { + entries.push(formatted); + } + } + } + + // Lexicographic sort equals chronological order since the timestamp prefix + // is ISO-like ("YYYY-MM-DD HH:MM:SS..."), matching extractGeckoLogs behavior. + entries.sort(); + + const totalCount = entries.length; + const limitedEntries = + limit !== undefined ? entries.slice(0, limit) : entries; + + return { + type: 'profile-logs', + entries: limitedEntries, + totalCount, + filters: + filterOptions.thread !== undefined || + module !== undefined || + level !== undefined || + search !== undefined || + limit !== undefined + ? { thread: filterOptions.thread, module, level, search, limit } + : undefined, + }; +} diff --git a/src/profile-query/formatters/page-load.ts b/src/profile-query/formatters/page-load.ts new file mode 100644 index 0000000000..d2eeb46b03 --- /dev/null +++ b/src/profile-query/formatters/page-load.ts @@ -0,0 +1,503 @@ +/* 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 { getSelectedThreadIndexes } from 'firefox-profiler/selectors/url-state'; +import { getCategories } from 'firefox-profiler/selectors/profile'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { isNetworkMarker } from 'firefox-profiler/profile-logic/marker-data'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { TimestampManager } from '../timestamps'; +import type { MarkerMap } from '../marker-map'; +import type { + ThreadPageLoadResult, + PageLoadResourceEntry, + PageLoadCategoryEntry, + JankPeriod, + JankFunction, +} from '../types'; +import type { NetworkPayload } from 'firefox-profiler/types/markers'; +import type { Thread, CategoryList } from 'firefox-profiler/types'; + +// ===== Navigation group helpers ===== + +// Internal milestone type that tracks the source marker index for handle assignment. +type NavGroupMilestone = { + name: string; + timeMs: number; + markerIndex: number; +}; + +type NavGroup = { + innerWindowID: number; + navStart: number; + loadEnd: number | null; + url: string | null; + milestones: NavGroupMilestone[]; +}; + +function getInnerWindowID(data: unknown): number | undefined { + if (data !== null && typeof data === 'object' && 'innerWindowID' in data) { + const id = (data as { innerWindowID?: unknown }).innerWindowID; + if (typeof id === 'number') { + return id; + } + } + return undefined; +} + +// Marker name -> milestone label for the common single-condition markers +const MILESTONE_MARKER_NAMES: Record = { + FirstContentfulPaint: 'FCP', + FirstContentfulComposite: 'FCC', + LargestContentfulPaint: 'LCP', + 'TimeToFirstInteractive (TTFI)': 'TTFI', +}; + +function getOrCreateNavGroup( + navGroups: Map, + innerWindowID: number, + navStart: number +): NavGroup { + let group = navGroups.get(innerWindowID); + if (!group) { + group = { + innerWindowID, + navStart, + loadEnd: null, + url: null, + milestones: [], + }; + navGroups.set(innerWindowID, group); + } + return group; +} + +function addMilestone( + group: NavGroup, + name: string, + markerEnd: number, + markerIndex: number +): void { + if (!group.milestones.some((m) => m.name === name)) { + group.milestones.push({ + name, + timeMs: markerEnd - group.navStart, + markerIndex, + }); + } +} + +function classifyContentType(contentType: string | null | undefined): string { + if (!contentType) { + return 'Other'; + } + const ct = contentType.toLowerCase().split(';')[0].trim(); + if (ct.includes('javascript') || ct.includes('ecmascript')) { + return 'JS'; + } + if (ct === 'text/css') { + return 'CSS'; + } + if (ct.startsWith('image/')) { + return 'Image'; + } + if (ct === 'text/html' || ct === 'application/xhtml+xml') { + return 'HTML'; + } + if (ct === 'application/json' || ct === 'text/json') { + return 'JSON'; + } + if ( + ct.startsWith('font/') || + ct.startsWith('application/font') || + ct === 'application/x-font-woff' + ) { + return 'Font'; + } + if (ct === 'application/wasm') { + return 'Wasm'; + } + return 'Other'; +} + +function filenameFromUrl(url: string): string { + let pathname = url; + try { + pathname = new URL(url).pathname; + } catch { + // Use raw url as fallback + } + const parts = pathname.split('/'); + const last = parts[parts.length - 1] || url; + return last.length > 50 ? last.slice(0, 47) + '...' : last; +} + +// ===== Leaf function name for a sample ===== + +function getLeafFunctionName( + sampleIndex: number, + thread: Thread +): string | null { + const stackIndex = thread.samples.stack[sampleIndex]; + if (stackIndex === null || stackIndex === undefined) { + return null; + } + const frameIndex = thread.stackTable.frame[stackIndex]; + const funcIndex = thread.frameTable.func[frameIndex]; + const nameIndex = thread.funcTable.name[funcIndex]; + return thread.stringTable.getString(nameIndex); +} + +// ===== Category counting helpers ===== + +function countCategoriesInRange( + thread: Thread, + categories: CategoryList, + startTime: number, + endTime: number +): PageLoadCategoryEntry[] { + const counts = new Map(); + let total = 0; + + for (let i = 0; i < thread.samples.length; i++) { + const t = thread.samples.time[i]; + if (t < startTime || t > endTime) { + continue; + } + const catIndex = thread.samples.category[i]; + const catName = + catIndex < categories.length ? categories[catIndex].name : 'Other'; + counts.set(catName, (counts.get(catName) ?? 0) + 1); + total++; + } + + if (total === 0) { + return []; + } + + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ + name, + count, + percentage: (count / total) * 100, + })); +} + +// ===== Main collector ===== + +export function collectThreadPageLoad( + store: Store, + threadMap: ThreadMap, + timestampManager: TimestampManager, + markerMap: MarkerMap, + threadHandle?: string, + options: { navigationIndex?: number; jankLimit?: number } = {} +): ThreadPageLoadResult { + const rawJankLimit = options.jankLimit ?? 10; + const jankLimit = rawJankLimit === 0 ? Infinity : rawJankLimit; + + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const allMarkerIndexes = threadSelectors.getFullMarkerListIndexes(state); + const categories = getCategories(state); + + // Use the unfiltered thread (all samples, no transforms) for sample-level access. + const rawThread: Thread = threadSelectors.getThread(state); + + // ===== Step 1: Build navigation groups from markers ===== + + const navGroups = new Map(); + + for (const i of allMarkerIndexes) { + const marker = fullMarkerList[i]; + const { name, data } = marker; + + if (marker.end === null) { + continue; + } + + if (name === 'DocumentLoad') { + const innerWindowID = getInnerWindowID(data); + if (innerWindowID === undefined) { + continue; + } + const group = getOrCreateNavGroup(navGroups, innerWindowID, marker.start); + group.loadEnd = marker.end; + addMilestone(group, 'Load', marker.end, i); + // Extract URL from payload text: "Document URL loaded after Xms..." + if (data !== null && typeof data === 'object' && 'name' in data) { + const textName = (data as { name?: unknown }).name; + if (typeof textName === 'string') { + const match = textName.match(/^Document (.+) loaded after/); + if (match) { + group.url = match[1]; + } + } + } + } else if ( + name === 'DOMContentLoaded' && + data !== null && + typeof data === 'object' && + 'category' in data && + (data as { category?: unknown }).category === 'Navigation' + ) { + const innerWindowID = getInnerWindowID(data); + if (innerWindowID === undefined) { + continue; + } + const group = getOrCreateNavGroup(navGroups, innerWindowID, marker.start); + addMilestone(group, 'DCL', marker.end, i); + } else { + const milestoneName = MILESTONE_MARKER_NAMES[name]; + if (milestoneName === undefined) { + continue; + } + const innerWindowID = getInnerWindowID(data); + if (innerWindowID === undefined) { + continue; + } + const group = getOrCreateNavGroup(navGroups, innerWindowID, marker.start); + addMilestone(group, milestoneName, marker.end, i); + } + } + + const realGroups: NavGroup[] = Array.from(navGroups.values()); + + realGroups.sort((a, b) => a.navStart - b.navStart); + + // Filter to groups that have at least a DocumentLoad (loadEnd != null) + const completeGroups = realGroups.filter((g) => g.loadEnd !== null); + + if (completeGroups.length === 0) { + return { + type: 'thread-page-load', + threadHandle: displayThreadHandle, + friendlyThreadName, + url: null, + navigationIndex: 0, + navigationTotal: 0, + navStartMs: 0, + milestones: [], + resourceCount: 0, + resourceAvgMs: null, + resourceMaxMs: null, + resourcesByType: [], + topResources: [], + totalSamples: 0, + categories: [], + jankTotal: 0, + jankPeriods: [], + }; + } + + // Select the requested navigation (1-based; default = last) + const navTotal = completeGroups.length; + const requestedIndex = options.navigationIndex ?? navTotal; + const clampedIndex = Math.max(1, Math.min(requestedIndex, navTotal)); + const nav = completeGroups[clampedIndex - 1]; + + const navStart = nav.navStart; + const loadEnd = nav.loadEnd!; + + // Add TTFB milestone from the main document's network marker + if (nav.url) { + for (const i of allMarkerIndexes) { + const m = fullMarkerList[i]; + if (!isNetworkMarker(m)) { + continue; + } + const d = m.data as NetworkPayload; + if ( + d.status === 'STATUS_STOP' && + d.URI === nav.url && + d.requestStart !== undefined && + d.responseStart !== undefined + ) { + nav.milestones.push({ + name: 'TTFB', + timeMs: d.responseStart - navStart, + markerIndex: i, + }); + break; + } + } + } + + // Sort milestones by timeMs + nav.milestones.sort((a, b) => a.timeMs - b.timeMs); + + // Data window ends at the largest non-TTFI milestone. TTFI reflects + // post-load JS work and would inflate the analysis sections. + const nonTtfiMs = nav.milestones + .filter((m) => m.name !== 'TTFI') + .map((m) => m.timeMs); + const dataWindowEndMs = + nonTtfiMs.length > 0 ? Math.max(...nonTtfiMs) : loadEnd - navStart; + const pageLoadEnd = navStart + dataWindowEndMs; + + // ===== Steps 2 & 4: Resources and Jank markers (single pass) ===== + + const resources: PageLoadResourceEntry[] = []; + const jankMarkerIndexes: number[] = []; + + for (const i of allMarkerIndexes) { + const m = fullMarkerList[i]; + + if (isNetworkMarker(m)) { + const d = m.data as NetworkPayload; + if ( + d.status === 'STATUS_STOP' && + d.startTime >= navStart && + d.startTime <= pageLoadEnd + ) { + resources.push({ + filename: filenameFromUrl(d.URI), + url: d.URI, + durationMs: d.endTime - d.startTime, + resourceType: classifyContentType(d.contentType), + markerHandle: markerMap.handleForMarker(threadIndexes, i), + }); + } + } else if ( + m.name === 'Jank' && + m.start >= navStart && + (m.end ?? m.start) <= pageLoadEnd + ) { + jankMarkerIndexes.push(i); + } + } + + resources.sort((a, b) => b.durationMs - a.durationMs); + + const resourceCount = resources.length; + let resourceAvgMs: number | null = null; + let resourceMaxMs: number | null = null; + + if (resourceCount > 0) { + const total = resources.reduce((sum, r) => sum + r.durationMs, 0); + resourceAvgMs = total / resourceCount; + resourceMaxMs = resources[0].durationMs; + } + + // Count by type + const typeCounts = new Map(); + for (const r of resources) { + typeCounts.set(r.resourceType, (typeCounts.get(r.resourceType) ?? 0) + 1); + } + const resourcesByType = Array.from(typeCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ({ + type, + count, + percentage: (count / resourceCount) * 100, + })); + + const topResources = resources.slice(0, 10); + + // ===== Step 3: CPU Categories ===== + + const cpuCategories = countCategoriesInRange( + rawThread, + categories, + navStart, + pageLoadEnd + ); + + const totalSamples = cpuCategories.reduce((s, c) => s + c.count, 0); + + // ===== Step 5: Jank periods ===== + + const jankTotal = jankMarkerIndexes.length; + const limitedJankIndexes = jankMarkerIndexes.slice(0, jankLimit); + + const jankPeriods: JankPeriod[] = limitedJankIndexes.map((i) => { + const m = fullMarkerList[i]; + const jStart = m.start; + const jEnd = m.end ?? m.start; + + // Single pass to collect both categories and leaf functions + const categoryCounts = new Map(); + const funcCounts = new Map(); + let categoryTotal = 0; + + for (let s = 0; s < rawThread.samples.length; s++) { + const t = rawThread.samples.time[s]; + if (t < jStart || t > jEnd) { + continue; + } + const catIndex = rawThread.samples.category[s]; + const catName = + catIndex < categories.length ? categories[catIndex].name : 'Other'; + categoryCounts.set(catName, (categoryCounts.get(catName) ?? 0) + 1); + categoryTotal++; + + const name = getLeafFunctionName(s, rawThread); + if (name !== null) { + funcCounts.set(name, (funcCounts.get(name) ?? 0) + 1); + } + } + + const jankCategoryEntries: PageLoadCategoryEntry[] = + categoryTotal === 0 + ? [] + : Array.from(categoryCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ + name, + count, + percentage: (count / categoryTotal) * 100, + })); + + const topFunctions: JankFunction[] = Array.from(funcCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, sampleCount]) => ({ name, sampleCount })); + + return { + startMs: jStart - navStart, + durationMs: jEnd - jStart, + markerHandle: markerMap.handleForMarker(threadIndexes, i), + startHandle: timestampManager.nameForTimestamp(jStart), + endHandle: timestampManager.nameForTimestamp(jEnd), + topFunctions, + categories: jankCategoryEntries, + }; + }); + + return { + type: 'thread-page-load', + threadHandle: displayThreadHandle, + friendlyThreadName, + url: nav.url, + navigationIndex: clampedIndex, + navigationTotal: navTotal, + navStartMs: navStart, + milestones: nav.milestones.map((m) => ({ + name: m.name, + timeMs: m.timeMs, + markerHandle: markerMap.handleForMarker(threadIndexes, m.markerIndex), + })), + resourceCount, + resourceAvgMs, + resourceMaxMs, + resourcesByType, + topResources, + totalSamples, + categories: cpuCategories, + jankTotal, + jankPeriods, + }; +} diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts new file mode 100644 index 0000000000..1cce4668d4 --- /dev/null +++ b/src/profile-query/formatters/profile-info.ts @@ -0,0 +1,169 @@ +/* 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 { + getProfile, + getThreadCPUTimeMs, + getRangeFilteredCombinedThreadActivitySlices, +} from 'firefox-profiler/selectors/profile'; +import { getProfileNameWithDefault } from 'firefox-profiler/selectors/url-state'; +import { buildProcessThreadList } from '../process-thread-list'; +import { collectSliceTree } from '../cpu-activity'; +import type { Store } from '../../types/store'; +import type { ThreadInfo, ProcessListItem } from '../process-thread-list'; +import type { TimestampManager } from '../timestamps'; +import type { ThreadMap } from '../thread-map'; +import type { ProfileInfoResult } from '../types'; + +/** + * Filter a list of processes by a search string. + * A process is included if its name or pid matches. + * A thread is included if its name or tid matches, or if its parent process matches. + */ +function applySearchFilter( + processes: ProcessListItem[], + search: string +): ProcessListItem[] { + const query = search.toLowerCase(); + const result: ProcessListItem[] = []; + + for (const process of processes) { + const processMatches = + process.name.toLowerCase().includes(query) || + String(process.pid).includes(query); + + const matchingThreads = processMatches + ? process.threads + : process.threads.filter( + (t) => + t.name.toLowerCase().includes(query) || + String(t.tid).includes(query) + ); + + if (matchingThreads.length > 0) { + result.push({ + ...process, + threads: matchingThreads, + remainingThreads: undefined, + }); + } + } + + return result; +} + +/** + * Collect profile information in structured format. + */ +export function collectProfileInfo( + store: Store, + timestampManager: TimestampManager, + threadMap: ThreadMap, + processIndexMap: Map, + showAll: boolean = false, + search?: string +): ProfileInfoResult { + const state = store.getState(); + const profile = getProfile(state); + const profileName = getProfileNameWithDefault(state); + const processCount = new Set(profile.threads.map((t) => t.pid)).size; + const threadCPUTimeMs = getThreadCPUTimeMs(state); + + // Build thread info array + const threads: ThreadInfo[] = profile.threads.map((thread, index) => ({ + threadIndex: index, + name: thread.name, + tid: thread.tid, + cpuMs: threadCPUTimeMs ? threadCPUTimeMs[index] : 0, + pid: thread.pid, + })); + + // Build the process/thread list (always show all when searching) + const result = buildProcessThreadList( + threads, + processIndexMap, + showAll || search !== undefined + ); + + // Apply process names, eTLD+1, and timing from the profile + result.processes.forEach((processItem) => { + const threadFromProcess = profile.threads.find( + (t) => t.pid === processItem.pid + ); + if (threadFromProcess) { + processItem.name = + threadFromProcess.processName || + threadFromProcess.processType || + 'unknown'; + processItem.etld1 = threadFromProcess['eTLD+1']; + processItem.startTime = threadFromProcess.processStartupTime; + processItem.endTime = threadFromProcess.processShutdownTime; + } + }); + + // Apply search filter after process names are resolved + const processesToShow = + search !== undefined + ? applySearchFilter(result.processes, search) + : result.processes; + + const processesData: ProfileInfoResult['processes'] = processesToShow.map( + (processItem) => { + let startTimeName: string | undefined; + let endTimeName: string | null | undefined; + if (processItem.startTime !== undefined) { + startTimeName = timestampManager.nameForTimestamp( + processItem.startTime + ); + if (processItem.endTime !== null && processItem.endTime !== undefined) { + endTimeName = timestampManager.nameForTimestamp(processItem.endTime); + } else { + endTimeName = null; + } + } + + return { + processIndex: processItem.processIndex, + pid: processItem.pid, + name: processItem.name, + etld1: processItem.etld1, + cpuMs: processItem.cpuMs, + startTime: processItem.startTime, + startTimeName, + endTime: processItem.endTime, + endTimeName, + threads: processItem.threads.map((thread) => ({ + threadIndex: thread.threadIndex, + threadHandle: threadMap.handleForThreadIndex(thread.threadIndex), + name: thread.name, + tid: thread.tid, + cpuMs: thread.cpuMs, + })), + remainingThreads: processItem.remainingThreads, + }; + } + ); + + // Collect CPU activity (respecting zoom) + const combinedCpuActivity = + getRangeFilteredCombinedThreadActivitySlices(state); + const cpuActivity = + combinedCpuActivity !== null + ? collectSliceTree(combinedCpuActivity, timestampManager) + : null; + + return { + type: 'profile-info', + name: profileName || 'Unknown Profile', + platform: profile.meta.oscpu || 'Unknown', + threadCount: profile.threads.length, + processCount, + showAll: showAll && search === undefined, + searchQuery: search, + processes: processesData, + remainingProcesses: + search !== undefined ? undefined : result.remainingProcesses, + cpuActivity, + }; +} diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts new file mode 100644 index 0000000000..4c3bc484f9 --- /dev/null +++ b/src/profile-query/formatters/thread-info.ts @@ -0,0 +1,456 @@ +/* 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 { + getSelectedThreadIndexes, + getAllCommittedRanges, +} from 'firefox-profiler/selectors/url-state'; +import { + getCategories, + getDefaultCategory, + getProfile, +} from 'firefox-profiler/selectors/profile'; +import { collectSliceTree } from '../cpu-activity'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import type { + ThreadInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadFunctionsResult, + FunctionFilterOptions, + TopFunctionInfo, +} from '../types'; +import { + extractFunctionData, + formatFunctionNameWithLibrary, +} from '../function-list'; +import { collectCallTree } from './call-tree'; +import type { CallTreeCollectionOptions } from './call-tree'; +import { + computeCallTreeTimings, + getCallTree, + computeCallNodeSelfAndSummary, +} from 'firefox-profiler/profile-logic/call-tree'; +import { getInvertedCallNodeInfo } from 'firefox-profiler/profile-logic/profile-data'; +import type { Store } from '../../types/store'; +import type { TimestampManager } from '../timestamps'; +import type { ThreadMap } from '../thread-map'; +import { getFunctionHandle } from '../function-map'; +import type { CallNodePath } from 'firefox-profiler/types'; + +/** + * Collect thread info as structured data. + */ +export function collectThreadInfo( + store: Store, + timestampManager: TimestampManager, + threadMap: ThreadMap, + threadHandle?: string +): ThreadInfoResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getRawThread(state); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const cpuActivitySlices = + threadSelectors.getRangeFilteredActivitySlices(state); + const cpuActivity = + cpuActivitySlices !== null + ? collectSliceTree(cpuActivitySlices, timestampManager) + : null; + + const actualThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + return { + type: 'thread-info', + threadHandle: actualThreadHandle, + name: thread.name, + friendlyName: friendlyThreadName, + tid: thread.tid, + createdAt: thread.registerTime, + createdAtName: timestampManager.nameForTimestamp(thread.registerTime), + endedAt: thread.unregisterTime, + endedAtName: + thread.unregisterTime !== null + ? timestampManager.nameForTimestamp(thread.unregisterTime) + : null, + sampleCount: thread.samples.length, + markerCount: thread.markers.length, + cpuActivity, + }; +} + +/** + * Collect thread samples data in structured format. + */ +export function collectThreadSamples( + store: Store, + threadMap: ThreadMap, + threadHandle?: string +): ThreadSamplesResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = getProfile(state).libs; + + // Get call trees for analysis + const functionListTree = threadSelectors.getFunctionListTree(state); + const callTree = threadSelectors.getCallTree(state); + + // Extract function data + const functions = extractFunctionData(functionListTree, thread, libs); + + // Sort by total and take top 50 + const sortedByTotal = functions + .slice() + .sort((a, b) => b.total - a.total) + .slice(0, 50); + + // Sort by self and take top 50 + const sortedBySelf = functions + .slice() + .sort((a, b) => b.self - a.self) + .slice(0, 50); + + // Convert top functions to structured format + const topFunctionsByTotal: TopFunctionInfo[] = sortedByTotal.map((func) => ({ + functionHandle: getFunctionHandle(func.funcIndex), + functionIndex: func.funcIndex, + name: func.funcName, + nameWithLibrary: func.funcName, // Already includes library from extractFunctionData + totalSamples: func.total, + totalPercentage: func.totalRelative * 100, + selfSamples: func.self, + selfPercentage: func.selfRelative * 100, + library: undefined, + })); + + const topFunctionsBySelf: TopFunctionInfo[] = sortedBySelf.map((func) => ({ + functionHandle: getFunctionHandle(func.funcIndex), + functionIndex: func.funcIndex, + name: func.funcName, + nameWithLibrary: func.funcName, // Already includes library from extractFunctionData + totalSamples: func.total, + totalPercentage: func.totalRelative * 100, + selfSamples: func.self, + selfPercentage: func.selfRelative * 100, + library: undefined, + })); + + // Create a map from funcIndex to function data for quick lookup + const funcMap = new Map(functions.map((f) => [f.funcIndex, f])); + + // Collect heaviest stack + const roots = callTree.getRoots(); + let heaviestStack: ThreadSamplesResult['heaviestStack'] = { + selfSamples: 0, + frameCount: 0, + frames: [], + }; + + if (roots.length > 0) { + let heaviestPath: CallNodePath = []; + let maxSelfSamples = Number.NEGATIVE_INFINITY; + + for (const root of roots) { + const candidatePath = callTree._internal.findHeaviestPathInSubtree(root); + const leafNodeIndex = + callTree._callNodeInfo.getCallNodeIndexFromPath(candidatePath); + + if (leafNodeIndex === null) { + continue; + } + + const candidateSelfSamples = callTree.getNodeData(leafNodeIndex).self; + if (candidateSelfSamples > maxSelfSamples) { + heaviestPath = candidatePath; + maxSelfSamples = candidateSelfSamples; + } + } + + if (heaviestPath.length > 0) { + const callNodeInfo = callTree._callNodeInfo; + const leafNodeIndex = callNodeInfo.getCallNodeIndexFromPath(heaviestPath); + + if (leafNodeIndex !== null) { + const leafNodeData = callTree.getNodeData(leafNodeIndex); + + heaviestStack = { + selfSamples: leafNodeData.self, + frameCount: heaviestPath.length, + frames: heaviestPath.map((funcIndex) => { + const funcName = formatFunctionNameWithLibrary( + funcIndex, + thread, + libs + ); + const funcData = funcMap.get(funcIndex); + return { + funcIndex, + name: funcName, + nameWithLibrary: funcName, + totalSamples: funcData?.total ?? 0, + totalPercentage: (funcData?.totalRelative ?? 0) * 100, + selfSamples: funcData?.self ?? 0, + selfPercentage: (funcData?.selfRelative ?? 0) * 100, + }; + }), + }; + } + } + } + + return { + type: 'thread-samples', + threadHandle: threadHandleDisplay, + friendlyThreadName, + topFunctionsByTotal, + topFunctionsBySelf, + heaviestStack, + }; +} + +/** + * Collect thread samples bottom-up data in structured format. + * Shows the inverted call tree (callers of hot functions). + */ +export function collectThreadSamplesBottomUp( + store: Store, + threadMap: ThreadMap, + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions +): ThreadSamplesBottomUpResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const thread = threadSelectors.getFilteredThread(state); + + // Collect inverted call tree + const callNodeInfo = threadSelectors.getCallNodeInfo(state); + const categories = getCategories(state); + const defaultCategory = getDefaultCategory(state); + const weightType = threadSelectors.getWeightTypeForCallTree(state); + + const samples = threadSelectors.getPreviewFilteredCtssSamples(state); + const sampleIndexToCallNodeIndex = + threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( + state + ); + + const callNodeSelfAndSummary = computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + + const invertedCallNodeInfo = getInvertedCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length + ); + + const invertedTimings = computeCallTreeTimings( + invertedCallNodeInfo, + callNodeSelfAndSummary + ); + + const invertedTree = getCallTree( + thread, + invertedCallNodeInfo, + categories, + samples, + invertedTimings, + weightType + ); + + const libs = getProfile(state).libs; + const invertedCallTree = collectCallTree(invertedTree, libs, callTreeOptions); + + return { + type: 'thread-samples-bottom-up', + threadHandle: threadHandleDisplay, + friendlyThreadName, + invertedCallTree, + }; +} + +/** + * Collect thread samples top-down data in structured format. + * Shows the regular call tree (top-down view of hot paths). + */ +export function collectThreadSamplesTopDown( + store: Store, + threadMap: ThreadMap, + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions +): ThreadSamplesTopDownResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const callTree = threadSelectors.getCallTree(state); + const libs = getProfile(state).libs; + + // Collect regular call tree + const regularCallTree = collectCallTree(callTree, libs, callTreeOptions); + + return { + type: 'thread-samples-top-down', + threadHandle: threadHandleDisplay, + friendlyThreadName, + regularCallTree, + }; +} + +/** + * Collect thread functions data in structured format. + * Lists all functions with their CPU percentages, supporting search and filtering. + */ +export function collectThreadFunctions( + store: Store, + threadMap: ThreadMap, + threadHandle?: string, + filterOptions?: FunctionFilterOptions +): ThreadFunctionsResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = getProfile(state).libs; + + // Get function list tree + const functionListTree = threadSelectors.getFunctionListTree(state); + + // Extract function data + const allFunctions = extractFunctionData(functionListTree, thread, libs); + const totalFunctionCount = allFunctions.length; + + // Check if we're zoomed (have committed ranges) + const committedRanges = getAllCommittedRanges(state); + const isZoomed = committedRanges.length > 0; + + // If zoomed, get full profile total samples for percentage calculation + // We can compute this from any function in allFunctions that has a non-zero totalRelative + // Formula: fullTotalSamples = total / totalRelative + // But since totalRelative is based on current view, we need the UNzoomed totalRelative + // Simpler approach: The raw thread has all samples - count them directly + let fullProfileTotalSamples: number | null = null; + if (isZoomed) { + // Use the same weighting as the call tree: sum weights, exclude null-stack samples + const rawThread = threadSelectors.getRawThread(state); + const { weight, stack } = rawThread.samples; + let total = 0; + for (let i = 0; i < rawThread.samples.length; i++) { + if (stack[i] !== null) { + total += weight ? (weight[i] ?? 1) : 1; + } + } + fullProfileTotalSamples = total; + } + + // Apply filters + let filteredFunctions = allFunctions; + + // Filter by search string (case-insensitive substring match) + if (filterOptions?.searchString) { + const searchLower = filterOptions.searchString.toLowerCase(); + filteredFunctions = filteredFunctions.filter((func) => + func.funcName.toLowerCase().includes(searchLower) + ); + } + + // Filter by minimum self time percentage + if (filterOptions?.minSelf !== undefined) { + const minSelfFraction = filterOptions.minSelf / 100; + filteredFunctions = filteredFunctions.filter( + (func) => func.selfRelative >= minSelfFraction + ); + } + + // Sort by self time (descending) + filteredFunctions.sort((a, b) => b.self - a.self); + + // Apply limit + const limit = filterOptions?.limit ?? filteredFunctions.length; + const limitedFunctions = filteredFunctions.slice(0, limit); + + // Convert to structured format + const functions: ThreadFunctionsResult['functions'] = limitedFunctions.map( + (func) => { + const nameWithLibrary = func.funcName; + // Extract library name if present (format: "library!function") + const bangIndex = nameWithLibrary.indexOf('!'); + const library = + bangIndex !== -1 ? nameWithLibrary.substring(0, bangIndex) : undefined; + const name = + bangIndex !== -1 + ? nameWithLibrary.substring(bangIndex + 1) + : nameWithLibrary; + + // Get full profile percentages if zoomed + let fullSelfPercentage: number | undefined; + let fullTotalPercentage: number | undefined; + if (fullProfileTotalSamples !== null) { + // Calculate percentages relative to full profile + fullSelfPercentage = (func.self / fullProfileTotalSamples) * 100; + fullTotalPercentage = (func.total / fullProfileTotalSamples) * 100; + } + + return { + functionHandle: getFunctionHandle(func.funcIndex), + functionIndex: func.funcIndex, + name, + nameWithLibrary, + selfSamples: func.self, + selfPercentage: func.selfRelative * 100, + totalSamples: func.total, + totalPercentage: func.totalRelative * 100, + library, + fullSelfPercentage, + fullTotalPercentage, + }; + } + ); + + return { + type: 'thread-functions', + threadHandle: threadHandleDisplay, + friendlyThreadName, + totalFunctionCount, + filteredFunctionCount: filteredFunctions.length, + filters: filterOptions + ? { + searchString: filterOptions.searchString, + minSelf: filterOptions.minSelf, + limit: filterOptions.limit, + } + : undefined, + functions, + }; +} diff --git a/src/profile-query/function-annotate.ts b/src/profile-query/function-annotate.ts new file mode 100644 index 0000000000..4a94d96f26 --- /dev/null +++ b/src/profile-query/function-annotate.ts @@ -0,0 +1,384 @@ +/* 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 { getProfile } from 'firefox-profiler/selectors/profile'; +import { getSelectedThreadIndexes } from 'firefox-profiler/selectors/url-state'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { parseFunctionHandle } from './function-map'; +import { getLibForFunc } from './function-list'; +import type { ThreadMap } from './thread-map'; +import { + getStackLineInfo, + getLineTimings, +} from 'firefox-profiler/profile-logic/line-timings'; +import { + getStackAddressInfo, + getAddressTimings, +} from 'firefox-profiler/profile-logic/address-timings'; +import { + getNativeSymbolInfo, + getNativeSymbolsForFunc, + findAddressProofForFile, +} from 'firefox-profiler/profile-logic/profile-data'; +import { fetchAssembly } from 'firefox-profiler/utils/fetch-assembly'; +import { fetchSource } from 'firefox-profiler/utils/fetch-source'; +import type { ExternalCommunicationDelegate } from 'firefox-profiler/utils/query-api'; +import type { + Profile, + IndexIntoFuncTable, + IndexIntoNativeSymbolTable, + Thread, +} from 'firefox-profiler/types'; +import type { + FunctionAnnotateResult, + AnnotateMode, + FunctionAsmAnnotation, + SourceAnnotationResult, + AsmAnnotationsResult, +} from './types'; +import type { Store } from '../types/store'; + +class NodeExternalCommunicationDelegate implements ExternalCommunicationDelegate { + async fetchUrlResponse(url: string, postData?: string): Promise { + const init: RequestInit = + postData !== undefined ? { method: 'POST', body: postData } : {}; + return fetch(url, init); + } + + async queryBrowserSymbolicationApi( + _path: string, + _requestJson: string + ): Promise { + throw new Error('No browser connection available in profiler-cli'); + } + + async fetchJSSourceFromBrowser(_source: string): Promise { + throw new Error('No browser connection available in profiler-cli'); + } +} + +const nodeDelegate = new NodeExternalCommunicationDelegate(); + +async function fetchSourceAnnotation( + funcIndex: IndexIntoFuncTable, + functionHandle: string, + mode: AnnotateMode, + thread: Thread, + profile: Profile, + symbolServerUrl: string, + archiveCache: Map>, + contextOption: string +): Promise { + const warnings: string[] = []; + const sourceIndex = profile.shared.funcTable.source[funcIndex]; + if (sourceIndex === null) { + if (mode === 'src') { + warnings.push( + `Function ${functionHandle} has no source index. Use --mode asm for assembly view.` + ); + } + return { annotation: null, warnings }; + } + + const { + stackTable, + frameTable, + funcTable: threadFuncTable, + samples, + } = thread; + const filename = thread.stringTable.getString( + thread.sources.filename[sourceIndex] + ); + const sourceUuid = thread.sources.id[sourceIndex]; + + const stackLineInfo = getStackLineInfo( + stackTable, + frameTable, + threadFuncTable, + sourceIndex + ); + const { totalLineHits, selfLineHits } = getLineTimings( + stackLineInfo, + samples + ); + + let samplesWithFunction = 0; + let samplesWithLineInfo = 0; + for (let si = 0; si < samples.length; si++) { + const stackIndex = samples.stack[si]; + if (stackIndex === null) { + continue; + } + const lineSetIndex = stackLineInfo.stackIndexToLineSetIndex[stackIndex]; + if (lineSetIndex === -1) { + continue; + } + const weight = samples.weight ? samples.weight[si] : 1; + samplesWithFunction += weight; + if (stackLineInfo.lineSetTable.self[lineSetIndex] !== -1) { + samplesWithLineInfo += weight; + } + } + + const addressProof = findAddressProofForFile(profile, sourceIndex); + + let fileLines: string[] | null = null; + let totalFileLines: number | null = null; + const fetchResult = await fetchSource( + filename, + sourceUuid, + symbolServerUrl, + addressProof, + archiveCache, + nodeDelegate + ); + if (fetchResult.type === 'SUCCESS') { + fileLines = fetchResult.source.split('\n'); + totalFileLines = fileLines.length; + } else { + const errorMessages = fetchResult.errors + .map((e) => JSON.stringify(e)) + .join('; '); + warnings.push(`Could not fetch source for ${filename}: ${errorMessages}`); + } + + const annotatedLineNums = new Set([ + ...totalLineHits.keys(), + ...selfLineHits.keys(), + ]); + let linesToShow: Set; + let contextMode: string; + + if (contextOption === 'file') { + linesToShow = new Set(); + const last = totalFileLines ?? Math.max(...annotatedLineNums); + for (let ln = 1; ln <= last; ln++) { + linesToShow.add(ln); + } + contextMode = 'full file'; + } else { + const parsed = parseInt(contextOption, 10); + const context = Math.max(0, isNaN(parsed) ? 2 : parsed); + linesToShow = new Set(); + for (const ln of annotatedLineNums) { + for (let ctx = Math.max(1, ln - context); ctx <= ln + context; ctx++) { + linesToShow.add(ctx); + } + } + contextMode = + context === 0 ? 'annotated lines only' : `±${context} lines context`; + } + + const sortedLines = Array.from(linesToShow).sort((a, b) => a - b); + return { + annotation: { + filename, + totalFileLines, + samplesWithFunction, + samplesWithLineInfo, + contextMode, + lines: sortedLines.map((ln) => ({ + lineNumber: ln, + selfSamples: selfLineHits.get(ln) ?? 0, + totalSamples: totalLineHits.get(ln) ?? 0, + sourceText: fileLines !== null ? (fileLines[ln - 1] ?? null) : null, + })), + }, + warnings, + }; +} + +async function fetchAsmAnnotations( + functionHandle: string, + nativeSymbolsForFunc: Set, + thread: Thread, + profile: Profile, + symbolServerUrl: string +): Promise { + const warnings: string[] = []; + + if (nativeSymbolsForFunc.size === 0) { + warnings.push( + `Function ${functionHandle} has no native symbols — may be JS-only or not symbolicated.` + ); + } + + const { + stackTable, + frameTable, + funcTable: threadFuncTable, + samples, + } = thread; + const nativeSymbolCount = nativeSymbolsForFunc.size; + + const results = await Promise.all( + Array.from(nativeSymbolsForFunc).map(async (nsIndex) => { + const nativeSymbolInfo = getNativeSymbolInfo( + nsIndex, + thread.nativeSymbols, + frameTable, + thread.stringTable + ); + const lib = profile.libs[nativeSymbolInfo.libIndex]; + + const stackAddressInfo = getStackAddressInfo( + stackTable, + frameTable, + threadFuncTable, + nsIndex + ); + const { totalAddressHits, selfAddressHits } = getAddressTimings( + stackAddressInfo, + samples + ); + + let fetchError: string | null = null; + let instructions: FunctionAsmAnnotation['instructions'] = []; + const localWarnings: string[] = []; + + try { + const fetchResult = await fetchAssembly( + nativeSymbolInfo, + lib, + symbolServerUrl, + nodeDelegate + ); + if (fetchResult.type === 'SUCCESS') { + instructions = fetchResult.instructions.map((instr) => ({ + address: instr.address, + selfSamples: selfAddressHits.get(instr.address) ?? 0, + totalSamples: totalAddressHits.get(instr.address) ?? 0, + decodedString: instr.decodedString, + })); + } else { + fetchError = fetchResult.errors + .map((e) => JSON.stringify(e)) + .join('; '); + localWarnings.push( + `Assembly fetch failed for ${nativeSymbolInfo.name}: ${fetchError}` + ); + } + } catch (e) { + fetchError = e instanceof Error ? e.message : String(e); + localWarnings.push( + `Assembly fetch threw for ${nativeSymbolInfo.name}: ${fetchError}` + ); + } + + return { + symbolName: nativeSymbolInfo.name, + symbolAddress: nativeSymbolInfo.address, + functionSize: nativeSymbolInfo.functionSizeIsKnown + ? nativeSymbolInfo.functionSize + : null, + fetchError, + instructions, + localWarnings, + }; + }) + ); + + const annotations: FunctionAsmAnnotation[] = []; + results.forEach((r, i) => { + warnings.push(...r.localWarnings); + annotations.push({ + compilationIndex: i + 1, + symbolName: r.symbolName, + symbolAddress: r.symbolAddress, + functionSize: r.functionSize, + nativeSymbolCount, + fetchError: r.fetchError, + instructions: r.instructions, + }); + }); + + return { annotations, warnings }; +} + +export async function functionAnnotate( + store: Store, + threadMap: ThreadMap, + archiveCache: Map>, + functionHandle: string, + mode: AnnotateMode, + symbolServerUrl: string, + contextOption: string +): Promise { + const state = store.getState(); + const profile = getProfile(state); + const { funcTable, stringArray, resourceTable } = profile.shared; + + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; + + const libraryName = getLibForFunc( + funcIndex, + funcTable, + resourceTable, + profile.libs + )?.name; + const fullName = libraryName ? `${libraryName}!${funcName}` : funcName; + + const threadIndexes = getSelectedThreadIndexes(state); + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getFilteredThread(state); + + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const threadHandle = threadMap.handleForThreadIndexes(threadIndexes); + + const nativeSymbolsForFunc = getNativeSymbolsForFunc( + funcIndex, + thread.frameTable + ); + + const { funcSelf, funcTotal } = threadSelectors.getFunctionListTimings(state); + const totalSelfSamples = funcSelf[funcIndex]; + const totalTotalSamples = funcTotal[funcIndex]; + + const srcPromise: Promise = + mode === 'src' || mode === 'all' + ? fetchSourceAnnotation( + funcIndex, + functionHandle, + mode, + thread, + profile, + symbolServerUrl, + archiveCache, + contextOption + ) + : Promise.resolve({ annotation: null, warnings: [] }); + + const asmPromise: Promise = + mode === 'asm' || mode === 'all' + ? fetchAsmAnnotations( + functionHandle, + nativeSymbolsForFunc, + thread, + profile, + symbolServerUrl + ) + : Promise.resolve({ annotations: [], warnings: [] }); + + const [ + { annotation: srcAnnotation, warnings: srcWarnings }, + { annotations: asmAnnotations, warnings: asmWarnings }, + ] = await Promise.all([srcPromise, asmPromise]); + + return { + type: 'function-annotate', + functionHandle, + funcIndex, + name: funcName, + fullName, + threadHandle, + friendlyThreadName, + totalSelfSamples, + totalTotalSamples, + mode, + srcAnnotation, + asmAnnotations, + warnings: [...srcWarnings, ...asmWarnings], + }; +} diff --git a/src/profile-query/function-list.ts b/src/profile-query/function-list.ts new file mode 100644 index 0000000000..ebb5edca97 --- /dev/null +++ b/src/profile-query/function-list.ts @@ -0,0 +1,533 @@ +/* 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 type { + Thread, + Lib, + FuncTable, + ResourceTable, +} from 'firefox-profiler/types'; +import { getFunctionHandle } from './function-map'; + +/** + * Look up the Lib record for a function, or undefined if none is associated. + */ +export function getLibForFunc( + funcIndex: number, + funcTable: FuncTable, + resourceTable: ResourceTable, + libs: Lib[] +): Lib | undefined { + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex === -1) { + return undefined; + } + const libIndex = resourceTable.lib[resourceIndex]; + if (libIndex !== null && libIndex !== undefined && libIndex >= 0) { + return libs[libIndex]; + } + return undefined; +} + +export type FunctionData = { + funcName: string; + funcIndex: number; + total: number; + self: number; + totalRelative: number; + selfRelative: number; +}; + +export type FunctionListStats = { + omittedCount: number; + maxTotal: number; + maxSelf: number; + sumSelf: number; +}; + +export type FormattedFunctionList = { + title: string; + lines: string[]; + stats: FunctionListStats | null; +}; + +/** + * A tree node representing a segment of a function name that can be truncated. + */ +type TruncNode = { + type: 'text' | 'nested'; + text: string; // For text nodes, the actual text. For nested, empty. + openBracket?: string; // '(' or '<' for nested nodes + closeBracket?: string; // ')' or '>' for nested nodes + children: TruncNode[]; // Child nodes (for nested nodes) +}; + +/** + * Parse a function name into a tree structure. + * Each nested section (templates, parameters) becomes a tree node that can be collapsed. + */ +function parseFunctionNameTree(name: string): TruncNode[] { + const stack: TruncNode[][] = [[]]; // Stack of node lists + let currentText = ''; + + const flushText = () => { + if (currentText) { + stack[stack.length - 1].push({ + type: 'text', + text: currentText, + children: [], + }); + currentText = ''; + } + }; + + for (let i = 0; i < name.length; i++) { + const char = name[i]; + + if (char === '<' || char === '(') { + flushText(); + + // Create a new nested node + const nestedNode: TruncNode = { + type: 'nested', + text: '', + openBracket: char, + closeBracket: char === '<' ? '>' : ')', + children: [], + }; + + // Add to current level + stack[stack.length - 1].push(nestedNode); + + // Push a new level for the nested content + stack.push(nestedNode.children); + } else if (char === '>' || char === ')') { + if (stack.length > 1) { + flushText(); + stack.pop(); + } else { + // Unmatched closing bracket (e.g. operator>>, operator>>) — treat as text + currentText += char; + } + } else { + currentText += char; + } + } + + flushText(); + return stack[0]; +} + +/** + * Render a tree of nodes to a string. + */ +function renderTree(nodes: TruncNode[]): string { + return nodes + .map((node) => { + if (node.type === 'text') { + return node.text; + } + // Nested node + const inner = renderTree(node.children); + return `${node.openBracket}${inner}${node.closeBracket}`; + }) + .join(''); +} + +/** + * Calculate the length of a tree if fully rendered. + */ +function treeLength(nodes: TruncNode[]): number { + return nodes.reduce((len, node) => { + if (node.type === 'text') { + return len + node.text.length; + } + // Nested: brackets + children + return len + 2 + treeLength(node.children); // 2 for open/close brackets + }, 0); +} + +/** + * Truncate a tree to fit within maxLength characters. + * Collapses nested nodes to `<...>` or `(...)` when needed. + */ +function truncateTree(nodes: TruncNode[], maxLength: number): string { + if (treeLength(nodes) <= maxLength) { + return renderTree(nodes); + } + + let result = ''; + + for (const node of nodes) { + const spaceLeft = maxLength - result.length; + if (spaceLeft <= 0) { + break; + } + + if (node.type === 'text') { + if (node.text.length <= spaceLeft) { + result += node.text; + } else { + // Truncate text, trying to break at :: for namespaces + const parts = node.text.split('::'); + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + (i < parts.length - 1 ? '::' : ''); + if (result.length + part.length <= maxLength) { + result += part; + } else { + break; + } + } + break; + } + } else { + // Nested node + const fullNested = renderTree(node.children); + const fullWithBrackets = `${node.openBracket}${fullNested}${node.closeBracket}`; + const collapsed = `${node.openBracket}...${node.closeBracket}`; + + if (fullWithBrackets.length <= spaceLeft) { + // Full content fits + result += fullWithBrackets; + } else if (collapsed.length <= spaceLeft) { + // Try to recursively truncate children + const availableForChildren = spaceLeft - 2; // 2 for brackets + const truncatedChildren = truncateTree( + node.children, + availableForChildren + ); + + if (truncatedChildren.length <= availableForChildren) { + result += `${node.openBracket}${truncatedChildren}${node.closeBracket}`; + } else { + // Just collapse + result += collapsed; + } + } else { + // Can't even fit collapsed version + break; + } + } + } + + return result; +} + +/** + * Find the last top-level `::` separator in a tree (not inside any nesting). + * Returns the index in the nodes array and position within that text node. + */ +function findLastTopLevelSeparator( + nodes: TruncNode[] +): { nodeIndex: number; position: number } | null { + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + if (node.type === 'text') { + const lastColons = node.text.lastIndexOf('::'); + if (lastColons !== -1) { + return { nodeIndex: i, position: lastColons }; + } + } + } + return null; +} + +/** + * Intelligently truncate a function name, preserving context and function name. + * Handles library prefixes (e.g., "nvoglv64.dll!functionName") by processing + * only the function name portion. + */ +export function truncateFunctionName( + functionName: string, + maxLength: number +): string { + if (functionName.length <= maxLength) { + return functionName; + } + + // Check if there's a library prefix (e.g., "nvoglv64.dll!functionName") + const bangIndex = functionName.indexOf('!'); + let libraryPrefix = ''; + let funcPart = functionName; + + if (bangIndex !== -1) { + libraryPrefix = functionName.substring(0, bangIndex + 1); // Include the '!' + funcPart = functionName.substring(bangIndex + 1); + + // Calculate space available for function name after prefix + const availableForFunc = maxLength - libraryPrefix.length; + + if (availableForFunc <= 10) { + // Library prefix is too long, fall back to simple truncation + return functionName.substring(0, maxLength - 3) + '...'; + } + + // If the function part fits, return it + if (funcPart.length <= availableForFunc) { + return functionName; + } + + // Otherwise, truncate the function part smartly + maxLength = availableForFunc; + } + + // Parse into tree + const tree = parseFunctionNameTree(funcPart); + + // Find the last top-level :: separator to split prefix/suffix + const separator = findLastTopLevelSeparator(tree); + + if (separator === null) { + // No namespace separator - just truncate the whole thing + return libraryPrefix + truncateTree(tree, maxLength); + } + + // Split into prefix (context) and suffix (function name) + const { nodeIndex, position } = separator; + const sepNode = tree[nodeIndex]; + + // Build prefix nodes + const prefixNodes: TruncNode[] = tree.slice(0, nodeIndex); + if (position > 0) { + // Include part of the separator node before :: + prefixNodes.push({ + type: 'text', + text: sepNode.text.substring(0, position + 2), // Include the :: + children: [], + }); + } else { + prefixNodes.push({ + type: 'text', + text: '::', + children: [], + }); + } + + // Build suffix nodes + const suffixNodes: TruncNode[] = []; + const remainingText = sepNode.text.substring(position + 2); + if (remainingText) { + suffixNodes.push({ + type: 'text', + text: remainingText, + children: [], + }); + } + suffixNodes.push(...tree.slice(nodeIndex + 1)); + + const prefixLen = treeLength(prefixNodes); + const suffixLen = treeLength(suffixNodes); + + // Check if both fit + if (prefixLen + suffixLen <= maxLength) { + return libraryPrefix + funcPart; + } + + // Allocate space: prioritize suffix (function name), up to 70% + const maxSuffixLen = Math.floor(maxLength * 0.7); + let suffixAlloc: number; + let prefixAlloc: number; + + if (suffixLen <= maxSuffixLen) { + // Suffix fits fully, give rest to prefix + suffixAlloc = suffixLen; + prefixAlloc = maxLength - suffixLen; + } else { + // Both need truncation - give at least 30% to prefix for context + prefixAlloc = Math.floor(maxLength * 0.3); + suffixAlloc = maxLength - prefixAlloc; + } + + const truncatedPrefix = truncateTree(prefixNodes, prefixAlloc); + const truncatedSuffix = truncateTree(suffixNodes, suffixAlloc); + + return libraryPrefix + truncatedPrefix + truncatedSuffix; +} + +/** + * Format a function name with its library/resource name. + * Returns "libraryName!functionName" or just "functionName" if no library is available. + */ +export function formatFunctionNameWithLibrary( + funcIndex: number, + thread: Thread, + libs: Lib[] +): string { + const funcName = thread.stringTable.getString( + thread.funcTable.name[funcIndex] + ); + const lib = getLibForFunc( + funcIndex, + thread.funcTable, + thread.resourceTable, + libs + ); + if (lib) { + return `${lib.name}!${funcName}`; + } + // Fall back to resource name if no library + const resourceIndex = thread.funcTable.resource[funcIndex]; + if (resourceIndex !== -1) { + const resourceName = thread.stringTable.getString( + thread.resourceTable.name[resourceIndex] + ); + if (resourceName && resourceName !== funcName) { + return `${resourceName}!${funcName}`; + } + } + return funcName; +} + +/** + * Extract function data from a CallTree (function list tree). + * Formats function names with library/resource information when available. + */ +export function extractFunctionData( + tree: { + getRoots(): number[]; + getNodeData(nodeIndex: number): { + total: number; + self: number; + totalRelative: number; + selfRelative: number; + }; + }, + thread: Thread, + libs: Lib[] +): FunctionData[] { + const roots = tree.getRoots(); + return roots.map((nodeIndex) => { + const data = tree.getNodeData(nodeIndex); + // The node index IS the function index for function list trees + const formattedName = formatFunctionNameWithLibrary( + nodeIndex, + thread, + libs + ); + return { + ...data, + funcName: formattedName, + funcIndex: nodeIndex, // Preserve the function index + }; + }); +} + +/** + * Sort functions by total time (descending). + */ +export function sortByTotal(functions: FunctionData[]): FunctionData[] { + return [...functions].sort((a, b) => b.total - a.total); +} + +/** + * Sort functions by self time (descending). + */ +export function sortBySelf(functions: FunctionData[]): FunctionData[] { + return [...functions].sort((a, b) => b.self - a.self); +} + +/** + * Format a single function entry with optional handle. + */ +function formatFunctionEntry( + func: FunctionData, + sortKey: 'total' | 'self' +): string { + const totalPct = (func.totalRelative * 100).toFixed(1); + const selfPct = (func.selfRelative * 100).toFixed(1); + const totalCount = Math.round(func.total); + const selfCount = Math.round(func.self); + + // Truncate function name to 120 characters (smart truncation preserves meaning) + const displayName = truncateFunctionName(func.funcName, 120); + + const handle = getFunctionHandle(func.funcIndex); + const handleStr = `${handle}. `; + + if (sortKey === 'total') { + return ` ${handleStr}${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)`; + } + return ` ${handleStr}${displayName} - self: ${selfCount} (${selfPct}%), total: ${totalCount} (${totalPct}%)`; +} + +/** + * Compute statistics for omitted functions. + */ +function computeOmittedStats( + omittedFunctions: FunctionData[] +): FunctionListStats | null { + if (omittedFunctions.length === 0) { + return null; + } + + const maxTotal = Math.max(...omittedFunctions.map((f) => f.total)); + const maxSelf = Math.max(...omittedFunctions.map((f) => f.self)); + const sumSelf = omittedFunctions.reduce((sum, f) => sum + f.self, 0); + + return { + omittedCount: omittedFunctions.length, + maxTotal, + maxSelf, + sumSelf, + }; +} + +/** + * Format a list of functions with a limit, showing statistics for omitted entries. + */ +export function formatFunctionList( + title: string, + functions: FunctionData[], + limit: number, + sortKey: 'total' | 'self' +): FormattedFunctionList { + const displayedFunctions = functions.slice(0, limit); + const omittedFunctions = functions.slice(limit); + + const lines = displayedFunctions.map((func) => + formatFunctionEntry(func, sortKey) + ); + + const stats = computeOmittedStats(omittedFunctions); + + if (stats) { + lines.push(''); + lines.push( + ` ... (${stats.omittedCount} more functions omitted, ` + + `max total: ${Math.round(stats.maxTotal)}, ` + + `max self: ${Math.round(stats.maxSelf)}, ` + + `sum of self: ${Math.round(stats.sumSelf)})` + ); + } + + return { + title, + lines, + stats, + }; +} + +/** + * Create both top function lists (by total and by self). + */ +export function createTopFunctionLists( + functions: FunctionData[], + limit: number +): { byTotal: FormattedFunctionList; bySelf: FormattedFunctionList } { + const byTotal = formatFunctionList( + 'Top Functions (by total time)', + sortByTotal(functions), + limit, + 'total' + ); + + const bySelf = formatFunctionList( + 'Top Functions (by self time)', + sortBySelf(functions), + limit, + 'self' + ); + + return { byTotal, bySelf }; +} diff --git a/src/profile-query/function-map.ts b/src/profile-query/function-map.ts new file mode 100644 index 0000000000..0b48685a92 --- /dev/null +++ b/src/profile-query/function-map.ts @@ -0,0 +1,35 @@ +/* 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 type { IndexIntoFuncTable } from 'firefox-profiler/types'; + +/** + * A handle like "f-123" always refers to funcTable index 123 for this profile, + * making handles stable across sessions for the same processed profile data. + */ +export function getFunctionHandle( + funcIndex: IndexIntoFuncTable +): `f-${number}` { + return `f-${funcIndex}`; +} + +/** + * Parse a function handle and validate it against the shared funcTable length. + */ +export function parseFunctionHandle( + functionHandle: string, + funcCount: number +): IndexIntoFuncTable { + const match = /^f-(\d+)$/.exec(functionHandle); + if (match === null) { + throw new Error(`Unknown function ${functionHandle}`); + } + + const funcIndex = Number(match[1]); + if (!Number.isInteger(funcIndex) || funcIndex < 0 || funcIndex >= funcCount) { + throw new Error(`Unknown function ${functionHandle}`); + } + + return funcIndex; +} diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts new file mode 100644 index 0000000000..55ad2cb4b1 --- /dev/null +++ b/src/profile-query/index.ts @@ -0,0 +1,1161 @@ +/* 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/. */ + +/* + * This implements a library for querying the contents of a profile. + * + * To use it it first needs to be built: + * yarn build-profile-query + * + * Then it can be used from an interactive node session: + * + * % node + * > const { ProfileQuerier } = (await import('./dist/profile-query.js')).default; + * undefined + * > const p1 = await ProfileQuerier.load("/Users/mstange/Downloads/merged-profile.json.gz"); + * > const p2 = await ProfileQuerier.load("https://profiler.firefox.com/from-url/http%3A%2F%2Fexample.com%2Fprofile.json/"); + * > const p3 = await ProfileQuerier.load("https://share.firefox.dev/4oLEjCw"); + */ + +import { + getProfile, + getProfileRootRange, +} from 'firefox-profiler/selectors/profile'; +import { + getAllCommittedRanges, + getIncludeIdleSamples, + getSelectedThreadIndexes, + getTransformStack, + getCurrentSearchString, + getProfileSpecificState, +} from 'firefox-profiler/selectors/url-state'; +import { + commitRange, + popCommittedRanges, + changeSelectedThreads, + changeCallTreeSearchString, + changeIncludeIdleSamples, + popTransformsFromStackForThreads, +} from '../actions/profile-view'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { TimestampManager } from './timestamps'; +import { ThreadMap } from './thread-map'; +import { parseFunctionHandle } from './function-map'; +import { getLibForFunc } from './function-list'; +import { MarkerMap } from './marker-map'; +import { loadProfileFromFileOrUrl, type LoadOptions } from './loader'; +import { collectProfileInfo } from './formatters/profile-info'; +import { + collectThreadInfo, + collectThreadSamples, + collectThreadSamplesTopDown, + collectThreadSamplesBottomUp, + collectThreadFunctions, +} from './formatters/thread-info'; +import { + collectThreadMarkers, + collectThreadNetwork, + collectMarkerStack, + collectMarkerInfo, + collectProfileLogs, +} from './formatters/marker-info'; +import { collectThreadPageLoad } from './formatters/page-load'; +import { parseTimeValue } from './time-range-parser'; +import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; +import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; +import type { + StartEndRange, + ThreadIndex, + ThreadsKey, +} from 'firefox-profiler/types'; +import type { + StatusResult, + SessionContext, + WithContext, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + ViewRangeResult, + ThreadSelectResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadNetworkResult, + ThreadFunctionsResult, + ThreadPageLoadResult, + ProfileLogsResult, + MarkerFilterOptions, + FunctionFilterOptions, + SampleFilterSpec, + FilterStackResult, + FilterEntry, +} from './types'; +import type { CallTreeCollectionOptions } from './formatters/call-tree'; + +import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; +import type { Store } from '../types/store'; + +export class ProfileQuerier { + _store: Store; + _processIndexMap: Map; + _timestampManager: TimestampManager; + _threadMap: ThreadMap; + _markerMap: MarkerMap; + _archiveCache: Map>; + /** + * Per-thread sizes of each filter "push group". One `filter push` adds one + * entry whose value is the number of Redux transforms it dispatched (e.g. + * `--merge f-1,f-2` -> 2). `filter pop N` removes N groups, popping the + * matching count of Redux transforms. Transforms that were already in the + * Redux stack when the querier was constructed (URL-loaded, etc.) are + * seeded as individual size-1 groups so they remain poppable. + * + * FIXME: Add a MergeSet transform so multiple functions can be merged in a + * single group entry rather than dispatching one transform per function. + */ + _pushGroupSizes: Map; + + constructor(store: Store, rootRange: StartEndRange) { + this._store = store; + this._processIndexMap = new Map(); + this._timestampManager = new TimestampManager(rootRange); + this._threadMap = new ThreadMap(); + this._archiveCache = new Map(); + this._pushGroupSizes = new Map(); + + // Build process index map + const state = this._store.getState(); + const profile = getProfile(state); + this._markerMap = new MarkerMap(); + const uniquePids = Array.from(new Set(profile.threads.map((t) => t.pid))); + uniquePids.forEach((pid, index) => { + this._processIndexMap.set(pid, index); + }); + + // Seed thread handles eagerly so they are available immediately after load. + profile.threads.forEach((_, index) => { + this._threadMap.handleForThreadIndex(index); + }); + + // Seed push-group sizes from any transforms already in the Redux stack + // (typically loaded from a profiler.firefox.com URL). Each such transform + // becomes its own size-1 group so it remains individually poppable. + const transformsPerThread = getProfileSpecificState(state).transforms; + for (const [rawKey, stack] of Object.entries(transformsPerThread)) { + if (stack.length === 0) { + continue; + } + const threadsKey: ThreadsKey = /^-?\d+$/.test(rawKey) + ? Number(rawKey) + : rawKey; + this._pushGroupSizes.set( + threadsKey, + Array.from({ length: stack.length }, () => 1) + ); + } + } + + /** + * Ensure `_pushGroupSizes[threadsKey]` matches the Redux stack length by + * prepending size-1 groups for any transforms we haven't accounted for. + * Guards against external stack mutations between operations. + */ + private _syncPushGroups(threadsKey: ThreadsKey): number[] { + let groups = this._pushGroupSizes.get(threadsKey); + if (groups === undefined) { + groups = []; + this._pushGroupSizes.set(threadsKey, groups); + } + const stackLength = getTransformStack( + this._store.getState(), + threadsKey + ).length; + const sum = groups.reduce((a, b) => a + b, 0); + if (sum < stackLength) { + for (let i = 0; i < stackLength - sum; i++) { + groups.unshift(1); + } + } + return groups; + } + + static async load( + filePathOrUrl: string, + options: LoadOptions = {} + ): Promise { + const { store, rootRange } = await loadProfileFromFileOrUrl( + filePathOrUrl, + options + ); + return new ProfileQuerier(store, rootRange); + } + + async profileInfo( + showAll: boolean = false, + search?: string + ): Promise> { + const result = await collectProfileInfo( + this._store, + this._timestampManager, + this._threadMap, + this._processIndexMap, + showAll, + search + ); + return { ...result, context: this._getContext() }; + } + + async threadInfo( + threadHandle?: string + ): Promise> { + const result = await collectThreadInfo( + this._store, + this._timestampManager, + this._threadMap, + threadHandle + ); + return { ...result, context: this._getContext() }; + } + + async threadSamples( + threadHandle?: string, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + return this._runWithSampleFilters( + threadHandle, + includeIdle, + search, + sampleFilters, + () => collectThreadSamples(this._store, this._threadMap, threadHandle) + ); + } + + async threadSamplesTopDown( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + return this._runWithSampleFilters( + threadHandle, + includeIdle, + search, + sampleFilters, + () => + collectThreadSamplesTopDown( + this._store, + this._threadMap, + threadHandle, + callTreeOptions + ) + ); + } + + async threadSamplesBottomUp( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + return this._runWithSampleFilters( + threadHandle, + includeIdle, + search, + sampleFilters, + () => + collectThreadSamplesBottomUp( + this._store, + this._threadMap, + threadHandle, + callTreeOptions + ) + ); + } + + /** + * Push a view range selection (commit a range). + * Supports multiple formats: + * - Marker handle: "m-1" (uses marker's start/end times) + * - Timestamp names: "ts-6,ts-7" + * - Seconds: "2.7,3.1" (default if no suffix) + * - Milliseconds: "2700ms,3100ms" + * - Percentage: "10%,20%" + */ + async pushViewRange(rangeName: string): Promise { + const state = this._store.getState(); + const rootRange = getProfileRootRange(state); + const zeroAt = rootRange.start; + + let startTimestamp: number; + let endTimestamp: number; + let markerInfo: ViewRangeResult['markerInfo'] = undefined; + + // Check if it's a marker handle (e.g., "m-1") + if (rangeName.startsWith('m-') && !rangeName.includes(',')) { + // Look up the marker + const { threadIndexes, markerIndex } = + this._markerMap.markerForHandle(rangeName); + const threadSelectors = getThreadSelectors(threadIndexes); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${rangeName} not found`); + } + + // Check if marker is an interval marker (has end time) + if (marker.end === null) { + throw new Error( + `Marker ${rangeName} is an instant marker (no duration). Only interval markers can be used for zoom ranges.` + ); + } + + startTimestamp = marker.start; + endTimestamp = marker.end; + + // Store marker info for enhanced output + const threadHandle = + this._threadMap.handleForThreadIndexes(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + markerInfo = { + markerHandle: rangeName, + markerName: marker.name, + threadHandle, + threadName: friendlyThreadName, + }; + } else { + // Split at comma for traditional range format + const parts = rangeName.split(',').map((s) => s.trim()); + if (parts.length !== 2) { + throw new Error( + `Invalid range format: "${rangeName}". Expected a marker handle (e.g., "m-1") or two comma-separated values (e.g., "2.7,3.1" or "ts-6,ts-7")` + ); + } + + // Parse start and end values (supports multiple formats) + const parsedStart = parseTimeValue(parts[0], rootRange); + const parsedEnd = parseTimeValue(parts[1], rootRange); + + // If parseTimeValue returns null, it's a timestamp name - look it up + startTimestamp = + parsedStart ?? + (() => { + const ts = this._timestampManager.timestampForName(parts[0]); + if (ts === null) { + throw new Error(`Unknown timestamp name: "${parts[0]}"`); + } + return ts; + })(); + + endTimestamp = + parsedEnd ?? + (() => { + const ts = this._timestampManager.timestampForName(parts[1]); + if (ts === null) { + throw new Error(`Unknown timestamp name: "${parts[1]}"`); + } + return ts; + })(); + } + + // Warn if the requested range extends outside the profile bounds + let warning: string | undefined; + if (startTimestamp < rootRange.start || endTimestamp > rootRange.end) { + const profileDuration = (rootRange.end - rootRange.start) / 1000; + warning = `Range extends outside the profile duration (${profileDuration.toFixed(3)}s). Did you mean to use milliseconds? Use the "ms" suffix for milliseconds (e.g. 0ms,400ms).`; + } + + // Get or create timestamp names for display + const startName = this._timestampManager.nameForTimestamp(startTimestamp); + const endName = this._timestampManager.nameForTimestamp(endTimestamp); + + // Convert absolute timestamps to relative timestamps. + // commitRange expects timestamps relative to the profile start (zeroAt), + // but we have absolute timestamps. The getCommittedRange selector will + // add zeroAt back to them. + const relativeStart = startTimestamp - zeroAt; + const relativeEnd = endTimestamp - zeroAt; + + // Dispatch the commitRange action with relative timestamps + this._store.dispatch(commitRange(relativeStart, relativeEnd)); + + // Get the zoom depth after pushing + const newState = this._store.getState(); + const committedRanges = getAllCommittedRanges(newState); + const zoomDepth = committedRanges.length; + + // Calculate duration + const duration = endTimestamp - startTimestamp; + + const message = `Pushed view range: ${startName} (${this._timestampManager.timestampString(startTimestamp)}) to ${endName} (${this._timestampManager.timestampString(endTimestamp)})`; + + return { + type: 'view-range', + action: 'push', + range: { + start: startTimestamp, + startName, + end: endTimestamp, + endName, + }, + message, + duration, + zoomDepth, + markerInfo, + warning, + }; + } + + /** + * Pop the most recent view range selection. + */ + async popViewRange(): Promise { + const state = this._store.getState(); + const committedRanges = getAllCommittedRanges(state); + + if (committedRanges.length === 0) { + throw new Error('No view ranges to pop'); + } + + // Pop the last committed range (index = length - 1) + const poppedIndex = committedRanges.length - 1; + this._store.dispatch(popCommittedRanges(poppedIndex)); + + const poppedRange = committedRanges[poppedIndex]; + + // Convert relative timestamps back to absolute timestamps + // committedRanges stores timestamps relative to the profile start (zeroAt) + const rootRange = getProfileRootRange(state); + const zeroAt = rootRange.start; + const absoluteStart = poppedRange.start + zeroAt; + const absoluteEnd = poppedRange.end + zeroAt; + + const startName = this._timestampManager.nameForTimestamp(absoluteStart); + const endName = this._timestampManager.nameForTimestamp(absoluteEnd); + + const message = `Popped view range: ${startName} (${this._timestampManager.timestampString(absoluteStart)}) to ${endName} (${this._timestampManager.timestampString(absoluteEnd)})`; + + return { + type: 'view-range', + action: 'pop', + range: { + start: absoluteStart, + startName, + end: absoluteEnd, + endName, + }, + message, + }; + } + + /** + * Clear all view range selections (return to root view). + */ + async clearViewRange(): Promise { + const state = this._store.getState(); + const committedRanges = getAllCommittedRanges(state); + + if (committedRanges.length === 0) { + const rootRange = getProfileRootRange(state); + const startName = this._timestampManager.nameForTimestamp( + rootRange.start + ); + const endName = this._timestampManager.nameForTimestamp(rootRange.end); + return { + type: 'view-range', + action: 'pop', + range: { + start: rootRange.start, + startName, + end: rootRange.end, + endName, + }, + message: `Already at full profile view: ${startName} (${this._timestampManager.timestampString(rootRange.start)}) to ${endName} (${this._timestampManager.timestampString(rootRange.end)})`, + }; + } + + // Pop all committed ranges (index 0 pops from the first one) + this._store.dispatch(popCommittedRanges(0)); + + const rootRange = getProfileRootRange(state); + const startName = this._timestampManager.nameForTimestamp(rootRange.start); + const endName = this._timestampManager.nameForTimestamp(rootRange.end); + + const message = `Cleared all view ranges, returned to full profile: ${startName} (${this._timestampManager.timestampString(rootRange.start)}) to ${endName} (${this._timestampManager.timestampString(rootRange.end)})`; + + return { + type: 'view-range', + action: 'pop', + range: { + start: rootRange.start, + startName, + end: rootRange.end, + endName, + }, + message, + }; + } + + /** + * Select one or more threads by handle (e.g., "t-0" or "t-0,t-1,t-2"). + */ + async threadSelect( + threadHandle: string + ): Promise> { + const threadIndexes = this._threadMap.threadIndexesForHandle(threadHandle); + + // Change the selected threads in the Redux store + this._store.dispatch(changeSelectedThreads(threadIndexes)); + + const state = this._store.getState(); + const profile = getProfile(state); + const threadNames = Array.from(threadIndexes).map( + (idx) => profile.threads[idx].name + ); + + return { + type: 'thread-select', + threadHandle, + threadNames, + context: this._getContext(), + }; + } + + /** + * Map the current Redux transform stack for `threadsKey` to FilterEntry[], + * grouping consecutive transforms that came from the same `filter push` + * (each push is recorded as one size in _pushGroupSizes). Transforms not + * accounted for by any group — e.g. URL-loaded ones encountered before the + * group map was seeded — each become their own size-1 entry. + */ + private _collectFilterEntries(threadsKey: ThreadsKey): FilterEntry[] { + const stack = getTransformStack(this._store.getState(), threadsKey); + const groups = this._syncPushGroups(threadsKey); + const entries: FilterEntry[] = []; + let offset = 0; + for (const size of groups) { + const transforms = stack.slice(offset, offset + size); + if (transforms.length === 0) { + break; + } + entries.push({ + index: entries.length + 1, + transforms, + description: describeTransformGroup(transforms), + }); + offset += size; + } + return entries; + } + + /** + * Push the Redux transforms for a filter spec as one filter entry. `--merge + * f-1,f-2` dispatches two Redux transforms but shows up — and pops — as a + * single entry. + */ + filterPush(spec: SampleFilterSpec, threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + const groups = this._syncPushGroups(threadsKey); + const countBefore = getTransformStack( + this._store.getState(), + threadsKey + ).length; + pushSpecTransforms(this._store, threadsKey, spec); + const countAfter = getTransformStack( + this._store.getState(), + threadsKey + ).length; + groups.push(countAfter - countBefore); + const filters = this._collectFilterEntries(threadsKey); + const pushed = filters[filters.length - 1]; + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'push', + message: `Pushed filter ${pushed.index}: ${pushed.description}`, + }; + } + + /** + * Pop the last `count` filter entries (default 1). Each entry is one + * previous `filter push` — multi-transform pushes (e.g. `--merge f-1,f-2`) + * undo as a single entry because that's how they were shown. + */ + filterPop(count: number = 1, threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + const groups = this._syncPushGroups(threadsKey); + const before = this._collectFilterEntries(threadsKey); + const toPop = Math.max(0, Math.min(count, groups.length)); + let transformsToPop = 0; + for (let i = 0; i < toPop; i++) { + transformsToPop += groups.pop()!; + } + const beforeStackLength = getTransformStack( + this._store.getState(), + threadsKey + ).length; + if (transformsToPop > 0) { + this._store.dispatch( + popTransformsFromStackForThreads( + threadsKey, + beforeStackLength - transformsToPop + ) + ); + } + const filters = this._collectFilterEntries(threadsKey); + const removed = before.slice(before.length - toPop).reverse(); + + let message: string; + if (toPop === 0) { + message = 'No filters to pop'; + } else if (removed.length === 1) { + message = `Popped filter: ${removed[0].description}`; + } else { + message = `Popped ${toPop} filters: ${removed.map((f) => f.description).join('; ')}`; + } + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'pop', + message, + }; + } + + /** + * Clear all transforms from the thread's transform stack. + */ + filterClear(threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + const entryCount = this._syncPushGroups(threadsKey).length; + this._pushGroupSizes.set(threadsKey, []); + if (entryCount > 0) { + this._store.dispatch(popTransformsFromStackForThreads(threadsKey, 0)); + } + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters: [], + action: 'clear', + message: + entryCount === 0 + ? 'No filters to clear' + : `Cleared ${entryCount} filter(s)`, + }; + } + + /** + * List the thread's full Redux transform stack as filter entries. + */ + filterList(threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters: this._collectFilterEntries(threadsKey), + }; + } + + /** + * Resolve thread indexes, apply idle/search/ephemeral-filter wrappers, collect, + * and attach common metadata. Shared by threadSamples, threadSamplesTopDown, + * and threadSamplesBottomUp. + */ + private _runWithSampleFilters( + threadHandle: string | undefined, + includeIdle: boolean, + search: string | undefined, + sampleFilters: SampleFilterSpec[] | undefined, + collect: () => T + ): WithContext< + T & { + activeOnly: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + } + > { + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const withIdle = includeIdle + ? () => this._withIncludedIdle(collect) + : collect; + const withSearch = search + ? () => this._withCallTreeSearch(search, withIdle) + : withIdle; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withSearch) + : withSearch(); + const activeFilters = this._collectFilterEntries( + getThreadsKey(threadIndexes) + ); + return { + ...result, + activeOnly, + search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + context: this._getContext(), + }; + } + + /** + * Temporarily push a list of sample filter specs as Redux transforms, run fn(), + * then pop them. Used to apply ephemeral (one-shot) filters to a single command. + */ + private _withEphemeralFilters( + threadIndexes: Set, + filters: SampleFilterSpec[], + fn: () => T + ): T { + if (filters.length === 0) { + return fn(); + } + const threadsKey = getThreadsKey(threadIndexes); + const stackLengthBefore = getTransformStack( + this._store.getState(), + threadsKey + ).length; + + try { + for (const spec of filters) { + pushSpecTransforms(this._store, threadsKey, spec); + } + return fn(); + } finally { + this._store.dispatch( + popTransformsFromStackForThreads(threadsKey, stackLengthBefore) + ); + } + } + + /** + * Turn on the "include idle samples" toggle around a computation, then + * restore the previous value. Used for the --include-idle slow path; the + * default CLI state already excludes idle, so no-wrap is the fast path. + */ + private _withIncludedIdle(fn: () => T): T { + const previous = getIncludeIdleSamples(this._store.getState()); + if (previous) { + return fn(); + } + this._store.dispatch(changeIncludeIdleSamples(true)); + try { + return fn(); + } finally { + this._store.dispatch(changeIncludeIdleSamples(previous)); + } + } + + /** + * Set the call tree search string around a computation, then restore the + * previous search string. + */ + private _withCallTreeSearch(searchString: string, fn: () => T): T { + const previousSearch = getCurrentSearchString(this._store.getState()); + this._store.dispatch(changeCallTreeSearchString(searchString)); + try { + return fn(); + } finally { + this._store.dispatch(changeCallTreeSearchString(previousSearch)); + } + } + + private _buildBaseStatus(state: ReturnType) { + const profile = getProfile(state); + const rootRange = getProfileRootRange(state); + const committedRanges = getAllCommittedRanges(state); + const selectedThreadIndexes = getSelectedThreadIndexes(state); + + const selectedThreadHandle = + selectedThreadIndexes.size > 0 + ? this._threadMap.handleForThreadIndexes(selectedThreadIndexes) + : null; + + const selectedThreads = Array.from(selectedThreadIndexes).map( + (threadIndex) => ({ + threadIndex, + name: profile.threads[threadIndex].name, + }) + ); + + const zeroAt = rootRange.start; + const viewRanges = committedRanges.map((range) => { + const absoluteStart = range.start + zeroAt; + const absoluteEnd = range.end + zeroAt; + return { + start: absoluteStart, + startName: this._timestampManager.nameForTimestamp(absoluteStart), + end: absoluteEnd, + endName: this._timestampManager.nameForTimestamp(absoluteEnd), + }; + }); + + return { + selectedThreadHandle, + selectedThreads, + viewRanges, + rootRange: { start: rootRange.start, end: rootRange.end }, + }; + } + + /** + * Get current session context for display in command outputs. + * This is a lightweight version of getStatus() that includes only + * the current view range (not the full stack). + */ + private _getContext(): SessionContext { + const state = this._store.getState(); + const { selectedThreadHandle, selectedThreads, viewRanges, rootRange } = + this._buildBaseStatus(state); + const currentViewRange = + viewRanges.length > 0 ? viewRanges[viewRanges.length - 1] : null; + return { + selectedThreadHandle, + selectedThreads, + currentViewRange, + rootRange, + }; + } + + /** + * Get current session status including selected threads and view ranges. + */ + async getStatus(): Promise { + const state = this._store.getState(); + const { selectedThreadHandle, selectedThreads, viewRanges, rootRange } = + this._buildBaseStatus(state); + + // Collect active filter stacks: every thread with a non-empty Redux + // transform stack, whether pushed via the CLI or loaded from the URL. + const transformsPerThread = getProfileSpecificState(state).transforms; + const filterStacks = Object.entries(transformsPerThread) + .filter(([, stack]) => stack.length > 0) + .map(([rawKey]) => { + // ThreadsKey is number | string; JSON-ish keys come back as strings. + const threadsKey: ThreadsKey = /^-?\d+$/.test(rawKey) + ? Number(rawKey) + : rawKey; + return { + threadsKey, + threadHandle: this._threadMap.handleForKey(threadsKey), + filters: this._collectFilterEntries(threadsKey), + }; + }); + + return { + type: 'status', + selectedThreadHandle, + selectedThreads, + viewRanges, + rootRange, + filterStacks, + }; + } + + /** + * Expand a function handle to show the full untruncated name. + */ + async functionExpand( + functionHandle: string + ): Promise> { + const state = this._store.getState(); + const profile = getProfile(state); + const { funcTable, resourceTable, stringArray } = profile.shared; + + // Look up the function + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; + const library = getLibForFunc( + funcIndex, + funcTable, + resourceTable, + profile.libs + )?.name; + const fullName = library ? `${library}!${funcName}` : funcName; + + return { + type: 'function-expand', + functionHandle, + funcIndex, + name: funcName, + fullName, + library, + context: this._getContext(), + }; + } + + /** + * Show detailed information about a function. + */ + async functionInfo( + functionHandle: string + ): Promise> { + const state = this._store.getState(); + const profile = getProfile(state); + const { funcTable, resourceTable, stringArray } = profile.shared; + + // Look up the function + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; + const resourceIndex = funcTable.resource[funcIndex]; + const isJS = funcTable.isJS[funcIndex]; + const relevantForJS = funcTable.relevantForJS[funcIndex]; + + let resource: FunctionInfoResult['resource']; + let library: FunctionInfoResult['library']; + + if (resourceIndex !== -1) { + resource = { + name: stringArray[resourceTable.name[resourceIndex]], + index: resourceIndex, + }; + } + + const lib = getLibForFunc( + funcIndex, + funcTable, + resourceTable, + profile.libs + ); + if (lib) { + library = { + name: lib.name, + path: lib.path, + debugName: lib.debugName, + debugPath: lib.debugPath, + breakpadId: lib.breakpadId, + }; + } + + const fullName = lib ? `${lib.name}!${funcName}` : funcName; + + return { + type: 'function-info', + functionHandle, + funcIndex, + name: funcName, + fullName, + isJS, + relevantForJS, + resource, + library, + context: this._getContext(), + }; + } + + /** + * List markers for a thread with aggregated statistics. + */ + async threadMarkers( + threadHandle?: string, + filterOptions?: MarkerFilterOptions + ): Promise> { + const result = await collectThreadMarkers( + this._store, + this._threadMap, + this._markerMap, + threadHandle, + filterOptions + ); + return { ...result, context: this._getContext() }; + } + + /** + * List completed network requests for a thread with timing phases. + */ + async threadNetwork( + threadHandle?: string, + filterOptions?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + } + ): Promise> { + const result = collectThreadNetwork( + this._store, + this._threadMap, + threadHandle, + filterOptions + ); + return { ...result, context: this._getContext() }; + } + + /** + * Summarize a page load: navigation timing, resource stats, CPU categories, and jank. + */ + async threadPageLoad( + threadHandle?: string, + options?: { navigationIndex?: number; jankLimit?: number } + ): Promise> { + const result = collectThreadPageLoad( + this._store, + this._threadMap, + this._timestampManager, + this._markerMap, + threadHandle, + options + ); + return { ...result, context: this._getContext() }; + } + + /** + * Extract Log-type markers from the profile in MOZ_LOG format. + * Iterates all threads by default; supports filtering by thread, module, level, search, and limit. + */ + async profileLogs( + filterOptions: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + } = {} + ): Promise> { + const result = collectProfileLogs( + this._store, + this._threadMap, + filterOptions + ); + return { ...result, context: this._getContext() }; + } + + /** + * List all functions for a thread with their CPU percentages. + * Supports filtering by search string, minimum self time, and limit. + */ + async threadFunctions( + threadHandle?: string, + filterOptions?: FunctionFilterOptions, + includeIdle: boolean = false, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadFunctions( + this._store, + this._threadMap, + threadHandle, + filterOptions + ); + const withIdle = includeIdle + ? () => this._withIncludedIdle(collect) + : collect; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withIdle) + : withIdle(); + const activeFilters = this._collectFilterEntries( + getThreadsKey(threadIndexes) + ); + return { + ...result, + activeOnly, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + context: this._getContext(), + }; + } + + /** + * Show detailed information about a specific marker. + */ + async markerInfo( + markerHandle: string + ): Promise> { + const result = await collectMarkerInfo( + this._store, + this._markerMap, + this._threadMap, + markerHandle + ); + return { ...result, context: this._getContext() }; + } + + async markerStack( + markerHandle: string + ): Promise> { + const result = await collectMarkerStack( + this._store, + this._markerMap, + this._threadMap, + markerHandle + ); + return { ...result, context: this._getContext() }; + } + + /** + * Annotate a function with per-line source or per-instruction assembly timing data. + */ + async functionAnnotate( + functionHandle: string, + mode: AnnotateMode, + symbolServerUrl: string, + contextOption: string = '2' + ): Promise> { + const result = await computeFunctionAnnotate( + this._store, + this._threadMap, + this._archiveCache, + functionHandle, + mode, + symbolServerUrl, + contextOption + ); + return { ...result, context: this._getContext() }; + } +} diff --git a/src/profile-query/loader.ts b/src/profile-query/loader.ts new file mode 100644 index 0000000000..e1f4228273 --- /dev/null +++ b/src/profile-query/loader.ts @@ -0,0 +1,292 @@ +/* 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 fs from 'fs'; + +import createStore from '../app-logic/create-store'; +import { unserializeProfileOfArbitraryFormat } from '../profile-logic/process-profile'; +import { + doSymbolicateProfile, + finalizeProfileView, + loadProfile, + triggerLoadingFromUrl, + waitingForProfileFromFile, +} from '../actions/receive-profile'; +import { changeIncludeIdleSamples } from '../actions/profile-view'; +import { updateUrlState } from '../actions/app'; +import { stateFromLocation } from '../app-logic/url-handling'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { + getUrlState, + getSymbolServerUrl, +} from 'firefox-profiler/selectors/url-state'; +import { + extractProfileUrlFromProfilerUrl, + fetchProfile, +} from '../utils/profile-fetch'; +import type { TemporaryError } from '../utils/errors'; +import type { Store } from '../types/store'; +import type { StartEndRange, Profile, UrlState } from 'firefox-profiler/types'; +import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; +import { SymbolStore } from '../profile-logic/symbol-store'; +import * as MozillaSymbolicationAPI from '../profile-logic/mozilla-symbolication-api'; + +/** + * Load phases, reported via the onPhaseChange callback. Mirrors the visible + * states the web UI goes through (`urlSetupPhase` + `symbolicationStatus`): + * fetching -> downloading/reading the profile + * processing -> parsing + PROFILE_LOADED + VIEW_FULL_PROFILE + * symbolicating -> doSymbolicateProfile in progress + * ready -> everything done + */ +export type LoadPhase = 'fetching' | 'processing' | 'symbolicating' | 'ready'; + +export interface LoadOptions { + /** Overrides both ?symbolServer= and the default server. */ + explicitSymbolServerUrl?: string; + /** Skip symbolication entirely (intended for CLI tests). */ + skipSymbolication?: boolean; + /** Reports loading phase transitions. */ + onPhaseChange?: (phase: LoadPhase) => void; +} + +export interface LoadResult { + store: Store; + rootRange: StartEndRange; +} + +function isUrl(input: string): boolean { + return input.startsWith('http://') || input.startsWith('https://'); +} + +function isProfilerFrontendUrl(url: string): boolean { + return ( + url.includes('profiler.firefox.com') || url.includes('share.firefox.dev') + ); +} + +async function followRedirects(url: string): Promise { + const response = await fetch(url, { method: 'HEAD', redirect: 'follow' }); + return response.url; +} + +/** + * Build a Node-compatible SymbolStore that fetches from a remote symbol server. + * Unlike the browser implementation, this does not cache in IndexedDB and has + * no browser fallback path. + */ +function createNodeSymbolStore(symbolServerUrl: string): SymbolStore { + return new SymbolStore({ + requestSymbolsFromServer: async (requests) => + MozillaSymbolicationAPI.requestSymbols( + 'symbol server', + requests, + async (path, json) => { + const response = await fetch(symbolServerUrl + path, { + body: json, + method: 'POST', + }); + return response.json(); + } + ), + requestSymbolsFromBrowser: async () => [], + requestSymbolsViaSymbolTableFromBrowser: async () => { + throw new Error('Symbol-table-from-browser is not supported in the CLI'); + }, + }); +} + +/** + * Parsed form of the user-provided input. Exactly one of `filePath` or + * `fetchUrl` is populated. When `fetchUrl` is populated, `location` is set + * iff the input was a profiler.firefox.com URL (so we have view settings to + * parse via `stateFromLocation`). + */ +type ParsedInput = + | { kind: 'file'; filePath: string } + | { kind: 'url'; fetchUrl: string; location: Location | null }; + +async function parseInput(input: string): Promise { + if (!isUrl(input)) { + return { kind: 'file', filePath: input }; + } + + // Short-URL redirects (share.firefox.dev -> profiler.firefox.com) must be + // followed before we can extract view settings from the URL. + let resolvedUrl = input; + if (input.includes('share.firefox.dev')) { + console.log('Following redirect from short URL...'); + resolvedUrl = await followRedirects(input); + console.log(`Redirected to: ${resolvedUrl}`); + } + + if (isProfilerFrontendUrl(resolvedUrl)) { + const fetchUrl = extractProfileUrlFromProfilerUrl(resolvedUrl); + if (fetchUrl === null) { + throw new Error( + `Unable to extract profile URL from profiler URL: ${resolvedUrl}` + ); + } + const parsed = new URL(resolvedUrl); + const location = { + pathname: parsed.pathname, + search: parsed.search, + hash: parsed.hash, + } as Location; + return { kind: 'url', fetchUrl, location }; + } + + // Direct URL pointing at a profile file. No view settings to parse. + return { kind: 'url', fetchUrl: input, location: null }; +} + +async function fetchAndParseProfile( + fetchUrl: string +): Promise<{ profile: Profile; upgradeInfo: ProfileUpgradeInfo }> { + console.log(`Fetching profile from ${fetchUrl}`); + const response = await fetchProfile({ + url: fetchUrl, + onTemporaryError: (e: TemporaryError) => { + if (e.attempt) { + console.log(`Retry ${e.attempt.count}/${e.attempt.total}...`); + } + }, + }); + + if (response.responseType === 'ZIP') { + throw new Error( + 'Zip files are not yet supported in the CLI. ' + + 'Please extract the profile from the zip file first, or use the web interface at profiler.firefox.com' + ); + } + + const upgradeInfo: ProfileUpgradeInfo = {}; + const profile = await unserializeProfileOfArbitraryFormat( + response.profile, + fetchUrl, + upgradeInfo + ); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + return { profile, upgradeInfo }; +} + +async function readAndParseFile( + filePath: string +): Promise<{ profile: Profile; upgradeInfo: ProfileUpgradeInfo }> { + const bytes = fs.readFileSync(filePath, null); + const upgradeInfo: ProfileUpgradeInfo = {}; + const profile = await unserializeProfileOfArbitraryFormat( + bytes, + filePath, + upgradeInfo + ); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + return { profile, upgradeInfo }; +} + +/** + * Override the symbolServerUrl field of the current URL state. `symbolServerUrl` + * has no dedicated action (the reducer is a read-only pass-through), so the + * only way to change it is to replace the whole UrlState via UPDATE_URL_STATE. + */ +function overrideSymbolServerUrl(store: Store, symbolServerUrl: string): void { + const current = getUrlState(store.getState()); + const next: UrlState = { ...current, symbolServerUrl }; + store.dispatch(updateUrlState(next)); +} + +/** + * Load a profile from a file path or URL. + * + * Mirrors the web app's profile loading pipeline: + * 1. Dispatch source-specific waiting action (sets dataSource in URL state). + * 2. Fetch/read + parse the profile, collecting upgradeInfo as an outparam. + * 3. Dispatch loadProfile(profile, {}, initialLoad=true) to fire PROFILE_LOADED. + * 4. For profiler.firefox.com URLs, build URL state via stateFromLocation + * (which runs the URL upgraders using upgradeInfo) and dispatch + * updateUrlState. + * 5. If --symbol-server was provided, overwrite urlState.symbolServerUrl. + * 6. Dispatch finalizeProfileView(null) to fire VIEW_FULL_PROFILE. In Node + * getSymbolStore() returns null, so symbolication is not kicked off here. + * 7. Symbolicate through Redux via doSymbolicateProfile, reading the symbol + * server URL from getSymbolServerUrl (--symbol-server > ?symbolServer= > + * default Mozilla server). + */ +export async function loadProfileFromFileOrUrl( + input: string, + options: LoadOptions = {} +): Promise { + const { explicitSymbolServerUrl, skipSymbolication, onPhaseChange } = options; + const store = createStore(); + console.log(`Loading profile from ${input}`); + + onPhaseChange?.('fetching'); + const parsed = await parseInput(input); + + let profile: Profile; + let upgradeInfo: ProfileUpgradeInfo; + if (parsed.kind === 'file') { + store.dispatch(waitingForProfileFromFile()); + ({ profile, upgradeInfo } = await readAndParseFile(parsed.filePath)); + } else { + store.dispatch(triggerLoadingFromUrl(parsed.fetchUrl)); + ({ profile, upgradeInfo } = await fetchAndParseProfile(parsed.fetchUrl)); + } + + onPhaseChange?.('processing'); + + // PROFILE_LOADED. initialLoad=true suppresses auto-finalize so we can + // apply URL state (and any --symbol-server override) before finalize runs. + await store.dispatch(loadProfile(profile, {}, /* initialLoad */ true)); + + // For profiler.firefox.com URLs, parse view settings (selected threads, + // transforms, committed ranges, symbolServer, etc.) into a fresh UrlState. + // For direct URLs and files, waitingForProfileFromFile / triggerLoadingFromUrl + // already set dataSource; all other URL state fields stay at reducer defaults, + // matching what the web app does for these inputs. + if (parsed.kind === 'url' && parsed.location !== null) { + const urlState = stateFromLocation(parsed.location, { + profile, + upgradeInfo, + }); + store.dispatch(updateUrlState(urlState)); + } + + if (explicitSymbolServerUrl) { + overrideSymbolServerUrl(store, explicitSymbolServerUrl); + } + + // VIEW_FULL_PROFILE. finalizeProfileView reads URL state, so this must come + // after updateUrlState. In Node, its internal symbolication attempt is a + // no-op because getSymbolStore returns null without window.indexedDB. + await store.dispatch(finalizeProfileView(null)); + + if (!skipSymbolication && profile.meta.symbolicated === false) { + onPhaseChange?.('symbolicating'); + const symbolServerUrl = getSymbolServerUrl(store.getState()); + console.log(`Symbolicating profile using ${symbolServerUrl}...`); + const symbolStore = createNodeSymbolStore(symbolServerUrl); + try { + await doSymbolicateProfile(store.dispatch, profile, symbolStore); + console.log('Symbolication complete'); + } catch (e) { + console.warn( + `Symbolication failed: ${e}. Loading profile without symbols.` + ); + } + } + + // The web defaults to "include idle samples"; the CLI defaults to "exclude". + store.dispatch(changeIncludeIdleSamples(false)); + + onPhaseChange?.('ready'); + + const state = store.getState(); + const rootRange = getProfileRootRange(state); + return { store, rootRange }; +} diff --git a/src/profile-query/marker-map.ts b/src/profile-query/marker-map.ts new file mode 100644 index 0000000000..7b8ccefc64 --- /dev/null +++ b/src/profile-query/marker-map.ts @@ -0,0 +1,70 @@ +/* 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 { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; +import type { + ThreadIndex, + MarkerIndex, + ThreadsKey, +} from 'firefox-profiler/types'; + +/** + * Represents a marker identified by its thread and marker index. + */ +export type MarkerId = { + threadIndexes: Set; + threadsKey: ThreadsKey; + markerIndex: MarkerIndex; +}; + +/** + * Maps marker handles (like "m-1", "m-2") to (threadIndex, markerIndex) pairs. + * This provides a user-friendly way to reference markers in the CLI. + * + * Since each thread has its own marker list, we need to store both the thread + * index and the marker index to uniquely identify a marker. + */ +export class MarkerMap { + _handleToMarker: Map = new Map(); + _markerToHandle: Map = new Map(); + _nextHandleId: number = 1; + + /** + * Get or create a handle for a marker. + * Returns the same handle if called multiple times with the same marker. + */ + handleForMarker( + threadIndexes: Set, + markerIndex: MarkerIndex + ): string { + const threadsKey = getThreadsKey(threadIndexes); + const reverseKey = `${threadsKey}:${markerIndex}`; + const existing = this._markerToHandle.get(reverseKey); + if (existing !== undefined) { + return existing; + } + + // Create a new handle + const handle = 'm-' + this._nextHandleId++; + this._handleToMarker.set(handle, { + threadIndexes, + threadsKey, + markerIndex, + }); + this._markerToHandle.set(reverseKey, handle); + return handle; + } + + /** + * Look up a marker by its handle. + * Throws an error if the handle is unknown. + */ + markerForHandle(markerHandle: string): MarkerId { + const markerId = this._handleToMarker.get(markerHandle); + if (markerId === undefined) { + throw new Error(`Unknown marker ${markerHandle}`); + } + return markerId; + } +} diff --git a/src/profile-query/process-thread-list.ts b/src/profile-query/process-thread-list.ts new file mode 100644 index 0000000000..0c9ae36b9c --- /dev/null +++ b/src/profile-query/process-thread-list.ts @@ -0,0 +1,208 @@ +/* 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/. */ + +export type ThreadInfo = { + threadIndex: number; + name: string; + tid: number | string; + cpuMs: number; + pid: string; +}; + +export type ProcessInfo = { + pid: string; + processIndex: number; + name: string; + cpuMs: number; + threads: Array<{ + threadIndex: number; + name: string; + tid: number | string; + cpuMs: number; + }>; +}; + +export type ProcessListItem = { + processIndex: number; + pid: string; + name: string; + etld1?: string; + cpuMs: number; + threads: Array<{ + threadIndex: number; + name: string; + tid: number | string; + cpuMs: number; + }>; + remainingThreads?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; + startTime?: number; + endTime?: number | null; +}; + +export type ProcessThreadListResult = { + processes: ProcessListItem[]; + remainingProcesses?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; +}; + +/** + * Build a hierarchical list of processes and threads for display. + * + * Shows: + * - Top 5 processes by CPU time + * - Any additional processes that contain threads from the top 20 threads overall + * - For each process, shows its top threads: + * - If the process has threads in the top 20 overall, show ALL of those threads + * - Otherwise, show up to 5 threads + * - Summary of remaining threads if any + * - Summary of remaining processes if any + */ +export function buildProcessThreadList( + threads: ThreadInfo[], + processIndexMap: Map, + showAll: boolean = false +): ProcessThreadListResult { + // Aggregate threads by process + const processCPUMap = new Map(); + + threads.forEach((thread) => { + const { pid, threadIndex, name, tid, cpuMs } = thread; + const existing = processCPUMap.get(pid); + + if (existing) { + existing.cpuMs += cpuMs; + existing.threads.push({ threadIndex, name, tid, cpuMs }); + } else { + const processIndex = processIndexMap.get(pid); + if (processIndex === undefined) { + throw new Error(`Process index not found for pid ${pid}`); + } + // Infer process name from first thread's process info + // In real usage, this would come from the thread's processName field + processCPUMap.set(pid, { + pid, + processIndex, + name: pid, // Will be overridden by caller + cpuMs, + threads: [{ threadIndex, name, tid, cpuMs }], + }); + } + }); + + // Sort threads within each process by CPU + processCPUMap.forEach((processInfo) => { + processInfo.threads.sort((a, b) => b.cpuMs - a.cpuMs); + }); + + // Get all processes sorted by CPU + const allProcesses = Array.from(processCPUMap.values()); + allProcesses.sort((a, b) => b.cpuMs - a.cpuMs); + + if (showAll) { + return { + processes: allProcesses.map( + ({ pid, processIndex, name, cpuMs, threads: allThreads }) => ({ + processIndex, + pid, + name, + cpuMs, + threads: allThreads, + }) + ), + }; + } + + // Get top 5 processes by CPU + const top5ProcessPids = new Set(allProcesses.slice(0, 5).map((p) => p.pid)); + + // Get top 20 threads overall + const allThreadsSorted = [...threads].sort((a, b) => b.cpuMs - a.cpuMs); + const top20Threads = allThreadsSorted.slice(0, 20); + const top20ThreadPids = new Set(top20Threads.map((t) => t.pid)); + + // Build a set of threadIndexes that are in the top 20 + const top20ThreadIndexes = new Set(top20Threads.map((t) => t.threadIndex)); + + // Determine which processes to show + const processesToShow = allProcesses.filter( + (p) => top5ProcessPids.has(p.pid) || top20ThreadPids.has(p.pid) + ); + const shownProcessPids = new Set(processesToShow.map((p) => p.pid)); + + // Build the result list + const result: ProcessListItem[] = processesToShow.map((processInfo) => { + const { pid, processIndex, name, cpuMs, threads: allThreads } = processInfo; + + // Separate threads into top-20 and others + const top20ThreadsInProcess = allThreads.filter((t) => + top20ThreadIndexes.has(t.threadIndex) + ); + const otherThreads = allThreads.filter( + (t) => !top20ThreadIndexes.has(t.threadIndex) + ); + + // Show all top-20 threads, plus fill up to 5 with other threads if needed + const threadsToShow = [...top20ThreadsInProcess]; + const remainingSlots = Math.max(0, 5 - threadsToShow.length); + threadsToShow.push(...otherThreads.slice(0, remainingSlots)); + + // Calculate remaining threads summary + const remainingThreads = otherThreads.slice(remainingSlots); + let remainingThreadsInfo: ProcessListItem['remainingThreads'] = undefined; + + if (remainingThreads.length > 0) { + const combinedCpuMs = remainingThreads.reduce( + (sum, t) => sum + t.cpuMs, + 0 + ); + const maxCpuMs = Math.max(...remainingThreads.map((t) => t.cpuMs)); + remainingThreadsInfo = { + count: remainingThreads.length, + combinedCpuMs, + maxCpuMs, + }; + } + + return { + processIndex, + pid, + name, + cpuMs, + threads: threadsToShow, + remainingThreads: remainingThreadsInfo, + }; + }); + + // Calculate remaining processes summary + const remainingProcesses = allProcesses.filter( + (processInfo) => !shownProcessPids.has(processInfo.pid) + ); + let remainingProcessesInfo: ProcessThreadListResult['remainingProcesses'] = + undefined; + + if (remainingProcesses.length > 0) { + const combinedCpuMs = remainingProcesses.reduce( + (sum, p) => sum + p.cpuMs, + 0 + ); + const maxCpuMs = Math.max(...remainingProcesses.map((p) => p.cpuMs)); + remainingProcessesInfo = { + count: remainingProcesses.length, + combinedCpuMs, + maxCpuMs, + }; + } + + return { + processes: result, + remainingProcesses: remainingProcessesInfo, + }; +} diff --git a/src/profile-query/thread-map.ts b/src/profile-query/thread-map.ts new file mode 100644 index 0000000000..d9e0798ab8 --- /dev/null +++ b/src/profile-query/thread-map.ts @@ -0,0 +1,64 @@ +/* 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 type { ThreadIndex, ThreadsKey } from 'firefox-profiler/types'; + +/** + * Maps thread handles (like "t-0", "t-1") to thread indices. + * This provides a user-friendly way to reference threads in the CLI. + * Supports multi-thread handles like "t-4,t-2,t-6" for selecting multiple threads. + */ +export class ThreadMap { + _map: Map = new Map(); + + handleForThreadIndex(threadIndex: ThreadIndex): string { + const handle = 't-' + threadIndex; + if (!this._map.has(handle)) { + this._map.set(handle, threadIndex); + } + return handle; + } + + threadIndexForHandle(threadHandle: string): ThreadIndex { + const threadIndex = this._map.get(threadHandle); + if (threadIndex === undefined) { + throw new Error(`Unknown thread ${threadHandle}`); + } + return threadIndex; + } + + threadIndexesForHandle(threadHandle: string): Set { + const handles = threadHandle.split(',').map((s) => s.trim()); + const indices = handles.map((handle) => { + const idx = this._map.get(handle); + if (idx === undefined) { + throw new Error(`Unknown thread ${handle}`); + } + return idx; + }); + return new Set(indices); + } + + handleForThreadIndexes(threadIndexes: Set): string { + const sorted = Array.from(threadIndexes).sort((a, b) => a - b); + return sorted.map((idx) => this.handleForThreadIndex(idx)).join(','); + } + + /** + * Convert a ThreadsKey back to a user-facing handle string (e.g. "t-0" or "t-0,t-1"). + * ThreadsKey can be a single ThreadIndex (number) or a comma-separated string of + * descending-sorted thread indexes. + */ + handleForKey(threadsKey: ThreadsKey): string { + if (typeof threadsKey === 'number') { + return this.handleForThreadIndex(threadsKey); + } + // String of comma-separated thread indexes (descending) -> sort ascending for display. + const indexes = threadsKey + .split(',') + .map(Number) + .sort((a, b) => a - b); + return indexes.map((idx) => this.handleForThreadIndex(idx)).join(','); + } +} diff --git a/src/profile-query/time-range-parser.ts b/src/profile-query/time-range-parser.ts new file mode 100644 index 0000000000..3266d1762d --- /dev/null +++ b/src/profile-query/time-range-parser.ts @@ -0,0 +1,63 @@ +/* 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 type { StartEndRange } from 'firefox-profiler/types'; + +/** + * Parse a time value from the push-range command. + * Supports multiple formats: + * - Timestamp names: "ts-6" (returns null, caller should look up in timestamp manager) + * - Seconds: "2.7" or "2.7s" (relative to profile start) + * - Milliseconds: "2700ms" (relative to profile start) + * - Percentage: "10%" (percentage through profile duration) + * + * Returns absolute timestamp in milliseconds, or null if it's a timestamp name. + */ +export function parseTimeValue( + value: string, + rootRange: StartEndRange +): number | null { + // Check if it's a timestamp name (starts with "ts") + if (value.startsWith('ts')) { + // Return null to signal caller should look it up + return null; + } + + // Check if it's a percentage + if (value.endsWith('%')) { + const percent = parseFloat(value.slice(0, -1)); + if (isNaN(percent)) { + throw new Error(`Invalid percentage: "${value}"`); + } + const duration = rootRange.end - rootRange.start; + return rootRange.start + (percent / 100) * duration; + } + + // Check if it's milliseconds + if (value.endsWith('ms')) { + const ms = parseFloat(value.slice(0, -2)); + if (isNaN(ms)) { + throw new Error(`Invalid milliseconds: "${value}"`); + } + return rootRange.start + ms; + } + + // Check if it's seconds with 's' suffix + if (value.endsWith('s')) { + const seconds = parseFloat(value.slice(0, -1)); + if (isNaN(seconds)) { + throw new Error(`Invalid seconds: "${value}"`); + } + return rootRange.start + seconds * 1000; + } + + // Default: treat as seconds (no suffix) + const seconds = parseFloat(value); + if (isNaN(seconds)) { + throw new Error( + `Invalid time value: "${value}". Expected timestamp name (ts-X), seconds (2.7), milliseconds (2700ms), or percentage (10%)` + ); + } + return rootRange.start + seconds * 1000; +} diff --git a/src/profile-query/timestamps.ts b/src/profile-query/timestamps.ts new file mode 100644 index 0000000000..48eb230c73 --- /dev/null +++ b/src/profile-query/timestamps.ts @@ -0,0 +1,311 @@ +/* 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/. */ + +/** + * TimestampManager provides compact, hierarchical names for timestamps to make + * them LLM-friendly and token-efficient. This allows LLMs to reference specific + * time points when using ProfileQuerier (e.g., for range selections). + * + * Naming scheme: + * - In-range timestamps [start, end]: "ts-" prefix (e.g., ts-0, ts-K, ts-gK) + * - Before-start timestamps: "ts<" prefix with exponential buckets (ts<0, ts<1, ...) + * - After-end timestamps: "ts>" prefix with exponential buckets (ts>0, ts>1, ...) + * + * The hierarchical algorithm creates shorter names for timestamps that are + * referenced early, with names growing longer as you drill down between existing + * marks. This keeps token usage low while maintaining precision. + */ + +import type { StartEndRange } from 'firefox-profiler/types'; +import { bisectionRightByKey } from 'firefox-profiler/utils/bisect'; +import { formatTimestamp } from 'firefox-profiler/utils/format-numbers'; + +/** + * Build the character alphabet used for timestamp names. + * Order: 0-9, a-z, A-Z (62 characters total). + */ +function _makeChars(): string[] { + const chars = []; + for (let i = 0; i < 10; i++) { + chars.push('' + i); + } + const aLower = 'a'.charCodeAt(0); + const aUpper = 'A'.charCodeAt(0); + for (let i = 0; i < 26; i++) { + chars.push(String.fromCharCode(aLower + i)); + chars.push(String.fromCharCode(aUpper + i)); + } + + return chars; +} + +function assert(condition: boolean) { + if (!condition) { + throw new Error('assert failed'); + } +} + +/** + * Item represents a node in the hierarchical timestamp tree. Each item + * corresponds to a specific timestamp and has an index in its level's + * character space (0-61). Items lazily create children as timestamps + * between existing marks are requested. + */ +class Item { + index: number; + timestamp: number; + + // Children are created on-demand and ordered by timestamp. + _children: Item[] | null = null; + + constructor(index: number, start: number) { + this.index = index; + this.timestamp = start; + } + + /** + * Get a hierarchical name for a timestamp within this item's range. + * + * Algorithm: + * 1. If timestamp matches an existing mark, return its name + * 2. Find the two adjacent marks that bracket the timestamp + * 3. If marks are adjacent (indices differ by 1), recurse into the left mark + * 4. Otherwise, interpolate to find a new index and insert a new mark + * + * This ensures timestamps requested early get shorter names, with names + * growing longer as you drill down between existing marks. + */ + nameForTimestamp(ts: number, end: number, prefix: string): string { + const start = this.timestamp; + if (ts < start || ts > end) { + throw new Error('out of range'); + } + if (ts === start) { + return prefix; + } + // Lazily initialize with boundary marks at indices 0 and MARKS_PER_LEVEL-1. + if (this._children === null) { + this._children = [new Item(0, start), new Item(MARKS_PER_LEVEL - 1, end)]; + } + // Binary search to find the left mark that brackets this timestamp. + const i = + bisectionRightByKey(this._children, ts, (item) => item.timestamp) - 1; + assert(i >= 0); + assert(i + 1 < this._children.length); + const left = this._children[i]; + const right = this._children[i + 1]; + assert(ts >= left.timestamp); + assert(ts < right.timestamp); + const leftIndex = left.index; + const rightIndex = right.index; + const indexDelta = rightIndex - leftIndex; + const rightTimestamp = right.timestamp; + // If marks are adjacent, recurse into the left mark's subrange. + if (indexDelta === 1) { + return left.nameForTimestamp( + ts, + rightTimestamp, + prefix + CHARS[leftIndex] + ); + } + // Interpolate to find a new index between the two marks. + const leftTimestamp = left.timestamp; + const relativeTimestamp = ts - leftTimestamp; + const timestampDelta = rightTimestamp - leftTimestamp; + const itemIndex = + leftIndex + + 1 + + Math.floor((relativeTimestamp / timestampDelta) * (indexDelta - 1)); + assert(itemIndex > leftIndex); + assert(itemIndex < rightIndex); + // Insert the new mark and return its name. + const item = new Item(itemIndex, ts); + this._children.splice(i + 1, 0, item); + return prefix + CHARS[itemIndex]; + } +} + +// Character alphabet: 0-9, a-z, A-Z (62 characters) +const CHARS = _makeChars(); +const MARKS_PER_LEVEL = CHARS.length; + +/** + * TimestampManager creates compact, hierarchical names for timestamps. + * + * Example names for range [1000, 2000]: + * - 1000 -> "ts-0" (range start) + * - 2000 -> "ts-Z" (range end) + * - 1500 -> "ts-K" (middle of range) + * - 1000.1 -> "ts-04" (between ts-0 and ts-1, drills into ts-0's subrange) + * - 500 -> "ts<0K" (before range start, in first bucket before-range) + * - 2500 -> "ts>0K" (after range end, in first bucket after-range) + * + * Out-of-bounds timestamps use exponentially doubling buckets: + * - ts<0: [start - 1×length, start] + * - ts<1: [start - 2×length, start - 1×length] + * - ts<2: [start - 4×length, start - 2×length] + * - ts buckets extending to the right. + */ +export class TimestampManager { + _rootRangeStart: number; + _rootRangeEnd: number; + _rootRangeLength: number; + _mainTree: Item; + // Trees for exponentially-spaced buckets before/after the main range. + // Keys are bucket numbers (0, 1, 2, ...), created on-demand. + _beforeBuckets: Map = new Map(); + _afterBuckets: Map = new Map(); + // Reverse lookup: timestamp name -> actual timestamp value. + // Only contains names that have been returned by nameForTimestamp(). + _nameToTimestamp: Map = new Map(); + _timestampToName: Map = new Map(); + + constructor(rootRange: StartEndRange) { + this._rootRangeStart = rootRange.start; + this._rootRangeEnd = rootRange.end; + this._rootRangeLength = rootRange.end - rootRange.start; + this._mainTree = new Item(0, rootRange.start); + } + + /** + * Get a compact name for a timestamp. Names are minted on-demand and + * cached for reverse lookup. + */ + nameForTimestamp(ts: number): string { + const cached = this._timestampToName.get(ts); + if (cached !== undefined) { + return cached; + } + + let name: string; + + // Handle special boundary cases. + if (ts === this._rootRangeStart) { + name = 'ts-0'; + } else if (ts === this._rootRangeEnd) { + name = 'ts-Z'; + } else if (ts < this._rootRangeStart) { + // Before-start: find the appropriate exponential bucket. + const distance = this._rootRangeStart - ts; + const bucketNum = this._getBucketNumber(distance); + const bucket = this._getOrCreateBeforeBucket(bucketNum); + const bucketEnd = this._getBeforeBucketEnd(bucketNum); + name = bucket.nameForTimestamp(ts, bucketEnd, `ts<${bucketNum}`); + } else if (ts > this._rootRangeEnd) { + // After-end: find the appropriate exponential bucket. + const distance = ts - this._rootRangeEnd; + const bucketNum = this._getBucketNumber(distance); + const bucket = this._getOrCreateAfterBucket(bucketNum); + const bucketEnd = this._getAfterBucketEnd(bucketNum); + name = bucket.nameForTimestamp(ts, bucketEnd, `ts>${bucketNum}`); + } else { + // In-range: use main tree. + name = this._mainTree.nameForTimestamp(ts, this._rootRangeEnd, 'ts-'); + } + + this._nameToTimestamp.set(name, ts); + this._timestampToName.set(ts, name); + return name; + } + + /** + * Reverse lookup: get the timestamp for a name that was previously + * returned by nameForTimestamp(). Returns null if the name is unknown. + */ + timestampForName(name: string): number | null { + return this._nameToTimestamp.get(name) ?? null; + } + + /** + * Format a timestamp as a human-readable string relative to range start. + */ + timestampString(ts: number): string { + return formatTimestamp(ts - this._rootRangeStart); + } + + /** + * Calculate which bucket number a timestamp belongs to based on distance + * from the range boundary. Buckets double in size exponentially. + * + * Bucket 0: distance <= 1×length + * Bucket 1: 1×length < distance <= 2×length + * Bucket 2: 2×length < distance <= 4×length + * Bucket n: 2^(n-1)×length < distance <= 2^n×length + */ + _getBucketNumber(distance: number): number { + const ratio = distance / this._rootRangeLength; + if (ratio <= 1) { + return 0; + } + return Math.ceil(Math.log2(ratio)); + } + + /** + * Get the start timestamp for a before-bucket. + * Bucket n covers [start - 2^n×length, start - 2^(n-1)×length]. + */ + _getBeforeBucketStart(bucketNum: number): number { + const distanceFromStart = Math.pow(2, bucketNum) * this._rootRangeLength; + return this._rootRangeStart - distanceFromStart; + } + + /** + * Get the end timestamp for a before-bucket. + */ + _getBeforeBucketEnd(bucketNum: number): number { + if (bucketNum === 0) { + return this._rootRangeStart; + } + const distanceFromStart = + Math.pow(2, bucketNum - 1) * this._rootRangeLength; + return this._rootRangeStart - distanceFromStart; + } + + /** + * Get the start timestamp for an after-bucket. + * Bucket n covers [end + 2^(n-1)×length, end + 2^n×length]. + */ + _getAfterBucketStart(bucketNum: number): number { + if (bucketNum === 0) { + return this._rootRangeEnd; + } + const distanceFromEnd = Math.pow(2, bucketNum - 1) * this._rootRangeLength; + return this._rootRangeEnd + distanceFromEnd; + } + + /** + * Get the end timestamp for an after-bucket. + */ + _getAfterBucketEnd(bucketNum: number): number { + const distanceFromEnd = Math.pow(2, bucketNum) * this._rootRangeLength; + return this._rootRangeEnd + distanceFromEnd; + } + + /** + * Get or create an Item tree for a before-bucket. + */ + _getOrCreateBeforeBucket(bucketNum: number): Item { + let bucket = this._beforeBuckets.get(bucketNum); + if (!bucket) { + const bucketStart = this._getBeforeBucketStart(bucketNum); + bucket = new Item(0, bucketStart); + this._beforeBuckets.set(bucketNum, bucket); + } + return bucket; + } + + /** + * Get or create an Item tree for an after-bucket. + */ + _getOrCreateAfterBucket(bucketNum: number): Item { + let bucket = this._afterBuckets.get(bucketNum); + if (!bucket) { + const bucketStart = this._getAfterBucketStart(bucketNum); + bucket = new Item(0, bucketStart); + this._afterBuckets.set(bucketNum, bucket); + } + return bucket; + } +} diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts new file mode 100644 index 0000000000..5c1dcfd2f4 --- /dev/null +++ b/src/profile-query/types.ts @@ -0,0 +1,696 @@ +/* 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/. */ + +/** + * Shared types for profile querying. + * These types are used by both profile-query (the library) and profiler-cli. + */ + +import type { Transform } from 'firefox-profiler/types'; + +// ===== Utility types ===== + +export type TopMarker = { + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; +}; + +export type FunctionDisplayInfo = { + name: string; + nameWithLibrary: string; + library?: string; +}; + +// ===== Filter Options ===== + +export type MarkerFilterOptions = { + searchString?: string; + minDuration?: number; // Minimum duration in milliseconds + maxDuration?: number; // Maximum duration in milliseconds + category?: string; // Filter by category name + hasStack?: boolean; // Only show markers with stack traces + limit?: number; // Limit the number of markers in aggregation (not output lines) + groupBy?: string; // Grouping strategy (e.g., "type,name" or "type,field:eventType") + autoGroup?: boolean; // Automatically determine grouping based on field variance + topN?: number; // Number of top markers to include per group in JSON output (default: 5) + list?: boolean; // Return a flat chronological list of all individual markers +}; + +export type FlatMarkerItem = { + handle: string; + name: string; + label: string; // Schema-derived table label (may equal name if no schema) + start: number; // Absolute milliseconds + duration?: number; // Milliseconds if interval marker + hasStack: boolean; + category: string; +}; + +export type FunctionFilterOptions = { + searchString?: string; // Substring search in function names + minSelf?: number; // Minimum self time percentage (0-100) + limit?: number; // Limit the number of functions in output +}; + +// ===== Sample Filter Stack ===== + +/** + * The specification for a single entry on the profiler-cli filter stack. + * Each entry corresponds to one `profiler-cli filter push` invocation. + */ +export type SampleFilterSpec = + // Phase 1: Redux transform-backed filters + | { type: 'excludes-function'; funcIndexes: number[] } + | { type: 'merge'; funcIndexes: number[] } + | { type: 'root-at'; funcIndex: number } + | { type: 'during-marker'; searchString: string } + // Phase 2: Extended filter-samples transforms + | { type: 'includes-function'; funcIndexes: number[] } + | { type: 'includes-prefix'; funcIndexes: number[] } + | { type: 'includes-suffix'; funcIndex: number } + | { type: 'outside-marker'; searchString: string }; + +/** + * One entry in the filter stack shown/managed by the CLI. + * + * One entry corresponds to one `filter push`. A push may dispatch multiple + * Redux transforms (e.g. `--merge f-1,f-2` dispatches two merge-function + * transforms); they are grouped into a single entry here so the CLI view + * matches what the user typed. Transforms already present when the session + * started (e.g. loaded from a profiler.firefox.com URL) each form their own + * single-transform entry so they remain individually poppable. + */ +export type FilterEntry = { + /** 1-based position in the filter list. */ + index: number; + /** The raw Redux transforms backing this entry (1 or more). */ + transforms: Transform[]; + /** Human-readable description. */ + description: string; +}; + +export type FilterStackResult = { + type: 'filter-stack'; + threadHandle: string; + filters: FilterEntry[]; + action?: 'push' | 'pop' | 'clear'; + message?: string; +}; + +// ===== Session Context ===== +// Context information included in all command results for persistent display + +export type SessionContext = { + selectedThreadHandle: string | null; // Combined handle like "t-0" or "t-0,t-1,t-2" + selectedThreads: Array<{ + threadIndex: number; + name: string; + }>; + currentViewRange: { + start: number; + startName: string; + end: number; + endName: string; + } | null; // null if viewing full profile + rootRange: { + start: number; + end: number; + }; +}; + +/** + * Wrapper type that adds session context to any result type. + */ +export type WithContext = T & { context: SessionContext }; + +// ===== Status Command ===== + +export type StatusResult = { + type: 'status'; + selectedThreadHandle: string | null; // Combined handle like "t-0" or "t-0,t-1,t-2" + selectedThreads: Array<{ + threadIndex: number; + name: string; + }>; + viewRanges: Array<{ + start: number; + startName: string; + end: number; + endName: string; + }>; + rootRange: { + start: number; + end: number; + }; + /** Filter stacks for all threads that have active filters */ + filterStacks: Array<{ + threadsKey: string | number; + threadHandle: string; + filters: FilterEntry[]; + }>; +}; + +// ===== Function Commands ===== + +export type FunctionExpandResult = { + type: 'function-expand'; + functionHandle: string; + funcIndex: number; + name: string; + fullName: string; + library?: string; +}; + +export type FunctionInfoResult = { + type: 'function-info'; + functionHandle: string; + funcIndex: number; + name: string; + fullName: string; + isJS: boolean; + relevantForJS: boolean; + resource?: { + name: string; + index: number; + }; + library?: { + name: string; + path: string; + debugName?: string; + debugPath?: string; + breakpadId?: string; + }; +}; + +// ===== Function Annotate ===== + +export type AnnotateMode = 'src' | 'asm' | 'all'; + +export type AnnotatedSourceLine = { + lineNumber: number; + selfSamples: number; + totalSamples: number; + sourceText: string | null; +}; + +export type FunctionSourceAnnotation = { + filename: string; + totalFileLines: number | null; + samplesWithFunction: number; + samplesWithLineInfo: number; + // Human-readable description of how lines were selected, e.g. "±2 lines context", "full function", "full file" + contextMode: string; + lines: AnnotatedSourceLine[]; +}; + +export type AnnotatedInstruction = { + address: number; + selfSamples: number; + totalSamples: number; + decodedString: string; +}; + +export type FunctionAsmAnnotation = { + compilationIndex: number; + symbolName: string; + symbolAddress: number; + functionSize: number | null; + nativeSymbolCount: number; + fetchError: string | null; + instructions: AnnotatedInstruction[]; +}; + +export type SourceAnnotationResult = { + annotation: FunctionSourceAnnotation | null; + warnings: string[]; +}; + +export type AsmAnnotationsResult = { + annotations: FunctionAsmAnnotation[]; + warnings: string[]; +}; + +export type FunctionAnnotateResult = { + type: 'function-annotate'; + functionHandle: string; + funcIndex: number; + name: string; + fullName: string; + threadHandle: string; + friendlyThreadName: string; + totalSelfSamples: number; + totalTotalSamples: number; + mode: AnnotateMode; + srcAnnotation: FunctionSourceAnnotation | null; + asmAnnotations: FunctionAsmAnnotation[]; + warnings: string[]; +}; + +// ===== View Range Commands ===== + +export type ViewRangeResult = { + type: 'view-range'; + action: 'push' | 'pop'; + range: { + start: number; + startName: string; + end: number; + endName: string; + }; + message: string; + // Enhanced information for better UX (optional, only present for 'push' action) + duration?: number; // Duration in milliseconds + zoomDepth?: number; // Current zoom stack depth + markerInfo?: { + // Present if zoomed to a marker + markerHandle: string; + markerName: string; + threadHandle: string; + threadName: string; + }; + warning?: string; // Present if the range extends outside the profile duration +}; + +// ===== Thread Commands ===== + +export type ThreadSelectResult = { + type: 'thread-select'; + threadHandle: string; + threadNames: string[]; +}; + +export type ThreadInfoResult = { + type: 'thread-info'; + threadHandle: string; + name: string; + friendlyName: string; + tid: number | string; + createdAt: number; + createdAtName: string; + endedAt: number | null; + endedAtName: string | null; + sampleCount: number; + markerCount: number; + cpuActivity: Array<{ + startTime: number; + startTimeName: string; + startTimeStr: string; + endTime: number; + endTimeName: string; + endTimeStr: string; + cpuMs: number; + depthLevel: number; + }> | null; +}; + +export type TopFunctionInfo = FunctionDisplayInfo & { + functionHandle: string; + functionIndex: number; + totalSamples: number; + totalPercentage: number; + selfSamples: number; + selfPercentage: number; +}; + +export type ThreadSamplesResult = { + type: 'thread-samples'; + threadHandle: string; + friendlyThreadName: string; + activeOnly?: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + topFunctionsByTotal: TopFunctionInfo[]; + topFunctionsBySelf: TopFunctionInfo[]; + heaviestStack: { + selfSamples: number; + frameCount: number; + frames: Array< + FunctionDisplayInfo & { + totalSamples: number; + totalPercentage: number; + selfSamples: number; + selfPercentage: number; + } + >; + }; +}; + +export type ThreadSamplesTopDownResult = { + type: 'thread-samples-top-down'; + threadHandle: string; + friendlyThreadName: string; + activeOnly?: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + regularCallTree: CallTreeNode; +}; + +export type ThreadSamplesBottomUpResult = { + type: 'thread-samples-bottom-up'; + threadHandle: string; + friendlyThreadName: string; + activeOnly?: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + invertedCallTree: CallTreeNode | null; +}; + +/** + * Scoring strategy for selecting which call tree nodes to include. + * The score determines node priority, with the constraint that child score ≤ parent score. + */ +export type CallTreeScoringStrategy = + | 'exponential-0.95' // totalPercentage * (0.95 ^ depth) - slow decay + | 'exponential-0.9' // totalPercentage * (0.9 ^ depth) - medium decay + | 'exponential-0.8' // totalPercentage * (0.8 ^ depth) - fast decay + | 'harmonic-0.1' // totalPercentage / (1 + 0.1 * depth) - very slow + | 'harmonic-0.5' // totalPercentage / (1 + 0.5 * depth) - medium + | 'harmonic-1.0' // totalPercentage / (1 + depth) - standard harmonic + | 'percentage-only'; // totalPercentage - no depth penalty + +export type CallTreeNode = FunctionDisplayInfo & { + callNodeIndex?: number; // Optional for root node + functionHandle?: string; // Optional for root node + functionIndex?: number; // Optional for root node + totalSamples: number; + totalPercentage: number; + selfSamples: number; + selfPercentage: number; + /** Original depth in tree before collapsing single-child chains */ + originalDepth: number; + children: CallTreeNode[]; + /** Information about truncated children, if any were omitted */ + childrenTruncated?: { + count: number; + combinedSamples: number; + combinedPercentage: number; + maxSamples: number; + maxPercentage: number; + depth: number; // Depth where children were truncated + }; +}; + +export type NetworkPhaseTimings = { + dns?: number; + tcp?: number; + tls?: number; + ttfb?: number; + download?: number; + mainThread?: number; +}; + +export type NetworkRequestEntry = { + url: string; + httpStatus?: number; + httpVersion?: string; + cacheStatus?: string; + transferSizeKB?: number; + startTime: number; + duration: number; + phases: NetworkPhaseTimings; +}; + +export type ThreadNetworkResult = { + type: 'thread-network'; + threadHandle: string; + friendlyThreadName: string; + totalRequestCount: number; + filteredRequestCount: number; + filters?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + }; + summary: { + cacheHit: number; + cacheMiss: number; + cacheUnknown: number; + phaseTotals: NetworkPhaseTimings; + }; + requests: NetworkRequestEntry[]; +}; + +export type ThreadMarkersResult = { + type: 'thread-markers'; + threadHandle: string; + friendlyThreadName: string; + totalMarkerCount: number; + filteredMarkerCount: number; + filters?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + category?: string; + hasStack?: boolean; + limit?: number; + }; + byType: Array<{ + markerName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: TopMarker[]; + subGroups?: MarkerGroupData[]; + subGroupKey?: string; + }>; + byCategory: Array<{ + categoryName: string; + categoryIndex: number; + count: number; + percentage: number; + }>; + customGroups?: MarkerGroupData[]; + flatMarkers?: FlatMarkerItem[]; +}; + +export type DurationStats = { + min: number; + max: number; + avg: number; + median: number; + p95: number; + p99: number; +}; + +export type RateStats = { + markersPerSecond: number; + minGap: number; + avgGap: number; + maxGap: number; +}; + +export type MarkerGroupData = { + groupName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: TopMarker[]; + subGroups?: MarkerGroupData[]; +}; + +export type ProfileLogsResult = { + type: 'profile-logs'; + entries: string[]; + totalCount: number; + filters?: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + }; +}; + +export type ThreadFunctionsResult = { + type: 'thread-functions'; + threadHandle: string; + friendlyThreadName: string; + activeOnly?: boolean; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + totalFunctionCount: number; + filteredFunctionCount: number; + filters?: { + searchString?: string; + minSelf?: number; + limit?: number; + }; + functions: Array< + { + functionHandle: string; + selfSamples: number; + selfPercentage: number; + totalSamples: number; + totalPercentage: number; + // Optional full profile percentages (present when zoomed) + fullSelfPercentage?: number; + fullTotalPercentage?: number; + } & FunctionDisplayInfo + >; +}; + +// ===== Marker Commands ===== + +export type MarkerInfoResult = { + type: 'marker-info'; + threadHandle: string; + friendlyThreadName: string; + markerHandle: string; + markerIndex: number; + name: string; + tooltipLabel?: string; + markerType?: string; + category: { + index: number; + name: string; + }; + start: number; + end: number | null; + duration?: number; + fields?: Array<{ + key: string; + label: string; + value: any; + formattedValue: string; + }>; + schema?: { + description?: string; + }; + stack?: StackTraceData; +}; + +export type MarkerStackResult = { + type: 'marker-stack'; + threadHandle: string; + friendlyThreadName: string; + markerHandle: string; + markerIndex: number; + markerName: string; + stack: StackTraceData | null; +}; + +export type StackTraceData = { + capturedAt?: number; + frames: FunctionDisplayInfo[]; + truncated: boolean; +}; + +// ===== Thread Page Load Command ===== + +export type NavigationMilestone = { + name: string; // 'FCP', 'LCP', 'DCL', 'Load', 'TTI' + timeMs: number; // relative to navStart + markerHandle: string; // e.g. "m-3" +}; + +export type PageLoadResourceEntry = { + filename: string; // last URL path segment, truncated to 50 chars + url: string; + durationMs: number; + resourceType: string; // 'JS', 'CSS', 'Image', 'HTML', 'JSON', 'Font', 'Wasm', 'Other' + markerHandle: string; // e.g. "m-5" +}; + +export type PageLoadCategoryEntry = { + name: string; + count: number; + percentage: number; +}; + +export type JankFunction = { + name: string; + sampleCount: number; +}; + +export type JankPeriod = { + startMs: number; // relative to navStart + durationMs: number; + markerHandle: string; // e.g. "m-7" + startHandle: string; // timestamp handle for zoom, e.g. "ts-3" + endHandle: string; // timestamp handle for zoom, e.g. "ts-4" + topFunctions: JankFunction[]; + categories: PageLoadCategoryEntry[]; +}; + +export type ThreadPageLoadResult = { + type: 'thread-page-load'; + threadHandle: string; + friendlyThreadName: string; + url: string | null; + navigationIndex: number; // 1-based + navigationTotal: number; + navStartMs: number; // absolute profile time of nav start + milestones: NavigationMilestone[]; + // Resources + resourceCount: number; + resourceAvgMs: number | null; + resourceMaxMs: number | null; + resourcesByType: Array<{ type: string; count: number; percentage: number }>; + topResources: PageLoadResourceEntry[]; // top 10 by duration + // CPU categories + totalSamples: number; + categories: PageLoadCategoryEntry[]; + // Jank + jankTotal: number; + jankPeriods: JankPeriod[]; // limited by jankLimit +}; + +// ===== Profile Commands ===== + +export type ProfileInfoResult = { + type: 'profile-info'; + name: string; + platform: string; + threadCount: number; + processCount: number; + showAll?: boolean; + searchQuery?: string; + processes: Array<{ + processIndex: number; + pid: string; + name: string; + etld1?: string; + cpuMs: number; + startTime?: number; + startTimeName?: string; + endTime?: number | null; + endTimeName?: string | null; + threads: Array<{ + threadIndex: number; + threadHandle: string; + name: string; + tid: number | string; + cpuMs: number; + }>; + remainingThreads?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; + }>; + remainingProcesses?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; + cpuActivity: Array<{ + startTime: number; + startTimeName: string; + startTimeStr: string; + endTime: number; + endTimeName: string; + endTimeStr: string; + cpuMs: number; + depthLevel: number; + }> | null; +}; diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 11f1a282a5..d39239f75b 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -508,6 +508,7 @@ export function getStackAndSampleSelectorsPerThread( getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, + getFunctionListTimings, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, diff --git a/src/selectors/per-thread/thread.tsx b/src/selectors/per-thread/thread.tsx index 0fbab489ab..59db24d074 100644 --- a/src/selectors/per-thread/thread.tsx +++ b/src/selectors/per-thread/thread.tsx @@ -51,6 +51,8 @@ import type { MarkerSelectorsPerThread } from './markers'; import { mergeThreads } from '../../profile-logic/merge-compare'; import { defaultThreadViewOptions } from '../../reducers/profile-view'; +import type { SliceTree } from '../../utils/slice-tree'; +import { getSlices } from '../../utils/slice-tree'; // Memoize some of these functions globally, so that in the common case we only // need to do these computations once globally instead of per thread. These @@ -127,6 +129,20 @@ export function getBasicThreadSelectorsPerThread( ProfileSelectors.getDefaultCategory, ProfileData.computeSamplesTableFromRawSamplesTable ); + const getActivitySlices: Selector = createSelector( + getSamplesTable, + (samples) => + samples.hasCPUDeltas + ? getSlices( + [0.05, 0.2, 0.4, 0.6, 0.8], + Float64Array.from( + samples.threadCPUPercent.subarray(0, samples.length), + (v) => v / 100 + ), + samples.time + ) + : null + ); const getNativeAllocations: Selector = ( state ) => getRawThread(state).nativeAllocations; @@ -189,6 +205,25 @@ export function getBasicThreadSelectorsPerThread( } ); + /** + * Get activity slices for the range-filtered thread (respecting zoom). + * This shows CPU activity only for the samples within the committed range. + */ + const getRangeFilteredActivitySlices: Selector = + createSelector(getRangeFilteredThread, (thread) => { + const samples = thread.samples; + return samples.hasCPUDeltas + ? getSlices( + [0.05, 0.2, 0.4, 0.6, 0.8], + Float64Array.from( + samples.threadCPUPercent.subarray(0, samples.length), + (v) => v / 100 + ), + samples.time + ) + : null; + }); + /** * The CallTreeSummaryStrategy determines how the call tree summarizes the * the current thread. By default, this is done by timing, but other @@ -400,6 +435,8 @@ export function getBasicThreadSelectorsPerThread( getThread, getSamplesTable, getTracedValuesBuffer, + getActivitySlices, + getRangeFilteredActivitySlices, getSamplesWeightType, getNativeAllocations, getJsAllocations, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 331b27ed6f..236ad21ce4 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -4,7 +4,10 @@ import { createSelector } from 'reselect'; import * as Tracks from '../profile-logic/tracks'; import * as CPU from '../profile-logic/cpu'; +import * as CombinedCPU from '../profile-logic/combined-cpu'; import * as UrlState from './url-state'; +import type { SliceTree } from '../utils/slice-tree'; +import { getSlices } from '../utils/slice-tree'; import { ensureExists } from '../utils/types'; import { accumulateCounterSamples, @@ -16,6 +19,7 @@ import { computeTabToThreadIndexesMap, computeStackTableFromRawStackTable, reserveFunctionsForCollapsedResources, + computeSamplesTableFromRawSamplesTable, } from '../profile-logic/profile-data'; import type { IPCMarkerCorrelations } from '../profile-logic/marker-data'; import { correlateIPCMarkers } from '../profile-logic/marker-data'; @@ -68,6 +72,7 @@ import type { MarkerSchema, MarkerSchemaByName, SampleUnits, + SamplesTable, IndexIntoSamplesTable, ExtraProfileInfoSection, TableViewOptions, @@ -721,6 +726,140 @@ export const getThreadActivityScores: Selector> = } ); +/** + * Get the CPU time in milliseconds for each thread. + * Returns an array of CPU times (one per thread), or null if no CPU delta + * information is available. This uses the raw sampleScore without boost factors. + */ +export const getThreadCPUTimeMs: Selector | null> = + createSelector(getProfile, (profile) => { + const { threads, meta } = profile; + const { sampleUnits } = meta; + + if (!sampleUnits || !sampleUnits.threadCPUDelta) { + return null; + } + + // Determine the conversion factor to milliseconds + let cpuDeltaToMs: number; + switch (sampleUnits.threadCPUDelta) { + case 'µs': + cpuDeltaToMs = 1 / 1000; + break; + case 'ns': + cpuDeltaToMs = 1 / 1000000; + break; + case 'variable CPU cycles': + // CPU cycles are not time units, return null + return null; + default: + return null; + } + + return threads.map((thread) => { + const { threadCPUDelta } = thread.samples; + if (!threadCPUDelta) { + return 0; + } + + // Ignore the first delta because it has no preceding sample interval. + const totalCPUDelta = threadCPUDelta + .slice(1) + .reduce((accum, delta) => accum + (delta ?? 0), 0); + return totalCPUDelta * cpuDeltaToMs; + }); + }); + +/** + * Get SamplesTable for all threads in the profile. + * Returns an array of SamplesTable objects, one per thread. + */ +export const getAllThreadsSamplesTables: Selector = + createSelector( + getProfile, + getStackTable, + getSampleUnits, + getReferenceCPUDeltaPerMs, + getDefaultCategory, + ( + profile, + stackTable, + sampleUnits, + referenceCPUDeltaPerMs, + defaultCategory + ) => { + return profile.threads.map((thread) => + computeSamplesTableFromRawSamplesTable( + thread.samples, + stackTable, + sampleUnits, + referenceCPUDeltaPerMs, + defaultCategory + ) + ); + } + ); + +/** + * Get combined CPU activity data from all threads. + * Returns combined time and CPU ratio arrays, or null if no CPU data is available. + */ +export const getCombinedThreadCPUData: Selector = + createSelector(getAllThreadsSamplesTables, (samplesTables) => + CombinedCPU.combineCPUDataFromThreads(samplesTables) + ); + +/** + * Get combined CPU activity data from all threads, filtered to the committed range. + * This respects zoom and shows only data within the current view. + */ +export const getRangeFilteredCombinedThreadCPUData: Selector = + createSelector( + getAllThreadsSamplesTables, + getCommittedRange, + (samplesTables, range) => + CombinedCPU.combineCPUDataFromThreads( + samplesTables, + range.start, + range.end + ) + ); + +/** + * Get activity slices for the combined CPU usage across all threads. + * Returns hierarchical slices showing periods of high combined CPU activity, + * or null if no CPU data is available. + */ +export const getCombinedThreadActivitySlices: Selector = + createSelector(getCombinedThreadCPUData, (combinedCPU) => { + if (combinedCPU === null || combinedCPU.maxCpuRatio === 0) { + return null; + } + const m = Math.ceil(combinedCPU.maxCpuRatio); + return getSlices( + [0.05 * m, 0.2 * m, 0.4 * m, 0.6 * m, 0.8 * m], + combinedCPU.cpuRatio, + combinedCPU.time + ); + }); + +/** + * Get activity slices for the combined CPU usage, filtered to the committed range. + * This respects zoom and shows only activity within the current view. + */ +export const getRangeFilteredCombinedThreadActivitySlices: Selector = + createSelector(getRangeFilteredCombinedThreadCPUData, (combinedCPU) => { + if (combinedCPU === null || combinedCPU.maxCpuRatio === 0) { + return null; + } + const m = Math.ceil(combinedCPU.maxCpuRatio); + return getSlices( + [0.05 * m, 0.2 * m, 0.4 * m, 0.6 * m, 0.8 * m], + combinedCPU.cpuRatio, + combinedCPU.time + ); + }); + /** * Get the pages array and construct a Map of pages that we can use to get the * relationships of tabs. The constructed map is `Map`. diff --git a/src/test/fixtures/profiles/marker-schema.ts b/src/test/fixtures/profiles/marker-schema.ts index 52257e9791..d5f2f61bfa 100644 --- a/src/test/fixtures/profiles/marker-schema.ts +++ b/src/test/fixtures/profiles/marker-schema.ts @@ -114,6 +114,8 @@ export const markerSchemaForTests: MarkerSchema[] = [ fields: [ { key: 'module', label: 'Module', format: 'string' }, { key: 'name', label: 'Name', format: 'string' }, + // New format: level is a string table index ("Error"/"Warning"/"Info"/"Debug"/"Verbose"). + { key: 'level', label: 'Level', format: 'unique-string' }, ], }, { diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 9f9343610e..6702059ad9 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -278,6 +278,11 @@ Object { "key": "name", "label": "Name", }, + Object { + "format": "unique-string", + "key": "level", + "label": "Level", + }, ], "name": "Log", "tableLabel": "({marker.data.module}) {marker.data.name}", diff --git a/src/test/store/profile-cpu.test.ts b/src/test/store/profile-cpu.test.ts new file mode 100644 index 0000000000..2bcb310473 --- /dev/null +++ b/src/test/store/profile-cpu.test.ts @@ -0,0 +1,16 @@ +/* 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 ProfileSelectors from '../../selectors/profile'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileWithThreadCPUDelta } from '../fixtures/profiles/processed-profile'; + +describe('profile CPU selectors', function () { + it('ignores the first threadCPUDelta entry when summing CPU time', function () { + const profile = getProfileWithThreadCPUDelta([[7000, 11000, 13000]], 'ns'); + const { getState } = storeWithProfile(profile); + + expect(ProfileSelectors.getThreadCPUTimeMs(getState())).toEqual([0.024]); + }); +}); diff --git a/src/test/store/transforms.test.ts b/src/test/store/transforms.test.ts index 0773185967..c99a1953de 100644 --- a/src/test/store/transforms.test.ts +++ b/src/test/store/transforms.test.ts @@ -2229,6 +2229,213 @@ describe('"filter-samples" transform', function () { dispatch(popTransformsFromStack(0)); }); }); + + describe('outside-marker filter type', function () { + // Same sample layout as the marker-search tests above: + // t=0: A→B→C t=1: A→B→C→D t=2: A→C t=3: A→B→E t=4: A→F + const { profile } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + addMarkersToThreadWithCorrespondingSamples( + profile.threads[threadIndex], + profile.shared, + [ + [ + 'DOMEvent', + 0, + 0.5, + { type: 'DOMEvent', latency: 7, eventType: 'click' }, + ], + [ + 'UserTiming', + 1.5, + 2.5, + { type: 'UserTiming', name: 'measure-2', entryType: 'measure' }, + ], + [ + 'UserTiming', + 2.5, + 3.5, + { type: 'UserTiming', name: 'measure-2', entryType: 'measure' }, + ], + ] + ); + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps samples outside a single marker range', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'outside-marker', + filter: 'DOMEvent', + }) + ); + // t=0 is inside DOMEvent (0–0.5); t=1,2,3,4 are kept. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 4, self: —)', + ' - B (total: 2, self: —)', + ' - C (total: 1, self: —)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ' - C (total: 1, self: 1)', + ' - F (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + + it('keeps samples outside multiple marker ranges', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'outside-marker', + filter: 'UserTiming', + }) + ); + // t=2 and t=3 are inside UserTiming ranges; t=0,1,4 are kept. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 3, self: —)', + ' - B (total: 2, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - F (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); + + describe('function-include filter type', function () { + const { + profile, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps only samples whose stack contains the specified function', function () { + const B = funcNames.indexOf('B'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'function-include', + filter: String(B), + }) + ); + // t=0 (A→B→C), t=1 (A→B→C→D), t=3 (A→B→E) contain B; t=2 and t=4 do not. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 3, self: —)', + ' - B (total: 3, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + + it('keeps samples containing any of the specified functions', function () { + const B = funcNames.indexOf('B'); + const F = funcNames.indexOf('F'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'function-include', + filter: `${B},${F}`, + }) + ); + // t=0,1,3 contain B; t=4 contains F; t=2 is dropped. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 4, self: —)', + ' - B (total: 3, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ' - F (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); + + describe('stack-prefix filter type', function () { + const { + profile, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps only samples whose stack starts with the specified prefix', function () { + const A = funcNames.indexOf('A'); + const B = funcNames.indexOf('B'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'stack-prefix', + filter: `${A},${B}`, + }) + ); + // t=0 (A→B→C), t=1 (A→B→C→D), t=3 (A→B→E) start with A→B; t=2 and t=4 do not. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 3, self: —)', + ' - B (total: 3, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); + + describe('stack-suffix filter type', function () { + const { + profile, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps only samples whose leaf frame is the specified function', function () { + const C = funcNames.indexOf('C'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'stack-suffix', + filter: String(C), + }) + ); + // t=0 (A→B→C) and t=2 (A→C) have C as their leaf; the rest do not. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 2, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: 1)', + ' - C (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); }); describe('expanded and selected CallNodePaths', function () { diff --git a/src/test/unit/activity-slice-tree.test.ts b/src/test/unit/activity-slice-tree.test.ts new file mode 100644 index 0000000000..95af457973 --- /dev/null +++ b/src/test/unit/activity-slice-tree.test.ts @@ -0,0 +1,73 @@ +/* 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 { getSlices, printSliceTree } from '../../utils/slice-tree'; + +function getSlicesEasy(threadCPUPercentage: number[]): string[] { + const time = threadCPUPercentage.map((_, i) => i); + const cpuRatio = new Float64Array(threadCPUPercentage.map((p) => p / 100)); + const slices = getSlices([0.05, 0.2, 0.4, 0.6, 0.8], cpuRatio, time); + return printSliceTree(slices); +} + +describe('Activity slice tree', function () { + it('allocates the right amount of slots', function () { + expect(getSlicesEasy([0, 0, 6, 0, 0, 0])).toEqual([ + '- 6% for 1.0ms (1 samples): 1.0ms - 2.0ms', + ]); + expect(getSlicesEasy([0, 0, 100, 0, 100, 0, 100, 0, 0, 0])).toEqual([ + '- 60% for 5.0ms (5 samples): 1.0ms - 6.0ms', + ' - 100% for 1.0ms (1 samples): 1.0ms - 2.0ms', + ' - 100% for 1.0ms (1 samples): 3.0ms - 4.0ms', + ' - 100% for 1.0ms (1 samples): 5.0ms - 6.0ms', + ]); + expect( + getSlicesEasy([ + 0, 0, 6, 0, 0, 0, 0, 34, 86, 34, 0, 0, 0, 0, 12, 9, 0, 0, 0, 7, 0, + ]) + ).toEqual([ + '- 10% for 18.0ms (18 samples): 1.0ms - 19.0ms', + ' - 51% for 3.0ms (3 samples): 6.0ms - 9.0ms', + ' - 86% for 1.0ms (1 samples): 7.0ms - 8.0ms', + ]); + }); + + it('keeps ancestors of interesting child slices', function () { + const slices = [ + { start: 0, end: 1, avg: 0.1, sum: 1, parent: null }, + ...Array.from({ length: 19 }, () => ({ + start: 0, + end: 1, + avg: 0.2, + sum: 10, + parent: null, + })), + { start: 0, end: 1, avg: 0.9, sum: 1000, parent: 0 }, + ]; + + expect(printSliceTree({ slices, time: [0, 1] })).toEqual([ + '- 10% for 1.0ms (1 samples): 0.0ms - 1.0ms', + ' - 90% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + ]); + }); +}); diff --git a/src/test/unit/combined-cpu.test.ts b/src/test/unit/combined-cpu.test.ts new file mode 100644 index 0000000000..87fbc6501a --- /dev/null +++ b/src/test/unit/combined-cpu.test.ts @@ -0,0 +1,98 @@ +/* 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 { combineCPUDataFromThreads } from 'firefox-profiler/profile-logic/combined-cpu'; +import type { SamplesTable } from 'firefox-profiler/types'; + +function createSamplesTable(time: number[], cpuRatio: number[]): SamplesTable { + // threadCPUPercent has length + 1 elements; the extra element covers "after last sample" + const percentValues = cpuRatio.map((v) => Math.round(v * 100)); + percentValues.push(0); + return { + time, + threadCPUPercent: Uint8Array.from(percentValues), + hasCPUDeltas: true, + // Other required fields (stubbed for test purposes) + stack: new Array(time.length).fill(null), + length: time.length, + weight: null, + weightType: 'samples', + category: new Uint8Array(time.length), + subcategory: new Uint8Array(time.length), + }; +} + +describe('combineCPUDataFromThreads', function () { + it('returns null when given empty array', function () { + const result = combineCPUDataFromThreads([]); + expect(result).toBeNull(); + }); + + it('returns single thread data unchanged for one thread', function () { + const samples = [createSamplesTable([0, 100, 200], [0.0, 0.5, 0.8])]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + expect(result!.time).toEqual([0, 100, 200]); + expect(Array.from(result!.cpuRatio)).toEqual([0.0, 0.5, 0.8]); + }); + + it('combines two threads with same sample times', function () { + const samples = [ + createSamplesTable([0, 100, 200], [0, 0.5, 0.3]), + createSamplesTable([0, 100, 200], [0, 0.4, 0.5]), + ]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + expect(result!.time).toEqual([0, 100, 200]); + expect(Array.from(result!.cpuRatio)).toEqual([0, 0.9, 0.8]); + }); + + it('combines threads with different sample times', function () { + const samples = [ + createSamplesTable([0, 100, 200], [0.0, 0.5, 0.8]), + createSamplesTable([50, 150, 250], [0.0, 0.3, 0.4]), + ]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + // Should have all unique time points + expect(result!.time).toEqual([0, 50, 100, 150, 200, 250]); + + // 0: thread1=bef, thread2=bef → 0.0 + // 0- 50: thread1=0.5, thread2=bef → 0.5 + // 50-100: thread1=0.5, thread2=0.3 → 0.8 + // 100-150: thread1=0.8, thread2=0.3 → 1.1 + // 150-200: thread1=0.8, thread2=0.4 → 1.2 + // 200-250: thread1=end, thread2=0.4 → 0.4 + const expected = [0.0, 0.5, 0.8, 1.1, 1.2, 0.4]; + const actual = Array.from(result!.cpuRatio); + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(actual[i]).toBeCloseTo(expected[i], 10); + } + }); + + it('handles threads with non-overlapping time ranges', function () { + const samples = [ + createSamplesTable([0, 10, 20], [0.0, 0.3, 0.5]), + createSamplesTable([30, 40, 50], [0.0, 0.4, 0.6]), + ]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + expect(result!.time).toEqual([0, 10, 20, 30, 40, 50]); + + // At times 0, 10, 20: only thread1 has samples + // At times 30, 40, 50: thread1 has ended (30 > 20), only thread2 contributes + expect(Array.from(result!.cpuRatio)).toEqual([ + 0.0, 0.3, 0.5, 0.0, 0.4, 0.6, + ]); + }); +}); diff --git a/src/test/unit/profile-query/call-tree.test.ts b/src/test/unit/profile-query/call-tree.test.ts new file mode 100644 index 0000000000..41dbbf43b1 --- /dev/null +++ b/src/test/unit/profile-query/call-tree.test.ts @@ -0,0 +1,468 @@ +/* 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 { collectCallTree } from '../../../profile-query/formatters/call-tree'; +import type { CallTreeNode } from '../../../profile-query/types'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; + +describe('call-tree collection', function () { + describe('simple linear tree', function () { + it('respects node budget', function () { + const { profile } = getProfileFromTextSamples(` + A + B + C + D + E + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + // Collect with budget of 3 nodes + const result = collectCallTree(callTree, libs, { + maxNodes: 3, + }); + + // Count nodes (excluding virtual root) + const nodeCount = countNodes(result) - 1; + expect(nodeCount).toBeLessThanOrEqual(3); + }); + + it('includes high-score nodes even when deep', function () { + const { profile } = getProfileFromTextSamples(` + A A A + B B B + C C C + D D D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + // With small budget, should still include D (100% at depth 3) + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + }); + + // Should include: A, B, C, D + const nodeNames = collectNodeNames(result); + expect(nodeNames).toContain('A'); + expect(nodeNames).toContain('B'); + expect(nodeNames).toContain('C'); + expect(nodeNames).toContain('D'); + }); + }); + + describe('branching tree', function () { + it('explores hot paths first', function () { + const { profile } = getProfileFromTextSamples(` + A A A A + B B C C + D D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + // With budget of 4: should get A, B (50%), D (50%), C (50%) + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + }); + + const nodeNames = collectNodeNames(result); + expect(nodeNames).toContain('A'); + expect(nodeNames).toContain('B'); // Hot child (50%) + expect(nodeNames).toContain('C'); // Also 50% + // D might or might not be included depending on score ordering + }); + + it('computes elided children stats', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A + B B C D E + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + // With budget of 2: A and B, should show C/D/E as elided + const result = collectCallTree(callTree, libs, { + maxNodes: 2, + }); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + expect(aNode.childrenTruncated).toBeDefined(); + expect(aNode.childrenTruncated?.count).toBeGreaterThan(0); + }); + }); + + describe('scoring strategies', function () { + it('exponential-0.9 balances depth and breadth', function () { + const { profile } = getProfileFromTextSamples(` + A A B + C C + D D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + scoringStrategy: 'exponential-0.9', + }); + + const nodeNames = collectNodeNames(result); + expect(nodeNames).toContain('A'); // 66% at depth 0 + expect(nodeNames).toContain('B'); // 33% at depth 0 + }); + + it('percentage-only ignores depth', function () { + const { profile } = getProfileFromTextSamples(` + A + B + C + D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + scoringStrategy: 'percentage-only', + }); + + // All nodes should have same priority (100%), so all included + const nodeCount = countNodes(result) - 1; + expect(nodeCount).toBe(4); + }); + }); + + describe('complex branching trees', function () { + it('handles multiple levels of branching correctly', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A B B C + D D E E F F G G + H H I I + J J + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 10, + scoringStrategy: 'exponential-0.9', + }); + + const nodeNames = collectNodeNames(result); + // Should include high-percentage nodes + expect(nodeNames).toContain('A'); // 66% at depth 0 + expect(nodeNames).toContain('B'); // 22% at depth 0 + expect(nodeNames).toContain('D'); // 22% under A + expect(nodeNames).toContain('E'); // 22% under A + + const nodeCount = countNodes(result) - 1; + expect(nodeCount).toBeLessThanOrEqual(10); + }); + + it('correctly computes elided children percentages', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A A A + B B C C D D E F G H + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + // Small budget to force truncation + const result = collectCallTree(callTree, libs, { + maxNodes: 3, + }); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + expect(aNode.childrenTruncated).toBeDefined(); + + // Verify the count and percentages are correct + const truncInfo = aNode.childrenTruncated!; + expect(truncInfo.count).toBeGreaterThan(0); + expect(truncInfo.combinedPercentage).toBeGreaterThan(0); + expect(truncInfo.maxPercentage).toBeGreaterThan(0); + // Max percentage should be <= combined percentage + expect(truncInfo.maxPercentage).toBeLessThanOrEqual( + truncInfo.combinedPercentage + ); + }); + + it('handles wide trees with many children', function () { + // Create a wide tree: A has 15 children + const samples = ` + A A A A A A A A A A A A A A A A + B C D E F G H I J K L M N O P Q + `; + + const { profile } = getProfileFromTextSamples(samples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + // First verify that A has many children + const roots = callTree.getRoots(); + expect(roots.length).toBe(1); + const aCallNode = roots[0]; + const aChildren = callTree.getChildren(aCallNode); + expect(aChildren.length).toBe(16); // B through Q + + const result = collectCallTree(callTree, libs, { + maxNodes: 5, // Small budget to ensure truncation + maxChildrenPerNode: 10, + }); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + + // A has 16 children, but we can only expand 10 (maxChildrenPerNode) + // With budget of 5 total nodes (A + 4 children), we should have truncation + // Either from the 10 expanded children (6 not included) + 6 not expanded = 12 total + // Or if fewer than 4 children included, even more truncated + expect(aNode.childrenTruncated).toBeDefined(); + expect(aNode.childrenTruncated!.count).toBeGreaterThan(0); + }); + + it('preserves correct ordering by sample count', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A + B B B C C D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 10, + }); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + + // Children should be ordered B (3 samples), C (2 samples), D (1 sample) + expect(aNode.children.length).toBeGreaterThanOrEqual(2); + expect(aNode.children[0].name).toBe('B'); // Highest sample count + expect(aNode.children[1].name).toBe('C'); + }); + }); + + describe('deep nested structures', function () { + it('includes deep hot paths over shallow cold paths', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A B C + D D D D D D D D + E E E E E E E E + F F F F F F F F + G G G G G G G G + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 8, + scoringStrategy: 'exponential-0.9', + }); + + const nodeNames = collectNodeNames(result); + // Should include deep path A->D->E->F->G even though it's deep + // because it's 80% of all samples + expect(nodeNames).toContain('A'); + expect(nodeNames).toContain('D'); + expect(nodeNames).toContain('E'); + expect(nodeNames).toContain('F'); + expect(nodeNames).toContain('G'); + }); + + it('respects maxDepth parameter', function () { + // Create deeply nested tree + const samples = Array(50) + .fill(null) + .map((_, i) => `Func${i}`) + .join('\n'); + + const { profile } = getProfileFromTextSamples(samples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 100, + maxDepth: 20, + }); + + const maxDepth = findMaxDepth(result); + expect(maxDepth).toBeLessThanOrEqual(20); + }); + }); + + describe('elided children statistics', function () { + it('correctly sums elided children samples', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A A A + B B B C C D E F G H + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + // Budget that includes A and B, but not the other children + const result = collectCallTree(callTree, libs, { + maxNodes: 2, + }); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + expect(aNode.children.length).toBe(1); + expect(aNode.children[0].name).toBe('B'); + + // Should have truncated info for C, D, E, F, G, H + expect(aNode.childrenTruncated).toBeDefined(); + expect(aNode.childrenTruncated!.count).toBe(6); + + // Combined samples should be 7 (C:2, D:1, E:1, F:1, G:1, H:1) + expect(aNode.childrenTruncated!.combinedSamples).toBe(7); + // Combined percentage should be 70% of total 10 samples (not relative to A) + expect(aNode.childrenTruncated!.combinedPercentage).toBeCloseTo(70, 0); + + // Max samples should be 2 (from C) + expect(aNode.childrenTruncated!.maxSamples).toBe(2); + // Max percentage should be 20% of total 10 samples (not relative to A) + expect(aNode.childrenTruncated!.maxPercentage).toBeCloseTo(20, 0); + }); + + it('correctly identifies depth where children were truncated', function () { + const { profile } = getProfileFromTextSamples(` + A A A A + B B B B + C D E F + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 2, + }); + + const aNode = result.children[0]; + const bNode = aNode.children[0]; + expect(bNode.name).toBe('B'); + + // B's children were truncated at depth 2 + expect(bNode.childrenTruncated).toBeDefined(); + expect(bNode.childrenTruncated!.depth).toBe(2); + }); + }); + + describe('depth limit', function () { + it('stops expanding beyond maxDepth', function () { + // Very deep tree + const samples = Array(100) + .fill(null) + .map((_, i) => `Func${i}`) + .join('\n'); + + const { profile } = getProfileFromTextSamples(samples); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const libs = profile.libs; + + const result = collectCallTree(callTree, libs, { + maxNodes: 1000, // High budget + maxDepth: 10, // But limited depth + }); + + const maxDepthFound = findMaxDepth(result); + expect(maxDepthFound).toBeLessThanOrEqual(10); + }); + }); +}); + +/** + * Count total nodes in tree (including root). + */ +function countNodes(node: CallTreeNode): number { + let count = 1; + for (const child of node.children) { + count += countNodes(child); + } + return count; +} + +/** + * Collect all node names in tree. + */ +function collectNodeNames(node: CallTreeNode): string[] { + const names = [node.name]; + for (const child of node.children) { + names.push(...collectNodeNames(child)); + } + return names; +} + +/** + * Find maximum depth in tree. + */ +function findMaxDepth(node: CallTreeNode): number { + if (node.children.length === 0) { + return node.originalDepth; + } + return Math.max(...node.children.map((child) => findMaxDepth(child))); +} diff --git a/src/test/unit/profile-query/cpu-activity.test.ts b/src/test/unit/profile-query/cpu-activity.test.ts new file mode 100644 index 0000000000..5d1b7f8dc0 --- /dev/null +++ b/src/test/unit/profile-query/cpu-activity.test.ts @@ -0,0 +1,48 @@ +/* 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 { collectSliceTree } from 'firefox-profiler/profile-query/cpu-activity'; +import { TimestampManager } from 'firefox-profiler/profile-query/timestamps'; + +describe('profile-query cpu activity', function () { + it('keeps interesting descendants nested under their parent in collected output', function () { + const slices = [ + { start: 0, end: 4, avg: 0.5, sum: 50, parent: null }, + { start: 1, end: 3, avg: 0.75, sum: 40, parent: 0 }, + { start: 2, end: 3, avg: 1, sum: 20, parent: 1 }, + ]; + const time = [0, 10, 20, 30, 40]; + const tsManager = new TimestampManager({ start: 0, end: 40 }); + + const result = collectSliceTree({ slices, time }, tsManager); + + expect(result).toHaveLength(3); + expect(result).toEqual([ + expect.objectContaining({ + startTime: 0, + endTime: 40, + cpuMs: 20, + depthLevel: 0, + }), + expect.objectContaining({ + startTime: 10, + endTime: 30, + cpuMs: 15, + depthLevel: 1, + }), + expect.objectContaining({ + startTime: 20, + endTime: 30, + cpuMs: 10, + depthLevel: 2, + }), + ]); + }); + + it('returns an empty list when there are no slices', function () { + const tsManager = new TimestampManager({ start: 0, end: 10 }); + + expect(collectSliceTree({ slices: [], time: [] }, tsManager)).toEqual([]); + }); +}); diff --git a/src/test/unit/profile-query/function-list.test.ts b/src/test/unit/profile-query/function-list.test.ts new file mode 100644 index 0000000000..d6a736d7cf --- /dev/null +++ b/src/test/unit/profile-query/function-list.test.ts @@ -0,0 +1,591 @@ +/* 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 { + extractFunctionData, + sortByTotal, + sortBySelf, + formatFunctionList, + createTopFunctionLists, + truncateFunctionName, + type FunctionData, +} from '../../../profile-query/function-list'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import type { Lib } from 'firefox-profiler/types'; + +function createMockTree(functions: FunctionData[]) { + return { + getRoots: () => functions.map((_, i) => i), + getNodeData: (index: number) => functions[index], + }; +} + +describe('function-list', function () { + describe('extractFunctionData', function () { + it('extracts function data from a tree', function () { + const { profile, derivedThreads } = getProfileFromTextSamples(` + foo + bar + `); + const [thread] = derivedThreads; + const libs: Lib[] = profile.libs; + + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + { + funcName: 'bar', + funcIndex: 1, + total: 80, + self: 60, + totalRelative: 0.4, + selfRelative: 0.3, + }, + ]; + + const tree = createMockTree(functions); + const result = extractFunctionData(tree, thread, libs); + + expect(result).toEqual(functions); + }); + }); + + describe('sortByTotal', function () { + it('sorts functions by total time descending', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 50, + self: 30, + totalRelative: 0.25, + selfRelative: 0.15, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 100, + self: 20, + totalRelative: 0.5, + selfRelative: 0.1, + }, + { + funcName: 'baz', + funcIndex: 0, + total: 75, + self: 40, + totalRelative: 0.375, + selfRelative: 0.2, + }, + ]; + + const sorted = sortByTotal(functions); + + expect(sorted.map((f) => f.funcName)).toEqual(['bar', 'baz', 'foo']); + expect(sorted.map((f) => f.total)).toEqual([100, 75, 50]); + }); + + it('does not mutate the original array', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 50, + self: 30, + totalRelative: 0.25, + selfRelative: 0.15, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 100, + self: 20, + totalRelative: 0.5, + selfRelative: 0.1, + }, + ]; + + const original = [...functions]; + sortByTotal(functions); + + expect(functions).toEqual(original); + }); + }); + + describe('sortBySelf', function () { + it('sorts functions by self time descending', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 30, + totalRelative: 0.5, + selfRelative: 0.15, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 50, + self: 40, + totalRelative: 0.25, + selfRelative: 0.2, + }, + { + funcName: 'baz', + funcIndex: 0, + total: 75, + self: 20, + totalRelative: 0.375, + selfRelative: 0.1, + }, + ]; + + const sorted = sortBySelf(functions); + + expect(sorted.map((f) => f.funcName)).toEqual(['bar', 'foo', 'baz']); + expect(sorted.map((f) => f.self)).toEqual([40, 30, 20]); + }); + }); + + describe('formatFunctionList', function () { + it('formats a complete list with no omissions', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 80, + self: 40, + totalRelative: 0.4, + selfRelative: 0.2, + }, + ]; + + const result = formatFunctionList( + 'Top Functions', + functions, + 10, + 'total' + ); + + expect(result.title).toBe('Top Functions'); + expect(result.stats).toBeNull(); + expect(result.lines.length).toBe(2); + expect(result.lines[0]).toContain('foo'); + expect(result.lines[0]).toContain('total: 100'); + expect(result.lines[1]).toContain('bar'); + }); + + it('formats a list with omissions and shows stats', function () { + const functions: FunctionData[] = [ + { + funcName: 'func1', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.333, + selfRelative: 0.25, + }, + { + funcName: 'func2', + funcIndex: 0, + total: 90, + self: 40, + totalRelative: 0.3, + selfRelative: 0.2, + }, + { + funcName: 'func3', + funcIndex: 0, + total: 80, + self: 30, + totalRelative: 0.267, + selfRelative: 0.15, + }, + { + funcName: 'func4', + funcIndex: 0, + total: 70, + self: 20, + totalRelative: 0.233, + selfRelative: 0.1, + }, + { + funcName: 'func5', + funcIndex: 0, + total: 60, + self: 10, + totalRelative: 0.2, + selfRelative: 0.05, + }, + ]; + + const result = formatFunctionList('Top Functions', functions, 3, 'self'); + + expect(result.title).toBe('Top Functions'); + expect(result.lines.length).toBe(5); // 3 functions + blank line + stats line + expect(result.stats).toEqual({ + omittedCount: 2, + maxTotal: 70, + maxSelf: 20, + sumSelf: 30, // 20 + 10 + }); + expect(result.lines[3]).toBe(''); + expect(result.lines[4]).toContain('2 more functions omitted'); + expect(result.lines[4]).toContain('max total: 70'); + expect(result.lines[4]).toContain('max self: 20'); + expect(result.lines[4]).toContain('sum of self: 30'); + }); + + it('formats entries with total first when sortKey is total', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + ]; + + const result = formatFunctionList( + 'Top Functions', + functions, + 10, + 'total' + ); + + expect(result.lines[0]).toMatch(/total:.*self:/); + expect(result.lines[0]).toContain('total: 100 (50.0%)'); + expect(result.lines[0]).toContain('self: 50 (25.0%)'); + }); + + it('formats entries with self first when sortKey is self', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + ]; + + const result = formatFunctionList('Top Functions', functions, 10, 'self'); + + expect(result.lines[0]).toMatch(/self:.*total:/); + expect(result.lines[0]).toContain('self: 50 (25.0%)'); + expect(result.lines[0]).toContain('total: 100 (50.0%)'); + }); + }); + + describe('createTopFunctionLists', function () { + it('creates two lists sorted by total and self', function () { + const functions: FunctionData[] = [ + { + funcName: 'highTotal', + funcIndex: 0, + total: 100, + self: 20, + totalRelative: 0.5, + selfRelative: 0.1, + }, + { + funcName: 'highSelf', + funcIndex: 0, + total: 50, + self: 40, + totalRelative: 0.25, + selfRelative: 0.2, + }, + { + funcName: 'mid', + funcIndex: 0, + total: 75, + self: 30, + totalRelative: 0.375, + selfRelative: 0.15, + }, + ]; + + const result = createTopFunctionLists(functions, 10); + + expect(result.byTotal.title).toBe('Top Functions (by total time)'); + expect(result.bySelf.title).toBe('Top Functions (by self time)'); + + // Check byTotal is sorted by total + expect(result.byTotal.lines[0]).toContain('highTotal'); + expect(result.byTotal.lines[1]).toContain('mid'); + expect(result.byTotal.lines[2]).toContain('highSelf'); + + // Check bySelf is sorted by self + expect(result.bySelf.lines[0]).toContain('highSelf'); + expect(result.bySelf.lines[1]).toContain('mid'); + expect(result.bySelf.lines[2]).toContain('highTotal'); + }); + + it('respects the limit and shows stats for omitted functions', function () { + const functions: FunctionData[] = [ + { + funcName: 'func1', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.4, + selfRelative: 0.2, + }, + { + funcName: 'func2', + funcIndex: 0, + total: 90, + self: 40, + totalRelative: 0.36, + selfRelative: 0.16, + }, + { + funcName: 'func3', + funcIndex: 0, + total: 80, + self: 30, + totalRelative: 0.32, + selfRelative: 0.12, + }, + ]; + + const result = createTopFunctionLists(functions, 2); + + // Each list should have 2 functions + blank + stats = 4 lines + expect(result.byTotal.lines.length).toBe(4); + expect(result.bySelf.lines.length).toBe(4); + + expect(result.byTotal.stats?.omittedCount).toBe(1); + expect(result.bySelf.stats?.omittedCount).toBe(1); + }); + }); + + describe('truncateFunctionName', function () { + it('returns names unchanged when they fit within the limit', function () { + expect(truncateFunctionName('RtlUserThreadStart', 120)).toBe( + 'RtlUserThreadStart' + ); + expect(truncateFunctionName('foo::bar::baz()', 120)).toBe( + 'foo::bar::baz()' + ); + expect( + truncateFunctionName('std::vector::push_back(int const&)', 120) + ).toBe('std::vector::push_back(int const&)'); + }); + + it('truncates simple C++ namespaced functions', function () { + const name = + 'some::very::long::namespace::hierarchy::with::many::levels::FunctionName()'; + const result = truncateFunctionName(name, 50); + + // Should preserve the function name at the end + expect(result).toContain('FunctionName()'); + // Should show some context at the beginning + expect(result).toContain('some::'); + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('truncates complex template parameters intelligently', function () { + const name = + 'std::_Hash,std::equal_to>,std::allocator>,0>>::~_Hash()'; + const result = truncateFunctionName(name, 120); + + // Should preserve namespace prefix and function name + expect(result).toContain('std::_Hash<'); + expect(result).toContain('~_Hash()'); + // Should have collapsed some template parameters + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('truncates function parameters while preserving function name', function () { + const name = + 'mozilla::wr::RenderThread::UpdateAndRender(mozilla::wr::WrWindowId, mozilla::layers::BaseTransactionId)'; + const result = truncateFunctionName(name, 120); + + // Function name should always be preserved + expect(result).toContain('UpdateAndRender('); + expect(result).toContain(')'); + // Should preserve context + expect(result).toContain('mozilla::wr::RenderThread::'); + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('handles library prefixes correctly', function () { + const name = + 'nvoglv64.dll!mozilla::wr::RenderThread::UpdateAndRender(mozilla::wr::WrWindowId)'; + const result = truncateFunctionName(name, 120); + + // Library prefix should be preserved + expect(result).toStartWith('nvoglv64.dll!'); + // Function should still be visible + expect(result).toContain('UpdateAndRender'); + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('handles very long library prefixes gracefully', function () { + const name = + 'a-very-long-library-name-that-is-too-long.dll!FunctionName()'; + const result = truncateFunctionName(name, 30); + + // Should fall back to simple truncation + expect(result.length).toBeLessThanOrEqual(30); + expect(result).toContain('...'); + }); + + it('truncates nested templates by collapsing inner content', function () { + const name = + 'mozilla::interceptor::FuncHook>::operator()'; + const result = truncateFunctionName(name, 120); + + // Should show outer template structure + expect(result).toContain('FuncHook<'); + expect(result).toContain('operator()'); + // Inner templates should be collapsed + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('handles functions with no namespaces', function () { + const name = 'malloc'; + expect(truncateFunctionName(name, 120)).toBe('malloc'); + + const name2 = 'RtlUserThreadStart'; + expect(truncateFunctionName(name2, 120)).toBe('RtlUserThreadStart'); + }); + + it('handles empty parameters', function () { + expect(truncateFunctionName('foo::bar()', 120)).toBe('foo::bar()'); + expect(truncateFunctionName('SomeClass::Method()', 120)).toBe( + 'SomeClass::Method()' + ); + }); + + it('preserves C++ operator names with unmatched angle/paren brackets', function () { + expect(truncateFunctionName('foo::operator>>(int)', 120)).toBe( + 'foo::operator>>(int)' + ); + expect(truncateFunctionName('foo::operator<<(int)', 120)).toBe( + 'foo::operator<<(int)' + ); + expect(truncateFunctionName('foo::operator->()', 120)).toBe( + 'foo::operator->()' + ); + }); + + it('breaks at namespace boundaries when truncating prefix', function () { + const name = + 'namespace1::namespace2::namespace3::namespace4::namespace5::FunctionName()'; + const result = truncateFunctionName(name, 50); + + // Should break at :: boundaries, not mid-word + expect(result).not.toMatch(/[a-z]::[A-Z]/); // No broken words + expect(result).toContain('FunctionName()'); + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('preserves closing parenthesis for functions with parameters', function () { + const name = 'SomeClass::Method(int, std::string, std::vector)'; + const result = truncateFunctionName(name, 40); + + // Should always have matching parentheses + expect(result).toContain('Method('); + expect(result).toContain(')'); + expect(result.length).toBeLessThanOrEqual(40); + }); + + it('handles deeply nested templates', function () { + const name = + 'std::vector>>>'; + const result = truncateFunctionName(name, 50); + + // Should show outer structure + expect(result).toContain('std::vector<'); + expect(result).toContain('>'); + // Should have collapsed inner content + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('allocates more space to suffix (function name) when possible', function () { + const name = + 'short::VeryLongFunctionNameThatShouldBePreservedBecauseItIsImportant(parameter1, parameter2, parameter3)'; + const result = truncateFunctionName(name, 100); + + // Function name should be prioritized over prefix + expect(result).toContain('VeryLongFunctionName'); + expect(result.length).toBeLessThanOrEqual(100); + }); + + it('handles mixed templates and parameters', function () { + const name = + 'std::map::insert(std::pair const&)'; + const result = truncateFunctionName(name, 60); + + expect(result).toContain('insert('); + expect(result).toContain(')'); + expect(result.length).toBeLessThanOrEqual(60); + }); + + it('returns consistent results for the same input', function () { + const name = + 'mozilla::wr::RenderThread::UpdateAndRender(mozilla::wr::WrWindowId)'; + const result1 = truncateFunctionName(name, 100); + const result2 = truncateFunctionName(name, 100); + + expect(result1).toBe(result2); + }); + + it('handles edge case of very small maxLength', function () { + const name = 'SomeClass::SomeMethod()'; + const result = truncateFunctionName(name, 15); + + // Should still produce something reasonable and prioritize the function name + expect(result.length).toBeLessThanOrEqual(15); + expect(result.length).toBeGreaterThan(0); + // When space is very limited, it may drop the namespace to show the function name + expect(result).toContain('SomeMethod'); + }); + + it('handles names with only templates and no function name', function () { + const name = 'std::vector'; + const result = truncateFunctionName(name, 50); + + expect(result).toContain('std::vector<'); + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('truncates while preserving critical structure markers', function () { + const name = 'foo::bar::qux(param1, param2, param3, param4)'; + const result = truncateFunctionName(name, 35); + + // Should maintain bracket pairing + const openAngles = (result.match(//g) || []).length; + const openParens = (result.match(/\(/g) || []).length; + const closeParens = (result.match(/\)/g) || []).length; + + // All opened brackets should be closed + expect(openAngles).toBe(closeAngles); + expect(openParens).toBe(closeParens); + expect(result.length).toBeLessThanOrEqual(35); + }); + }); +}); diff --git a/src/test/unit/profile-query/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts new file mode 100644 index 0000000000..2f416c6352 --- /dev/null +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -0,0 +1,1082 @@ +/* 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 { + computeDurationStats, + computeRateStats, + collectMarkerInfo, + collectMarkerStack, + collectThreadMarkers, + collectThreadNetwork, +} from 'firefox-profiler/profile-query/formatters/marker-info'; +import { MarkerMap } from 'firefox-profiler/profile-query/marker-map'; +import { ThreadMap } from 'firefox-profiler/profile-query/thread-map'; +import { getCategories } from 'firefox-profiler/selectors/profile'; +import { + getProfileWithMarkers, + getProfileFromTextSamples, + getNetworkMarkers, +} from '../../fixtures/profiles/processed-profile'; +import type { NetworkMarkersOptions } from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { INTERVAL } from 'firefox-profiler/app-logic/constants'; + +import type { Marker } from 'firefox-profiler/types'; + +function setupWithMarkers( + markers: Parameters[0] +) { + const profile = getProfileWithMarkers(markers); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + threadMap.handleForThreadIndex(0); + + function registerMarker(markerIndex: number): string { + return markerMap.handleForMarker(new Set([0]), markerIndex); + } + + return { store, threadMap, markerMap, registerMarker }; +} + +describe('marker-info utility functions', function () { + describe('computeDurationStats', function () { + function makeMarker(start: number, end: number | null): Marker { + return { + start, + end, + name: 'TestMarker', + category: 0, + data: null, + threadId: null, + }; + } + + it('returns undefined for empty marker list', function () { + expect(computeDurationStats([])).toBe(undefined); + }); + + it('returns undefined for instant markers only', function () { + const markers = [ + makeMarker(0, null), + makeMarker(1, null), + makeMarker(2, null), + ]; + expect(computeDurationStats(markers)).toBe(undefined); + }); + + it('computes stats for interval markers', function () { + const markers = [ + makeMarker(0, 1), // 1ms + makeMarker(1, 3), // 2ms + makeMarker(3, 6), // 3ms + makeMarker(6, 10), // 4ms + makeMarker(10, 15), // 5ms + ]; + + const stats = computeDurationStats(markers); + expect(stats).toBeDefined(); + expect(stats!.min).toBe(1); + expect(stats!.max).toBe(5); + expect(stats!.avg).toBe(3); + expect(stats!.median).toBe(3); + // For 5 items: p95 = floor(5 * 0.95) = floor(4.75) = 4th index (0-based) = 5 + expect(stats!.p95).toBe(5); + // For 5 items: p99 = floor(5 * 0.99) = floor(4.95) = 4th index (0-based) = 5 + expect(stats!.p99).toBe(5); + }); + + it('handles mixed instant and interval markers', function () { + const markers = [ + makeMarker(0, null), // instant + makeMarker(1, 2), // 1ms + makeMarker(2, null), // instant + makeMarker(3, 5), // 2ms + ]; + + const stats = computeDurationStats(markers); + expect(stats).toBeDefined(); + expect(stats!.min).toBe(1); + expect(stats!.max).toBe(2); + expect(stats!.avg).toBe(1.5); + }); + + it('computes correct percentiles for larger datasets', function () { + // Create 100 markers with durations 1-100ms + const markers = Array.from({ length: 100 }, (_, i) => + makeMarker(i * 10, i * 10 + i + 1) + ); + + const stats = computeDurationStats(markers); + expect(stats).toBeDefined(); + expect(stats!.min).toBe(1); + expect(stats!.max).toBe(100); + // Median: floor(100/2) = 50th index (0-based) = value 51 + expect(stats!.median).toBe(51); + // p95 = floor(100 * 0.95) = 95th index (0-based) = value 96 + expect(stats!.p95).toBe(96); + // p99 = floor(100 * 0.99) = 99th index (0-based) = value 100 + expect(stats!.p99).toBe(100); + }); + }); + + describe('computeRateStats', function () { + function makeMarker(start: number, end: number | null): Marker { + return { + start, + end, + name: 'TestMarker', + category: 0, + data: null, + threadId: null, + }; + } + + it('handles empty marker list', function () { + const stats = computeRateStats([]); + expect(stats.markersPerSecond).toBe(0); + expect(stats.minGap).toBe(0); + expect(stats.avgGap).toBe(0); + expect(stats.maxGap).toBe(0); + }); + + it('handles single marker', function () { + const stats = computeRateStats([makeMarker(5, 10)]); + expect(stats.markersPerSecond).toBe(0); + expect(stats.minGap).toBe(0); + expect(stats.avgGap).toBe(0); + expect(stats.maxGap).toBe(0); + }); + + it('computes rate for evenly spaced markers', function () { + // Markers at 0, 100, 200, 300, 400 (100ms gaps) + const markers = [ + makeMarker(0, null), + makeMarker(100, null), + makeMarker(200, null), + makeMarker(300, null), + makeMarker(400, null), + ]; + + const stats = computeRateStats(markers); + // Time range: 400 - 0 = 400ms = 0.4s + // 5 markers in 0.4s = 12.5 markers/sec + expect(stats.markersPerSecond).toBeCloseTo(12.5, 5); + expect(stats.minGap).toBe(100); + expect(stats.avgGap).toBe(100); + expect(stats.maxGap).toBe(100); + }); + + it('computes rate for unevenly spaced markers', function () { + const markers = [ + makeMarker(0, null), + makeMarker(10, null), // 10ms gap + makeMarker(15, null), // 5ms gap + makeMarker(100, null), // 85ms gap + ]; + + const stats = computeRateStats(markers); + // Time range: 100 - 0 = 100ms = 0.1s + // 4 markers in 0.1s = 40 markers/sec + expect(stats.markersPerSecond).toBeCloseTo(40, 5); + expect(stats.minGap).toBe(5); + expect(stats.avgGap).toBeCloseTo((10 + 5 + 85) / 3, 5); + expect(stats.maxGap).toBe(85); + }); + + it('sorts markers by start time before computing gaps', function () { + // Provide markers out of order + const markers = [ + makeMarker(100, null), + makeMarker(0, null), + makeMarker(50, null), + ]; + + const stats = computeRateStats(markers); + // After sorting: 0, 50, 100 + // Gaps: 50, 50 + expect(stats.minGap).toBe(50); + expect(stats.avgGap).toBe(50); + expect(stats.maxGap).toBe(50); + }); + + it('handles markers at same timestamp', function () { + const markers = [ + makeMarker(100, null), + makeMarker(100, null), // Same timestamp + makeMarker(200, null), + ]; + + const stats = computeRateStats(markers); + // Gaps: 0, 100 + expect(stats.minGap).toBe(0); + expect(stats.avgGap).toBe(50); + expect(stats.maxGap).toBe(100); + }); + }); + + describe('collectThreadMarkers', function () { + it('creates nested custom groups for multi-key marker grouping', function () { + const profile = getProfileWithMarkers([ + [ + 'DOMEvent', + 0, + 2, + { eventType: 'click', latency: 1 } as Record, + ], + [ + 'DOMEvent', + 3, + 6, + { eventType: 'keydown', latency: 2 } as Record, + ], + [ + 'DOMEvent', + 7, + 9, + { eventType: 'click', latency: 3 } as Record, + ], + ]); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + groupBy: 'type,field:eventType', + } + ); + + expect(result.customGroups).toBeDefined(); + expect(result.customGroups).toHaveLength(1); + expect(result.customGroups?.[0].groupName).toBe('DOMEvent'); + expect(result.customGroups?.[0].count).toBe(3); + expect(result.customGroups?.[0].subGroups).toEqual([ + expect.objectContaining({ + groupName: 'click', + count: 2, + }), + expect.objectContaining({ + groupName: 'keydown', + count: 1, + }), + ]); + }); + + it('reports the raw categoryIndex in byCategory (not recovered by name)', function () { + // Guard against regressions that look up the index via findIndex on + // the category name, which would both be O(n) and collide if two + // categories shared a name. + const profile = getProfileWithMarkers([ + [ + 'DOMEvent', + 0, + 2, + { eventType: 'click', latency: 1 } as Record, + ], + ]); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers(store, threadMap, markerMap); + expect(result.byCategory).toHaveLength(1); + const entry = result.byCategory[0]; + expect(typeof entry.categoryIndex).toBe('number'); + expect(entry.categoryIndex).toBeGreaterThanOrEqual(0); + // categoryName must resolve from the same index it reports. + const categories = getCategories(store.getState()); + expect(categories[entry.categoryIndex]?.name).toBe(entry.categoryName); + }); + + it('resolves unique-string field values via the string table when grouping', function () { + // The Log marker schema declares `level` as format: 'unique-string', + // meaning the raw payload value is a string-table index. Grouping must + // resolve it back to the interned string (e.g. "Error") rather than + // returning the numeric index. + const profile = getProfileWithMarkers([ + [ + 'Log', + 0, + null, + { type: 'Log', level: 'Error', message: 'a' } as Record< + string, + unknown + >, + ], + [ + 'Log', + 1, + null, + { type: 'Log', level: 'Error', message: 'b' } as Record< + string, + unknown + >, + ], + [ + 'Log', + 2, + null, + { type: 'Log', level: 'Warning', message: 'c' } as Record< + string, + unknown + >, + ], + ]); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { groupBy: 'field:level' } + ); + + expect(result.customGroups).toEqual([ + expect.objectContaining({ groupName: 'Error', count: 2 }), + expect.objectContaining({ groupName: 'Warning', count: 1 }), + ]); + }); + + it('auto-groups by a schema-declared enum-like field (schema-driven)', function () { + // With --auto-group and enough markers of the same name, pick a field + // from the schema (not ad-hoc Object.keys heuristics) whose format is + // enum-like (string / unique-string / integer / pid / tid) to sub-group + // on. DOMEvent's `eventType` is declared `format: 'string'`. + const eventTypes = [ + 'click', + 'mousemove', + 'keydown', + 'focus', + 'blur', + 'input', + ]; + const profile = getProfileWithMarkers( + eventTypes.map( + (eventType, i) => + [ + 'DOMEvent', + i, + i + 1, + { type: 'DOMEvent', eventType, latency: i } as Record< + string, + unknown + >, + ] as [string, number, number, Record] + ) + ); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { autoGroup: true } + ); + + const domEventStats = result.byType.find( + (s) => s.markerName === 'DOMEvent' + ); + expect(domEventStats).toBeDefined(); + expect(domEventStats!.subGroupKey).toBe('eventType'); + // 6 distinct values, so every eventType should show up as its own group. + const groupNames = domEventStats!.subGroups!.map((g) => g.groupName); + expect(new Set(groupNames)).toEqual(new Set(eventTypes)); + }); + + it('auto-groups on unique-string fields with resolved string values', function () { + // Log.level is `format: 'unique-string'`; auto-group must resolve the + // string-table index before scoring cardinality, and the resulting sub- + // group names must be the interned strings, not integers. + const levels = ['Error', 'Error', 'Warning', 'Warning', 'Info', 'Debug']; + const profile = getProfileWithMarkers( + levels.map( + (level, i) => + [ + 'Log', + i, + null, + { type: 'Log', level, message: `m${i}` } as Record< + string, + unknown + >, + ] as [string, number, null, Record] + ) + ); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { autoGroup: true } + ); + + const logStats = result.byType.find((s) => s.markerName === 'Log'); + expect(logStats).toBeDefined(); + expect(logStats!.subGroupKey).toBe('level'); + const groupNames = logStats!.subGroups!.map((g) => g.groupName); + // Must be interned strings, not integer indices. + expect(new Set(groupNames)).toEqual( + new Set(['Error', 'Warning', 'Info', 'Debug']) + ); + }); + }); +}); + +describe('collectMarkerInfo', function () { + it('returns structured data with correct fields for an interval marker', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'DOMEvent', + 10, + 30, + { type: 'DOMEvent', eventType: 'click', latency: 5 }, + ], + ]); + const handle = registerMarker(0); + + const result = collectMarkerInfo(store, markerMap, threadMap, handle); + + expect(result.type).toBe('marker-info'); + expect(result.name).toBe('DOMEvent'); + expect(result.markerType).toBe('DOMEvent'); + expect(result.start).toBe(10); + expect(result.end).toBe(30); + expect(result.duration).toBe(20); + expect(result.fields).toBeDefined(); + const eventTypeField = result.fields!.find((f) => f.key === 'eventType'); + expect(eventTypeField).toBeDefined(); + expect(eventTypeField!.label).toBe('Event Type'); + expect(eventTypeField!.value).toBe('click'); + }); + + it('returns undefined duration for instant markers', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + ['DOMEvent', 5, null, { type: 'DOMEvent', eventType: 'scroll' }], + ]); + const handle = registerMarker(0); + + const result = collectMarkerInfo(store, markerMap, threadMap, handle); + + expect(result.end).toBeNull(); + expect(result.duration).toBeUndefined(); + }); + + it('excludes hidden fields from result', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'MarkerWithHiddenField', + 0, + 5, + { type: 'MarkerWithHiddenField', hiddenString: 'secret' }, + ], + ]); + const handle = registerMarker(0); + + const result = collectMarkerInfo(store, markerMap, threadMap, handle); + + const hiddenField = result.fields?.find((f) => f.key === 'hiddenString'); + expect(hiddenField).toBeUndefined(); + }); +}); + +describe('collectThreadMarkers topN option', function () { + it('defaults to 5 top markers per group', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['Phase', 0, 1, { type: 'tracing', interval: 'start' }], + ['Phase', 1, 2, { type: 'tracing', interval: 'start' }], + ['Phase', 2, 3, { type: 'tracing', interval: 'start' }], + ['Phase', 3, 4, { type: 'tracing', interval: 'start' }], + ['Phase', 4, 5, { type: 'tracing', interval: 'start' }], + ['Phase', 5, 6, { type: 'tracing', interval: 'start' }], + ['Phase', 6, 7, { type: 'tracing', interval: 'start' }], + ]); + + const result = collectThreadMarkers(store, threadMap, markerMap); + + const phaseStats = result.byType.find((s) => s.markerName === 'Phase'); + expect(phaseStats).toBeDefined(); + expect(phaseStats!.count).toBe(7); + expect(phaseStats!.topMarkers).toHaveLength(5); + }); + + it('respects topN option', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['Phase', 0, 1, { type: 'tracing', interval: 'start' }], + ['Phase', 1, 2, { type: 'tracing', interval: 'start' }], + ['Phase', 2, 3, { type: 'tracing', interval: 'start' }], + ['Phase', 3, 4, { type: 'tracing', interval: 'start' }], + ['Phase', 4, 5, { type: 'tracing', interval: 'start' }], + ['Phase', 5, 6, { type: 'tracing', interval: 'start' }], + ['Phase', 6, 7, { type: 'tracing', interval: 'start' }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + topN: 10, + } + ); + + const phaseStats = result.byType.find((s) => s.markerName === 'Phase'); + expect(phaseStats).toBeDefined(); + expect(phaseStats!.count).toBe(7); + expect(phaseStats!.topMarkers).toHaveLength(7); + }); +}); + +describe('collectThreadMarkers list option', function () { + it('returns flatMarkers when list: true', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ['DOMEvent', 20, null, { type: 'DOMEvent', eventType: 'keydown' }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + expect(result.flatMarkers).toBeDefined(); + expect(result.flatMarkers).toHaveLength(2); + }); + + it('flatMarkers is undefined without list option', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ]); + + const result = collectThreadMarkers(store, threadMap, markerMap); + + expect(result.flatMarkers).toBeUndefined(); + }); + + it('each flat marker has correct fields', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 5, 15, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + const m = result.flatMarkers![0]; + expect(m.handle).toMatch(/^m-/); + expect(m.name).toBe('DOMEvent'); + expect(m.start).toBe(5); + expect(m.duration).toBe(10); + expect(m.hasStack).toBe(false); + expect(m.category).toBeDefined(); + }); + + it('instant markers have undefined duration', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 5, null, { type: 'DOMEvent', eventType: 'scroll' }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + expect(result.flatMarkers![0].duration).toBeUndefined(); + }); + + it('uses schema-derived label separate from name', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + const m = result.flatMarkers![0]; + expect(m.name).toBe('DOMEvent'); + expect(m.label).toContain('click'); + expect(m.label).not.toBe(m.name); + }); + + it('search filter applies to flat list', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 5, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + [ + 'UserTiming', + 10, + 15, + { type: 'UserTiming', name: 'myMark', entryType: 'measure' }, + ], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + searchString: 'DOMEvent', + } + ); + + expect(result.flatMarkers).toHaveLength(1); + expect(result.flatMarkers![0].name).toBe('DOMEvent'); + }); +}); + +describe('collectMarkerStack', function () { + it('returns null stack for a marker without a cause', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + ['DOMEvent', 0, 5, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + ]); + const handle = registerMarker(0); + + const result = collectMarkerStack(store, markerMap, threadMap, handle); + + expect(result.type).toBe('marker-stack'); + expect(result.markerName).toBe('DOMEvent'); + expect(result.stack).toBeNull(); + }); + + it('returns stack frames for a marker with a cause stack', function () { + const { profile } = getProfileFromTextSamples(` + rootFunc + leafFunc + `); + const thread = profile.threads[0]; + const stackIndex = thread.samples.stack[0]; + + if (stackIndex === null || stackIndex === undefined) { + throw new Error('Expected a non-null stack index from text samples'); + } + + const stringTable = StringTable.withBackingArray( + profile.shared.stringArray + ); + const markerNameIdx = stringTable.indexForString('TestMarker'); + thread.markers.name.push(markerNameIdx); + thread.markers.startTime.push(1); + thread.markers.endTime.push(5); + thread.markers.phase.push(INTERVAL); + thread.markers.category.push(0); + thread.markers.data.push({ + type: 'Text', + name: 'TestMarker', + cause: { stack: stackIndex }, + }); + thread.markers.length++; + + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + threadMap.handleForThreadIndex(0); + const handle = markerMap.handleForMarker(new Set([0]), 0); + + const result = collectMarkerStack(store, markerMap, threadMap, handle); + + expect(result.stack).not.toBeNull(); + expect(result.stack!.frames.length).toBeGreaterThan(0); + // Leaf frame first + expect(result.stack!.frames[0].name).toBe('leafFunc'); + }); +}); + +describe('collectThreadNetwork', function () { + function setupWithNetworkMarkers( + options: Array> + ) { + const markers = options.flatMap((o) => getNetworkMarkers(o)); + const profile = getProfileWithMarkers(markers); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + return { store, threadMap }; + } + + it('counts only STATUS_STOP markers, ignoring STATUS_START', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/a', + startTime: 0, + fetchStart: 1, + endTime: 5, + }, + { + id: 2, + uri: 'https://example.com/b', + startTime: 6, + fetchStart: 7, + endTime: 10, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.totalRequestCount).toBe(2); + expect(result.requests).toHaveLength(2); + }); + + it('filters by searchString case-insensitively', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://api.example.com/data', + startTime: 0, + fetchStart: 1, + endTime: 5, + }, + { + id: 2, + uri: 'https://static.example.com/img.png', + startTime: 6, + fetchStart: 7, + endTime: 10, + }, + { + id: 3, + uri: 'https://api.example.com/users', + startTime: 11, + fetchStart: 12, + endTime: 15, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + searchString: 'API', + }); + + expect(result.totalRequestCount).toBe(3); + expect(result.filteredRequestCount).toBe(2); + expect(result.requests.every((r) => r.url.includes('api'))).toBe(true); + }); + + it('filters by minDuration', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/fast', + startTime: 0, + fetchStart: 0, + endTime: 1, + }, + { + id: 2, + uri: 'https://example.com/slow', + startTime: 2, + fetchStart: 2, + endTime: 10, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + minDuration: 5, + }); + + expect(result.filteredRequestCount).toBe(1); + expect(result.requests[0].url).toContain('slow'); + }); + + it('filters by maxDuration', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/fast', + startTime: 0, + fetchStart: 0, + endTime: 1, + }, + { + id: 2, + uri: 'https://example.com/slow', + startTime: 2, + fetchStart: 2, + endTime: 10, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + maxDuration: 5, + }); + + expect(result.filteredRequestCount).toBe(1); + expect(result.requests[0].url).toContain('fast'); + }); + + it('limit restricts the requests list but summary stats cover all filtered results', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/a', + startTime: 0, + fetchStart: 0, + endTime: 5, + }, + { + id: 2, + uri: 'https://example.com/b', + startTime: 6, + fetchStart: 6, + endTime: 11, + }, + { + id: 3, + uri: 'https://example.com/c', + startTime: 12, + fetchStart: 12, + endTime: 17, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + limit: 2, + }); + + expect(result.filteredRequestCount).toBe(3); + expect(result.requests).toHaveLength(2); + // All 3 counted in summary, not just the 2 returned + expect(result.summary.cacheUnknown).toBe(3); + }); + + it('limit 0 means no limit — all requests are returned', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { id: 1, startTime: 0, fetchStart: 0, endTime: 5 }, + { id: 2, startTime: 6, fetchStart: 6, endTime: 11 }, + { id: 3, startTime: 12, fetchStart: 12, endTime: 17 }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + limit: 0, + }); + + expect(result.requests).toHaveLength(3); + }); + + it('accumulates cache stats correctly', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 1, + payload: { cache: 'Hit' }, + }, + { + id: 2, + startTime: 2, + fetchStart: 2, + endTime: 3, + payload: { cache: 'MemoryHit' }, + }, + { + id: 3, + startTime: 4, + fetchStart: 4, + endTime: 5, + payload: { cache: 'Prefetched' }, + }, + { + id: 4, + startTime: 6, + fetchStart: 6, + endTime: 7, + payload: { cache: 'Miss' }, + }, + { + id: 5, + startTime: 8, + fetchStart: 8, + endTime: 9, + payload: { cache: 'DiskStorage' }, + }, + { id: 6, startTime: 10, fetchStart: 10, endTime: 11 }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.cacheHit).toBe(3); + expect(result.summary.cacheMiss).toBe(2); + expect(result.summary.cacheUnknown).toBe(1); + }); + + it('extracts phase timings per request', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 100, + payload: { + domainLookupStart: 0, + domainLookupEnd: 5, + connectStart: 5, + tcpConnectEnd: 15, + requestStart: 20, + responseStart: 50, + responseEnd: 80, + }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + const phases = result.requests[0].phases; + + expect(phases.dns).toBe(5); + expect(phases.tcp).toBe(10); + expect(phases.ttfb).toBe(30); + expect(phases.download).toBe(30); + expect(phases.mainThread).toBe(20); + expect(phases.tls).toBeUndefined(); + }); + + it('extracts TLS phase only when secureConnectionStart > 0', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 50, + payload: { + connectStart: 5, + tcpConnectEnd: 10, + secureConnectionStart: 10, + connectEnd: 18, + }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.requests[0].phases.tls).toBe(8); + }); + + it('skips TLS phase when secureConnectionStart is 0', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 50, + payload: { + secureConnectionStart: 0, + connectEnd: 10, + }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.requests[0].phases.tls).toBeUndefined(); + }); + + it('accumulates phase totals in summary across all filtered requests', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 20, + payload: { requestStart: 0, responseStart: 8 }, + }, + { + id: 2, + startTime: 21, + fetchStart: 21, + endTime: 41, + payload: { requestStart: 21, responseStart: 33 }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.phaseTotals.ttfb).toBe(20); + }); + + it('sets filters field only when at least one filter is applied', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { id: 1, startTime: 0, fetchStart: 0, endTime: 5 }, + ]); + + const noFilters = collectThreadNetwork(store, threadMap); + const withFilter = collectThreadNetwork(store, threadMap, undefined, { + searchString: 'example', + }); + + expect(noFilters.filters).toBeUndefined(); + expect(withFilter.filters).toBeDefined(); + expect(withFilter.filters?.searchString).toBe('example'); + }); + + it('returns zero requests when no markers match filters', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/', + startTime: 0, + fetchStart: 0, + endTime: 5, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + searchString: 'no-match-here', + }); + + expect(result.totalRequestCount).toBe(1); + expect(result.filteredRequestCount).toBe(0); + expect(result.requests).toHaveLength(0); + }); + + it('returns correct duration on each request entry', function () { + // The merged marker sets data.startTime to the START marker's table time + // (0), so total duration = endTime - startTime = 25 - 0 = 25. + const { store, threadMap } = setupWithNetworkMarkers([ + { id: 1, startTime: 0, fetchStart: 5, endTime: 25 }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.requests[0].duration).toBe(25); + }); +}); diff --git a/src/test/unit/profile-query/process-thread-list.test.ts b/src/test/unit/profile-query/process-thread-list.test.ts new file mode 100644 index 0000000000..5070144322 --- /dev/null +++ b/src/test/unit/profile-query/process-thread-list.test.ts @@ -0,0 +1,436 @@ +/* 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 { buildProcessThreadList } from 'firefox-profiler/profile-query/process-thread-list'; + +import type { ThreadInfo } from 'firefox-profiler/profile-query/process-thread-list'; + +describe('buildProcessThreadList', function () { + function createThread( + threadIndex: number, + pid: string, + name: string, + cpuMs: number + ): ThreadInfo { + return { threadIndex, pid, name, tid: threadIndex, cpuMs }; + } + + it('shows top 5 processes by CPU, plus any needed for top 20 threads', function () { + // All 7 threads are in top 20, so all 7 processes should be shown + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p2', 'Thread2', 80), + createThread(2, 'p3', 'Thread3', 60), + createThread(3, 'p4', 'Thread4', 40), + createThread(4, 'p5', 'Thread5', 20), + createThread(5, 'p6', 'Thread6', 10), + createThread(6, 'p7', 'Thread7', 5), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ['p7', 6], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + // All 7 threads are in top 20, so all 7 processes are shown + expect(result.processes.length).toBe(7); + expect(result.processes.map((p) => p.pid)).toEqual([ + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'p6', + 'p7', + ]); + }); + + it('includes processes with threads in top 20, even if not in top 5 processes', function () { + // Process p1 has high CPU from one thread + // Process p2 has low CPU total but has a thread in the top 20 + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p1', 'Thread2', 1), + createThread(2, 'p1', 'Thread3', 1), + createThread(3, 'p2', 'HighCPU', 50), // This thread is in top 20 + createThread(4, 'p2', 'LowCPU', 0.5), + createThread(5, 'p3', 'Thread6', 80), + createThread(6, 'p4', 'Thread7', 70), + createThread(7, 'p5', 'Thread8', 60), + createThread(8, 'p6', 'Thread9', 55), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + // Should include p2 even though it's not in top 5 by total CPU + // because it has a thread (t3) in the top 20 + expect(result.processes.map((p) => p.pid)).toContain('p2'); + }); + + it('summarizes only hidden processes in remainingProcesses', function () { + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'P1-A', 110), + createThread(1, 'p1', 'P1-B', 109), + createThread(2, 'p1', 'P1-C', 108), + createThread(3, 'p1', 'P1-D', 107), + createThread(4, 'p2', 'P2-A', 106), + createThread(5, 'p2', 'P2-B', 105), + createThread(6, 'p2', 'P2-C', 104), + createThread(7, 'p2', 'P2-D', 103), + createThread(8, 'p3', 'P3-A', 102), + createThread(9, 'p3', 'P3-B', 101), + createThread(10, 'p3', 'P3-C', 100), + createThread(11, 'p3', 'P3-D', 99), + createThread(12, 'p4', 'P4-A', 98), + createThread(13, 'p4', 'P4-B', 97), + createThread(14, 'p4', 'P4-C', 96), + createThread(15, 'p4', 'P4-D', 95), + createThread(16, 'p5', 'P5-A', 94), + createThread(17, 'p5', 'P5-B', 93), + createThread(18, 'p5', 'P5-C', 92), + createThread(19, 'p6', 'P6-top-thread', 91), + createThread(20, 'p6', 'P6-low-thread', 1), + createThread(21, 'p7', 'P7-A', 30), + createThread(22, 'p7', 'P7-B', 28), + createThread(23, 'p8', 'P8-A', 29), + createThread(24, 'p8', 'P8-B', 28), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ['p7', 6], + ['p8', 7], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes.map((p) => p.pid)).toEqual([ + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'p6', + ]); + expect(result.remainingProcesses).toEqual({ + count: 2, + combinedCpuMs: 115, + maxCpuMs: 58, + }); + }); + + it('shows up to 5 threads per process when none are in top 20', function () { + // Create 4 high-CPU processes that will be in top 5 + const threads: ThreadInfo[] = []; + threads.push(createThread(0, 'p-high-0', 'High1', 10000)); + threads.push(createThread(1, 'p-high-1', 'High2', 9000)); + threads.push(createThread(2, 'p-high-2', 'High3', 8000)); + threads.push(createThread(3, 'p-high-3', 'High4', 7000)); + + // p1 will be 5th by total CPU (with many threads but none in top 20) + threads.push(createThread(10, 'p1', 'Thread1', 600)); + threads.push(createThread(11, 'p1', 'Thread2', 500)); + threads.push(createThread(12, 'p1', 'Thread3', 400)); + threads.push(createThread(13, 'p1', 'Thread4', 300)); + threads.push(createThread(14, 'p1', 'Thread5', 200)); + threads.push(createThread(15, 'p1', 'Thread6', 100)); + threads.push(createThread(16, 'p1', 'Thread7', 50)); + // p1 total: 2150ms, should be 5th place + + // Add threads that will fill positions 5-20 in top 20, pushing out p1's threads + threads.push(createThread(4, 'p2', 'Med1', 6000)); + threads.push(createThread(5, 'p2', 'Med2', 5000)); + threads.push(createThread(6, 'p3', 'Med3', 4000)); + threads.push(createThread(7, 'p3', 'Med4', 3000)); + threads.push(createThread(8, 'p4', 'Med5', 2000)); + threads.push(createThread(9, 'p4', 'Med6', 1000)); + threads.push(createThread(20, 'p5', 'Med7', 900)); + threads.push(createThread(21, 'p5', 'Med8', 800)); + threads.push(createThread(22, 'p6', 'Med9', 700)); + threads.push(createThread(23, 'p6', 'Med10', 650)); + threads.push(createThread(24, 'p7', 'Med11', 640)); + threads.push(createThread(25, 'p7', 'Med12', 630)); + threads.push(createThread(26, 'p8', 'Med13', 620)); + threads.push(createThread(27, 'p8', 'Med14', 610)); + // Top 20 are now: 10000, 9000, 8000, 7000, 6000, 5000, 4000, 3000, 2000, 1000, 900, 800, 700, 650, 640, 630, 620, 610, 600, 500 + // p1's highest is 600ms (position 19) and 500ms (position 20) + + const processIndexMap = new Map([ + ['p-high-0', 0], + ['p-high-1', 1], + ['p-high-2', 2], + ['p-high-3', 3], + ['p1', 4], + ['p2', 5], + ['p3', 6], + ['p4', 7], + ['p5', 8], + ['p6', 9], + ['p7', 10], + ['p8', 11], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + expect(p1).toBeDefined(); + // t10 and t11 from p1 are in top 20, plus we fill up to 5 total + expect(p1!.threads.length).toBe(5); + // Should show the 2 from top 20 plus the next 3 highest + expect(p1!.threads.map((t) => t.threadIndex)).toEqual([10, 11, 12, 13, 14]); + }); + + it('includes summary for remaining threads', function () { + // Create scenario where only some threads from p1 are in top 20 + const threads: ThreadInfo[] = []; + + // Add 15 high-CPU threads from other processes + for (let i = 0; i < 15; i++) { + threads.push( + createThread(i, `p-high-${i}`, `HighCPU${i}`, 1000 - i * 10) + ); + } + + // Add p1 threads - the first 5 will be in top 20 (850ms is above 910ms cutoff) + threads.push(createThread(15, 'p1', 'Thread1', 950)); // In top 20 + threads.push(createThread(16, 'p1', 'Thread2', 940)); // In top 20 + threads.push(createThread(17, 'p1', 'Thread3', 930)); // In top 20 + threads.push(createThread(18, 'p1', 'Thread4', 920)); // In top 20 + threads.push(createThread(19, 'p1', 'Thread5', 910)); // In top 20 (20th place) + // These are not in top 20 + threads.push(createThread(20, 'p1', 'Thread6', 50)); + threads.push(createThread(21, 'p1', 'Thread7', 40)); + threads.push(createThread(22, 'p1', 'Thread8', 30)); + + const processIndexMap = new Map([['p1', 100]]); + for (let i = 0; i < 15; i++) { + processIndexMap.set(`p-high-${i}`, i); + } + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + expect(p1).toBeDefined(); + + // Should show 5 top-20 threads + expect(p1!.threads.length).toBe(5); + expect(p1!.threads.map((t) => t.threadIndex)).toEqual([15, 16, 17, 18, 19]); + + // Should have remaining threads summary + expect(p1!.remainingThreads).toEqual({ + count: 3, + combinedCpuMs: 120, // 50 + 40 + 30 + maxCpuMs: 50, + }); + }); + + it('shows ALL top-20 threads from a process, even if more than 5', function () { + // This is the critical test case for the bug fix: + // If a process has 7 threads in the top 20, all 7 should be shown, + // not just the first 5. + const threads: ThreadInfo[] = [ + // Process p1 has 7 threads in the top 20 + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p1', 'Thread2', 95), + createThread(2, 'p1', 'Thread3', 90), + createThread(3, 'p1', 'Thread4', 85), + createThread(4, 'p1', 'Thread5', 80), + createThread(5, 'p1', 'Thread6', 75), + createThread(6, 'p1', 'Thread7', 70), + // These threads from p1 are not in top 20 + createThread(7, 'p1', 'Thread8', 5), + createThread(8, 'p1', 'Thread9', 4), + // Other processes to fill out the top 20 + createThread(9, 'p2', 'Thread10', 65), + createThread(10, 'p2', 'Thread11', 60), + createThread(11, 'p3', 'Thread12', 55), + createThread(12, 'p3', 'Thread13', 50), + createThread(13, 'p4', 'Thread14', 45), + createThread(14, 'p4', 'Thread15', 40), + createThread(15, 'p5', 'Thread16', 35), + createThread(16, 'p5', 'Thread17', 30), + createThread(17, 'p6', 'Thread18', 25), + createThread(18, 'p6', 'Thread19', 20), + createThread(19, 'p7', 'Thread20', 15), + createThread(20, 'p7', 'Thread21', 10), + createThread(21, 'p8', 'Thread22', 9), + createThread(22, 'p8', 'Thread23', 8), + createThread(23, 'p9', 'Thread24', 7), + createThread(24, 'p9', 'Thread25', 6), + // More threads below top 20 - these push out t7 and t8 from p1 + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ['p7', 6], + ['p8', 7], + ['p9', 8], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + expect(p1).toBeDefined(); + + // Should show all 7 threads from top 20, not just 5 + expect(p1!.threads.length).toBe(7); + expect(p1!.threads.map((t) => t.threadIndex)).toEqual([ + 0, 1, 2, 3, 4, 5, 6, + ]); + + // Should have remaining threads summary for the 2 threads not in top 20 + expect(p1!.remainingThreads).toEqual({ + count: 2, + combinedCpuMs: 9, // 5 + 4 + maxCpuMs: 5, + }); + }); + + it('sorts threads by CPU within each process', function () { + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Low', 10), + createThread(1, 'p1', 'High', 100), + createThread(2, 'p1', 'Medium', 50), + ]; + + const processIndexMap = new Map([['p1', 0]]); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes[0].threads.map((t) => t.name)).toEqual([ + 'High', + 'Medium', + 'Low', + ]); + }); + + it('handles empty thread list', function () { + const threads: ThreadInfo[] = []; + const processIndexMap = new Map(); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes).toEqual([]); + expect(result.remainingProcesses).toBeUndefined(); + }); + + it('handles single thread', function () { + const threads: ThreadInfo[] = [createThread(0, 'p1', 'OnlyThread', 100)]; + + const processIndexMap = new Map([['p1', 0]]); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes.length).toBe(1); + expect(result.processes[0].threads.length).toBe(1); + expect(result.processes[0].remainingThreads).toBeUndefined(); + expect(result.remainingProcesses).toBeUndefined(); + }); + + it('correctly aggregates CPU time per process', function () { + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p1', 'Thread2', 50), + createThread(2, 'p1', 'Thread3', 25), + createThread(3, 'p2', 'Thread4', 200), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + const p2 = result.processes.find((p) => p.pid === 'p2'); + + expect(p1!.cpuMs).toBe(175); // 100 + 50 + 25 + expect(p2!.cpuMs).toBe(200); + }); + + it('includes summary for remaining processes', function () { + // Create a scenario with many processes, where only some are shown + // We need the top 5 processes to be shown, but processes 6-10 should NOT have + // any threads in the top 20 overall + const threads: ThreadInfo[] = []; + + // Add 20 high-CPU threads from top 5 processes + // Each of these processes gets 4 threads in the top 20 + for (let procNum = 0; procNum < 5; procNum++) { + for (let threadNum = 0; threadNum < 4; threadNum++) { + const threadIndex = procNum * 4 + threadNum; + const cpuMs = 1000 - threadIndex * 10; // 1000, 990, 980, ... down to 810 + threads.push( + createThread( + threadIndex, + `p${procNum}`, + `Thread${threadIndex}`, + cpuMs + ) + ); + } + } + + // Add 5 more processes with low CPU (not in top 20) + // These should not be shown + for (let procNum = 5; procNum < 10; procNum++) { + const threadIndex = 20 + procNum - 5; + const cpuMs = 50 - (procNum - 5) * 10; // 50, 40, 30, 20, 10 + threads.push( + createThread(threadIndex, `p${procNum}`, `Thread${threadIndex}`, cpuMs) + ); + } + + const processIndexMap = new Map(); + for (let i = 0; i < 10; i++) { + processIndexMap.set(`p${i}`, i); + } + + const result = buildProcessThreadList(threads, processIndexMap); + + // Should show only top 5 processes (those with threads in top 20) + expect(result.processes.length).toBe(5); + expect(result.processes.map((p) => p.pid)).toEqual([ + 'p0', + 'p1', + 'p2', + 'p3', + 'p4', + ]); + + // Should have remaining processes summary for the last 5 processes + expect(result.remainingProcesses).toEqual({ + count: 5, + combinedCpuMs: 150, // 50 + 40 + 30 + 20 + 10 + maxCpuMs: 50, + }); + }); +}); diff --git a/src/test/unit/profile-query/profile-querier-annotate.test.ts b/src/test/unit/profile-query/profile-querier-annotate.test.ts new file mode 100644 index 0000000000..4a8a43ac84 --- /dev/null +++ b/src/test/unit/profile-query/profile-querier-annotate.test.ts @@ -0,0 +1,293 @@ +/* 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/. */ + +/** + * Unit tests for ProfileQuerier.functionAnnotate. + * + * fetchSource and fetchAssembly are mocked because they make network requests. + * + * NOTE on sample layout: _parseTextSamples uses the FIRST row to determine + * column widths. Functions with long names (e.g. A[file:f.c][line:10]) must + * be in row 1 so their column is wide enough. Use single-row samples when the + * function under test should be both root and leaf. + */ + +import { ProfileQuerier } from 'firefox-profiler/profile-query'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { storeWithProfile } from '../../fixtures/stores'; + +jest.mock('firefox-profiler/utils/fetch-source'); +jest.mock('firefox-profiler/utils/fetch-assembly'); + +function funcHandle( + funcNamesDictPerThread: Array<{ [name: string]: number }>, + name: string +): string { + return `f-${funcNamesDictPerThread[0][name]}`; +} + +function makeQuerier( + profile: ReturnType['profile'] +) { + const store = storeWithProfile(profile); + return new ProfileQuerier(store, getProfileRootRange(store.getState())); +} + +describe('ProfileQuerier.functionAnnotate', function () { + let fetchSource: jest.Mock; + + beforeEach(function () { + fetchSource = jest.requireMock( + 'firefox-profiler/utils/fetch-source' + ).fetchSource; + fetchSource.mockResolvedValue({ type: 'ERROR', errors: [] }); + }); + + describe('aggregate self/total sample counts', function () { + it('counts self when function is the only frame (root = leaf)', async function () { + // Single-row samples: A is simultaneously root and leaf in all 3 samples. + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] A[file:f.c][line:10] A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + expect(result.totalSelfSamples).toBe(3); + expect(result.totalTotalSamples).toBe(3); + }); + + it('distinguishes self from total when A is not always the leaf', async function () { + // A must be in row 1 so column widths are determined correctly. + // Sample 1: A@10 (root) → B (leaf) → A.self=0, A.total=1 + // Sample 2: B (root) → A@10 (leaf) → A.self=1, A.total=1 + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] B + B A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + expect(result.totalSelfSamples).toBe(1); + expect(result.totalTotalSamples).toBe(2); + }); + }); + + describe('src mode - line timings', function () { + it('attributes self and total hits to the correct lines', async function () { + // Single-row samples: A is root and leaf, hits different lines per sample. + // Sample 1: A@line10 → self@10 += 1, total@10 += 1 + // Sample 2: A@line12 → self@12 += 1, total@12 += 1 + // Sample 3: A@line10 → self@10 += 1, total@10 += 1 + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] A[file:f.c][line:12] A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + + const line10 = src!.lines.find((l) => l.lineNumber === 10); + const line12 = src!.lines.find((l) => l.lineNumber === 12); + + expect(line10).toBeDefined(); + expect(line10!.selfSamples).toBe(2); + expect(line10!.totalSamples).toBe(2); + + expect(line12).toBeDefined(); + expect(line12!.selfSamples).toBe(1); + expect(line12!.totalSamples).toBe(1); + }); + + it('separates self (leaf) from total (any stack position) for line hits', async function () { + // Sample 1: A@10 (root) → B (leaf): line10.self=0, line10.total=1 + // Sample 2: B (root) → A@10 (leaf): line10.self=1, line10.total=1 + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] B + B A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + + const line10 = src!.lines.find((l) => l.lineNumber === 10); + expect(line10).toBeDefined(); + expect(line10!.selfSamples).toBe(1); + expect(line10!.totalSamples).toBe(2); + }); + + it('includes source text when fetchSource succeeds', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: 'line one\nline two\nline three\nline four\nline five', + }); + + // Single-row: A is leaf, hit at line 2. + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:2] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.totalFileLines).toBe(5); + + const line2 = src!.lines.find((l) => l.lineNumber === 2); + expect(line2!.sourceText).toBe('line two'); + }); + + it('leaves sourceText null and adds a warning when fetchSource fails', async function () { + fetchSource.mockResolvedValue({ + type: 'ERROR', + errors: [{ type: 'NO_KNOWN_CORS_URL' }], + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:5] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.totalFileLines).toBeNull(); + + const line5 = src!.lines.find((l) => l.lineNumber === 5); + expect(line5!.sourceText).toBeNull(); + expect(result.warnings.some((w) => w.includes('f.c'))).toBe(true); + }); + + it('adds a warning and returns null srcAnnotation when function has no source index', async function () { + // A has no [file:] attribute → funcTable.source[funcIndex] is null + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + expect(result.srcAnnotation).toBeNull(); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('no source index'); + }); + }); + + describe('--context option', function () { + it('shows all lines when context is "file"', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join( + '\n' + ), + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000', + 'file' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.contextMode).toBe('full file'); + expect(src!.lines.length).toBe(20); + expect(src!.lines[0].lineNumber).toBe(1); + expect(src!.lines[19].lineNumber).toBe(20); + }); + + it('shows annotated lines ± N context lines when context is a number', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join( + '\n' + ), + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000', + '1' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.contextMode).toBe('±1 lines context'); + + const lineNumbers = src!.lines.map((l) => l.lineNumber); + expect(lineNumbers).toContain(9); + expect(lineNumbers).toContain(10); + expect(lineNumbers).toContain(11); + expect(lineNumbers).not.toContain(1); + expect(lineNumbers).not.toContain(20); + }); + + it('shows only annotated lines when context is 0', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join( + '\n' + ), + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000', + '0' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.contextMode).toBe('annotated lines only'); + + const lineNumbers = src!.lines.map((l) => l.lineNumber); + expect(lineNumbers).toEqual([10]); + }); + }); +}); diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts new file mode 100644 index 0000000000..185328c970 --- /dev/null +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -0,0 +1,369 @@ +/* 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/. */ + +/** + * Unit tests for ProfileQuerier class. + * + * NOTE: Currently minimal tests. + * + * The ProfileQuerier class is tested through integration tests in bash scripts + * (bin/profiler-cli-test) that load real profiles and verify the output. + * + * Unit tests can be added here for specific utility methods or edge cases that + * are easier to test in isolation. The summarize() method uses the + * buildProcessThreadList function which is thoroughly tested in + * process-thread-list.test.ts. + */ + +import { ProfileQuerier } from 'firefox-profiler/profile-query'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { storeWithProfile } from '../../fixtures/stores'; + +describe('ProfileQuerier', function () { + describe('pushViewRange', function () { + it('changes thread samples output to show functions in the selected range', async function () { + // Create a profile with samples at different times that have different call stacks + // Time 0-10ms: call stack has functions A, B, C + // Time 10-20ms: call stack has functions A, B, D + // Time 20-30ms: call stack has functions A, B, E + const { profile } = getProfileFromTextSamples(` + 0 10 20 + A A A + B B B + C D E + `); + + // Set up the store with the profile + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + // Create ProfileQuerier + const querier = new ProfileQuerier(store, rootRange); + + // Get baseline thread samples (should show all functions A, B, C, D, E) + // Don't pass thread handle - use default selected thread + const baselineSamples = await querier.threadSamples(); + const allFunctions = [ + ...baselineSamples.topFunctionsByTotal.map((f) => f.name), + ...baselineSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(allFunctions).toContain('A'); + expect(allFunctions).toContain('B'); + // At least some of C, D, E should appear + const hasC = allFunctions.includes('C'); + const hasD = allFunctions.includes('D'); + const hasE = allFunctions.includes('E'); + expect(hasC || hasD || hasE).toBe(true); + + // Create timestamp names for a narrower range + // The profile has samples at 0ms, 10ms, 20ms + // Select from just after start to just before end to focus on middle sample + const startName = querier._timestampManager.nameForTimestamp( + rootRange.start + 8 + ); + const endName = querier._timestampManager.nameForTimestamp( + rootRange.start + 12 + ); + + // Push a range that includes only the middle sample (at 10ms) + // This should focus on the call stack with D + await querier.pushViewRange(`${startName},${endName}`); + + // Get thread samples again - should now focus on the selected range + const rangedSamples = await querier.threadSamples(); + + // The output should still contain A and B (common to all stacks) + const rangedAllFunctions = [ + ...rangedSamples.topFunctionsByTotal.map((f) => f.name), + ...rangedSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(rangedAllFunctions).toContain('A'); + expect(rangedAllFunctions).toContain('B'); + + // After pushing a range, the samples should be different from baseline + expect(rangedSamples).not.toBe(baselineSamples); + }); + + it('popViewRange restores the previous view', async function () { + const { profile } = getProfileFromTextSamples(` + 0 10 20 + A A A + B B B + C D E + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + const querier = new ProfileQuerier(store, rootRange); + + // Get baseline samples + const baselineSamples = await querier.threadSamples(); + + // Create timestamp names and push a range + const startName = querier._timestampManager.nameForTimestamp( + rootRange.start + 5 + ); + const endName = querier._timestampManager.nameForTimestamp( + rootRange.start + 15 + ); + await querier.pushViewRange(`${startName},${endName}`); + const rangedSamples = await querier.threadSamples(); + + // Samples should be different after push + expect(rangedSamples).not.toBe(baselineSamples); + + // Pop the range + const popResult = await querier.popViewRange(); + expect(popResult.message).toContain('Popped view range'); + + // Samples should be back to baseline (or at least different from ranged) + const afterPopSamples = await querier.threadSamples(); + expect(afterPopSamples).not.toBe(rangedSamples); + }); + + it('shows non-empty output after pushing a range with samples', async function () { + // Create a profile with many samples across a longer time range + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 5 6 7 8 9 10 11 12 + A A A A A A A A A A A A A + B B B B B B B B B B B B B + C C C D D D E E E F F F G + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + const querier = new ProfileQuerier(store, rootRange); + + // Push a range that includes samples in the middle (5-8ms should include samples at 5, 6, 7, 8) + const startName = querier._timestampManager.nameForTimestamp( + rootRange.start + 5 + ); + const endName = querier._timestampManager.nameForTimestamp( + rootRange.start + 8 + ); + await querier.pushViewRange(`${startName},${endName}`); + + const rangedSamples = await querier.threadSamples(); + + // The output should NOT be empty - it should contain functions from the selected range + const rangedFunctions = [ + ...rangedSamples.topFunctionsByTotal.map((f) => f.name), + ...rangedSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(rangedFunctions).toContain('A'); + expect(rangedFunctions).toContain('B'); + + // Should show D and/or E (which are in the range) + const hasD = rangedFunctions.includes('D'); + const hasE = rangedFunctions.includes('E'); + expect(hasD || hasE).toBe(true); + + // Should show actual function data, not empty sections + expect(rangedSamples.topFunctionsByTotal.length).toBeGreaterThan(0); + expect(rangedSamples.topFunctionsBySelf.length).toBeGreaterThan(0); + }); + + it('works correctly with absolute timestamps and non-zero profile start', async function () { + // Create a profile that starts at 1000ms (not zero) + const { profile } = getProfileFromTextSamples(` + 1000 1005 1010 1015 1020 + A A A A A + B B B B B + C D E F G + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + const querier = new ProfileQuerier(store, rootRange); + + // Push a range using absolute timestamps + // pushViewRange should convert these to relative timestamps for commitRange + const startName = querier._timestampManager.nameForTimestamp(1005); + const endName = querier._timestampManager.nameForTimestamp(1015); + await querier.pushViewRange(`${startName},${endName}`); + + const rangedSamples = await querier.threadSamples(); + + // Should contain functions from the selected range (1005-1015ms) + const rangedFunctions2 = [ + ...rangedSamples.topFunctionsByTotal.map((f) => f.name), + ...rangedSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(rangedFunctions2).toContain('A'); + expect(rangedFunctions2).toContain('B'); + + // Should contain D and E which are in the middle of the range + const hasD = rangedFunctions2.includes('D'); + const hasE = rangedFunctions2.includes('E'); + expect(hasD || hasE).toBe(true); + }); + }); + + describe('search', function () { + // Helper to collect all function names in a call tree + function collectTreeNames(node: { + name: string; + children?: { name: string; children?: unknown[] }[]; + }): string[] { + const names: string[] = [node.name]; + if (node.children) { + for (const child of node.children) { + names.push( + ...collectTreeNames(child as Parameters[0]) + ); + } + } + return names; + } + + it('threadSamplesTopDown with search only shows branches containing the search term', async function () { + // Two separate call trees: + // A → B → C (3 samples) + // X → Y → Z (2 samples) + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + const result = await querier.threadSamplesTopDown( + undefined, + undefined, + false, + 'X' + ); + + expect(result.search).toBe('X'); + const names = collectTreeNames(result.regularCallTree); + expect(names).toContain('X'); + expect(names).toContain('Y'); + expect(names).toContain('Z'); + expect(names).not.toContain('A'); + expect(names).not.toContain('B'); + expect(names).not.toContain('C'); + }); + + it('threadSamplesBottomUp with search only shows branches containing the search term', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + const result = await querier.threadSamplesBottomUp( + undefined, + undefined, + false, + 'X' + ); + + expect(result.search).toBe('X'); + const names = result.invertedCallTree + ? collectTreeNames(result.invertedCallTree) + : []; + // Bottom-up tree roots by leaf function — X branch leaves should appear + expect(names.some((n) => ['X', 'Y', 'Z'].includes(n))).toBe(true); + expect(names).not.toContain('A'); + expect(names).not.toContain('B'); + expect(names).not.toContain('C'); + }); + + it('threadSamples with search filters the top functions list', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + const result = await querier.threadSamples(undefined, false, 'X'); + + expect(result.search).toBe('X'); + const allNames = [ + ...result.topFunctionsByTotal.map((f) => f.name), + ...result.topFunctionsBySelf.map((f) => f.name), + ]; + expect(allNames.some((n) => ['X', 'Y', 'Z'].includes(n))).toBe(true); + expect(allNames).not.toContain('A'); + expect(allNames).not.toContain('B'); + expect(allNames).not.toContain('C'); + }); + + it('search does not persist to subsequent calls', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + // Call with search + await querier.threadSamplesTopDown(undefined, undefined, false, 'X'); + + // Call without search — should restore and show all branches + const result = await querier.threadSamplesTopDown(); + const names = collectTreeNames(result.regularCallTree); + expect(names).toContain('A'); + expect(names).toContain('X'); + expect(result.search).toBeUndefined(); + }); + }); + + describe('threadSamples', function () { + it('searches all roots when choosing the heaviest stack', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B C D Y Y + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + const querier = new ProfileQuerier(store, rootRange); + + const samples = await querier.threadSamples(); + + expect(samples.heaviestStack.selfSamples).toBe(2); + expect(samples.heaviestStack.frames.map((frame) => frame.name)).toEqual([ + 'X', + 'Y', + ]); + }); + }); +}); diff --git a/src/test/unit/profile-query/time-range-parser.test.ts b/src/test/unit/profile-query/time-range-parser.test.ts new file mode 100644 index 0000000000..1091cc48af --- /dev/null +++ b/src/test/unit/profile-query/time-range-parser.test.ts @@ -0,0 +1,145 @@ +/* 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 { parseTimeValue } from '../../../profile-query/time-range-parser'; +import type { StartEndRange } from 'firefox-profiler/types'; + +describe('parseTimeValue', () => { + const rootRange: StartEndRange = { + start: 1000, + end: 11000, + }; + + describe('timestamp names', () => { + it('returns null for timestamp names', () => { + expect(parseTimeValue('ts-0', rootRange)).toBe(null); + expect(parseTimeValue('ts-6', rootRange)).toBe(null); + expect(parseTimeValue('ts-Z', rootRange)).toBe(null); + expect(parseTimeValue('ts<0', rootRange)).toBe(null); + expect(parseTimeValue('ts>1', rootRange)).toBe(null); + }); + }); + + describe('seconds (no suffix)', () => { + it('parses seconds as default format', () => { + expect(parseTimeValue('0', rootRange)).toBe(1000); + expect(parseTimeValue('1', rootRange)).toBe(2000); + expect(parseTimeValue('5', rootRange)).toBe(6000); + expect(parseTimeValue('10', rootRange)).toBe(11000); + }); + + it('parses decimal seconds', () => { + expect(parseTimeValue('0.5', rootRange)).toBe(1500); + expect(parseTimeValue('2.7', rootRange)).toBe(3700); + expect(parseTimeValue('3.14', rootRange)).toBe(4140); + }); + + it('handles leading zeros', () => { + expect(parseTimeValue('0.001', rootRange)).toBe(1001); + expect(parseTimeValue('00.5', rootRange)).toBe(1500); + }); + }); + + describe('seconds with suffix', () => { + it('parses seconds with "s" suffix', () => { + expect(parseTimeValue('0s', rootRange)).toBe(1000); + expect(parseTimeValue('1s', rootRange)).toBe(2000); + expect(parseTimeValue('5s', rootRange)).toBe(6000); + }); + + it('parses decimal seconds with "s" suffix', () => { + expect(parseTimeValue('0.5s', rootRange)).toBe(1500); + expect(parseTimeValue('2.7s', rootRange)).toBe(3700); + }); + }); + + describe('milliseconds', () => { + it('parses milliseconds', () => { + expect(parseTimeValue('0ms', rootRange)).toBe(1000); + expect(parseTimeValue('1000ms', rootRange)).toBe(2000); + expect(parseTimeValue('2700ms', rootRange)).toBe(3700); + expect(parseTimeValue('10000ms', rootRange)).toBe(11000); + }); + + it('parses decimal milliseconds', () => { + expect(parseTimeValue('500ms', rootRange)).toBe(1500); + expect(parseTimeValue('0.5ms', rootRange)).toBe(1000.5); + }); + }); + + describe('percentages', () => { + it('parses percentages of profile duration', () => { + // Profile duration is 10000ms (11000 - 1000) + expect(parseTimeValue('0%', rootRange)).toBe(1000); + expect(parseTimeValue('10%', rootRange)).toBe(2000); + expect(parseTimeValue('50%', rootRange)).toBe(6000); + expect(parseTimeValue('100%', rootRange)).toBe(11000); + }); + + it('parses decimal percentages', () => { + expect(parseTimeValue('5%', rootRange)).toBe(1500); + expect(parseTimeValue('25%', rootRange)).toBe(3500); + expect(parseTimeValue('17%', rootRange)).toBe(2700); + }); + + it('handles percentages over 100%', () => { + expect(parseTimeValue('150%', rootRange)).toBe(16000); + }); + }); + + describe('error handling', () => { + it('throws on invalid seconds', () => { + expect(() => parseTimeValue('abc', rootRange)).toThrow( + 'Invalid time value' + ); + expect(() => parseTimeValue('', rootRange)).toThrow('Invalid time value'); + }); + + it('throws on invalid milliseconds', () => { + expect(() => parseTimeValue('abcms', rootRange)).toThrow( + 'Invalid milliseconds' + ); + expect(() => parseTimeValue('ms', rootRange)).toThrow( + 'Invalid milliseconds' + ); + }); + + it('throws on invalid percentages', () => { + expect(() => parseTimeValue('abc%', rootRange)).toThrow( + 'Invalid percentage' + ); + expect(() => parseTimeValue('%', rootRange)).toThrow( + 'Invalid percentage' + ); + }); + + it('throws on invalid seconds with suffix', () => { + expect(() => parseTimeValue('abcs', rootRange)).toThrow( + 'Invalid seconds' + ); + expect(() => parseTimeValue('s', rootRange)).toThrow('Invalid seconds'); + }); + }); + + describe('edge cases', () => { + it('handles negative values', () => { + expect(parseTimeValue('-1', rootRange)).toBe(0); + expect(parseTimeValue('-1s', rootRange)).toBe(0); + expect(parseTimeValue('-1000ms', rootRange)).toBe(0); + }); + + it('handles very large values', () => { + // 1000000 seconds = 1000000000ms, plus rootRange.start (1000ms) + expect(parseTimeValue('1000000', rootRange)).toBe(1000001000); + expect(parseTimeValue('1000000s', rootRange)).toBe(1000001000); + }); + + it('handles zero', () => { + expect(parseTimeValue('0', rootRange)).toBe(1000); + expect(parseTimeValue('0s', rootRange)).toBe(1000); + expect(parseTimeValue('0ms', rootRange)).toBe(1000); + expect(parseTimeValue('0%', rootRange)).toBe(1000); + }); + }); +}); diff --git a/src/test/unit/profile-query/timestamps.test.ts b/src/test/unit/profile-query/timestamps.test.ts new file mode 100644 index 0000000000..35113ab0b9 --- /dev/null +++ b/src/test/unit/profile-query/timestamps.test.ts @@ -0,0 +1,133 @@ +/* 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 { TimestampManager } from 'firefox-profiler/profile-query/timestamps'; + +/** + * Unit tests for TimestampManager class. + */ + +describe('TimestampManager', function () { + describe('in-range timestamps', function () { + it('assigns short hierarchical names', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + expect(m.nameForTimestamp(1000)).toBe('ts-0'); + expect(m.nameForTimestamp(2000)).toBe('ts-Z'); + expect(m.nameForTimestamp(1500)).toBe('ts-K'); + expect(m.nameForTimestamp(1002)).toBe('ts-1'); + expect(m.nameForTimestamp(1000.1)).toBe('ts-04'); + expect(m.nameForTimestamp(1001)).toBe('ts-0K'); + expect(m.nameForTimestamp(1006)).toBe('ts-2'); + }); + }); + + describe('before-range timestamps', function () { + it('uses ts< prefix with exponential buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Range length = 1000 + // ts<0 covers [0, 1000] (1×length before start) + // ts<1 covers [-1000, 0] (2×length before start) + // ts<2 covers [-3000, -1000] (4×length before start) + + // Timestamps in bucket 0 + expect(m.nameForTimestamp(500)).toMatch(/^ts<0/); + expect(m.nameForTimestamp(999)).toMatch(/^ts<0/); + + // Timestamps in bucket 1 + expect(m.nameForTimestamp(-500)).toMatch(/^ts<1/); + expect(m.nameForTimestamp(-999)).toMatch(/^ts<1/); + + // Timestamps in bucket 2 + expect(m.nameForTimestamp(-1500)).toMatch(/^ts<2/); + expect(m.nameForTimestamp(-2999)).toMatch(/^ts<2/); + }); + + it('creates hierarchical names within buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Request two timestamps and verify they get valid bucket-0 names + const name1 = m.nameForTimestamp(500); + const name2 = m.nameForTimestamp(250); + + expect(name1).toMatch(/^ts<0[0-9a-zA-Z]+$/); + expect(name2).toMatch(/^ts<0[0-9a-zA-Z]+$/); + + // They should be different names + expect(name1).not.toBe(name2); + }); + }); + + describe('after-range timestamps', function () { + it('uses ts> prefix with exponential buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Range length = 1000 + // ts>0 covers [2000, 3000] (1×length after end) + // ts>1 covers [3000, 4000] (2×length after end) + // ts>2 covers [4000, 6000] (4×length after end) + + // Timestamps in bucket 0 + expect(m.nameForTimestamp(2500)).toMatch(/^ts>0/); + expect(m.nameForTimestamp(2999)).toMatch(/^ts>0/); + + // Timestamps in bucket 1 + expect(m.nameForTimestamp(3500)).toMatch(/^ts>1/); + expect(m.nameForTimestamp(3999)).toMatch(/^ts>1/); + + // Timestamps in bucket 2 + expect(m.nameForTimestamp(5000)).toMatch(/^ts>2/); + expect(m.nameForTimestamp(5999)).toMatch(/^ts>2/); + }); + + it('creates hierarchical names within buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Request two timestamps and verify they get valid bucket-0 names + const name1 = m.nameForTimestamp(2500); + const name2 = m.nameForTimestamp(2750); + + expect(name1).toMatch(/^ts>0[0-9a-zA-Z]+$/); + expect(name2).toMatch(/^ts>0[0-9a-zA-Z]+$/); + + // They should be different names + expect(name1).not.toBe(name2); + }); + }); + + describe('reverse lookup', function () { + it('returns timestamps for names that were previously minted', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + + // Mint some names + const name1 = m.nameForTimestamp(1000); + const name2 = m.nameForTimestamp(1500); + const name3 = m.nameForTimestamp(500); + const name4 = m.nameForTimestamp(2500); + + // Reverse lookup should work + expect(m.timestampForName(name1)).toBe(1000); + expect(m.timestampForName(name2)).toBe(1500); + expect(m.timestampForName(name3)).toBe(500); + expect(m.timestampForName(name4)).toBe(2500); + }); + + it('returns null for unknown names', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + expect(m.timestampForName('ts-X')).toBe(null); + expect(m.timestampForName('ts<0Y')).toBe(null); + expect(m.timestampForName('unknown')).toBe(null); + }); + + it('handles repeated requests for the same timestamp', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + + // Request same timestamp twice + const name1 = m.nameForTimestamp(1500); + const name2 = m.nameForTimestamp(1500); + + // Should get the same name + expect(name1).toBe(name2); + + // Reverse lookup should work + expect(m.timestampForName(name1)).toBe(1500); + }); + }); +}); diff --git a/src/test/unit/window-console.test.ts b/src/test/unit/window-console.test.ts index 8dbd8a0a3f..975e53b985 100644 --- a/src/test/unit/window-console.test.ts +++ b/src/test/unit/window-console.test.ts @@ -120,7 +120,7 @@ describe('console-accessible values on the window object', function () { null, { type: 'Log', - level: 1, + level: 'Error', message: 'ParentChannelListener::ParentChannelListener [this=7fb5e19b98d0]', }, @@ -131,22 +131,27 @@ describe('console-accessible values on the window object', function () { null, { type: 'Log', - level: 2, + level: 'Warning', message: 'nsJARChannel::nsJARChannel [this=0x87f1ec80]\n', }, ], - ['cubeb', 200, null, { type: 'Log', level: 3, message: 'cubeb_init' }], + [ + 'cubeb', + 200, + null, + { type: 'Log', level: 'Info', message: 'cubeb_init' }, + ], [ 'AudioStream', 210, null, - { type: 'Log', level: 4, message: 'AudioStream init\n' }, + { type: 'Log', level: 'Debug', message: 'AudioStream init\n' }, ], [ 'VideoSink', 220, null, - { type: 'Log', level: 5, message: 'VideoSink::VideoSink' }, + { type: 'Log', level: 'Verbose', message: 'VideoSink::VideoSink' }, ], ]); const store = storeWithProfile(profile); diff --git a/src/types/globals/global.d.ts b/src/types/globals/global.d.ts index c9260274e8..969c46442c 100644 --- a/src/types/globals/global.d.ts +++ b/src/types/globals/global.d.ts @@ -26,3 +26,8 @@ declare module '*.png' { const content: string; export default content; } + +declare module '*.txt' { + const content: string; + export default content; +} diff --git a/src/types/markers.ts b/src/types/markers.ts index 9f15b29104..47f085fde0 100644 --- a/src/types/markers.ts +++ b/src/types/markers.ts @@ -633,6 +633,14 @@ export type ChromeEventPayload = { /** * Gecko includes rich log information. This marker payload is used to mirror that * log information in the profile. + * + * Two formats are in use: + * - Legacy: { name, module } where module may be "D/nsHttp" (level prefix + * included) or just "nsHttp" (bare module name, implicitly Debug level). + * - New: { level, message } where `level` is a string table index resolving + * to "Error" / "Warning" / "Info" / "Debug" / "Verbose", the module name is + * taken from the marker's own name field, and an optional `color` hint may + * be present. */ export type LogMarkerPayload = | { @@ -642,8 +650,10 @@ export type LogMarkerPayload = } | { type: 'Log'; + // String table index resolving to "Error", "Warning", "Info", "Debug", or "Verbose". level: number; message: string; + color?: string; }; export type DOMEventMarkerPayload = { diff --git a/src/types/transforms.ts b/src/types/transforms.ts index 0837ced0d7..7abf9cc69b 100644 --- a/src/types/transforms.ts +++ b/src/types/transforms.ts @@ -24,10 +24,25 @@ import type { ImplementationFilter } from './actions'; /** * This type represents the filter types for the 'filter-samples' transform. - * Currently the only filter type is 'marker-search', but in the future we may - * add more types of filters. + * + * - 'marker-search': keep only samples whose timestamp falls within a matching marker range. + * - 'outside-marker': keep only samples whose timestamp falls OUTSIDE any matching marker range. + * - 'function-include': keep only samples whose stack contains any of the given functions + * (encoded as comma-separated funcIndexes in the `filter` string). + * - 'stack-prefix': keep only samples whose stack starts with the given sequence of functions + * (encoded as comma-separated funcIndexes, root-first). + * - 'stack-suffix': keep only samples whose leaf frame is the given function + * (encoded as a single funcIndex). + * + * Note: 'outside-marker', 'function-include', 'stack-prefix', and 'stack-suffix' are used + * by the profiler-cli tool only and are not serialized to profile URLs. */ -export type FilterSamplesType = 'marker-search'; +export type FilterSamplesType = + | 'marker-search' + | 'outside-marker' + | 'function-include' + | 'stack-prefix' + | 'stack-suffix'; /* * Define all of the transforms on an object to conveniently access mapped types and do @@ -369,13 +384,11 @@ export type TransformDefinitions = { }; /** - * Filter the samples in the thread by the filter. - * Currently it only supports filtering by the marker name but can be extended - * to support more filters in the future. + * Filter the samples in the thread by the filter. See FilterSamplesType for + * the supported filter types. */ 'filter-samples': { readonly type: 'filter-samples'; - // Expand this type when you need to support more than just the marker. readonly filterType: FilterSamplesType; readonly filter: string; }; diff --git a/src/utils/slice-tree.ts b/src/utils/slice-tree.ts new file mode 100644 index 0000000000..c2289b2b7b --- /dev/null +++ b/src/utils/slice-tree.ts @@ -0,0 +1,205 @@ +/* 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/. */ + +export type Slice = { + start: number; + end: number; + avg: number; + sum: number; + parent: number | null; +}; + +function addIndexIntervalsExceedingThreshold( + threshold: number, + cpuRatio: Float64Array, + time: number[], + items: Slice[], + parent: number | null, + startIndex: number = 0, + endIndex: number = cpuRatio.length - 1 +) { + let currentStartIndex = startIndex; + while (true) { + let currentEndIndex = endIndex; + while ( + currentStartIndex < currentEndIndex && + cpuRatio[currentStartIndex + 1] < threshold + ) { + currentStartIndex++; + } + + while ( + currentStartIndex < currentEndIndex && + cpuRatio[currentEndIndex] < threshold + ) { + currentEndIndex--; + } + + if (currentStartIndex === currentEndIndex) { + break; + } + + const startTime = time[currentStartIndex]; + let sum = 0; + let lastEndIndexWithAvgExceedingThreshold = currentStartIndex + 1; + let lastEndIndexWithAvgExceedingThresholdAvg = threshold; + let lastEndIndexWithAvgExceedingThresholdSum = 0; + let timeBefore = startTime; + for (let i = currentStartIndex + 1; i <= currentEndIndex; i++) { + const timeAfter = time[i]; + const timeDelta = timeAfter - timeBefore; + sum += cpuRatio[i] * timeDelta; + if (timeAfter > startTime) { + const avg = sum / (timeAfter - startTime); + if (avg >= threshold) { + lastEndIndexWithAvgExceedingThreshold = i; + lastEndIndexWithAvgExceedingThresholdAvg = avg; + lastEndIndexWithAvgExceedingThresholdSum = sum; + } + } + timeBefore = timeAfter; + } + + items.push({ + start: currentStartIndex, + end: lastEndIndexWithAvgExceedingThreshold, + avg: lastEndIndexWithAvgExceedingThresholdAvg, + sum: lastEndIndexWithAvgExceedingThresholdSum, + parent, + }); + currentStartIndex = lastEndIndexWithAvgExceedingThreshold; + } +} + +export type SliceTree = { + slices: Slice[]; + time: number[]; +}; + +export function getSlices( + thresholds: number[], + cpuRatio: Float64Array, + time: number[], + startIndex: number = 0, + endIndex: number = cpuRatio.length - 1 +): SliceTree { + const firstThreshold = thresholds[0]; + const slices = new Array(); + addIndexIntervalsExceedingThreshold( + firstThreshold, + cpuRatio, + time, + slices, + null, + startIndex, + endIndex + ); + for (let i = 0; i < slices.length; i++) { + const slice = slices[i]; + const nextThreshold = thresholds.find((thresh) => thresh > slice.avg); + if (nextThreshold === undefined) { + continue; + } + addIndexIntervalsExceedingThreshold( + nextThreshold, + cpuRatio, + time, + slices, + i, + slice.start, + slice.end + ); + } + return { slices, time }; +} + +function sliceToString(slice: Slice, time: number[]): string { + const { avg, start, end } = slice; + const startTime = time[start]; + const endTime = time[end]; + const duration = endTime - startTime; + const sampleCount = end - start; + return `${Math.round(avg * 100)}% for ${duration.toFixed(1)}ms (${sampleCount} samples): ${startTime.toFixed(1)}ms - ${endTime.toFixed(1)}ms`; +} + +function appendSliceSubtree( + slices: Slice[], + startIndex: number, + parent: number | null, + childrenStartPerParent: Array, + interestingSliceIndexes: Set, + nestingDepth: number, + time: number[], + s: string[] +) { + for (let i = startIndex; i < slices.length; i++) { + if (!interestingSliceIndexes.has(i)) { + continue; + } + + const slice = slices[i]; + if (slice.parent !== parent) { + break; + } + + s.push(' '.repeat(nestingDepth) + '- ' + sliceToString(slice, time)); + + const childrenStart = childrenStartPerParent[i]; + if (childrenStart !== null) { + appendSliceSubtree( + slices, + childrenStart, + i, + childrenStartPerParent, + interestingSliceIndexes, + nestingDepth + 1, + time, + s + ); + } + } +} + +export function printSliceTree({ slices, time }: SliceTree): string[] { + if (slices.length === 0) { + return ['No significant activity.']; + } + + const childrenStartPerParent = new Array(slices.length); + const indexAndSumPerSlice = []; + for (let i = 0; i < slices.length; i++) { + childrenStartPerParent[i] = null; + const { parent, sum } = slices[i]; + indexAndSumPerSlice.push({ i, sum }); + if (parent !== null && childrenStartPerParent[parent] === null) { + childrenStartPerParent[parent] = i; + } + } + indexAndSumPerSlice.sort((a, b) => b.sum - a.sum); + const interestingSliceIndexes = new Set(); + for (const { i } of indexAndSumPerSlice.slice(0, 20)) { + let currentIndex: number | null = i; + while ( + currentIndex !== null && + !interestingSliceIndexes.has(currentIndex) + ) { + interestingSliceIndexes.add(currentIndex); + currentIndex = slices[currentIndex].parent; + } + } + + const s = new Array(); + appendSliceSubtree( + slices, + 0, + null, + childrenStartPerParent, + interestingSliceIndexes, + 0, + time, + s + ); + + return s; +} diff --git a/src/utils/window-console.ts b/src/utils/window-console.ts index 96bb9c74b3..e88a025c4f 100644 --- a/src/utils/window-console.ts +++ b/src/utils/window-console.ts @@ -22,7 +22,12 @@ import { getThemePreference, setThemePreference, } from 'firefox-profiler/utils/dark-mode'; +import { printSliceTree } from 'firefox-profiler/utils/slice-tree'; import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; +import { + formatLogTimestamp, + formatLogStatement, +} from 'firefox-profiler/profile-logic/marker-data'; // Despite providing a good libdef for Object.defineProperty, Flow still // special-cases the `value` property: if it's missing it throws an error. Using @@ -53,6 +58,7 @@ export type ExtraPropertiesOnWindowForConsole = { ) => Promise; extractGeckoLogs: () => string; totalMarkerDuration: (markers: any) => number; + activity: () => void; shortenUrl: typeof shortenUrl; getState: GetState; selectors: typeof selectorsForConsole; @@ -271,24 +277,8 @@ export function addDataToWindowObject( }; // This function extracts MOZ_LOGs saved as markers in a Firefox profile, - // using the MOZ_LOG canonical format. All logs are saved as a debug log - // because the log level information isn't saved in these markers. + // using the MOZ_LOG canonical format. target.extractGeckoLogs = function () { - function pad(p: string | number, c: number) { - return String(p).padStart(c, '0'); - } - - // This transforms a timestamp to a string as output by mozlog usually. - function d2s(ts: number) { - const d = new Date(ts); - // new Date rounds down the timestamp (in milliseconds) to the lower integer, - // let's get the microseconds and nanoseconds differently. - // This will be imperfect because of float rounding errors but still better - // than not having them. - const ns = Math.trunc((ts - Math.trunc(ts)) * 10 ** 6); - return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)} ${pad(d.getUTCHours(), 2)}:${pad(d.getUTCMinutes(), 2)}:${pad(d.getUTCSeconds(), 2)}.${pad(d.getUTCMilliseconds(), 3)}${pad(ns, 6)} UTC`; - } - const logs = []; // This algorithm loops over the raw marker table instead of using the @@ -298,14 +288,6 @@ export function addDataToWindowObject( const range = selectorsForConsole.profile.getPreviewSelectionRange(getState()); - const LOG_LEVEL_LETTER: Record = { - 1: 'E', - 2: 'W', - 3: 'I', - 4: 'D', - 5: 'V', - }; - for (const thread of profile.threads) { const { markers } = thread; @@ -318,25 +300,26 @@ export function addDataToWindowObject( startTime <= range.end ) { const data = markers.data[i] as LogMarkerPayload; - const strTimestamp = d2s(profile.meta.startTime + startTime); + const absoluteTs = profile.meta.startTime + startTime; + const strTimestamp = formatLogTimestamp(absoluteTs); const processName = thread.processName ?? 'Unknown Process'; - - let statement; - if ('message' in data) { - if (!data.message) { - continue; - } - const moduleName = profile.shared.stringArray[markers.name[i]]; - const levelLetter = LOG_LEVEL_LETTER[data.level] ?? 'D'; - statement = `${strTimestamp} - [${processName} ${thread.pid}: ${thread.name}]: ${levelLetter}/${moduleName} ${data.message.trim()}`; - } else { - if (!data.name) { - continue; - } - const prefix = data.module.includes('/') ? '' : 'D/'; - statement = `${strTimestamp} - [${processName} ${thread.pid}: ${thread.name}]: ${prefix}${data.module} ${data.name.trim()}`; + const stringArray = profile.shared.stringArray; + // For the new format the module name lives in the marker's name field. + // For the legacy format it is embedded in data.module; formatLogStatement + // handles that internally, so this value is not used in that case. + const moduleName = stringArray[markers.name[i]]; + const statement = formatLogStatement( + strTimestamp, + processName, + thread.pid, + thread.name, + data, + moduleName, + stringArray + ); + if (statement !== null) { + logs.push(statement); } - logs.push(statement); } } } @@ -366,6 +349,14 @@ export function addDataToWindowObject( return totalDuration; }; + target.activity = function () { + const slices = + selectorsForConsole.selectedThread.getActivitySlices(getState()); + if (slices) { + console.log(printSliceTree(slices).join('\n')); + } + }; + target.shortenUrl = shortenUrl; target.getState = getState; target.selectors = selectorsForConsole; diff --git a/tsconfig.json b/tsconfig.json index 28b14b261a..f6c0bf5a04 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,11 @@ // React & JSX "jsx": "react-jsx" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "__mocks__/**/*.ts"], + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "profiler-cli/**/*.ts", + "__mocks__/**/*.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/yarn.lock b/yarn.lock index 15acf9b8cc..b949fc354a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3657,6 +3657,11 @@ command-line-usage@^7.0.3: table-layout "^4.1.0" typical "^7.1.1" +commander@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"