diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b60c7eec..4d85f7e43e 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 6d621273ed..ee48a80fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,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/eslint.config.mjs b/eslint.config.mjs index 6706b48b92..e7d2843208 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/**', 'docs-user/**', @@ -247,7 +248,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..36068e7f37 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,53 @@ module.exports = { escapeString: true, printBasicPrototype: true, }, - verbose: false, +}; + +module.exports = { + projects: [ + // ======================================================================== + // 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', + }, + }, + ], }; diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 9475a20d4c..89e8893018 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -125,6 +125,15 @@ CallNodeContextMenu--transform-focus-category = Focus on category { $cat .title = Focusing on the nodes that belong to the same category as the selected node, thereby merging all nodes that belong to another category. + +# This is used as the context menu item to apply the "Drop category" transform. +# Variables: +# $categoryName (String) - Name of the category to drop. +CallNodeContextMenu--transform-drop-category = Drop samples with category { $categoryName } + .title = + Dropping samples with a category removes all samples whose leaf frame + belongs to that category. + CallNodeContextMenu--transform-collapse-function-subtree = Collapse function .title = Collapsing a function will remove everything it called, and assign @@ -1180,6 +1189,11 @@ TransformNavigator--focus-self = Focus Self: { $item } # $item (String) - Name of the category that transform applied to. TransformNavigator--focus-category = Focus category: { $item } +# "Drop samples with category" transform (drop is a verb, as in remove). +# Variables: +# $item (String) - Name of the category that transform applied to. +TransformNavigator--drop-category = Drop samples with category: { $item } + # "Merge call node" transform. # See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=merge # Variables: diff --git a/package.json b/package.json index e091457a9c..4856db18ce 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "build-symbolicator-cli": "cross-env NODE_ENV=production node scripts/build-symbolicator.mjs", "build-prod:quiet": "yarn build-prod", "build-symbolicator-cli:quiet": "yarn build-symbolicator-cli", + "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", "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", @@ -43,15 +46,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 --selectProjects=browser", "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 --selectProjects=cli cli-integration", "postinstall": "patch-package" }, "license": "MPL-2.0", @@ -88,6 +92,7 @@ "long": "^5.3.2", "memoize-immutable": "^3.0.0", "memoize-one": "^6.0.0", + "commander": "14.0.3", "minimist": "^1.2.8", "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", diff --git a/profiler-cli-case-study-2.md b/profiler-cli-case-study-2.md new file mode 100644 index 0000000000..141411abcf --- /dev/null +++ b/profiler-cli-case-study-2.md @@ -0,0 +1,527 @@ +# Profiler CLI Case Study 2: Investigating Repeated Rendering Spikes in Firefox + +**Profile:** https://share.firefox.dev/4oLEjCw +**Date:** November 4, 2025 +**Investigator:** Claude (via profiler-cli CLI) + +## Executive Summary + +Using profiler-cli, I investigated a Firefox performance profile showing repeated GPU rendering spikes. The investigation revealed that the GPU Renderer thread was spending ~27% of spike time in Present operations (DirectComposition/DXGI), triggered by a loop of WM_PAINT messages on the main thread. The main thread would trigger rendering work, wait for the GPU (FlushRendering), and repeat. + +## Investigation Process + +### Initial Exploration + +```bash +profiler-cli load 'https://share.firefox.dev/4oLEjCw' +profiler-cli profile info +``` + +**Observation:** The profile overview immediately showed the GPU process (p-14) consuming 16.1s of CPU, with the Renderer thread (t-93) at 7.9s being the hottest thread. Multiple CPU spike periods were visible at 160% (2 cores). + +### Deep Dive into GPU Thread + +```bash +profiler-cli thread select t-93 +profiler-cli thread samples +``` + +**Problem:** The output was **extremely verbose** - over 2000 lines for the full profile view. While comprehensive, it required significant scrolling and cognitive effort to digest. The top functions list showed 50 entries before truncating 2224 more. + +**Finding:** In the full profile: + +- 63.5% of time: Thread idle/waiting +- 36.5% of time: Active rendering work +- 16.4% of active time: DCSwapChain::Present operations +- 20.4% of active time: composite_simple + +### Zooming into Spike Periods + +```bash +profiler-cli view push ts-6,ts-7 +profiler-cli thread samples | head -n 100 +``` + +**Positive Experience:** After zooming into a specific spike period (391ms), the output became **much more manageable** - only 179 samples vs 14,466 for the full profile. The percentages shifted dramatically: + +- 42.5% idle (down from 63.5%) +- 57.5% in UpdateAndRender +- 27.4% in Present operations + +This focused view made it easy to see that during spikes, the thread was spending proportionally more time presenting frames. + +```bash +profiler-cli status +profiler-cli view pop +``` + +**Positive Experience:** The `status` command clearly showed my current context (selected thread and view range). `view pop` cleanly restored the previous view. + +### Investigating the Trigger + +```bash +profiler-cli thread select t-0 +profiler-cli thread samples | head -n 80 +``` + +**Finding:** The main thread (GeckoMain) was: + +- 43% idle (waiting for GPU) +- 77% of active time in OnPaint → ProcessPendingUpdates +- Waiting in PCompositorBridge::Msg_FlushRendering + +**Root cause:** A loop of WM_PAINT messages triggering repeated rendering work, with the main thread blocking on GPU completion before proceeding. + +## What Worked Well + +### 1. **Progressive Exploration Model** + +The workflow of `profile info` → `thread select` → `thread samples` → `view push` → drill down worked naturally. Each command provided the context needed for the next step. + +### 2. **Thread Handle System** + +Thread handles like `t-93`, `t-0` were **concise and memorable**. Once I saw "t-93 (Renderer)" in the profile overview, I could directly select it without searching. + +### 3. **Time Range Navigation** + +- **Timestamp names** (ts-6, ts-7, etc.) made it trivial to zoom into spike periods identified in the overview +- **View range stack** (`push`/`pop`) allowed easy exploration without losing context +- `status` command provided clear confirmation of current state + +### 4. **Profile Info Overview** + +The hierarchical CPU activity breakdown was excellent: + +``` +- 81% for 30409.5ms (1865812 samples): [ts-1,ts-z] + - 160% for 390.6ms (27322 samples): [ts-6,ts-7] + - 160% for 255.3ms (18215 samples): [ts-8,ts-9] +``` + +This immediately highlighted where to investigate, with ready-to-use timestamp ranges. + +### 5. **Consistent Command Structure** + +Commands followed predictable patterns: + +- `profiler-cli ` (e.g., `thread select`, `view push`) +- Optional flags for refinement (`--thread t-0`) +- Clear, descriptive output + +## What Didn't Work Well + +### 1. **Overwhelming Verbosity in Wide Views** ⚠️ + +**Problem:** `thread samples` output for the full profile was **2000+ lines**. This is cognitively exhausting in a terminal. + +**Impact:** + +- Hard to find actionable information quickly +- Need to pipe through `head` or scroll extensively +- Function list shows "50 entries" but mentions "2224 more omitted" - makes it unclear if I'm missing something important + +**Suggestion:** Add a `--limit N` flag to truncate output: + +```bash +profiler-cli thread samples --limit 20 # Show only top 20 functions +``` + +Or make the default output more concise (e.g., top 15-20 functions only, with an explicit "use --verbose for full output" message). + +### 2. **No Function Search/Filter** ❌ + +**Problem:** Once I saw the profile overview, I wanted to search for specific functions (e.g., "how much time in Present?"). Currently, I have to: + +1. Run `thread samples` (2000+ lines) +2. Manually search through output or pipe to `grep` +3. Parse percentages manually + +**Suggestion:** Add function search/filter: + +```bash +profiler-cli thread search "Present" +profiler-cli thread functions --filter "atidxx64" # Show only AMD driver functions +profiler-cli function info "DCSwapChain::Present" # Details about a specific function +``` + +### 3. **Call Tree Format is Hard to Parse** + +**Problem:** The ASCII tree is deeply nested and uses UTF-8 box characters: + +``` +└─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ ├─ └─ ├─ ... +``` + +After 10+ levels of nesting, it's **visually overwhelming** and hard to follow lineage. + +**Impact:** + +- Difficult to trace execution paths +- Hard to identify "where am I in the stack?" +- The "... (N more children)" truncation breaks flow + +**Suggestion:** + +- Limit tree depth display (show top 5-10 levels by default) +- Add indentation-based format option: + ``` + RenderThread::UpdateAndRender [57.5%] + RendererOGL::UpdateAndRender [54.2%] + wr_renderer_render [48.6%] + Renderer::render [48.6%] + Renderer::draw_frame [43.0%] + composite_frame [35.2%] + composite_simple [35.2%] + PresentImpl [27.4%] + ``` +- Add a `--tree-depth N` flag + +### 4. **No Comparison Between Time Ranges** ❌ + +**Problem:** I identified a spike period (ts-6 to ts-7) where Present was 27.4% of time, vs 16.4% in the full profile. But I had to **manually compare** by running commands twice and noting differences. + +**Suggestion:** Add range comparison: + +```bash +profiler-cli view compare ts-6,ts-7 vs ts-8,ts-9 +# Shows side-by-side differences in top functions +``` + +### 5. **No Markers/Events View** ❌ + +**Problem:** The thread info showed "297515 markers" for the main thread, but there's **no way to view them**. Markers often provide critical context (e.g., "Reflow", "Styles", "JavaScript" markers). + +**Suggestion:** Implement marker commands: + +```bash +profiler-cli thread markers # List recent markers +profiler-cli thread markers --type Reflow # Filter by type +profiler-cli marker info # Marker details +``` + +### 6. **Missing Symbol Information is Opaque** 🔶 + +**Problem:** AMD GPU driver functions appear as: + +``` +atidxx64.dll!fun_3e8f0 - total: 2354 (16.3%) +atidxx64.dll!fun_a56960 - self: 598 (4.1%) +``` + +These are **meaningless** for diagnosis. While it's expected that third-party binaries lack symbols, profiler-cli provides **no indication** that: + +- These are unsymbolicated +- What type of component this is (GPU driver) +- Whether symbolication was attempted + +**Impact:** Users may think these are real function names rather than placeholder addresses. + +**Suggestion:** + +- Clearly mark unsymbolicated functions: `atidxx64.dll!` +- Group by module in output: "AMD GPU Driver (unsymbolicated): 25% total" +- Add metadata about module types (system library, GPU driver, etc.) + +### 7. **No Aggregated "Waiting Time" View** ⚠️ + +**Problem:** I saw 63.5% of GPU thread time was in `ZwWaitForAlertByThreadId` (waiting), but there's no easy way to see: + +- What the thread is waiting _for_ +- All waiting periods aggregated +- Patterns in wait times + +**Suggestion:** + +```bash +profiler-cli thread waits # Show all wait operations +profiler-cli thread waits --min-duration 10ms # Filter significant waits +``` + +### 8. **No "Heaviest Stack" or Sample View** ❌ + +**Problem:** The profiler UI shows "heaviest stack" (the single most expensive call stack). This is often the smoking gun. profiler-cli only shows aggregated functions and trees. + +**Suggestion:** + +```bash +profiler-cli thread stacks # Show heaviest individual stacks +profiler-cli thread stacks --limit 5 # Top 5 heaviest +profiler-cli sample info # Details about a specific sample +``` + +## Cognitive Load Assessment + +### Low Cognitive Load ✓ + +- **Progressive disclosure:** Start with overview, drill down as needed +- **Consistent patterns:** Commands are predictable +- **Clear state:** `status` always shows where you are +- **Good naming:** Thread handles (t-93) and timestamp names (ts-6) are intuitive + +### High Cognitive Load ⚠️ + +- **Output volume:** Full profile views are overwhelming (2000+ lines) +- **Manual correlation:** Must compare outputs mentally or with external tools +- **Tree parsing:** Deep call stacks are hard to follow +- **Missing context:** No markers, no sample-level view, no wait analysis + +### Recommendations + +1. **Default to concise output** (top 15-20 items), with `--verbose` for full details +2. **Add summary statistics** at the end of output (e.g., "Top 3 functions account for 45% of time") +3. **Implement filtering** to reduce noise (by function name, module, threshold) +4. **Add comparison commands** to reduce mental arithmetic + +## Output Quality + +### What's Good ✓ + +- **Percentages are clear:** Both absolute (time) and relative (%) shown +- **Hierarchical structure:** Process → Thread → Function breakdowns are logical +- **Time formatting:** Milliseconds for short durations, seconds for long +- **Sample counts:** Shown alongside time, helpful for confidence + +### What's Missing ⚠️ + +- **Context indicators:** No indication when symbols are missing +- **Noise filtering:** Low-impact functions (< 1%) dominate output +- **Actionable guidance:** Output doesn't suggest next steps (e.g., "Focus on these 3 hot functions") +- **Visual hierarchy:** Everything has equal weight in plain text + +### What's Excessive 🔶 + +- **Boilerplate call stacks:** Lines 1-15 of every stack are always the same (RtlUserThreadStart → BaseThreadInitThunk → ...) +- **Truncated function names:** Some C++ template names are cut off mid-word (e.g., `mozilla::interceptor::FuncHook # Obvious +profiler-cli profile info # Logical +profiler-cli thread select t-93 # Clear +profiler-cli thread samples # Descriptive +profiler-cli view push ts-6,ts-7 # Intuitive +profiler-cli status # Expected +``` + +### Awkward Commands ⚠️ + +- **Piping to head:** `profiler-cli thread samples | head -n 100` - shouldn't need shell plumbing for basic limiting +- **Filtering not built-in:** Must use `grep` externally +- **No inline thread selection:** `profiler-cli thread samples --thread t-93` doesn't work, must select first + +### Missing Commands ❌ + +```bash +profiler-cli thread markers # Not implemented +profiler-cli thread waits # Not implemented +profiler-cli thread stacks # Not implemented +profiler-cli function info # Not implemented +profiler-cli view compare # Not implemented +profiler-cli thread functions # Not implemented (list top functions only, no tree) +``` + +## Handling of Missing Symbols + +The profile includes AMD GPU driver code (`atidxx64.dll`) with no symbols. profiler-cli handled this **functionally** but **poorly for UX**: + +### What Works ✓ + +- Functions are assigned placeholder names (fun_3e8f0) +- Percentages are calculated correctly +- Call stacks show the unsymbolicated frames +- Module name (atidxx64.dll) is preserved + +### What's Broken 🔶 + +- **No indication these are unsymbolicated** - looks like real function names +- **No module-level grouping** - can't easily see "25% in AMD driver" +- **No hints about why** - is this expected? Is symbolication available? +- **Addresses are obfuscated** - fun_3e8f0 doesn't show the actual address (0x3e8f0) + +### Impact on Investigation + +Despite missing symbols, I could still: + +- ✓ Identify that GPU driver code was hot (atidxx64.dll functions in top list) +- ✓ See that it was called from D3D11/DXGI Present operations +- ✓ Quantify the time spent (27% in spike periods) + +But I couldn't: + +- ❌ Understand _what_ the driver was doing (memory allocation? rendering? waiting?) +- ❌ Distinguish different driver functions (fun_3e8f0 vs fun_a56960 - which is which?) +- ❌ Know if this is normal or indicates a problem + +### Recommendation + +``` +atidxx64.dll!<0x3e8f0> [unsymbolicated] - total: 2354 (16.3%) + Note: AMD GPU Driver - symbols unavailable + +Or group in output: + GPU Driver Activity (unsymbolicated): 25.4% total + atidxx64.dll!<0x3e8f0>: 16.3% + atidxx64.dll!<0xa56960>: 4.1% + atidxx64.dll!<0xa48860>: 1.6% +``` + +## Performance Profile Summary + +### The Problem + +Firefox was experiencing repeated CPU spikes (160% = 2 cores) every few hundred milliseconds, lasting 200-400ms each. + +### Root Cause + +1. **Main thread:** Continuous WM_PAINT message loop +2. **Main thread:** Triggers rendering via OnPaint → ProcessPendingUpdates +3. **Main thread:** Blocks waiting for GPU (PCompositorBridge::Msg_FlushRendering) +4. **GPU Renderer thread:** Processes frame rendering (WebRender) +5. **GPU Renderer thread:** 27% of spike time spent in DirectComposition Present operations +6. **Repeat:** Pattern repeats every ~300ms + +### Bottleneck + +The GPU Present path (DirectComposition → DXGI → AMD driver) is the bottleneck during spikes. The main thread is waiting for these Present operations to complete before continuing. + +### Likely Issue + +Either: + +- **VSync blocking:** Waiting for monitor refresh before presenting +- **GPU saturation:** AMD driver queueing work faster than GPU can execute +- **Desktop Window Manager contention:** Windows DWM compositing is slow + +## Overall Assessment + +### profiler-cli Strengths 💪 + +1. **Progressive exploration** model is natural and effective +2. **Time range navigation** (timestamps + view stack) is excellent +3. **Thread selection** with handles is simple and memorable +4. **Profile overview** immediately surfaces hot spots +5. **Consistent command structure** reduces learning curve + +### profiler-cli Weaknesses 😓 + +1. **Output verbosity** makes wide-scope views painful +2. **No filtering or search** forces manual grepping +3. **Missing features:** No markers, no waits, no stacks, no comparison +4. **Poor symbol UX:** Unsymbolicated code looks like real function names +5. **Call tree format** is hard to parse at depth + +### Would I Use profiler-cli for Real Investigations? + +**Yes, but with caveats:** + +**For quick triage:** ✓ Excellent - `profile info` + `thread select` + targeted `view push` works great + +**For deep investigation:** ⚠️ Frustrating - need to: + +- Pipe through `head` constantly to manage output +- Keep the profiler UI open for markers, stacks, and visual navigation +- Manually grep for function names +- Copy/paste outputs for comparison + +**profiler-cli is currently a "first-look tool"** - great for initial exploration, but you'll switch to the profiler UI for serious debugging. + +## Priority Improvements + +### P0 (Critical for Real Use) + +1. **Add `--limit` flag** to all commands that generate lists +2. **Implement marker viewing** (thread markers is wired up but not functional) +3. **Add function search/filter** (`profiler-cli thread functions --filter "Present"`) + +### P1 (High Value) + +4. **Improve call tree display** (limit depth, better formatting) +5. **Mark unsymbolicated functions clearly** +6. **Add module-level grouping** for unsymbolicated code + +### P2 (Nice to Have) + +7. **Add stack/sample viewing** (heaviest stacks) +8. **Add wait analysis** (thread waits) +9. **Add comparison** (view compare) +10. **Add inline thread selection** (`--thread` flag on all commands) + +## Comparison with Case Study 1 + +Both case studies investigated **the same profile** (https://share.firefox.dev/4oLEjCw) and reached remarkably **consistent conclusions**, validating the findings: + +### Identical Core Issues ✓ + +1. **Missing library/module context** - Both flagged this as the #1 critical problem +2. **Excessive output truncation** - Call trees, function lists, heaviest stacks all cut off too early +3. **Output verbosity** - Full profile views are overwhelming +4. **Missing marker support** - Identified as a major gap +5. **Same performance diagnosis** - Both found GPU rendering with repeated Present operations + +### Converging Recommendations ✓ + +Both case studies independently proposed: + +- **Time range format flexibility** - Support seconds (2.7,3.1) not just timestamp names +- **Function search/filtering** - Need to find specific functions +- **Deeper output limits** - Show more functions, more tree depth, more frames +- **Status command** - Show current context (thread, range, session) +- **Separate sample commands** - Split `thread samples` into focused views + +### Key Disagreements 🤔 + +**Function Handles:** + +- **Case Study 1 proposed:** Function handles like `f-234` for brevity +- **Case Study 2 (this):** Rejected handles as cognitive overhead; prefer smart truncation + +**Analysis:** I agree with Case Study 2 (my own conclusion). Function handles add indirection ("what was f-234 again?") and break copy/paste workflows. Smart truncation achieves the same brevity without the cognitive tax. + +**Command naming:** + +- **Case Study 1:** `view push-range` (explicit) +- **Case Study 2:** `view push` (concise) + +**Analysis:** Both work. I slightly prefer `view push` for brevity, but consistency with other `push-X` commands could justify `push-range`. Not a strong opinion. + +### Unique Insights + +**From Case Study 1:** + +- Detailed design recommendations section (§1-§9) +- Proposed named ranges (`spike:1`, `longest-frame`) +- Identified negative nanosecond timestamp bug in `view pop` +- Provided implementation timeline estimates (2-10 weeks to production ready) + +**From Case Study 2 (this):** + +- Cognitive load assessment framework (low/high cognitive load categories) +- "First-look tool" vs "primary investigation tool" distinction +- More focus on real-world workflow pain points +- Specific call-out of AMD driver symbol handling +- Emphasis on filtering as a solution to verbosity + +### Validation + +The **high degree of overlap** between independent investigations of the same profile demonstrates: + +1. ✅ The issues are real and reproducible +2. ✅ The proposed solutions are well-aligned +3. ✅ The priority rankings are consistent (module names > truncation > markers) +4. ✅ The overall assessment is reliable ("promising foundation with critical gaps") + +## Conclusion + +profiler-cli is a **promising tool** that successfully enables command-line profile investigation. The core workflow is solid, and for focused investigations (zoomed into specific time ranges), it's quite effective. + +However, **output verbosity and missing features** significantly limit its utility for complex investigations. Adding filtering, limiting, and marker viewing would transform profiler-cli from a "triage tool" into a "primary investigation tool." + +The handling of unsymbolicated code is **functional but needs UX work** - it's not a blocker, but better clarity would help users understand what they're looking at. + +**Bottom line:** profiler-cli has excellent bones, but needs refinement to handle the scale and complexity of real-world performance profiles. + +**Cross-validation with Case Study 1:** The independent investigation reached nearly identical conclusions, confirming these findings are robust and actionable. diff --git a/profiler-cli-case-study.md b/profiler-cli-case-study.md new file mode 100644 index 0000000000..4bde5f02ec --- /dev/null +++ b/profiler-cli-case-study.md @@ -0,0 +1,718 @@ +# Profiler CLI - Case Study Report + +## Profile Investigation Summary + +**Profile:** https://share.firefox.dev/4oLEjCw (Firefox 146 on Windows 11) + +### Findings + +The profile shows **bursty rendering activity** rather than sustained performance issues. Key observations: + +1. **Thread CPU Distribution:** + - GPU process (p-14): 16.1s total CPU time + - Renderer thread (t-93): 7.9s (26% active, 63.5% idle overall) + - WRWorker threads: ~1.5s each (88-95% idle) + - Parent Process GeckoMain (t-0): 7.9s (42% waiting, 26% sleeping) + +2. **Activity Pattern:** + - Baseline: 81% CPU utilization across ~30 seconds + - Spikes: 160% CPU (2 cores saturated) in bursts of 200-1000ms + - Most threads spend majority of time idle waiting for work + +3. **Active Work Breakdown:** + - GPU Renderer: 7.5% checking device state (`WaitForFrameGPUQuery`), ~9% waiting on GPU operations + - Main thread: Blocked on IPC (`SendFlushRendering`), waiting for compositor responses + - WRWorker threads: Skia rendering (`SkRasterPipelineBlitter`, path operations) + - **Limited visibility:** ~15% of time in unsymbolicated GPU driver functions (`fun_a56960`, etc.) - can't determine which library without module names + +**Diagnosis:** This is not a "slow" profile - it's a profile of normal responsive rendering with expected idle time. The system waits appropriately between frames and for GPU operations to complete. No obvious bottleneck identified, though GPU driver work (which accounts for a significant portion of time) cannot be fully characterized without library/module context. + +--- + +## profiler-cli Usability Evaluation + +### What Worked Well + +**1. Fast Profile Loading** +Loading a remote profile from share.firefox.dev was smooth and quick. The daemon model works well. + +**2. Progressive Exploration** +The workflow of `profile info` → `thread select` → `thread samples` felt natural for drilling down into threads. + +**3. View Range Zooming** +`view push-range ts-6,ts-7` successfully filtered to spike periods. The concept of pushing/popping ranges is solid. + +**4. Command Consistency** +Commands follow predictable patterns: `thread select`, `thread info`, `thread samples`. Easy to remember. + +**5. Output Quality - Thread Info** +The CPU activity timeline with indented percentages is excellent: + +``` +- 26% for 30404.1ms (14464 samples): [ts-2,ts-Yz] + - 40% for 4829.3ms (2238 samples): [ts-5,ts-Fa] + - 60% for 161.3ms (73 samples): [ts-FX,ts-FY] +``` + +This nested view clearly shows when and where CPU spikes occur. + +--- + +### Critical Issues + +**1. Missing Library/Module Names** +The single biggest problem. Output shows bare function names without context: + +- `fun_a56960`, `fun_a48860`, `fun_1159e6` - which library are these from? +- `0x7ffdbb3c8055`, `0x13c9bd2dcf1` - what module contains these addresses? + +**Context:** Graphics drivers often don't provide symbol information, so function names like `fun_a56960` are expected. However, the web UI shows **which library** the function is in (e.g., `nvoglv64.dll`, `amdvlk64.dll`, `d3d11.dll`), which is crucial for diagnosis. + +**Impact:** Cannot tell if time is spent in: + +- GPU driver code (expected for rendering) +- System libraries (might indicate OS contention) +- Unknown/JIT code (might indicate JavaScript or corrupted stacks) +- Third-party DLLs (might indicate extension issues) + +Even without function symbols, knowing "14% of time in GPU driver" vs "14% in unknown code" is the difference between actionable insight and confusion. + +**Needed:** + +- Show library/module names for all functions: `nvoglv64.dll!fun_a56960` +- Group by module in top functions: "15% in nvoglv64.dll (GPU driver)" +- Annotate unknown addresses with their module when available +- Special handling for JIT code addresses (mark as "JS JIT" if from SpiderMonkey heap) + +**2. Truncated Call Trees** +Regular call trees are cut off early, showing only 10 levels before "..." when there are clearly more levels. Example: + +``` +└─ └─ └─ └─ └─ └─ └─ └─ └─ └─ MessageLoop::Run() [total: 100.0%, self: 0.0%] +``` + +Then it just stops, even though there's clearly more interesting work below. + +**Impact:** Cannot see the actual work being done, only the dispatch machinery. + +**Needed:** + +- Show more levels by default (at least 20-30) +- Add a parameter to control depth: `--max-depth=50` or `--full-tree` +- Smart truncation: continue showing branches with >5% self time + +**3. Timestamp Display Issues** +After `view pop-range`, timestamps were shown as: + +``` +ts<78 (-3,703,142,204,026ns) to ts<79 (-3,702,751,569,159ns) +``` + +**Problems:** + +- Negative nanosecond values are meaningless to users +- Should show relative times like "2.701s to 3.092s" as push-range did +- Inconsistent between push and pop + +**4. Limited Function List** +"Top Functions" shows only 20 functions, then: + +``` +... (6603 more functions omitted, max total: 16392, max self: 6916, sum of self: 2818) +``` + +**Impact:** Cannot see secondary bottlenecks. If top function is 42% waiting (expected), I need to see what the other 58% is doing. + +**Needed:** + +- Show at least top 50 functions +- Add `--limit=N` parameter +- Better filtering: `--min-self-time=1%` to hide trivial functions + +**5. Heaviest Stack Truncation** +The heaviest stack shows "... (42 frames skipped)" in the middle: + +``` + 20. NS_ProcessNextEvent(nsIThread*, bool) + ... (42 frames skipped) + 63. nsWindow::WindowProcInternal(...) +``` + +**Impact:** Cannot see the full execution path. The skipped frames are often the most important. + +**Needed:** Never skip frames in "heaviest stack" - it's only one stack, show all frames. + +--- + +### Missing Features + +**1. Marker Support** +Commands exist in help text (`thread markers`) but aren't implemented. Markers are crucial for understanding: + +- Layout/style/reflow costs +- JavaScript function names +- IPC message types +- GPU command boundaries + +**Impact:** Major gap. Half of profiling insight comes from markers. + +**2. Time Range Selection by Content** +No way to find "ranges where thread X is >80% active" or "show me the longest frame". Currently must: + +- Read profile info manually +- Copy timestamp names +- Push range manually + +**Needed:** + +- `view find-spikes --thread t-93 --min-cpu=80%` +- `view find-longest-frame` +- `view show-frame 42` (jump to Nth frame) + +**3. Cross-Thread Analysis** +No way to see what multiple threads were doing during the same time range. Had to manually: + +- Push range for spike period +- Select thread, view samples +- Select another thread, view samples +- Mentally correlate + +**Needed:** + +- `thread compare t-0 t-93` showing both threads side-by-side +- `profile samples --all-threads` during current view range + +**4. Function Listing/Search** +No way to search for specific functions. Wanted to find all places where `nsTreeImageListener::AddCell` appears (saw it used 0.3% CPU), but had to scroll through output. + +**Needed:** + +- `thread functions` to list all functions with CPU time +- `thread functions -E "nsTree"` to filter with regex (see Design Recommendations §6) + +**5. JavaScript-Specific Commands** +No way to view just JavaScript execution: + +- Filter to JIT frames vs C++ frames +- See hot JavaScript functions +- Understand script URLs + +**6. Export/Save** +No way to save investigation results. Had to pipe to `head` manually. Would want: + +- `thread samples --output=report.txt` +- `profile export --format=json` for scripting + +--- + +### Cognitive Load Assessment + +**Learning Curve: Low ✓** + +- Commands are intuitive if you understand profiling concepts +- Help text is clear +- Predictable command structure + +**Mental Model: Good ✓** + +- Daemon/client separation is invisible (good) +- Thread selection persists across commands (good) +- View range stack metaphor is clear + +**Context Switching: Moderate** + +- Remembering timestamp names (ts-6, ts-7) is awkward +- Have to remember which thread is selected +- No way to see "current state" - need `profiler-cli status` showing: + - Current session + - Selected thread + - Current view range stack + +**Memory Burden: High** + +- Timestamp names are opaque (ts-6 vs ts-FX) +- Must remember findings from previous commands +- No way to annotate or save intermediate results + +--- + +### Output Quality + +**Profile Info: Excellent ✓✓✓** + +- Clear hierarchy (processes → threads) +- CPU percentages make relative costs obvious +- Timeline sections show burst patterns +- Top threads immediately visible + +**Thread Info: Excellent ✓✓✓** + +- Nested CPU activity is perfect for finding spikes +- Sample counts + durations both shown +- Thread lifecycle (created/ended) useful + +**Thread Samples: Good but Limited** + +- Top functions by total/self time is standard profiler output +- Inverted call tree is useful +- Heaviest stack helps identify primary path + +**Problems:** + +- Too much truncation (as detailed above) +- No percentage filter (hide <1% functions) +- Call tree depth insufficient +- Missing symbols are jarring + +--- + +### Ergonomics + +**Command Length: Mixed** + +- Short commands are nice: `thread info`, `profile info` +- Thread handles work well: `t-93` is concise +- Timestamp ranges are verbose: `view push-range ts-6,ts-7` + - **Addressed in Design Recommendations §2**: support `view push 2.7,3.1` (seconds) + +**Discoverability: Good ✓** + +- `--help` shows all commands +- Error messages are clear +- Command structure is guessable + +**Error Recovery: Needs Work** + +- No undo for thread selection (minor) +- Can't peek at view range without pushing +- No validation of timestamp names before pushing + +**Workflow Efficiency:** + +- Too many steps to compare threads during a spike +- No way to iterate quickly through interesting ranges +- Must manually correlate information across commands + +--- + +### Design Recommendations + +This section addresses key design questions that arose during the case study. + +#### 1. Timestamp Display: Always Show Both + +**Current issue:** Compact names (ts-6) are opaque; long timestamps are hard to remember. + +**Recommendation:** Show both everywhere: + +``` +Pushed view range: ts-6 (2.701s) to ts-7 (3.092s) +Popped view range: ts-6 (2.701s) to ts-7 (3.092s) +``` + +**Benefits:** + +- Compact names for scripting/reference: `view push ts-6,ts-7` +- Human-readable context for understanding +- Consistency between push and pop + +#### 2. Timestamp Range Input: Support Multiple Formats + +**Current issue:** "ts-6,ts-7" is verbose - requires copying from profile info output. + +**Recommendation:** Accept multiple formats, parse intelligently: + +```bash +view push ts-6,ts-7 # Timestamp names (current) +view push 2.7,3.1 # Relative seconds (new, most ergonomic) +view push 2.7s,3.1s # Explicit unit (new) +view push 2700ms,3100ms # Milliseconds (new) +view push 10%,20% # Percentage through profile (new) +``` + +**Default unit:** Seconds (most natural) + +**Benefits:** + +- Fast iteration: `view push 2.7,3.1` is much shorter than `view push ts-6,ts-7` +- Intuitive: "zoom into 2.7 to 3.1 seconds" is clear +- Backward compatible: timestamp names still work +- Scriptable: can compute times programmatically + +**Implementation note:** Detect format by pattern - if contains `ts-`, use name lookup; if numeric, parse as time; if contains `%`, parse as percentage. + +#### 3. Separate Commands for Sample Views + +**Current issue:** `thread samples` dumps everything. Flags like `--limit` or `--min-self-time` would apply to all sections, which is awkward. + +**Recommendation:** Split into focused commands: + +```bash +thread samples-top [--limit=N] [--min-self=1%] [--by={total|self}] + # Just top functions by total/self time + +thread samples-tree [--max-depth=N] [--min-percentage=1%] + # Just regular call tree + +thread samples-inverted [--max-depth=N] [--min-percentage=1%] + # Just inverted call tree + +thread samples-heaviest [--no-skip | --max-frames=N] + # Just heaviest stack + +thread samples # Keep for backward compatibility + # All views (current behavior) +``` + +**Benefits:** + +- Each view has appropriate parameters +- Faster output when you only need one view +- More composable with shell tools (`| grep`, `| less`) +- Can set sensible per-view defaults + +**Alternative considered:** `thread samples --view=top --limit=50` + +- Rejected: less ergonomic, harder to discover views + +#### 4. Heaviest Stack Truncation: Increase Cap with Safety Limit + +**Current issue:** 27 frames shown, 42 skipped - way too aggressive. + +**Recommendation:** + +- Default: Show up to **200 frames** (covers 99% of real stacks) +- Safety: Cap at **500 frames** to prevent terminal flooding from infinite recursion +- Flag: `--max-frames=N` to override +- Never skip in the middle - if truncated, show first N frames with clear message: + ``` + ... (300 more frames omitted - use --max-frames to see all) + ``` + +**Rationale:** + +- 200 frames handles even deep template/async stacks +- 500 frame safety net catches bugs +- Skipping frames in the middle destroys diagnostic value + +#### 5. Function Names: Smart Truncation, No Handles + +**Issue:** Long C++ names with templates are verbose and hard to scan. + +**Recommendation: Smart truncation without handles** + +Function handles (`f-234`) add cognitive overhead and indirection. Instead: + +**Length cap: 100 characters** with smart truncation: + +``` +# Original (150 chars): +std::_Hash,std::equal_to>,std::allocator>,0>>::~_Hash() + +# Truncated (100 chars): +std::_Hash>::~_Hash() +``` + +**Rules:** + +1. Keep module/library name: `nvoglv64.dll!` always shown +2. Keep actual function name: `~_Hash()` always shown +3. Truncate middle of namespaces/templates: `...` +4. Preserve enough to be unique in context + +**For call trees:** Even more aggressive (60 char limit) since indentation eats space: + +``` +mozilla::wr::RenderThread::UpdateAndRender(...) +``` + +**Benefits:** + +- No cognitive overhead of handle indirection +- Still readable at a glance +- No need for separate lookup command +- Can copy/paste into search + +**Alternative considered:** Function handles like `f-234` + +- Rejected: requires mental mapping, breaks copy/paste, adds complexity + +#### 6. Function Search: Use `thread functions` with Grep Patterns + +**Current issue:** No way to search for functions. + +**Recommendation:** + +```bash +thread functions # List all functions with CPU time +thread functions -E "nsTree" # Regex filter (like grep -E) +thread functions -i "layout" # Case-insensitive (like grep -i) +thread functions --min-self=1% # Only functions with >1% self time +``` + +**Output format:** + +``` +Functions in thread t-0 (GeckoMain): + 42.2% ZwUserMsgWaitForMultipleObjectsEx + 26.4% ZwWaitForAlertByThreadId + 8.1% NtUserMessageCall + 1.3% memset + ... (showing 45 of 6623 functions) +``` + +**Benefits:** + +- Familiar grep-style interface +- Composable: can still pipe to grep for more filtering +- Consistent with ripgrep conventions + +**Name:** `thread functions` (not `thread search`) because it's listing/filtering functions, not searching arbitrary text. + +#### 7. Command Structure: Clarify State vs Time Range + +**Current issue:** Inconsistency between `thread select` and `view push-range` - both change "view state" but use different command prefixes. + +**Recommendation: Separate concerns clearly** + +Two types of state: + +1. **Thread selection** - which thread to analyze +2. **Time range** - which time window to analyze + +**Proposed structure:** + +```bash +# Thread selection +thread select t-93 # Select thread +thread info # Info for selected thread +thread samples-top # Samples for selected thread + +# Time range (keep "view" for time, since it's the "view" into the timeline) +view push ts-6,ts-7 # Push time range +view pop # Pop time range +view clear # Clear all ranges (back to full profile) +view list # Show range stack + +# Status (what's my current context?) +status # Show session, selected thread, range stack + # Output: + # Session: ttzltpqjsi (profile: https://share.firefox.dev/4oLEjCw) + # Thread: t-93 (Renderer) + # View ranges: [ts-6 (2.701s) → ts-7 (3.092s)] +``` + +**Alternative considered:** `time-range push` instead of `view push` + +- Rejected: "view" is shorter, intuitive (you're changing your view of the timeline) +- "time-range" is verbose and awkward + +**Alternative considered:** `view` command shows status + +- Rejected: `view push/pop/clear` makes `view` ambiguous (verb vs noun) +- Better to have explicit `status` command + +**Benefits:** + +- Clear separation: `thread` = which thread, `view` = which time +- Consistent: all state changes are explicit commands +- `status` shows everything at once + +#### 8. Function Name Repetition: Acceptable with Module Context + +**Issue:** Function names appear many times in call tree output. + +**Analysis:** This is actually fine and expected: + +- Call trees inherently repeat names (parent nodes) +- Module prefixes (`nvoglv64.dll!`) add context, not noise +- Truncation (rule #5) keeps length manageable +- Terminal scrollback handles repetition well + +**No action needed.** The proposed module display and truncation rules are sufficient. + +#### 9. View Range Ergonomics: Range Names for Common Patterns + +**Additional idea:** For common access patterns, support named ranges: + +```bash +view push spike:1 # First detected CPU spike >80% +view push spike:next # Next spike after current range +view push frame:5 # 5th vsync frame (if markers present) +view push longest-frame # Longest frame in profile +``` + +**Implementation:** These would be computed on-demand, not persisted. + +**Benefits:** + +- Very fast exploration: "show me the spikes" +- No need to manually parse profile info output +- Great for CI/CD: "report on longest frame" + +**Priority:** Medium (do after basic time format support) + +--- + +### Specific Improvements Needed + +See **Design Recommendations** section above for detailed proposals on command structure, timestamp formats, and function display. + +**High Priority:** + +1. **Show library/module names** - essential context even without symbols (Design Rec. §1) +2. **Fix timestamp display** - show both compact name and readable time (Design Rec. §1) +3. **Support time formats** - accept seconds, ms, % in addition to timestamp names (Design Rec. §2) +4. **Separate sample commands** - `thread samples-top`, `samples-tree`, etc. (Design Rec. §3) +5. **Deeper call trees** - show 30+ levels by default, cap at 200 for call trees +6. **Fix heaviest stack truncation** - show up to 200 frames, never skip middle (Design Rec. §4) +7. **Implement markers** - huge gap in functionality + +**Medium Priority:** + +8. **Status command** - show session/thread/range state (Design Rec. §7) +9. **Function listing** - `thread functions -E "pattern"` (Design Rec. §6) +10. **Smart function truncation** - 100 char cap, preserve module + function name (Design Rec. §5) +11. **Cross-thread views** - compare threads during same range +12. **Named ranges** - `view push spike:1`, `longest-frame` (Design Rec. §9) + +**Low Priority:** + +13. **Export results** - save to file +14. **Progress indicators** - loading large profiles +15. **Color output** - highlight high percentages in output + +--- + +### Comparison to Web UI + +**profiler-cli Advantages:** + +- Much faster for quick triage +- Easy to script/automate +- Lower memory usage +- Works over SSH +- Can process many profiles in batch + +**Web UI Advantages:** + +- Visual timeline shows everything at once +- Mouse hover reveals details instantly +- Can see multiple threads simultaneously +- Marker tooltips show rich information +- Source view integration +- Network panel, memory tracks, etc. + +**Ideal Use Cases for profiler-cli:** + +- Quick "what's slow?" triage +- CI/CD performance monitoring +- Batch analysis of many profiles +- Server-side investigation (no GUI) +- Extracting specific data for reports + +**Where profiler-cli Falls Short:** + +- Complex multi-thread timing issues +- Understanding frame scheduling +- Correlating markers with samples +- Visual pattern recognition +- Exploratory analysis without hypothesis + +--- + +### Overall Assessment + +**Current State: Promising Foundation (60% there)** + +profiler-cli successfully demonstrates that CLI profiling is viable and valuable. The core architecture (daemon model, thread selection, view ranges) is sound. For profiles where you can identify which libraries are consuming time and single-threaded bottlenecks, it works reasonably well. + +**Critical Gaps:** + +- Library/module context is essential - without it, functions are unidentifiable blobs +- Output truncation hides too much information +- Missing marker support eliminates half of profiling value + +**Recommendation:** +Fix the three critical gaps above before adding new features. A tool that shows incomplete information (truncated trees, missing module context, no markers) frustrates users more than missing features. + +**Potential:** +If library names, depth, and markers are addressed, profiler-cli could become the standard first-response tool for performance issues. "Run profiler-cli first, open web UI if needed" would be a great workflow. + +**Estimated to "Production Ready":** + +- With critical fixes: 2-3 weeks +- With medium priority features: 4-6 weeks +- With low priority polish: 8-10 weeks + +The foundation is solid. The gaps are addressable. The value proposition is clear. + +--- + +### Summary of Key Design Decisions + +Based on the case study investigation, here are the recommended design directions: + +**1. Command Structure** (Design Rec. §7) + +```bash +thread select t-93 # Select which thread +thread samples-top # View top functions (separate commands per view) +view push 2.7,3.1 # Push time range (view = time window) +status # Show current state +``` + +- `thread` for thread operations, `view` for time ranges, `status` for context +- No function handles (f-234) - use smart truncation instead + +**2. Time Range Input** (Design Rec. §2) + +```bash +view push ts-6,ts-7 # Timestamp names (keep for compatibility) +view push 2.7,3.1 # Seconds (NEW, default - most ergonomic) +view push 2700ms,3100ms # Milliseconds (NEW) +view push 10%,20% # Percentage (NEW) +``` + +- Always display both: "ts-6 (2.701s)" in output + +**3. Sample View Commands** (Design Rec. §3) + +- Separate commands: `samples-top`, `samples-tree`, `samples-inverted`, `samples-heaviest` +- Each has appropriate flags: `--limit`, `--max-depth`, `--min-self`, etc. +- Keep `thread samples` for backward compatibility (shows all) + +**4. Function Display** (Design Rec. §5) + +- Show module names: `nvoglv64.dll!fun_a56960` +- Smart truncation: 100 chars max, preserve module + function name +- Call trees: 60 chars (indentation eats space) +- No function handles + +**5. Output Limits** + +- Top functions: Show 50 by default (was 20) +- Call tree depth: Show 30+ levels by default (was ~10) +- Heaviest stack: Show 200 frames (was 27), never skip middle +- Safety cap: 500 frames max to catch infinite recursion + +**6. Function Search** (Design Rec. §6) + +```bash +thread functions # List all with CPU% +thread functions -E "nsTree" # Regex filter (grep-style) +``` + +**7. Status/Context** + +```bash +status # Show session, selected thread, view range stack +``` + +These decisions prioritize: + +- **Ergonomics**: `view push 2.7,3.1` is much faster than `view push ts-6,ts-7` +- **Consistency**: Clear separation between `thread` (which) and `view` (when) +- **Readability**: Module names and smart truncation over handles +- **Composability**: Separate commands work better with pipes/scripts +- **Discoverability**: Grep-style flags, clear command names diff --git a/profiler-cli-filters-and-bookmarks-proposal.md b/profiler-cli-filters-and-bookmarks-proposal.md new file mode 100644 index 0000000000..f3cb652386 --- /dev/null +++ b/profiler-cli-filters-and-bookmarks-proposal.md @@ -0,0 +1,488 @@ +# Profiler CLI Filters and Bookmarks Proposal + +**Status:** Phase 1 + Phase 2 implemented; Phase 3 (strip-prefix, strip-suffix, merge-regex) and marker display filters and bookmarks are future work +**Created:** 2025-01-04 +**Last Updated:** 2026-04-11 +**Related:** profiler-cli-todo.md + +--- + +## Key Design Decisions + +Based on feedback, this proposal includes: + +1. ✅ **Consistent terminology** - `push/pop/clear` for zoom and filters, `load/unload` for bookmarks +2. ✅ **Multi-thread selection** - `thread select t-0,t-93` works for sticky state +3. ✅ **Clear OR vs AND** - `-any` suffix for OR, repeated flags for AND +4. ✅ **Per-profile bookmarks** - scoped to current profile, not global (future) +5. ✅ **Zoom validation** - nested ranges must be contained within parent +6. ✅ **Unified naming** - `--includes-prefix` instead of `--starts-with-function`/`--starts-with-sequence` +7. ✅ **Balanced zoom syntax** - `zoom push ts-6,ts-7` and `zoom push m-158` +8. ✅ **Single filter stack** - one ordered stack for sample/stack filters (order matters for dependencies) +9. ✅ **Prefix means exact sequence** - `--includes-prefix f-1,f-2,f-3` means starts with f-1→f-2→f-3 exactly +10. ✅ **Per-thread filter stacks** - each thread has its own filter context +11. ✅ **Function handles are global** - `f-N` is index N into the shared funcTable, stable across sessions and threads +12. ⏳ **Separate marker filters** - marker display filtering independent from sample/stack filtering (future) + +--- + +## Overview + +This proposal defines a comprehensive system for managing analysis state in profiler-cli with **four independent dimensions**: + +1. **Thread selection** (global) - which thread(s) you're analyzing +2. **Zoom** (global) - time range you're focused on +3. **Sample/Stack filters** (per-thread) - how to filter and transform samples ✅ implemented +4. **Marker filters** (per-thread) - how to filter marker display ⏳ future + +Each dimension supports: + +- **Ephemeral use** - apply once via flags (applies to that command only) +- **Sticky state** - persists across commands via push/pop/clear +- **Bookmarks** - save and restore complex views ⏳ future + +--- + +## Design Principles + +1. **Ephemeral by default** - All commands accept filter flags that apply only to that invocation +2. **Explicit stickiness** - Making state sticky requires explicit commands (`select`, `zoom push`, `filter push`) +3. **Clear state** - `profiler-cli status` always shows current thread, zoom, and active filters +4. **Composable** - Filters, zoom, and thread selection are independent dimensions +5. **Saveable** - Complex views can be bookmarked and recalled ⏳ future + +--- + +## Core Syntax + +### 1. Ephemeral Filters (Flags) ✅ implemented + +The following filter flags work on `thread samples`, `thread samples-top-down`, +`thread samples-bottom-up`, and `thread functions`. They apply only to that one +invocation and do not affect the sticky filter stack. + +```bash +# Ephemeral sample inclusion/exclusion +profiler-cli thread samples --includes-function f-142 +profiler-cli thread samples --includes-any-function f-142,f-143 +profiler-cli thread samples --includes-prefix f-100,f-101,f-142 +profiler-cli thread samples --includes-suffix f-142 +profiler-cli thread samples --excludes-function f-142 +profiler-cli thread samples --excludes-any-function f-142,f-143 + +# Ephemeral marker-based filters +profiler-cli thread samples --during-marker --search Paint +profiler-cli thread samples --outside-marker --search GC + +# Ephemeral stack transforms +profiler-cli thread samples --merge f-142,f-143 +profiler-cli thread samples --root-at f-142 + +# Multiple ephemeral filters on one command — applied in left-to-right order +profiler-cli thread samples --excludes-function f-142 --merge f-143 --during-marker --search Paint + +# Ephemeral and sticky filters compose: +# sticky filters are applied first (already in the Redux transform stack), +# then ephemeral filters are layered on top for that one invocation. +profiler-cli thread functions --limit 20 --excludes-function f-142 +``` + +**Not yet implemented for ephemeral use:** + +- `--zoom ` on thread commands (use `profiler-cli zoom push` instead) +- `--threads t-0,t-93` on thread commands (use `profiler-cli thread select` instead) +- `--strip-prefix`, `--strip-suffix`, `--merge-regex` (Phase 3, see below) +- `--during-marker --category` (only `--search` is supported) + +### 2. Sticky Thread Selection ✅ implemented + +```bash +profiler-cli thread select t-93 # Select single thread (sticky) +profiler-cli thread samples # Uses t-93 + +profiler-cli thread select t-0,t-93 # Select multiple threads (sticky) +profiler-cli thread samples # Uses both threads + +profiler-cli thread select t-0 # Switch to different thread +``` + +### 3. Sticky Zoom (Stack-based) ✅ implemented + +```bash +profiler-cli zoom push ts-6,ts-7 # Push zoom level +profiler-cli thread samples # Uses zoomed range + +profiler-cli zoom push ts-6a,ts-6c # Zoom further (within previous range) +profiler-cli thread samples # Uses nested zoom + +profiler-cli zoom pop # Pop one zoom level (back to ts-6,ts-7) +profiler-cli zoom pop # Pop again (back to full profile) + +profiler-cli zoom clear # Clear entire zoom stack + +profiler-cli zoom push m-158 # Zoom to marker's time range +``` + +### 4. Sticky Sample/Stack Filters (Per-Thread) ✅ implemented + +Each thread has its own filter stack. `profiler-cli filter push` appends a filter that +persists across all subsequent analysis commands until popped. + +Function handles (`f-N`) identify index N in the shared funcTable — they are +stable across sessions for the same profile. + +```bash +# Select thread first +profiler-cli thread select t-93 + +# Push filters onto THIS THREAD's stack (applied in push order) + +# Include/exclude samples +profiler-cli filter push --includes-function f-142 # keep samples whose stack contains f-142 +profiler-cli filter push --includes-any-function f-142,f-143 # keep samples with f-142 OR f-143 +profiler-cli filter push --includes-prefix f-100,f-200 # keep samples whose stack starts f-100→f-200 +profiler-cli filter push --includes-suffix f-142 # keep samples whose leaf frame is f-142 +profiler-cli filter push --excludes-function f-142 # drop samples containing f-142 +profiler-cli filter push --excludes-any-function f-142,f-143 # drop samples containing f-142 or f-143 +profiler-cli filter push --during-marker --search Paint # keep samples during Paint markers +profiler-cli filter push --outside-marker --search GC # keep samples outside GC markers + +# Stack transforms +profiler-cli filter push --merge f-142,f-143,f-144 # collapse these functions out of stacks +profiler-cli filter push --root-at f-142 # re-root all stacks at f-142 + +# Order matters — each filter sees stacks as left by the previous filter: +profiler-cli filter push --root-at f-100 # 1. re-root at f-100 +profiler-cli filter push --includes-prefix f-100 # 2. keep only stacks starting with f-100 +# Filter 2 operates on stacks already transformed by filter 1 + +# Management +profiler-cli filter list # Show filters for current thread +profiler-cli filter pop # Pop most recent filter +profiler-cli filter pop 3 # Pop 3 most recent filters +profiler-cli filter clear # Clear all filters for current thread + +# Switch threads — different filter stack! +profiler-cli thread select t-0 +profiler-cli filter list # t-0's filters (independent from t-93) + +# All analysis commands use current thread's filters automatically +profiler-cli thread samples +profiler-cli thread functions +profiler-cli thread samples-top-down +``` + +**Not yet implemented (Phase 3 / deferred):** + +- `--strip-prefix f-1,f-2,f-3` — structural stack mutation +- `--strip-suffix f-x` — structural stack mutation +- `--merge-regex "^pattern"` — regex-based merge across funcTable + +### 5. Sticky Marker Filters (Per-Thread) ⏳ not yet implemented + +Planned: each thread will also have its own marker filter stack controlling +which markers appear in `profiler-cli thread markers` output. + +```bash +# Future syntax (not yet implemented) +profiler-cli marker filter push --search Paint +profiler-cli marker filter push --category Graphics +profiler-cli marker filter push --min-duration 5 +profiler-cli marker filter pop +profiler-cli marker filter clear +``` + +Note: sample filters that reference markers (`--during-marker`, `--outside-marker`) +are already implemented and operate on the **unfiltered** marker set, independent +of any future marker display filters. + +### 6. Bookmarks ⏳ not yet implemented + +Planned: save and restore complex views (zoom + thread selection, or filter stacks). + +```bash +# Future syntax (not yet implemented) +profiler-cli bookmark view spike1 --zoom ts-6,ts-7 --threads t-0,t-93 +profiler-cli bookmark filter no-allocators --merge f-142,f-143 +profiler-cli thread samples --view spike1 # ephemeral bookmark use +profiler-cli bookmark load view spike1 # sticky bookmark use +profiler-cli bookmark list +profiler-cli bookmark delete spike1 +``` + +### 7. Status Command ✅ implemented + +Shows current thread, zoom stack, and all active filter stacks: + +```bash +profiler-cli status + +# Example output: +Session Status: + Selected thread: t-0 (GeckoMain) + View range: ts-6 to ts-7 + Filters for t-0: + 1. [merge] merge: f-142, f-143 + 2. [during-marker] during marker matching: "Paint" + 3. [includes-function] includes function: f-200 +``` + +--- + +## Filter Types + +### Sample Filters (Inclusion/Exclusion) ✅ implemented + +Control which samples are included in analysis. All take `f-N` function handles. + +```bash +# Include samples where stack contains the function(s) +--includes-function f-142 # contains f-142 (repeat push for AND) +--includes-any-function f-142,f-143 # contains f-142 OR f-143 (single push) + +--includes-prefix f-1,f-2,f-3 # stack starts with f-1→f-2→f-3 (root-first) +--includes-suffix f-999 # leaf frame is f-999 + +--during-marker --search Paint # timestamp falls within a matching marker + +# Exclude samples where stack contains the function(s) +--excludes-function f-142 +--excludes-any-function f-142,f-143 # contains f-142 OR f-143 + +--outside-marker --search GC # timestamp outside matching markers + +# AND semantics via repeated push: +profiler-cli filter push --includes-function f-1 # keep samples with f-1 +profiler-cli filter push --includes-function f-2 # AND also with f-2 +``` + +**Not yet implemented:** + +- `--during-marker --category ` (only `--search` supported) + +### Stack Transform Filters ✅ partially implemented + +Modify the structure of stacks before or after sample filtering. + +```bash +# Merge: remove functions from stacks, collapsing their callers to callees +--merge f-142,f-143 # f-A → f-142 → f-B becomes f-A → f-B +--root-at f-142 # re-root all stacks at f-142 (focus-function) +``` + +**Phase 3 (not yet implemented):** + +```bash +--strip-prefix f-1,f-2,f-3 # remove leading root frames from stacks +--strip-suffix f-999 # remove trailing leaf frames from stacks +--merge-regex "^(malloc|free)" # merge functions matching a regex +``` + +--- + +## Example Scenarios + +### Scenario 1: Quick CPU Spike Investigation + +```bash +# See profile overview +$ profiler-cli profile info +# Output shows a spike around ts-6,ts-7 + +# Quick peek at that spike +$ profiler-cli zoom push ts-6,ts-7 +$ profiler-cli thread samples --limit 20 + +# Check what's happening in the Renderer thread during that time +$ profiler-cli thread samples --thread t-26 --limit 20 + +# Make thread selection sticky and investigate further +$ profiler-cli thread select t-26 +$ profiler-cli thread samples + +# Done with spike +$ profiler-cli zoom pop +``` + +### Scenario 2: Eliminating Allocator Noise + +```bash +# Call tree shows allocator noise +$ profiler-cli thread functions --search malloc +# → f-142. libmalloc!malloc_zone_malloc - self: 8.2% + +# Try merging ephemerally first +$ profiler-cli thread samples --merge f-142,f-143 --limit 30 +# Better! See actual work + +# Make it sticky +$ profiler-cli filter push --merge f-142,f-143 +$ profiler-cli thread samples +# Clean call tree across all subsequent commands + +# Remove when done +$ profiler-cli filter clear +``` + +### Scenario 3: Analyzing Time in a Specific Function + +```bash +# Find an expensive function +$ profiler-cli thread functions --search PresentImpl +# → f-500. XUL!CDXGISwapChain::PresentImpl - self: 16.4% + +# Ephemeral: see the subtree rooted at it +$ profiler-cli thread samples --root-at f-500 --limit 30 + +# Sticky: focus all analysis on samples containing it +$ profiler-cli filter push --includes-function f-500 +$ profiler-cli filter push --root-at f-500 +$ profiler-cli thread samples +$ profiler-cli thread functions + +# Clear when done +$ profiler-cli filter clear +``` + +### Scenario 4: Cross-Thread Causality + +```bash +# Look at main thread markers +$ profiler-cli thread select t-0 +$ profiler-cli marker info m-158 +# WindowProc WM_PAINT at ... (33.52ms) + +# Zoom to that marker +$ profiler-cli zoom push m-158 + +# See what the Renderer thread was doing during the same window +$ profiler-cli thread samples --thread t-26 --limit 20 + +# Make it sticky and drill further +$ profiler-cli thread select t-26 +$ profiler-cli filter push --during-marker --search Paint +$ profiler-cli thread samples +# Now only see Renderer work that happened during Paint markers +$ profiler-cli filter clear + +$ profiler-cli zoom pop +``` + +--- + +## Implementation Status + +### Phase 1 — Implemented ✅ (Redux transform-backed) + +| Flag | Redux transform | +| ------------------------------------------------------------- | ---------------------------------- | +| `--excludes-function f-x` / `--excludes-any-function f-x,f-y` | `drop-function` (one per func) | +| `--merge f-x,f-y,f-z` | `merge-function` (one per func) | +| `--root-at f-x` | `focus-function` | +| `--during-marker --search Text` | `filter-samples` (`marker-search`) | + +### Phase 2 — Implemented ✅ (extended `filter-samples` transforms) + +New `FilterSamplesType` values in `src/types/transforms.ts` / `src/profile-logic/transforms.ts`: + +| Flag | filterType | Logic | +| ------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------- | +| `--includes-function f-x` / `--includes-any-function f-x,f-y` | `function-include` | Walk stackTable; keep samples whose stack contains any of the funcs | +| `--includes-prefix f-1,f-2,f-3` | `stack-prefix` | Keep samples whose root-first frame sequence matches | +| `--includes-suffix f-x` | `stack-suffix` | Keep samples whose leaf frame is the given func | +| `--outside-marker --search Text` | `outside-marker` | Inverse of `marker-search` | + +### Phase 3 — Deferred + +- `--strip-prefix f-1,f-2,f-3` — requires new structural stack transform (removes leading frames) +- `--strip-suffix f-x` — requires new structural stack transform (removes trailing frames) +- `--merge-regex "^pattern"` — requires regex scan of the funcTable before dispatching transforms + +### Architecture + +- **`src/profile-query/filter-stack.ts`** — `FilterStack` class: per-thread `Map`; `push()` dispatches Redux transforms immediately; `pop()` uses `POP_TRANSFORMS_FROM_STACK` to unwind them; `pushSpecTransforms()` exported for ephemeral use +- **`ProfileQuerier`** — `filterPush/Pop/List/Clear` for sticky filters; `_withEphemeralFilters()` for one-shot use; filter state in `getStatus()` +- Sticky filters live in the Redux transform stack and are automatically applied by all analysis commands via `getFilteredThread()` +- Ephemeral filters are pushed on top of sticky ones for the duration of one call, then popped +- `outside-marker`, `function-include`, `stack-prefix`, `stack-suffix` are profiler-cli-only and not URL-serializable + +--- + +## Implementation Notes + +### Per-Thread Filter Scoping + +**Why per-thread?** The Redux transform stack is per-thread (`ThreadsKey` → `TransformStack`). Pushing a filter dispatches to a specific thread's transform stack. + +Function handles themselves are global (index into `profile.shared.funcTable`), so `f-142` means the same function regardless of which thread's filter stack it is pushed onto. + +```bash +profiler-cli thread select t-0 +profiler-cli filter push --includes-function f-142 # on t-0's transform stack + +profiler-cli thread select t-26 +profiler-cli filter list # t-26's stack (empty — independent from t-0) +``` + +### Filter Application Order + +Filters are applied in **push order**. Because each filter sees the thread data +as transformed by all previous filters, the order determines the semantics: + +```bash +# Root-at first, then include-prefix: the prefix check runs on re-rooted stacks +profiler-cli filter push --root-at f-100 +profiler-cli filter push --includes-prefix f-100 + +# Merge first, then include: only samples where f-142 appears AFTER merging allocators +profiler-cli filter push --merge f-300,f-301 +profiler-cli filter push --includes-function f-142 +``` + +### Actual `profiler-cli status` Output Format + +``` +Session Status: + Selected thread: t-0 (GeckoMain) + View range: Full profile + Filters for t-0: + 1. [excludes-function] excludes function: f-184 + 2. [during-marker] during marker matching: "Paint" + 3. [includes-function] includes function: f-200 +``` + +Filter type labels in output match the `SampleFilterSpec.type` field: +`excludes-function`, `merge`, `root-at`, `during-marker`, `includes-function`, +`includes-prefix`, `includes-suffix`, `outside-marker`. + +--- + +## Open Questions + +1. ✅ **Multiple threads in sticky state?** - RESOLVED: `thread select t-0,t-93` +2. ✅ **Filter combination logic?** - RESOLVED: `-any` suffix for OR, repeated pushes for AND +3. ✅ **Zoom validation?** - RESOLVED: nested zooms validated against parent range +4. ✅ **Bookmark namespaces?** - RESOLVED design (per-profile); implementation deferred +5. ✅ **Prefix semantics?** - RESOLVED: `--includes-prefix f-1,f-2,f-3` means exactly f-1→f-2→f-3 root-first +6. ✅ **Filter application order?** - RESOLVED: single ordered stack applied in push order +7. ✅ **Function handle scope?** - RESOLVED: `f-N` is global (shared funcTable index), not per-thread + +--- + +## Summary + +This proposal creates a consistent, composable system for managing analysis state in profiler-cli: + +- **Ephemeral filters** via flags on any thread analysis command — not persisted ✅ +- **Sticky filters** via `filter push/pop/clear` — persists across commands ✅ +- **Consistent terminology**: push/pop/clear for zoom and filters, load/unload for bookmarks +- **Stack-based**: zoom and filters can be layered and unwound ✅ +- **Single filter stack**: one ordered stack for all filters per thread (order matters!) ✅ +- **OR vs AND semantics**: `-any` suffix for OR within a single push, repeated pushes for AND ✅ +- **Global function handles**: `f-N` stable across sessions and threads ✅ +- **Status** always shows current context including active filters ✅ +- **Composable**: ephemeral filters layer on top of sticky ones ✅ +- **Nested zoom validation**: ranges must be properly contained ✅ +- **Bookmarks**: design resolved, implementation deferred ⏳ +- **Marker display filters**: design resolved, implementation deferred ⏳ +- **Phase 3 stack transforms** (`--strip-prefix/suffix`, `--merge-regex`): deferred ⏳ diff --git a/profiler-cli-marker-support-plan.md b/profiler-cli-marker-support-plan.md new file mode 100644 index 0000000000..f14994d66f --- /dev/null +++ b/profiler-cli-marker-support-plan.md @@ -0,0 +1,816 @@ +# Marker Support Implementation Plan for Profiler CLI + +## Overview + +This document outlines the implementation plan for adding comprehensive marker support to profiler-cli. Markers provide ~50% of profiling insight (Layout/Reflow, JavaScript names, IPC messages, GPU boundaries) and are a critical missing feature. + +## Background + +### Marker Data Model (from codebase analysis) + +**Core Types:** + +- `Marker`: `{ start: ms, end: ms | null, name: string, category: number, data: MarkerPayload | null, threadId: Tid | null }` +- `MarkerPayload`: Union of 30+ specific payload types (Network, GCMajor, FileIO, IPC, DOMEvent, etc.) +- `MarkerSchema`: Defines display rules (`tooltipLabel`, `tableLabel`, `chartLabel`, `fields[]`, `display[]`, `description`) + +**Web UI Views:** + +1. **Marker Chart**: Rows grouped by (category, name), shows rectangles for intervals or diamonds for instants + - Each marker has a `chartLabel` (can be templated from payload data) + - Markers filtered by `display: ['marker-chart']` in schema +2. **Marker Table**: One row per marker with columns: start, end, name, tableLabel + - `tableLabel` is templated from marker schema +3. **Tooltip/Sidebar**: Shows `tooltipLabel`, all field key-value pairs, type description, stack trace if available + +**Key Properties:** + +- Instant markers: `end === null` +- Interval markers: `end !== null` +- Markers can have stacks (cause backtraces) +- Markers can have rich structured data (via MarkerSchema fields) +- Tens of thousands of markers per thread is common + +## Problem Statement + +We cannot naively print all markers and their fields to the CLI because: + +1. Profiles often contain 10,000+ markers per thread +2. Each marker can have 5-15 fields with verbose values +3. This would overwhelm both the reader and the LLM context window +4. Different marker types need different presentation strategies + +## Design Principles + +1. **Aggregation First**: Show summaries, not raw data +2. **Progressive Disclosure**: Start with overview, allow drilling down +3. **Context-Aware Grouping**: Group markers intelligently based on their characteristics +4. **Actionable Insights**: Present data that helps diagnose performance issues +5. **Format Flexibility**: Support both human-readable and machine-parseable output + +## Implementation Phases + +### Phase 1: Basic Marker Listing (MVP) + +**Goal**: Show high-level marker distribution and basic statistics + +**Commands:** + +```bash +profiler-cli thread markers # List marker groups with counts +profiler-cli thread markers --summary # Show aggregate statistics +profiler-cli marker info # Show details for a specific marker +``` + +**`profiler-cli thread markers` output:** + +``` +Markers in thread t-93 (Renderer) — 14,523 markers + +By Type (top 15): + Reflow 4,234 markers (interval: min=0.12ms, avg=2.3ms, max=45.2ms) + DOMEvent 3,891 markers (interval: min=0.01ms, avg=0.5ms, max=12.1ms) + Styles 2,456 markers (interval: min=0.05ms, avg=1.2ms, max=8.7ms) + JavaScript 1,823 markers (instant) + Paint 892 markers (interval: min=0.3ms, avg=5.1ms, max=23.4ms) + Network 234 markers (interval: min=5.2ms, avg=234.5ms, max=2341.2ms) + GCSlice 156 markers (interval: min=0.8ms, avg=12.3ms, max=156.7ms) + IPC (IPCOut) 89 markers (interval: min=0.01ms, avg=2.1ms, max=45.2ms) + ... (7 more types) + +By Category: + Layout 6,892 markers (47.5%) + JavaScript 4,234 markers (29.1%) + Graphics 1,456 markers (10.0%) + ... (4 more categories) + +Rate Analysis (markers/second): + DOMEvent: 45.2 markers/sec (rate: min=0.5ms, avg=22.1ms, max=2341ms) + Reflow: 12.3 markers/sec (rate: min=1.2ms, avg=81.2ms, max=5234ms) + Styles: 8.9 markers/sec (rate: min=2.1ms, avg=112.4ms, max=8912ms) + +Use --type to filter, --details for per-marker info, or m- handles to inspect individual markers. +``` + +**`profiler-cli thread markers --summary` output:** + +``` +Marker Summary for thread t-93 (Renderer) + +Total markers: 14,523 +Time range: 2.145s - 15.891s (13.746s) +Marker types: 22 +Marker categories: 7 + +Instant markers: 2,891 (19.9%) +Interval markers: 11,632 (80.1%) + +Duration statistics (interval markers only): + Min: 0.01ms + Avg: 3.4ms + Median: 1.2ms + P95: 12.3ms + P99: 45.6ms + Max: 234.5ms + +Longest intervals: + m-1234: Paint - 234.5ms (7.234s - 7.469s) + m-5678: GCMajor - 156.7ms (10.123s - 10.280s) + m-3456: Reflow - 89.3ms (12.456s - 12.545s) + m-7890: Network (https://example.com/api) - 2341.2ms (3.234s - 5.575s) + m-2345: DOMEvent (click) - 45.2ms (8.123s - 8.168s) +``` + +**Implementation Tasks:** + +- [x] Create `formatters/marker-info.ts` for marker formatting logic +- [x] Implement `MarkerMap` class (similar to `ThreadMap`, `FunctionMap`) +- [x] Add marker aggregation functions (group by type, category, compute stats) +- [x] Add `ProfileQuerier.threadMarkers()` method +- [x] Add `ProfileQuerier.markerInfo(markerHandle)` method +- [x] Wire up in protocol.ts, daemon.ts, index.ts +- [x] Add unit tests for utility functions (formatDuration, computeDurationStats, computeRateStats) + +**Status: ✅ COMPLETE** (Commits: 25eaf637, 63e5d2d7) + +**Data Structures:** + +```typescript +// MarkerMap for handle management +class MarkerMap { + private markers: Marker[] = []; + private handleToIndex: Map = new Map(); + private indexToHandle: Map = new Map(); + + registerMarker(marker: Marker, index: MarkerIndex): string; + getMarker(handle: string): Marker | null; + getMarkers(): Marker[]; +} + +// Aggregation structures +interface MarkerTypeStats { + typeName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: Array<{ handle: string; label: string; duration?: number }>; +} + +interface DurationStats { + min: number; + max: number; + avg: number; + median: number; + p95: number; + p99: number; +} + +interface RateStats { + markersPerSecond: number; + minGap: number; + avgGap: number; + maxGap: number; +} +``` + +### Phase 2: Filtering and Search + +**Goal**: Allow users to filter markers by various criteria + +**Status: ✅ COMPLETE** (Commits: 1e478dcc, 43ccb20c) + +**What was implemented:** + +Phase 2 provides both text-based search and duration-based filtering: + +**Search filtering** leverages the existing marker search functionality built into the profiler codebase. This approach: + +- Reuses tested, proven code from the web UI +- Avoids duplicating complex filtering logic +- Provides a simple, flexible search interface + +**Duration filtering** is implemented by filtering marker indexes after search filtering: + +- Filters markers by minimum and/or maximum duration in milliseconds +- Excludes instant markers when duration constraints are specified +- Supports combination with search filtering + +**Commands:** + +```bash +profiler-cli thread markers --search DOMEvent # Search for "DOMEvent" markers +profiler-cli thread markers --search Stack # Search for markers with "Stack" in name +profiler-cli thread markers --category Graphics # Filter by Graphics category +profiler-cli thread markers --category GC # Partial match: matches "GC / CC" +profiler-cli thread markers --has-stack # Only markers with stack traces +profiler-cli thread markers --min-duration 10 # Markers with duration >= 10ms +profiler-cli thread markers --max-duration 100 # Markers with duration <= 100ms +profiler-cli thread markers --limit 1000 # Limit to first 1000 markers +profiler-cli thread markers --min-duration 5 --max-duration 50 # Markers between 5-50ms +profiler-cli thread markers --category Other --min-duration 10 # Combined category and duration +profiler-cli thread markers --has-stack --category Layout --min-duration 1 # All filters combined +profiler-cli thread markers --category Layout --limit 50 # Limit after filtering +profiler-cli thread markers --search Reflow --min-duration 5 # Combined search and duration +``` + +**Output example:** + +```bash +$ profiler-cli thread markers --search DOMEvent + +Markers in thread t-0 (Parent Process) — 2849 markers (filtered from 258060) + +By Type (top 15): + DOMEvent 2849 markers (interval: min=0.40μs, avg=14.53μs, max=2.25ms) + +By Category: + DOM 2849 markers (100.0%) + +Rate Analysis (markers/second): + DOMEvent: 93.7 markers/sec (rate: min=2.40μs, avg=10.68ms, max=3.37s) + +Use --search to filter markers, or m- handles to inspect individual markers. +``` + +**Implementation approach:** + +```typescript +// Use built-in marker search instead of custom filtering +if (searchString) { + store.dispatch(changeMarkersSearchString(searchString)); +} +const filteredIndexes = searchString + ? threadSelectors.getSearchFilteredMarkerIndexes(state) + : threadSelectors.getFullMarkerListIndexes(state); +// Always clear search after use +store.dispatch(changeMarkersSearchString('')); +``` + +**Completed Tasks:** + +- [x] Add `--search` parameter to CLI +- [x] Wire up `changeMarkersSearchString` action dispatch +- [x] Use `getSearchFilteredMarkerIndexes` selector for filtered results +- [x] Add try/finally block to ensure search string is cleared +- [x] Update protocol, daemon, and CLI to pass search string +- [x] Show "(filtered from N)" when filters are active +- [x] Update help text with search examples +- [x] Add `--min-duration` and `--max-duration` parameters to CLI +- [x] Implement duration filtering by whittling down filtered marker indexes +- [x] Add validation for duration parameter inputs +- [x] Update help text with duration filtering examples +- [x] Add `--category` parameter to CLI for filtering by category name +- [x] Implement case-insensitive substring matching for category filtering +- [x] Update help text with category filtering examples + +**Additional enhancements (implemented):** + +- [x] `--min-duration ` - Filter by minimum duration (Committed: 43ccb20c) +- [x] `--max-duration ` - Filter by maximum duration (Committed: 43ccb20c) +- [x] `--category ` - Filter by category name with case-insensitive substring matching (Committed: 7a44d4f9) +- [x] `--has-stack` - Only show markers with stack traces (Committed: bd52b911) +- [x] `--limit ` - Limit the number of markers used in aggregation (Committed: pending) + +Duration filtering is implemented by filtering the marker indexes after search filtering. Instant markers (markers with no end time) are excluded when duration constraints are specified. + +Category filtering uses case-insensitive substring matching on category names, allowing partial matches (e.g., "GC" matches "GC / CC"). + +Stack filtering checks for markers that have a `cause` field in their data payload, which contains stack trace information. + +Limit caps the number of markers used in aggregation after all other filters are applied. This is useful for quickly examining a subset of markers from large profiles. + +**Future enhancements (not yet implemented):** + +The following filtering options could be added in the future: + +- [ ] `--field :` - Filter by field values + +### Phase 3: Smart Grouping and Sub-grouping + +**Goal**: Handle markers with similar names but different characteristics + +**Status: ✅ COMPLETE** (Commits: 7c64ef07, ae17f140) + +**What was implemented:** + +Phase 3 provides multi-level marker grouping with both manual and automatic grouping strategies: + +**Custom grouping** via `--group-by` allows hierarchical grouping by any combination of: + +- `type`: Marker type (data.type) +- `name`: Marker name +- `category`: Category name +- `field:`: Any marker field (e.g., `field:eventType`, `field:phase`) + +**Auto-grouping** via `--auto-group` uses a smart heuristic to automatically select the best field for sub-grouping: + +- Prefers fields with 3-20 unique values (ideal grouping range) +- Avoids fields with too many unique values (>50, likely IDs or timestamps) +- Requires fields to appear in >80% of markers +- Boosts score for fields that appear in all markers + +**Commands:** + +```bash +profiler-cli thread markers --group-by type,name # Group by type, then name +profiler-cli thread markers --group-by type,field:eventType # Group by type, then eventType field +profiler-cli thread markers --group-by category,type # Group by category, then type +profiler-cli thread markers --auto-group # Automatic smart grouping +profiler-cli thread markers --search DOMEvent --group-by field:eventType # Filter + custom grouping +``` + +**Output example:** + +```bash +$ profiler-cli thread markers --search DOMEvent --auto-group --limit 200 + +Markers in thread t-0 (Parent Process) — 200 markers (filtered from 258060) + +By Type (top 15): + DOMEvent 200 markers (interval: min=0.60μs, avg=41.65μs, max=2.06ms) + pointermove 59 markers (interval: min=1.30μs, avg=2.82μs, max=4.50μs) + mousemove 59 markers (interval: min=16.00μs, avg=27.55μs, max=84.30μs) + MozAfterPaint 16 markers (interval: min=1.30μs, avg=4.41μs, max=8.50μs) + mouseenter 15 markers (interval: min=0.60μs, avg=1.07μs, max=3.20μs) + mouseleave 13 markers (interval: min=0.60μs, avg=1.25μs, max=3.10μs) + ... +``` + +**Completed Tasks:** + +- [x] Implement multi-level grouping (group by multiple keys) +- [x] Add auto-grouping heuristic (analyze marker field variance with smart scoring) +- [x] Add `--group-by` flag with support for `type`, `name`, `category`, `field:` +- [x] Implement sub-group statistics (per-group duration/rate stats) +- [x] Add hierarchical display with proper indentation +- [x] Limit recursive depth to 3 levels to prevent excessive nesting + +### Phase 4: Marker Details and Field Display + +**Goal**: Show detailed information for individual markers + +**Status: ✅ COMPLETE** (Commits: 77c95d9d, d9817b0d) + +**What was implemented:** + +Phase 4 provides comprehensive marker inspection with detailed field display and complete stack trace viewing. Marker handles are now visible in marker listings, making it easy to inspect specific markers. + +**Commands:** + +```bash +profiler-cli marker info # Full marker details with stack preview +profiler-cli marker stack # Complete stack trace (all frames) +``` + +**Actual output:** + +```bash +$ profiler-cli thread markers --has-stack --limit 3 + +Markers in thread t-0 (Parent Process) — 3 markers (filtered from 258060) + +By Type (top 15): + TextStack 1 markers (interval: min=651.20μs, avg=651.20μs, max=651.20μs) + Examples: m-1 (651.20μs) + Text 1 markers (interval: min=1.93ms, avg=1.93ms, max=1.93ms) + Examples: m-2 (1.93ms) + FlowMarker 1 markers (instant) + Examples: m-3 + +$ profiler-cli marker info m-1 + +Marker m-1: NotifyObservers - NotifyObservers + +Type: TextStack +Category: Other +Time: 1h2m - 1h2m (651.20μs) +Thread: t-0 (Parent Process) + +Fields: + Details: profiler-started + +Stack trace: + Captured at: 1h2m + [1] xul.dll!NotifyObservers(char const*, nsISupports*) + [2] xul.dll!NotifyProfilerStarted(mozilla::PowerOfTwo const&, mozilla::Maybe) + [3] xul.dll!profiler_start(mozilla::PowerOfTwo) + ... + [20] xul.dll!js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::) + ... (101 more frames) + +Use 'profiler-cli marker stack m-1' for the full stack trace. + +$ profiler-cli marker stack m-1 + +Stack trace for marker m-1: NotifyObservers + +Thread: t-0 (Parent Process) +Captured at: 1h2m + + [1] xul.dll!NotifyObservers(char const*, nsISupports*) + [2] xul.dll!NotifyProfilerStarted(mozilla::PowerOfTwo const&, mozilla::Maybe) + ... + [120] ntdll.dll!RtlUserThreadStart + [121] (root) +``` + +**Implementation details:** + +- **Marker handles visible**: Top 3 example markers shown for each type with handles and durations +- **`profiler-cli marker info`**: Shows full marker details with stack trace preview (first 20 frames) +- **`profiler-cli marker stack`**: Displays complete stack traces without frame limit +- **Stack formatting**: Reuses formatFunctionNameWithLibrary() for consistent display with library names +- **MarkerSchema integration**: Fields formatted using existing MarkerSchema formatters from web UI + +**Implementation Tasks:** + +- [x] Implement `markerInfo(handle)` method +- [x] Format marker fields using MarkerSchema formatters +- [x] Add stack trace formatting (walks stack table, formats with library names) +- [x] Implement `markerStack(handle)` method for full stack traces +- [x] Display marker handles in listings (top 3 examples per type) +- [x] Wire up in protocol.ts, daemon.ts, index.ts +- [x] Update CLI help text and examples + +**Future enhancements (not yet implemented):** + +- [ ] `profiler-cli marker expand ` - Show full field values for truncated fields +- [ ] `--format json` option for machine-readable output + +### Phase 5: Temporal Visualization (ASCII Charts) + +**Goal**: Provide a compact visual representation of marker timing + +**Commands:** + +```bash +profiler-cli thread markers --timeline # ASCII timeline +profiler-cli thread markers --type Reflow --timeline # Timeline for specific type +profiler-cli thread markers --histogram # Duration histogram +``` + +**Output example:** + +```bash +$ profiler-cli thread markers --type Reflow --timeline + +Reflow markers timeline (thread t-93, 2.145s - 15.891s, 13.746s total) + +Duration histogram (4,234 markers): + 0-1ms ████████████████████████████████████████ 1,892 (44.7%) + 1-2ms ████████████████████ 956 (22.6%) + 2-5ms ████████████ 587 (13.9%) + 5-10ms ██████ 324 (7.7%) + 10-20ms ███ 234 (5.5%) + 20-50ms ██ 189 (4.5%) + 50-100ms █ 45 (1.1%) + 100ms+ █ 7 (0.2%) + +Timeline (each char = 137ms, | = marker): +2.1s | | || | | | | || | | | | | 3.5s +3.5s | | | || ||| | | | | || | | | | 4.9s +4.9s | ||| | | | || | | | || | | | | 6.3s +6.3s | | | ||| | | | | | | | | | | | | 7.7s +7.7s | || | | | | || | | | || | | | | 9.1s +9.1s | | | || | | | | | | || | | | | | 10.5s +10.5s | | | | || | | || | | | | || | | | 11.9s +11.9s | | | || | | | | | | || | | | | | 13.3s +13.3s | | | | | || | | | || | | | | | | | | 14.7s +14.7s | || | | | | | || | | | | | | | | | 15.9s + +Density over time (markers per second): + 2-4s: 12.3/s ████████ + 4-6s: 18.7/s ████████████ + 6-8s: 8.9/s ██████ + 8-10s: 23.4/s ███████████████ + 10-12s: 15.6/s ██████████ + 12-14s: 11.2/s ███████ + 14-16s: 6.7/s ████ + +Peak activity: 8.123s - 8.456s (23 markers in 333ms window) +``` + +**Implementation Tasks:** + +- [ ] Implement ASCII timeline generator +- [ ] Implement duration histogram generator +- [ ] Add density analysis (markers per time bucket) +- [ ] Make timeline resolution configurable (auto-adjust to terminal width) +- [ ] Add `--width` flag to control chart width + +### Phase 6: Advanced Analysis Features + +**Goal**: Provide deeper insights into marker patterns + +**Commands:** + +```bash +profiler-cli thread markers --rate-analysis # Analyze marker rate patterns +profiler-cli thread markers --type Network --waterfall # Network waterfall chart +profiler-cli thread markers --overlap-analysis # Find overlapping markers +profiler-cli thread markers --critical-path # Identify critical path markers +``` + +**Rate Analysis Output:** + +```bash +$ profiler-cli thread markers --type DOMEvent --rate-analysis + +Rate analysis for DOMEvent markers (thread t-93) + +Overall rate: 45.2 markers/sec + +Inter-marker gaps (time between successive markers): + Min: 0.5ms + Avg: 22.1ms + Median: 18.7ms + P95: 89.3ms + P99: 234.5ms + Max: 2341.2ms + +Burst detection (3+ markers within 50ms): + Burst at 8.123s: 5 markers in 23ms (click cascade) + Burst at 10.234s: 8 markers in 45ms (scroll events) + Burst at 12.456s: 4 markers in 31ms (mousemove cluster) + ... (12 more bursts) + +Idle periods (>1000ms without markers): + 1.234s - 2.567s (1333ms) + 5.678s - 7.123s (1445ms) + 13.890s - 15.234s (1344ms) +``` + +**Network Waterfall Output:** + +```bash +$ profiler-cli thread markers --type Network --waterfall + +Network waterfall (thread t-93, 50 requests) + +Time Duration Status URL +2.145s ████████ 200 https://example.com/api/users +2.234s ██ 200 https://cdn.example.com/logo.png +2.267s ███ 200 https://cdn.example.com/style.css +2.289s ████ 200 https://cdn.example.com/app.js +2.345s ████████████ 200 https://api.example.com/data?page=1 +2.456s ██ 304 https://cdn.example.com/font.woff2 +... + +Legend: + ████ = Request (DNS + Connect + Request + Response) + Each █ = ~50ms +``` + +**Implementation Tasks:** + +- [ ] Implement rate analysis (gap statistics, burst detection) +- [ ] Implement overlap detection (find concurrent markers) +- [ ] Add network waterfall visualization +- [ ] Add critical path analysis (longest marker chains) +- [ ] Add `--export` flag to save analysis to JSON/CSV + +## Technical Implementation Details + +### Component Structure + +``` +src/profile-query/ +├── index.ts # ProfileQuerier class +├── formatters/ +│ ├── marker-info.ts # Marker listing/summary formatters +│ ├── marker-details.ts # Individual marker detail formatter +│ ├── marker-timeline.ts # ASCII timeline/histogram generators +│ └── marker-analysis.ts # Advanced analysis formatters +├── marker-map.ts # MarkerMap handle manager +├── marker-aggregator.ts # Marker aggregation logic +├── marker-filter.ts # Marker filtering logic +└── marker-grouping.ts # Smart grouping heuristics +``` + +### Key Algorithms + +**1. Marker Aggregation** + +```typescript +function aggregateMarkersByType( + markers: Marker[], + markerSchemaByName: MarkerSchemaByName +): MarkerTypeStats[] { + const groups = new Map(); + + for (const marker of markers) { + const type = marker.data?.type ?? 'Unknown'; + if (!groups.has(type)) { + groups.set(type, []); + } + groups.get(type)!.push(marker); + } + + return Array.from(groups.entries()).map(([type, markers]) => ({ + typeName: type, + count: markers.length, + isInterval: markers[0].end !== null, + durationStats: computeDurationStats(markers), + rateStats: computeRateStats(markers), + topMarkers: selectTopMarkers(markers, 5), + })); +} +``` + +**2. Duration Statistics** + +```typescript +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)], + }; +} +``` + +**3. Rate Statistics** + +```typescript +function computeRateStats(markers: Marker[]): RateStats { + 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; + const markersPerSecond = (markers.length / timeRange) * 1000; + + return { + markersPerSecond, + minGap: Math.min(...gaps), + avgGap: gaps.reduce((a, b) => a + b, 0) / gaps.length, + maxGap: Math.max(...gaps), + }; +} +``` + +**4. Smart Grouping** + +```typescript +function autoGroupMarkers( + markers: Marker[], + schema: MarkerSchema +): GroupingStrategy { + // Analyze variance in marker fields + const fieldVariance = analyzeFieldVariance(markers, schema); + + // If a field has high variance (e.g., eventType in DOMEvent markers), + // use it as a grouping key + const highVarianceFields = fieldVariance + .filter((f) => f.uniqueRatio > 0.3) // >30% unique values + .sort((a, b) => b.uniqueRatio - a.uniqueRatio); + + if (highVarianceFields.length > 0) { + return { type: 'field', field: highVarianceFields[0].key }; + } + + // Fall back to type-level grouping + return { type: 'type' }; +} +``` + +### Protocol Updates + +**`protocol.ts`:** + +```typescript +export type ClientCommand = + | { command: 'thread'; subcommand: 'markers'; options?: MarkerListOptions } + | { command: 'marker'; subcommand: 'info'; marker: string } + | { command: 'marker'; subcommand: 'stack'; marker: string } + | { command: 'marker'; subcommand: 'expand'; marker: string; field: string }; +// ... existing commands + +interface MarkerListOptions { + type?: string; // Filter by type + category?: string; // Filter by category + minDuration?: number; // Min duration in ms + maxDuration?: number; // Max duration in ms + nameFilter?: string; // Regex for name + fieldFilter?: string; // Format: "field:value" + hasStack?: boolean; // Only markers with stacks + groupBy?: string; // Grouping strategy + timeline?: boolean; // Show ASCII timeline + histogram?: boolean; // Show duration histogram + summary?: boolean; // Show summary only + limit?: number; // Limit output lines + format?: 'text' | 'json'; // Output format +} +``` + +## Testing Strategy + +1. **Unit Tests** (`src/test/unit/profile-query-markers.test.ts`): + - Test marker aggregation functions + - Test filtering logic + - Test statistics calculations + - Test ASCII chart generation + +2. **Integration Tests** (`yarn test-cli`): + - Test marker listing with real profiles + - Test filtering combinations + - Test grouping strategies + - Test marker detail display + +3. **Manual Testing**: + - Test with large profiles (10k+ markers) + - Test with different marker types + - Test edge cases (no markers, single marker, all instant/interval) + +## Performance Considerations + +1. **Lazy Marker Loading**: Don't load all marker details unless needed +2. **Pagination**: For large result sets, support pagination (e.g., `--page 2 --page-size 50`) +3. **Streaming Output**: For very large listings, stream output instead of buffering +4. **Caching**: Cache aggregated stats in ProfileQuerier for repeated queries +5. **Sampling**: For >10k markers, consider sampling for histogram/timeline + +## Open Questions / Design Decisions + +1. **Handle Persistence**: Should marker handles (m-N) be stable across sessions, or ephemeral? + - **Decision**: Ephemeral within session (like function handles), reset on each `profiler-cli load` + +2. **Default Grouping**: What should be the default grouping strategy? + - **Decision**: Group by type first, with option to drill down + +3. **Timeline Resolution**: How to auto-adjust timeline character width? + - **Decision**: Divide time range by terminal width (default 80 chars), cap at 1 char = 10ms minimum + +4. **Field Display**: Should we show all fields by default or only non-hidden fields? + - **Decision**: Follow marker schema `hidden` flag, add `--all-fields` to override + +5. **Stack Traces**: Should stacks be shown inline or require separate command? + - **Decision**: Show truncated stack (top 5 frames) in `marker info`, full stack in `marker stack` + +6. **JSON Output**: What should the JSON schema look like? + - **Decision**: Match web UI's marker structure, include all computed stats + +## Success Metrics + +1. Can view marker distribution across a thread in <5 seconds +2. Can identify performance bottlenecks (long markers) in <3 commands +3. Can filter to specific marker types/categories in 1 command +4. Can inspect individual marker details including stack traces +5. Output is readable and actionable for performance analysis + +## Future Enhancements (Post-Launch) + +1. **Marker Comparison**: Compare marker patterns between two time ranges +2. **Marker Correlation**: Find correlations between different marker types +3. **Marker Export**: Export filtered markers to flamegraph format +4. **Marker Diff**: Compare markers between two profiles +5. **Smart Filters**: Pre-defined filters for common analysis tasks (e.g., "long-layout", "slow-network") +6. **Interactive Mode**: TUI for browsing markers interactively + +## Implementation Priority + +**Must Have (Phase 1 & 2):** + +- Basic marker listing with aggregation +- Marker type/category filtering +- Individual marker details +- Duration statistics + +**Should Have (Phase 3 & 4):** + +- Smart grouping by fields +- Marker stack traces +- Field-based filtering + +**Nice to Have (Phase 5 & 6):** + +- ASCII timeline/histogram +- Rate analysis +- Network waterfall +- Critical path analysis + +## Timeline Estimate + +- Phase 1 (Basic Listing): 2-3 days +- Phase 2 (Filtering): 1-2 days +- Phase 3 (Grouping): 1-2 days +- Phase 4 (Details): 1-2 days +- Phase 5 (Visualization): 2-3 days +- Phase 6 (Advanced): 2-3 days + +**Total**: ~2 weeks for full implementation +**MVP (Phases 1-2)**: ~1 week diff --git a/profiler-cli-todo.md b/profiler-cli-todo.md new file mode 100644 index 0000000000..8eb0386b7c --- /dev/null +++ b/profiler-cli-todo.md @@ -0,0 +1,494 @@ +# Profiler CLI To-Do List + +Feature wishlist and improvement ideas for the profiler-cli profile query tool. + +--- + +## Active Proposals + +**[Filters and Bookmarks System](profiler-cli-filters-and-bookmarks-proposal.md)** - Comprehensive design for ephemeral vs sticky state, filter system, and bookmarks. + +--- + +## Critical Priority (Blocking Effective Use) + +### 1. Persistent Context Display ✅ + +Every command output displays current context in a compact header: + +``` +[Thread: t-0 (GeckoMain) | View: Full profile | Full: 30.42s] +[Thread: t-0 (GeckoMain) | View: ts-Fo→ts-Fu (851.1ms) | Full: 30.42s] +[Thread: t-0,t-93 (GeckoMain, Renderer) | View: Full profile | Full: 30.42s] +``` + +### 2. Function Search/Filter ✅ + +Commands available: + +```bash +profiler-cli thread functions # List all functions with CPU% +profiler-cli thread functions --search Present # Substring search +profiler-cli thread functions --min-self 1 # Filter by self time percentage +profiler-cli thread functions --limit 50 # Limit results +``` + +### 3. Smart Range Navigation ⚠️ + +**Status:** Partially implemented - marker handles work, CPU spike navigation doesn't yet + +**Implemented:** + +```bash +profiler-cli zoom push m-158 # Zoom to marker's time range +``` + +**Still needed:** + +```bash +profiler-cli profile hotspots # List all high-CPU periods +profiler-cli zoom push --spike 1 # Jump to first spike +profiler-cli zoom push --spike next # Next spike from current position +profiler-cli profile hotspots --min-cpu 150% # Find sustained >150% periods +``` + +**Enhancement:** Named bookmarks: + +```bash +profiler-cli zoom push ts-Fo,ts-Fu --name "resize-thrash" +profiler-cli zoom list +profiler-cli zoom push resize-thrash +``` + +### 4. Cross-Thread Marker View ✅ + +Commands available: + +```bash +profiler-cli thread markers --thread t-0,t-93 # Merged view of specific threads +profiler-cli thread functions --thread t-0,t-93 # Functions from multiple threads +profiler-cli thread samples --thread t-0,t-93 # Samples from multiple threads +profiler-cli thread select t-0,t-93 # Select threads (sticky) +``` + +**Not yet implemented:** + +```bash +profiler-cli marker related m-158 # Show markers on other threads at same time +``` + +--- + +## High Priority (Significant Value) + +### 5. Relative Handle References ❌ + +**Problem:** Must scroll back to find handles like "m-168" when investigating + +**Proposed:** + +```bash +profiler-cli marker info m-@1 # First marker in last listing +profiler-cli marker info m-@last # Last marker in last listing +profiler-cli marker info m-@longest # Longest duration in last listing +profiler-cli marker info m-@prev # Previously inspected marker + +profiler-cli function expand f-@1 # First function in last listing +profiler-cli function expand f-@highest # Highest self-time +``` + +**Alternative:** Show rank in listings: + +``` +Markers in thread t-0 — 50 markers + #1 → m-147 (28.89ms) Runnable + #2 → m-148 (15.23ms) Runnable +``` + +Then allow: `profiler-cli marker info #1` or `profiler-cli marker info @1` + +### 6. Stack Availability Indicators ✅ + +Visual indicators (✓/✗) next to marker handles with legend: + +``` +Markers in thread t-0 (Parent Process) — 50 markers (filtered from 258060) +Legend: ✓ = has stack trace, ✗ = no stack trace + +By Name (top 15): + SimpleTaskQueue::AddTask 7 markers (instant) + Examples: m-25 ✓, m-26 ✓, m-27 ✓ + Runnable 15 markers (interval: min=1µs, avg=285µs) + Examples: m-20 ✗ (3.95ms), m-21 ✗ (61µs) +``` + +### 7. Bottom-Up Call Tree ✅ + +Command available: + +```bash +profiler-cli thread samples-bottom-up # Bottom-up call tree +``` + +Shows inverted call tree starting from leaf functions, directly answering "what code paths lead to this bottleneck?" + +### 8. Sample Output Filtering ⚠️ + +**Status:** Partially implemented (--limit exists for markers but not samples) + +**Flags needed:** + +```bash +profiler-cli thread samples --limit 30 # Top 30 functions only +profiler-cli thread samples --min-self 1% # Hide functions <1% self time +profiler-cli thread samples --max-depth 15 # Limit tree depth +profiler-cli thread samples --top-only # Skip call trees +profiler-cli thread samples --tree-only # Skip top functions list +``` + +**Enhancement:** Show truncation stats when call tree is cut off: + +``` +Regular Call Tree (showing top 30 functions, 249 lines omitted): + (root) [total: 100.0%] + └─ ... + +[249 lines omitted: 142 unique functions, max self time 0.3%, cumulative 2.1%] +``` + +### 9. Dual Percentages When Zoomed ✅ + +When zoomed, shows both view and full profile percentages: + +Note: In the current implementation, function handles are canonical shared +`funcTable` indices. `f-123` means "function at shared funcTable index 123", not +the first function encountered in the current session. + +```bash +# When zoomed: +Functions (by self time): + f-1. win32u.dll!ZwUserMsgWaitForMultipleObjectsEx - self: 2024 (39.8% of view, 12.3% of full) + +# When not zoomed: +Functions (by self time): + f-1. win32u.dll!ZwUserMsgWaitForMultipleObjectsEx - self: 6916 (42.2%), total: 6916 (42.2%) +``` + +### 10. Inline Thread Selection ⚠️ + +**Proposed:** + +```bash +profiler-cli thread samples --thread t-93 # Query without selecting +profiler-cli thread info --thread t-0 # Peek at thread +profiler-cli thread markers --thread t-93 --search Paint +``` + +### 11. Function Info Shows Full Name ✅ + +The `profiler-cli function info` command displays both full and short names: + +Note: Function info is resolved from `profile.shared.funcTable`, so the handle is +profile-global rather than thread-local. + +```bash +Function f-1: + Full name: win32u.dll!ZwUserMsgWaitForMultipleObjectsEx + Short name: ZwUserMsgWaitForMultipleObjectsEx + Is JS: false + ... +``` + +### 12. Module-Level Grouping ❌ + +**Proposed:** + +``` +Top Functions (by self time): + 63.5% ntdll.dll!ZwWaitForAlertByThreadId + 8.6% ntdll.dll!NtWaitForSingleObject + ... + +Module Summary: + 14.3% atidxx64.dll (AMD GPU Driver - 23 functions) + 12.8% xul.dll (Firefox Core - 156 functions) + 9.2% ntdll.dll (Windows NT - 12 functions) +``` + +### 13. Profile Summary Command ❌ + +**Proposed:** + +```bash +profiler-cli summary # Overall profile summary +profiler-cli thread summary # Current thread(s) summary +``` + +Example output: + +``` +Profile Summary [ts-Fo → ts-Fu] (851ms, 100% CPU) + +Top Threads: + t-93 (Renderer): 36.5% active, 63.5% waiting + t-0 (GeckoMain): 42.2% waiting, 34.3% active + +Hot Functions: + 16.3% dxgi.dll!CDXGISwapChain::PresentImpl + 7.7% WaitForFrameGPUQuery + 4.1% atidxx64.dll (AMD driver) + +Activity: + 91,300 Layout operations (30K style flushes) + 17 Composite frames (avg: 12.7ms) + 3 CSS transitions (200ms each) +``` + +--- + +## Medium Priority (Nice to Have) + +### 14. CPU Activity Timestamps Inline ✅ + +Timestamp names show actual times inline: + +```bash +profiler-cli profile info + +CPU activity over time: +- 100% for 390.6ms: [ts-6 → ts-7] (2.701s - 3.092s) +- 100% for 255.3ms: [ts-8 → ts-9] (3.102s - 3.357s) +- 100% for 851.1ms: [ts-Fo → ts-Fu] (9.453s - 10.305s) +``` + +### 15. Enhanced Zoom Output ✅ + +Zoom output shows duration, depth, and marker context: + +```bash +profiler-cli zoom push ts-i,ts-M +Pushed view range: ts-i → ts-M (6.991s - 10.558s, duration 3.567s) + ts-i: Start of CPU spike #3 (100% CPU sustained) + ts-M: End of marker m-143 (Composite frame) + Zoom depth: 2/5 (use "profiler-cli zoom pop" to go back) +``` + +### 16. Range Comparison ❌ + +**Proposed:** + +```bash +profiler-cli compare ts-6,ts-7 vs ts-8,ts-9 # Compare two ranges + +# Example output: +Comparison: [ts-6,ts-7] (391ms) vs [ts-8,ts-9] (255ms) + +CPU Activity: + Range 1: 100% CPU (390.6ms) Range 2: 100% CPU (255.3ms) + +Top Functions: + Range 1 Range 2 + 42.2% ZwWaitForAlertByThreadId 45.1% ZwWaitForAlertByThreadId (+2.9%) + 16.3% CDXGISwapChain::Present 18.7% CDXGISwapChain::Present (+2.4%) +``` + +### 17. Wait/Idle Analysis ❌ + +**Proposed:** + +```bash +profiler-cli thread waits # Show all wait operations +profiler-cli thread waits --min-duration 10ms # Significant waits only +profiler-cli thread waits --summary # Aggregate stats +``` + +### 18. Frame-Level Analysis ❌ + +**Proposed:** + +```bash +profiler-cli thread frames # List paint/composite frames +profiler-cli thread frames --slow # Frames >16ms (jank) +profiler-cli frame info 72 # Details about frame #72 +``` + +### 19. Stack-Level Inspection ❌ + +**Proposed:** + +```bash +profiler-cli thread stacks # Show heaviest individual stacks +profiler-cli thread stacks --limit 5 # Top 5 heaviest +``` + +### 20. Split Sample Command ⚠️ + +**Proposed:** + +```bash +profiler-cli thread samples-top [--limit N] # Just top functions +profiler-cli thread samples-tree [--max-depth N] # Just call tree +profiler-cli thread samples # All views (backward compat) +``` + +Note: `samples-bottom-up` is already a separate command (item #7). + +### 21. Cross-Thread Context ❌ + +**Proposed:** + +```bash +profiler-cli profile info --in-zoom # Show top threads/CPU in current zoom range +profiler-cli marker related m-158 # Show markers on other threads at same time +``` + +Example: + +``` +$ profiler-cli marker related m-168 +Marker m-168: Reflow (interruptible) at 3717465.724ms + +Related markers (±10ms window): + t-93 (Renderer): + m-5432 [3717465.2ms] Composite #72 (started 0.5ms before) + m-5433 [3717467.1ms] Texture uploads (during reflow) +``` + +### 22. Filter Provenance ❌ + +**Problem:** Output shows "50 markers (filtered from 258060)" but doesn't explain how filtering reduced the count + +Was it zoom? Search filter? Duration filter? Limit? Users can't tell what contributed to the reduction. + +**Proposed:** Show filter provenance chain: + +``` +50 markers shown (zoom: 258060 → 91300, filters: 91300 → 1200, limit: 1200 → 50) +``` + +Or more compact: + +``` +50 markers (from 258060: zoom→91300, filters→1200, limit→50) +``` + +This helps users understand whether they're missing important data due to filters or just seeing everything that matches their criteria. + +--- + +## Low Priority (Polish) + +### 23. Frequency Analysis Terminology ✅ + +Marker output uses clear terminology: + +``` +Frequency Analysis: + Image Paint: 29081.3 markers/sec (interval: min=4µs, avg=36µs, max=468µs) +``` + +The first number is frequency (markers/sec), and the min/avg/max values are intervals (time gaps between markers). + +### 24. Export/Save ❌ + +**Proposed:** + +```bash +profiler-cli thread samples --output report.txt # Save to file +profiler-cli thread markers --json > markers.json # JSON already works +profiler-cli session export investigation.pqsession # Save entire session state +``` + +### 25. Color Output ❌ + +**Proposed:** Color-code percentages, durations, and handles for easier scanning + +### 26. Progress Indicators ❌ + +**Proposed:** Show progress for operations >1s: + +``` +$ profiler-cli load large-profile.json.gz +Loading profile... 45% (123MB/273MB) +``` + +### 27. Sparklines/Histograms ❌ + +**Proposed:** ASCII sparklines for temporal distribution: + +``` +Markers in thread t-0: + Reflow: 534 markers ▁▂▃▅▇█▇▅▃▂▁ (peak: ts-Fo → ts-Fu) + Paint: 127 markers ▃▃▂▂▅▅▇█▃▂▁ (peak: ts-r → ts-s) +``` + +### 28. Smart Function Name Display ❌ + +**Enhancements:** + +- Allow `--name-width N` to control truncation +- Show ellipsis `...` when truncated +- For very long names, show start + end + +### 29. Auto-Suggest Next Steps ❌ + +**Proposed:** After commands, suggest related actions: + +``` +$ profiler-cli marker info m-168 +Marker m-168: Reflow (interruptible) - 907µs +... + +💡 Next steps: + profiler-cli marker stack m-168 # View call stack + profiler-cli zoom push m-168 # Zoom to this marker's time range + profiler-cli thread markers --search Reflow --min-duration 500 # Find similar markers +``` + +--- + +## Summary by Priority + +**Critical:** 1 item remaining (3 completed) + +- Smart range navigation - partially implemented (markers work, CPU spikes don't) + +**High:** 4 items remaining (5 completed) + +- Relative handle references +- Sample output filtering - partially implemented +- Inline thread selection +- Module-level grouping +- Profile summary command + +**Medium:** 7 items remaining (2 completed) + +- Range comparison +- Wait/idle analysis +- Frame-level analysis +- Stack-level inspection +- Split sample command - partially implemented +- Cross-thread context +- Filter provenance + +**Low:** 6 items remaining (1 completed) + +- Export/save +- Color output +- Progress indicators +- Sparklines/histograms +- Smart function name display +- Auto-suggest next steps + +--- + +## Core Features Already Implemented + +- **Marker support** with rich filtering (`--search`, `--category`, `--min-duration`, `--max-duration`, `--has-stack`, `--limit`, `--group-by`, `--auto-group`) +- **Function handles** (`function expand`, `function info`) +- **Smart function name truncation** (120 char limit, tree-based parsing) +- **Zoom range management** (`zoom push`, `zoom pop`, `zoom clear`, `status`) +- **Library/module names** in function display +- **Timestamp names + readable times** +- **Deep call trees** (Regular and Bottom-up/Inverted) +- **Persistent context display** in all command outputs diff --git a/profiler-cli/.npmignore b/profiler-cli/.npmignore new file mode 100644 index 0000000000..8bc7c4fe8e --- /dev/null +++ b/profiler-cli/.npmignore @@ -0,0 +1,32 @@ +# Source files - only publish the built dist/profiler-cli.js +*.ts +webpack.config.js + +# Build artifacts (except dist/profiler-cli.js which is in "files") +dist/* +!dist/profiler-cli.js + +# Development files +node_modules/ +*.log +*.tmp +.DS_Store + +# Git files +.git/ +.gitignore + +# Editor configs +.vscode/ +.idea/ +*.swp +*.swo + +# Test files +*.test.ts +*.test.js +__tests__/ +tests/ + +# CI/CD +.github/ diff --git a/profiler-cli/CONTRIBUTING.md b/profiler-cli/CONTRIBUTING.md new file mode 100644 index 0000000000..3664cf4972 --- /dev/null +++ b/profiler-cli/CONTRIBUTING.md @@ -0,0 +1,267 @@ +# 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 and is shared with the web app. The CLI daemon is just an IPC wrapper around it — query logic belongs in `src/profile-query/`, not in `daemon.ts`. + +## Project Structure + +``` +profiler-cli/ +├── src/ +│ ├── index.ts # CLI entry point, argument parsing, command routing +│ ├── client.ts # Client logic: spawn daemon, send commands via socket +│ ├── daemon.ts # Daemon logic: load profile, listen on socket, handle commands +│ ├── session.ts # Session file management, socket paths, validation +│ ├── protocol.ts # TypeScript types for IPC messages +│ ├── formatters.ts # Plain-text formatters for structured command results +│ ├── constants.ts # Build-time constants (BUILD_HASH, etc.) +│ └── test/ +│ ├── unit/ # CLI unit tests +│ └── integration/ # CLI integration tests +├── package.json # npm distribution metadata (dependencies defined in root) +└── dist/ # Bundled executable output +``` + +## 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 ~640KB executable to `profiler-cli/dist/profiler-cli.js` (~187KB gzipped) with zero runtime dependencies +- **Published artifact**: `profiler-cli/dist/profiler-cli.js` is published to npm as `@firefox-profiler/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) 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):** + +```typescript +// Client → Daemon +type ClientMessage = + | { type: 'command'; command: ClientCommand } + | { type: 'shutdown' } + | { type: 'status' }; + +type ClientCommand = + | { command: 'profile'; subcommand: 'info' | 'threads'; all?: boolean; search?: string } + | { command: 'thread'; subcommand: 'info' | 'select' | 'samples' | 'samples-top-down' | 'samples-bottom-up' | 'markers' | 'functions'; thread?: string; ... } + | { command: 'marker'; subcommand: 'info' | 'select' | 'stack'; marker?: string } + | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } + | { command: 'function'; subcommand: 'info' | 'select' | 'expand'; function?: string } + | { command: 'zoom'; subcommand: 'push' | 'pop' | 'clear'; range?: string } + | { command: 'filter'; subcommand: 'push' | 'pop' | 'list' | 'clear'; thread?: string; spec?: SampleFilterSpec; count?: number } + | { command: 'status' }; + +// Daemon → Client +type ServerResponse = + | { type: 'success'; result: string | CommandResult } + | { type: 'error'; error: string } + | { type: 'loading' } + | { type: 'ready' }; +``` + +**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 + +**Symlinks:** + +- `current` symlink uses relative path (`sessionId.sock`) +- Resolved to absolute 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-`. + +## 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 + +To add a new command, modify **5 files** (client.ts doesn't need changes as it generically forwards commands). 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: Parse CLI arguments in `index.ts` + +Add a case to the command switch, and add a corresponding case to the `formatOutput` switch: + +```typescript +// In the main command switch: +case 'allocation': { + const subcommand = argv._[1] ?? 'info'; + if (subcommand === 'info') { + const result = await sendCommand( + SESSION_DIR, + { command: 'allocation', subcommand, thread: argv.thread }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; +} + +// In the formatOutput switch: +case 'allocation-info': + return formatAllocationInfoResult(result); +``` + +client.ts doesn't need changes — it generically forwards all commands via `sendCommand()`. + +### Step 3: Handle the command in `daemon.ts` + +Add a case to `processCommand()`: + +```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` + +```typescript +export function formatAllocationInfoResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context)]; + lines.push(`Total allocated: ${result.totalBytes} bytes`); + return lines.join('\n'); +} +``` + +Then import it at the top of `index.ts`. + +### Step 6: Update documentation + +- Add the command to the help text in `index.ts` (the `printUsage()` function) +- Add the command to the "Commands" section of `README.md` +- Remove it from the "Known Gaps" section 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..d64b657627 --- /dev/null +++ b/profiler-cli/README.md @@ -0,0 +1,123 @@ +# 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@alpha +``` + +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 +profiler-cli thread functions # List all functions with CPU percentages +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) | +| `--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..a1feff4dbd --- /dev/null +++ b/profiler-cli/guide.txt @@ -0,0 +1,397 @@ +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 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 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) + Use "profiler-cli filter push" to add a filter to the current thread's stack. + Every subsequent analysis command sees all pushed filters automatically. + Filters are per-thread: each thread has its own independent filter stack. + 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 (AND: both must pass) + profiler-cli thread samples # both filters active + profiler-cli thread functions # both filters active + profiler-cli filter pop # remove filter 2 + profiler-cli filter pop 2 # remove last 2 filters + profiler-cli filter list # show active filters in push order + profiler-cli filter clear # remove all filters + + Use "profiler-cli status" to inspect the full session state: selected thread, zoom stack, and + all per-thread filters. + + Sticky + ephemeral compose: sticky filters apply first (already in the transform + stack), then ephemeral filters 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 + + 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][]' + +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 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..8d39ab6dd0 --- /dev/null +++ b/profiler-cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@firefox-devtools/profiler-cli", + "version": "0.1.0-alpha.1", + "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", + "scripts": { + "prepublishOnly": "test -f dist/profiler-cli.js || (echo 'Run yarn build-profiler-cli from the repo root first' && exit 1)" + }, + "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": "https://github.com/firefox-devtools/profiler", + "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..321778a22b --- /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. + + +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[] }] + } + +SessionContext (present on all command results): + { + selectedThreadHandle, + selectedThreads: [{ threadIndex, name }], + currentViewRange: { start, startName, end, endName } | null, + rootRange: { start, end } + } diff --git a/profiler-cli/src/client.ts b/profiler-cli/src/client.ts new file mode 100644 index 0000000000..3c6c681283 --- /dev/null +++ b/profiler-cli/src/client.ts @@ -0,0 +1,380 @@ +/* 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) { + 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); + + // On Windows, named pipes are not filesystem files so existsSync always returns false + if ( + !socketPath || + (process.platform !== 'win32' && !fs.existsSync(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 +): 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]; + + // Spawn the daemon process (detached from parent) + const child = child_process.spawn( + process.execPath, // node + [scriptPath, '--daemon', absolutePath, '--session', targetSessionId], + { + 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) + const profileLoadMaxAttempts = 600; // 600 * 100ms = 60 seconds + attempts = 0; + + 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 '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 ${profileLoadMaxAttempts * 100}ms` + ); +} + +/** + * 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..e00d7f37c8 --- /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, + 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, + excludesAnyFunction: opts.excludesAnyFunction, + merge: opts.merge, + rootAt: opts.rootAt, + includesFunction: opts.includesFunction, + includesAnyFunction: opts.includesAnyFunction, + 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 = parseInt(String(raw), 10); + if (isNaN(count) || count <= 0) { + console.error('Error: count must be a positive integer'); + process.exit(1); + } + 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..e827d06d94 --- /dev/null +++ b/profiler-cli/src/commands/profile.ts @@ -0,0 +1,102 @@ +/* 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 } 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 = parseInt(opts.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(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..52ec66d31e --- /dev/null +++ b/profiler-cli/src/commands/shared.ts @@ -0,0 +1,111 @@ +/* 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'; + +/** + * 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( + '--excludes-any-function ', + 'Drop samples containing any of these functions (OR)' + ) + .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-any-function ', + 'Alias for --includes-function (explicit OR form)' + ) + .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..2e1e91051b --- /dev/null +++ b/profiler-cli/src/commands/thread.ts @@ -0,0 +1,505 @@ +/* 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 } 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(', ')}` + ); +} + +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); + + let callTreeOptions = undefined; + if (opts.maxLines !== undefined || opts.scoring !== undefined) { + callTreeOptions = {} as { + maxNodes?: number; + scoringStrategy?: CallTreeScoringStrategy; + }; + if (opts.maxLines !== undefined) { + const maxLines = parseInt(String(opts.maxLines), 10); + if (isNaN(maxLines) || maxLines <= 0) { + console.error('Error: --max-lines must be a positive integer'); + process.exit(1); + } + callTreeOptions.maxNodes = maxLines; + } + if (opts.scoring !== undefined) { + if (!VALID_SCORING_STRATEGIES.includes(opts.scoring)) { + console.error( + `Error: --scoring must be one of: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); + process.exit(1); + } + callTreeOptions.scoringStrategy = + opts.scoring as CallTreeScoringStrategy; + } + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-top-down', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions, + 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); + + let callTreeOptions = undefined; + if (opts.maxLines !== undefined || opts.scoring !== undefined) { + callTreeOptions = {} as { + maxNodes?: number; + scoringStrategy?: CallTreeScoringStrategy; + }; + if (opts.maxLines !== undefined) { + const maxLines = parseInt(String(opts.maxLines), 10); + if (isNaN(maxLines) || maxLines <= 0) { + console.error('Error: --max-lines must be a positive integer'); + process.exit(1); + } + callTreeOptions.maxNodes = maxLines; + } + if (opts.scoring !== undefined) { + if (!VALID_SCORING_STRATEGIES.includes(opts.scoring)) { + console.error( + `Error: --scoring must be one of: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); + process.exit(1); + } + callTreeOptions.scoringStrategy = + opts.scoring as CallTreeScoringStrategy; + } + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-bottom-up', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions, + 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)' + ) + ).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 + ) { + 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.minDuration !== undefined) { + const minDuration = parseFloat(opts.minDuration); + if (isNaN(minDuration) || minDuration < 0) { + console.error( + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + markerFilters.minDuration = minDuration; + } + if (opts.maxDuration !== undefined) { + const maxDuration = parseFloat(opts.maxDuration); + if (isNaN(maxDuration) || maxDuration < 0) { + console.error( + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + markerFilters.maxDuration = maxDuration; + } + if (opts.limit !== undefined) { + const limit = parseInt(opts.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(1); + } + markerFilters.limit = limit; + } + if (opts.topN !== undefined) { + const topN = parseInt(opts.topN, 10); + if (isNaN(topN) || topN <= 0) { + console.error('Error: --top-n must be a positive integer'); + process.exit(1); + } + markerFilters.topN = topN; + } + } + + 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) { + const v = parseFloat(opts.minDuration); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + networkFilters.minDuration = v; + } + if (opts.maxDuration !== undefined) { + const v = parseFloat(opts.maxDuration); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + networkFilters.maxDuration = v; + } + if (opts.limit !== undefined) { + const v = parseInt(opts.limit, 10); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --limit must be a non-negative integer (0 = show all)' + ); + process.exit(1); + } + networkFilters.limit = v; + } 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) { + const v = parseInt(opts.navigation, 10); + if (isNaN(v) || v < 1) { + console.error( + 'Error: --navigation must be a positive integer (1-based index)' + ); + process.exit(1); + } + pageLoadOptions.navigationIndex = v; + } + if (opts.jankLimit !== undefined) { + const v = parseInt(opts.jankLimit, 10); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --jank-limit must be a non-negative integer (0 = show all)' + ); + process.exit(1); + } + pageLoadOptions.jankLimit = v === 0 ? undefined : v; + } + + 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) { + const minSelf = parseFloat(opts.minSelf); + if (isNaN(minSelf) || minSelf < 0 || minSelf > 100) { + console.error( + 'Error: --min-self must be a number between 0 and 100 (percentage)' + ); + process.exit(1); + } + functionFilters.minSelf = minSelf; + } + if (opts.limit !== undefined) { + const limit = parseInt(opts.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(1); + } + functionFilters.limit = limit; + } + } + + 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..70dd05cf64 --- /dev/null +++ b/profiler-cli/src/daemon.ts @@ -0,0 +1,467 @@ +/* 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 { + 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 loadingProfile: boolean = false; + private profileLoadError: Error | null = null; + + constructor(sessionDir: string, profilePath: string, sessionId?: string) { + this.sessionDir = sessionDir; + this.profilePath = profilePath; + this.sessionId = sessionId || generateSessionId(); + 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 { + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + + console.log = (...args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write(`[LOG] ${new Date().toISOString()} ${message}\n`); + originalConsoleLog(...args); + }; + + console.error = (...args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write(`[ERROR] ${new Date().toISOString()} ${message}\n`); + originalConsoleError(...args); + }; + + console.warn = (...args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write(`[WARN] ${new Date().toISOString()} ${message}\n`); + originalConsoleWarn(...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.loadingProfile = true; + try { + console.log('Loading profile...'); + this.querier = await ProfileQuerier.load(this.profilePath); + console.log('Profile loaded successfully'); + this.loadingProfile = false; + } catch (error) { + console.error(`Failed to load profile: ${error}`); + this.profileLoadError = + error instanceof Error ? error : new Error(String(error)); + this.loadingProfile = false; + } + } + + private handleConnection(socket: net.Socket): void { + console.log('Client connected'); + + let buffer = ''; + + 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()) { + this.handleMessage(line, socket); + } + } + }); + + socket.on('error', (error) => { + console.error(`Socket error: ${error}`); + }); + + socket.on('end', () => { + console.log('Client disconnected'); + }); + } + + private handleMessage(line: string, socket: net.Socket): void { + try { + const message = JSON.parse(line) as ClientMessage; + console.log(`Received message: ${message.type}`); + + this.processMessage(message) + .then((response) => { + 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'); + }); + } catch (error) { + console.error(`Failed to parse message: ${error}`); + const errorResponse: ServerResponse = { + type: 'error', + error: `Failed to parse message: ${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}`, + }; + } + if (this.loadingProfile) { + return { type: 'loading' }; + } + if (this.querier) { + return { type: 'ready' }; + } + // Shouldn't happen, but handle gracefully + 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.loadingProfile) { + return { + type: 'error', + error: 'Profile still loading, try again shortly', + }; + } + if (!this.querier) { + return { + type: 'error', + error: 'Profile not loaded', + }; + } + + 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 +): Promise { + const daemon = new Daemon(sessionDir, profilePath, sessionId); + await daemon.start(); +} diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts new file mode 100644 index 0000000000..3a7bd74007 --- /dev/null +++ b/profiler-cli/src/formatters.ts @@ -0,0 +1,1496 @@ +/* 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, +} from './protocol'; +import { truncateFunctionName } from '../../src/profile-query/function-list'; +import { describeSpec } from '../../src/profile-query/filter-stack'; + +/** + * 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; + const formatDuration = (ms: number): string => { + if (ms < 1000) { + return `${ms.toFixed(1)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; + }; + + 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.spec.type}] ${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.spec.type}] ${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 percentage = Math.round( + (activity.cpuMs / (activity.endTime - activity.startTime)) * 100 + ); + 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 percentage = Math.round( + (activity.cpuMs / (activity.endTime - activity.startTime)) * 100 + ); + 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, 120); + + // 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'); +} + +/** + * Format a ThreadSamplesResult as plain text. + */ +export function formatThreadSamplesResult( + result: WithContext +): 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` : ''; + let output = `${contextHeader} + +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; + + 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, 120); + 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, 120); + 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, 120); + 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, 120); + 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, 120); + 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 { + 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` : ''; + let output = `${contextHeader} + +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; + + // 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 { + 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` : ''; + let output = `${contextHeader} + +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; + + // 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'); + } + + // Handle custom grouping if present + if (result.customGroups && result.customGroups.length > 0) { + formatMarkerGroupsForDisplay(lines, result.customGroups, 0); + } else { + // Default aggregation by marker name + lines.push('By Name (top 15):'); + const topTypes = result.byType.slice(0, 15); + for (const stats of topTypes) { + let line = ` ${stats.markerName.padEnd(25)} ${stats.count.toString().padStart(5)} 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(25)} ${stats.count.toString().padStart(5)} 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); + } + } +} + +/** + * Helper function to format duration in milliseconds. + */ +function formatDuration(ms: number): string { + if (ms < 1) { + return `${(ms * 1000).toFixed(0)}µs`; + } else if (ms < 1000) { + return `${ms.toFixed(2)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +/** + * 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, 120); + + // 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} ${Math.round(m.timeMs)}ms`); + } + } + + 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) { + lines.push(' By type:'); + for (const t of result.resourcesByType) { + const countStr = String(t.count).padStart(4); + const pctStr = t.percentage.toFixed(1).padStart(5); + lines.push(` ${t.type.padEnd(8)} ${countStr} (${pctStr}%)`); + } + lines.push(''); + } + + if (result.topResources.length > 0) { + lines.push(' Top 10 longest:'); + result.topResources.forEach((r, idx) => { + const num = String(idx + 1).padStart(3); + const dur = formatDuration(r.durationMs).padStart(7); + const file = r.filename.padEnd(50); + 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 ${Math.round(jank.startMs)}ms ${Math.round(jank.durationMs)}ms 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'); +} diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts new file mode 100644 index 0000000000..47b172d6cf --- /dev/null +++ b/profiler-cli/src/index.ts @@ -0,0 +1,184 @@ +/* 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; + if (!profilePath) { + console.error('Error: Profile path required for daemon mode'); + process.exit(1); + } + await startDaemon(SESSION_DIR, profilePath, sessionId); + 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') + ).action(async (profilePath: string, opts) => { + console.log(`Loading profile from ${profilePath}...`); + const sessionId = await startNewDaemon( + SESSION_DIR, + profilePath, + opts.session + ); + 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..186ac2c8eb --- /dev/null +++ b/profiler-cli/src/output.ts @@ -0,0 +1,91 @@ +/* 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, +} 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); + default: + throw assertExhaustiveCheck(result); + } +} diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts new file mode 100644 index 0000000000..94d0287f82 --- /dev/null +++ b/profiler-cli/src/protocol.ts @@ -0,0 +1,202 @@ +/* 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, + 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, +} 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, +} 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: '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; + +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..5932919830 --- /dev/null +++ b/profiler-cli/src/session.ts @@ -0,0 +1,245 @@ +/* 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'); + + if (!fs.existsSync(currentSessionFile)) { + return null; + } + + try { + return fs.readFileSync(currentSessionFile, 'utf-8').trim(); + } catch (_error) { + // ENOENT means no active session — not an error condition + return null; + } +} + +/** + * 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)) { + // console.error( + // `Session ${sessionId} daemon process with PID ${metadata.pid} not found. Cleaning up.` + // ); + return null; + } + + // Check if socket exists (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && !fs.existsSync(metadata.socketPath)) { + // console.error(`Session ${sessionId} socket not found. Cleaning up.`); + 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..330c63b7e2 --- /dev/null +++ b/profiler-cli/src/test/integration/basic.test.ts @@ -0,0 +1,341 @@ +/* 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']); + + const filterListResult = await cli(ctx, ['filter', 'list', '--json']); + const filterList = JSON.parse(filterListResult.stdout) as { + type: string; + threadHandle: string; + filters: Array<{ + spec: { type: string; funcIndexes?: number[] }; + }>; + }; + + expect(filterList.type).toBe('filter-stack'); + expect(filterList.threadHandle).toBe('t-0'); + expect(filterList.filters).toHaveLength(1); + expect(filterList.filters[0].spec).toEqual({ + type: 'merge', + funcIndexes: [1], + }); + + const statusResult = await cli(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as { + type: string; + filterStacks: Array<{ + threadHandle: string; + filters: Array<{ + spec: { type: string; funcIndexes?: number[] }; + }>; + }>; + }; + + expect(status.type).toBe('status'); + expect(status.filterStacks).toHaveLength(1); + expect(status.filterStacks[0]).toEqual( + expect.objectContaining({ + threadHandle: 't-0', + filters: [ + expect.objectContaining({ + spec: { type: 'merge', funcIndexes: [1] }, + }), + ], + }) + ); + + 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<{ + spec: { type: string; funcIndexes?: number[] }; + }>; + }; + + expect(explicitThreadFilterList.threadHandle).toBe('t-0'); + expect(explicitThreadFilterList.filters).toHaveLength(1); + expect(explicitThreadFilterList.filters[0].spec).toEqual({ + type: 'merge', + funcIndexes: [1], + }); + }); + + 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..1b1ee29113 --- /dev/null +++ b/profiler-cli/src/test/integration/sessions.test.ts @@ -0,0 +1,251 @@ +/* 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'; + const session3 = 'test-session-3'; + + // Start three 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, + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-3.json', + '--session', + session3, + ]); + + // Query each session explicitly + const result1 = await cli(ctx, ['profile', 'info', '--session', session1]); + expect(result1.stdout).toContain('This profile contains'); + + const result2 = await cli(ctx, ['profile', 'info', '--session', session2]); + expect(result2.stdout).toContain('This profile contains'); + + // Query current session (should be session3) + const result3 = await cli(ctx, ['profile', 'info']); + expect(result3.stdout).toContain('This profile contains'); + + // Note: We don't assert that results differ, as different test profiles + // might coincidentally have identical summaries. + + // Stop all sessions (mix of positional arg and --session flag) + await cli(ctx, ['stop', session1]); + await cli(ctx, ['stop', '--session', session2]); + await cli(ctx, ['stop', session3]); + }); + + 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..126c298c60 --- /dev/null +++ b/profiler-cli/src/test/integration/utils.ts @@ -0,0 +1,197 @@ +/* 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 }, + }; +} + +/** + * 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. + */ +export async function runCli( + ctx: CliTestContext, + args: string[], + options?: { + reject?: boolean; + timeout?: number; + } +): Promise { + const result = await exec(process.execPath, [CLI_BIN, ...args], { + env: ctx.env, + timeout: options?.timeout ?? 30000, + }); + + // Throw if reject is true (default) and command failed + if ((options?.reject ?? true) && 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 succeed. + */ +export async function cli( + ctx: CliTestContext, + args: string[] +): Promise { + return runCli(ctx, args); +} + +/** + * Run a profiler-cli command and expect it to fail. + */ +export async function cliFail( + ctx: CliTestContext, + args: string[] +): Promise { + try { + await runCli(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..83e6161c62 --- /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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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: 1.00s] + +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/client.test.ts b/profiler-cli/src/test/unit/client.test.ts new file mode 100644 index 0000000000..215a9a5a05 --- /dev/null +++ b/profiler-cli/src/test/unit/client.test.ts @@ -0,0 +1,28 @@ +/* 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 client. + * + * NOTE: This file intentionally contains no tests. + * + * The client.ts module handles cross-process communication via Unix sockets, + * which requires spawning separate daemon processes. This is too complex to + * manage reliably in Jest unit tests. + * + * Instead, client functionality is tested through integration tests in bash + * scripts: + * - bin/profiler-cli-test: Basic daemon lifecycle and client-server communication + * - bin/profiler-cli-test-multi: Concurrent client sessions + * + * Do not add unit tests here. If you need to test pure utility functions from + * client.ts, extract them to a separate module and test that module instead. + */ + +describe('profiler-cli client', function () { + it('has no unit tests (see comment above)', function () { + // This test exists only to prevent Jest from complaining about an empty suite + expect(true).toBe(true); + }); +}); diff --git a/profiler-cli/src/test/unit/daemon.test.ts b/profiler-cli/src/test/unit/daemon.test.ts new file mode 100644 index 0000000000..fa91e5e5e2 --- /dev/null +++ b/profiler-cli/src/test/unit/daemon.test.ts @@ -0,0 +1,28 @@ +/* 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 daemon. + * + * NOTE: This file intentionally contains no tests. + * + * The daemon.ts module handles cross-process communication via Unix sockets, + * spawns background processes, and manages long-lived server state. This is + * too complex to manage reliably in Jest unit tests. + * + * Instead, daemon functionality is tested through integration tests in bash + * scripts: + * - bin/profiler-cli-test: Basic daemon lifecycle (start, connect, stop) + * - bin/profiler-cli-test-multi: Multiple concurrent daemon sessions + * + * Do not add unit tests here. If you need to test pure utility functions from + * daemon.ts, extract them to a separate module and test that module instead. + */ + +describe('profiler-cli daemon', function () { + it('has no unit tests (see comment above)', function () { + // This test exists only to prevent Jest from complaining about an empty suite + expect(true).toBe(true); + }); +}); 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..7b648a45aa --- /dev/null +++ b/profiler-cli/src/utils/parse.ts @@ -0,0 +1,221 @@ +/* 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[]; + excludesAnyFunction?: string[]; + merge?: string[]; + rootAt?: string[]; + includesFunction?: string[]; + includesAnyFunction?: 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.excludesAnyFunction ?? []) { + 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.includesAnyFunction ?? []) { + 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', + 'excludesAnyFunction', + 'merge', + 'rootAt', + 'includesFunction', + 'includesAnyFunction', + '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', + '--excludes-any-function', + '--merge', + '--root-at', + '--includes-function', + '--includes-any-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': + case 'excludesAnyFunction': + 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': + case 'includesAnyFunction': + 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/src/actions/profile-view.ts b/src/actions/profile-view.ts index a7bf7ddc39..34bdfc7fab 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2103,6 +2103,14 @@ export function handleCallNodeTransformShortcut( }) ); break; + case 'G': + dispatch( + addTransformToStack(threadsKey, { + type: 'drop-category', + category, + }) + ); + break; default: // This did not match a call tree transform. } diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index eecd06a2be..2430c5c2db 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -53,7 +53,7 @@ import { StringTable } from 'firefox-profiler/utils/string-table'; import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile'; -export const CURRENT_URL_VERSION = 15; +export const CURRENT_URL_VERSION = 16; /** * This static piece of state might look like an anti-pattern, but it's a relatively @@ -1351,6 +1351,10 @@ const _upgraders: { .map(mapIndexesInTransform) .join('~'); }, + [16]: (_) => { + // Added the 'drop-category' transform (short key 'dg'). No existing URLs + // need rewriting; this is a new transform with no prior encoding to migrate. + }, }; for (let destVersion = 1; destVersion <= CURRENT_URL_VERSION; destVersion++) { diff --git a/src/components/shared/CallNodeContextMenu.tsx b/src/components/shared/CallNodeContextMenu.tsx index 1a360f01c6..561412df8b 100644 --- a/src/components/shared/CallNodeContextMenu.tsx +++ b/src/components/shared/CallNodeContextMenu.tsx @@ -422,6 +422,13 @@ class CallNodeContextMenuImpl extends React.PureComponent { }); break; } + case 'drop-category': { + addTransformToStack(threadsKey, { + type: 'drop-category', + category, + }); + break; + } case 'filter-samples': throw new Error( "Filter samples transform can't be applied from the call node context menu." @@ -714,6 +721,22 @@ class CallNodeContextMenuImpl extends React.PureComponent { }) : null} + {hasCategory + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-drop-category', + l10nProps: { + vars: { categoryName }, + elems: { strong: }, + }, + shortcut: 'G', + icon: 'Drop', + onClick: this._handleClick, + transform: 'drop-category', + title: '', + content: 'Drop samples with this category', + }) + : null} + {this.renderTransformMenuItem({ l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', shortcut: 'c', 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..35999f2684 --- /dev/null +++ b/src/profile-logic/combined-cpu.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 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 (using a min-heap approach) + * 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..cf3a1f5abe 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,92 @@ 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"). + * + * @param timestampStr - Pre-formatted timestamp from formatLogTimestamp() + * @param processName - Thread's process name (e.g. "GeckoMain") + * @param pid - Process ID + * @param threadName - Thread name (e.g. "Compositor") + * @param data - The LogMarkerPayload from the marker table + * @param moduleName - For the new format: module name resolved from the + * profile string table (markers.name). Ignored for the + * legacy format, which encodes the module in data.module. + * @param stringArray - Profile shared string table, used to resolve data.level + * in the new format. + */ +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/transforms.ts b/src/profile-logic/transforms.ts index 05ba86b2bc..ab9b02d3d8 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -74,6 +74,7 @@ const TRANSFORM_OBJ: { [key in TransformType]: true } = { 'collapse-recursion': true, 'collapse-function-subtree': true, 'focus-category': true, + 'drop-category': true, 'filter-samples': true, }; export const ALL_TRANSFORM_TYPES: TransformType[] = Object.keys( @@ -100,6 +101,9 @@ ALL_TRANSFORM_TYPES.forEach((transform: TransformType) => { case 'focus-category': shortKey = 'fg'; break; + case 'drop-category': + shortKey = 'dg'; + break; case 'merge-call-node': shortKey = 'mcn'; break; @@ -277,6 +281,19 @@ export function parseTransforms(transformString: string): TransformStack { } break; } + case 'drop-category': { + // e.g. "dg-3" + const [, categoryRaw] = tuple; + const category = parseInt(categoryRaw, 10); + // Validate that the category makes sense. + if (!isNaN(category) && category >= 0) { + transforms.push({ + type: 'drop-category', + category, + }); + } + break; + } case 'focus-subtree': case 'merge-call-node': { // e.g. "f-js-xFFpUMl-i" or "f-cpp-0KV4KV5KV61KV7KV8K" @@ -317,6 +334,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 +364,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 +385,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); } @@ -378,6 +422,7 @@ export function stringifyTransforms(transformStack: TransformStack): string { case 'focus-function': return `${shortKey}-${transform.funcIndex}`; case 'focus-category': + case 'drop-category': return `${shortKey}-${transform.category}`; case 'collapse-resource': return `${shortKey}-${transform.implementation}-${transform.resourceIndex}-${transform.collapsedFuncIndex}`; @@ -449,6 +494,16 @@ export function getTransformLabelL10nIds( }; } + if (transform.type === 'drop-category') { + if (categories === undefined) { + throw new Error('Expected categories to be defined.'); + } + return { + l10nId: 'TransformNavigator--drop-category', + item: categories[transform.category].name, + }; + } + if (transform.type === 'filter-samples') { switch (transform.filterType) { case 'marker-search': @@ -457,6 +512,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); } @@ -551,6 +614,12 @@ export function applyTransformToCallNodePath( callNodePath, callNodeInfo ); + case 'drop-category': + return _dropCategoryInCallNodePath( + transform.category, + callNodePath, + callNodeInfo + ); case 'merge-call-node': return _mergeNodeInCallNodePath(transform.callNodePath, callNodePath); case 'merge-function': @@ -672,6 +741,23 @@ function _removeOtherCategoryFunctionsInNodePathWithFunction( return newCallNodePath; } +// If the leaf node of the call node path belongs to the dropped category, +// return an empty path — the whole sample is gone. +function _dropCategoryInCallNodePath( + category: IndexIntoCategoryList, + callNodePath: CallNodePath, + callNodeInfo: CallNodeInfo +): CallNodePath { + const leafCallNodeIndex = callNodeInfo.getCallNodeIndexFromPath(callNodePath); + if ( + leafCallNodeIndex !== null && + callNodeInfo.categoryForNode(leafCallNodeIndex) === category + ) { + return []; + } + return callNodePath; +} + function _collapseResourceInCallNodePath( resourceIndex: IndexIntoResourceTable, collapsedFuncIndex: IndexIntoFuncTable, @@ -1456,6 +1542,20 @@ export function focusCategory(thread: Thread, category: IndexIntoCategoryList) { }); } +/** + * Drop any samples whose leaf stack frame belongs to the given category. + */ +export function dropCategory(thread: Thread, category: IndexIntoCategoryList) { + return timeCode('dropCategory', () => { + const { stackTable } = thread; + + return updateThreadStacks(thread, stackTable, (stack) => + // Drop any sample whose leaf frame belongs to the given category. + stack !== null && stackTable.category[stack] === category ? null : stack + ); + }); +} + /** * When restoring function in a CallNodePath there can be multiple correct CallNodePaths * that could be restored. The best approach would probably be to restore to the @@ -1730,70 +1830,167 @@ 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 - ); }); } @@ -1834,6 +2031,8 @@ export function applyTransform( return focusSelf(thread, transform.funcIndex, transform.implementation); case 'focus-category': return focusCategory(thread, transform.category); + case 'drop-category': + return dropCategory(thread, transform.category); case 'collapse-resource': return collapseResource( thread, @@ -2059,16 +2258,62 @@ export function translateTransform( funcIndex: newFuncIndex, }; } - case 'focus-category': { + case 'focus-category': + case 'drop-category': { // We don't sanitize-away categories, so this transform doesn't need to // be translated. return transform; } 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..3e6af4bec3 --- /dev/null +++ b/src/profile-query/README.md @@ -0,0 +1,99 @@ +# Profile Query Library + +A library for programmatically querying the contents of a Firefox Profiler profile. + +## Usage + +**Note:** Most users should use the [profiler-cli](../profiler-cli/README.md) (`profiler-cli` command) instead of using this library directly. + +### 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. + +## Available Methods + +### Loading + +- `static async load(filePathOrUrl: string): Promise` - Load a profile from file or URL + +### Profile & Thread Info + +- `async profileInfo(showAll?: boolean, search?: string): Promise>` - Get profile summary (processes, threads, CPU activity) +- `async threadInfo(threadHandle?: string): Promise>` - Get detailed thread information +- `async threadSelect(threadHandle: string): Promise` - Select a thread for subsequent queries + +### Thread Samples + +- `async threadSamples(threadHandle?: string, includeIdle?: boolean, search?: string, sampleFilters?: SampleFilterSpec[]): Promise>` - Get top functions and heaviest stack for a thread +- `async threadSamplesTopDown(threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, includeIdle?: boolean, search?: string, sampleFilters?: SampleFilterSpec[]): Promise>` - Get top-down call tree +- `async threadSamplesBottomUp(threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, includeIdle?: boolean, search?: string, sampleFilters?: SampleFilterSpec[]): Promise>` - Get bottom-up (inverted) call tree + +### Markers + +- `async threadMarkers(threadHandle?: string, filterOptions?: MarkerFilterOptions): Promise>` - List markers with aggregated statistics +- `async markerInfo(markerHandle: string): Promise>` - Get detailed information about a specific marker +- `async markerStack(markerHandle: string): Promise>` - Get the stack trace captured with a marker + +### Functions + +- `async functionExpand(functionHandle: string): Promise>` - Show the full untruncated name of a function +- `async functionInfo(functionHandle: string): Promise>` - Show detailed information about a function (library, resource, JS flags) +- `async threadFunctions(threadHandle?: string, filterOptions?: FunctionFilterOptions, includeIdle?: boolean, sampleFilters?: SampleFilterSpec[]): Promise>` - List all functions with CPU percentages + +### View Range (Zoom) + +- `async pushViewRange(rangeName: string): Promise` - Push a zoom range (supports timestamps, marker handles, seconds, milliseconds, or percentage values) +- `async popViewRange(): Promise` - Pop the most recent zoom range +- `async clearViewRange(): Promise` - Clear all zoom ranges, returning to full profile view + +### Filter Stack + +- `filterPush(spec: SampleFilterSpec, threadHandle?: string): FilterStackResult` - Push a sample filter onto the stack for the current thread +- `filterPop(count?: number, threadHandle?: string): FilterStackResult` - Pop the last `count` filters (default: 1) +- `filterClear(threadHandle?: string): FilterStackResult` - Clear all filters for the current thread +- `filterList(threadHandle?: string): FilterStackResult` - List all active filters for the current thread + +### Session Status + +- `async getStatus(): Promise` - Get current session status (selected thread, zoom range stack, active filter stacks) + +## 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..dab814d5bc --- /dev/null +++ b/src/profile-query/cpu-activity.ts @@ -0,0 +1,208 @@ +import type { Slice, SliceTree } from 'firefox-profiler/utils/slice-tree'; +import type { TimestampManager } from './timestamps'; + +export interface CpuActivityEntry { + startTime: number; + startTimeName: string; + startTimeStr: string; // Formatted timestamp string (e.g., "6.991s") + endTime: number; + endTimeName: string; + endTimeStr: string; // Formatted timestamp string (e.g., "10.558s") + cpuMs: number; + depthLevel: number; +} + +function sliceToString( + slice: Slice, + time: number[], + tsManager: TimestampManager +): string { + const { avg, start, end } = slice; + const startTime = time[start]; + const endTime = time[end]; + const duration = endTime - startTime; + const startName = tsManager.nameForTimestamp(startTime); + const endName = tsManager.nameForTimestamp(endTime); + const startTimeStr = tsManager.timestampString(startTime); + const endTimeStr = tsManager.timestampString(endTime); + return `${Math.round(avg * 100)}% for ${duration.toFixed(1)}ms: [${startName} → ${endName}] (${startTimeStr} - ${endTimeStr})`; +} + +function appendSliceSubtree( + slices: Slice[], + startIndex: number, + parent: number | null, + childrenStartPerParent: number[], + interestingSliceIndexes: Set, + nestingDepth: number, + time: number[], + s: string[], + tsManager: TimestampManager +) { + 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, tsManager) + ); + + const childrenStart = childrenStartPerParent[i]; + if (childrenStart !== null) { + appendSliceSubtree( + slices, + childrenStart, + i, + childrenStartPerParent, + interestingSliceIndexes, + nestingDepth + 1, + time, + s, + tsManager + ); + } + } +} + +export function printSliceTree( + { slices, time }: SliceTree, + tsManager: TimestampManager +): string[] { + if (slices.length === 0) { + return ['No significant activity.']; + } + + const childrenStartPerParent = new Array(slices.length); + const indexAndSumPerSlice = new Array(slices.length); + 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( + indexAndSumPerSlice.slice(0, 20).map((x) => x.i) + ); + // console.log(interestingSliceIndexes); + + const s = new Array(); + appendSliceSubtree( + slices, + 0, + null, + childrenStartPerParent, + interestingSliceIndexes, + 0, + time, + s, + tsManager + ); + + return s; +} + +/** + * Collect CPU activity slices as structured data. + */ +export function collectSliceTree( + { slices, time }: SliceTree, + tsManager: TimestampManager +): CpuActivityEntry[] { + if (slices.length === 0) { + return []; + } + + const childrenStartPerParent = new Array(slices.length); + const indexAndSumPerSlice = new Array(slices.length); + 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( + indexAndSumPerSlice.slice(0, 20).map((x) => x.i) + ); + + 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: number[], + 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..e4e26cf073 --- /dev/null +++ b/src/profile-query/filter-stack.ts @@ -0,0 +1,234 @@ +/* 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/. */ + +/** + * FilterStack manages the per-thread sample filter stacks for the profiler-cli CLI. + * + * Each thread (identified by its ThreadsKey) has an independent stack of filter + * entries. Each entry corresponds to one `profiler-cli filter push` invocation and may + * have dispatched one or more Redux transforms to the store. + * + * Popping entries removes both the in-memory records and the corresponding Redux + * transforms, using the POP_TRANSFORMS_FROM_STACK action. + */ + +import { getTransformStack } from '../selectors/url-state'; +import { addTransformToStack } from '../actions/profile-view'; +import type { SampleFilterSpec, FilterEntry } from './types'; +import type { Store } from '../types/store'; +import type { ThreadsKey } from 'firefox-profiler/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 new Error( + `Unhandled filter spec type: ${(spec as SampleFilterSpec).type}` + ); + } +} + +/** + * Push the Redux transforms for a filter spec and return the number pushed. + * Exported so the ProfileQuerier can use it for ephemeral (non-tracked) filters. + */ +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 new Error( + `Unhandled filter spec type: ${(spec as SampleFilterSpec).type}` + ); + } +} + +export class FilterStack { + /** Per-thread filter entries. Key is the ThreadsKey. */ + private _stacks: Map = new Map(); + + /** + * Push a new filter entry for the given thread. + * Dispatches the necessary Redux transforms immediately. + */ + push( + store: Store, + threadsKey: ThreadsKey, + spec: SampleFilterSpec + ): FilterEntry { + const entries = this._getOrCreate(threadsKey); + const reduxTransformCount = pushSpecTransforms(store, threadsKey, spec); + const entry: FilterEntry = { + index: entries.length + 1, + spec, + description: describeSpec(spec), + reduxTransformCount, + }; + entries.push(entry); + return entry; + } + + /** + * Pop the last `count` filter entries for the given thread. + * Dispatches POP_TRANSFORMS_FROM_STACK to remove the corresponding Redux transforms. + * Returns the removed entries (most recent first). + */ + pop(store: Store, threadsKey: ThreadsKey, count: number = 1): FilterEntry[] { + const entries = this._getOrCreate(threadsKey); + if (count <= 0 || entries.length === 0) { + return []; + } + const actualCount = Math.min(count, entries.length); + const removed = entries.splice(entries.length - actualCount, actualCount); + + // Compute how many Redux transforms need to be popped. + const totalReduxPops = removed.reduce( + (sum, e) => sum + e.reduxTransformCount, + 0 + ); + if (totalReduxPops > 0) { + const state = store.getState(); + const currentLength = getTransformStack(state, threadsKey).length; + store.dispatch({ + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex: currentLength - totalReduxPops, + }); + } + + return removed.reverse(); + } + + /** + * Clear all filter entries for the given thread. + */ + clear(store: Store, threadsKey: ThreadsKey): FilterEntry[] { + const entries = this._getOrCreate(threadsKey); + const count = entries.length; + if (count === 0) { + return []; + } + return this.pop(store, threadsKey, count); + } + + /** + * Return all filter entries for the given thread (a snapshot, not live reference). + */ + list(threadsKey: ThreadsKey): FilterEntry[] { + return [...(this._stacks.get(threadsKey) ?? [])]; + } + + /** + * Return all thread keys that have at least one active filter. + */ + activeThreadsKeys(): ThreadsKey[] { + return Array.from(this._stacks.entries()) + .filter(([, entries]) => entries.length > 0) + .map(([key]) => key); + } + + private _getOrCreate(threadsKey: ThreadsKey): FilterEntry[] { + let entries = this._stacks.get(threadsKey); + if (entries === undefined) { + entries = []; + this._stacks.set(threadsKey, entries); + } + return entries; + } +} diff --git a/src/profile-query/formatters/call-tree.ts b/src/profile-query/formatters/call-tree.ts new file mode 100644 index 0000000000..356f139438 --- /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 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: + // Default to exponential-0.9 + return totalPercentage * Math.pow(0.94, depth); + } +} + +/** + * 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 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..a3bef32284 --- /dev/null +++ b/src/profile-query/formatters/marker-info.ts @@ -0,0 +1,1881 @@ +/* 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 { formatTimestamp } from 'firefox-profiler/utils/format-numbers'; +import { changeMarkersSearchString } from '../../actions/profile-view'; +import { + formatFunctionNameWithLibrary, + truncateFunctionName, +} from '../function-list'; +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, +} from 'firefox-profiler/types'; +import type { + MarkerStackResult, + MarkerInfoResult, + StackTraceData, + ThreadMarkersResult, + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, + MarkerGroupData, + DurationStats, + RateStats, + MarkerFilterOptions, + ProfileLogsResult, +} from '../types'; +import { + isNetworkMarker, + LOG_LEVEL_STRING_TO_LETTER, + LOG_LETTER_TO_LEVEL, + formatLogTimestamp, + formatLogStatement, +} from 'firefox-profiler/profile-logic/marker-data'; +import type { + NetworkPayload, + LogMarkerPayload, +} from 'firefox-profiler/types/markers'; + +/** + * Aggregated statistics for a group of markers. + */ +interface MarkerTypeStats { + 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), + }; +} + +/** + * Format a duration in milliseconds to a human-readable string. + * Exported for testing. + */ +export function formatDuration(ms: number): string { + if (ms < 1) { + return `${(ms * 1000).toFixed(2)}μs`; + } else if (ms < 1000) { + return `${ms.toFixed(2)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +/** + * 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, limit } = 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; + }); + } + + // Apply limit if specified (after all filters) + if (limit !== undefined && filteredIndexes.length > limit) { + filteredIndexes = filteredIndexes.slice(0, limit); + } + + 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; +}> { + const hasEnd = items.some((item) => item.marker.end !== null); + + // Get top markers - sort by duration if interval markers, otherwise take first N + const sortedItems = hasEnd + ? [...items].sort( + (a, b) => + b.marker.end! - b.marker.start - (a.marker.end! - a.marker.start) + ) + : items.slice(0, maxCount); + + 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 +): 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)'; + } + 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. + * + * Scoring heuristic: + * - Prefers fields with a moderate number of unique values (3-20 ideal) + * - Avoids fields with too many unique values (likely IDs or timestamps) + * - Avoids fields with too few unique values (not enough variety) + * - Prefers fields that appear in most markers (>80%) + * - Excludes fields that look like IDs (end with "ID" or "Id") + * - Prefers fields with semantic names (type, event, phase, status, etc.) + */ +function analyzeFieldVariance( + markers: Marker[] +): { field: string; variance: number } | null { + if (markers.length === 0) { + return null; + } + + // Get the marker schema for the first marker to find available fields + const firstMarkerType = markers[0].data?.type; + if (!firstMarkerType) { + return null; + } + + // Analyze each field to find the one with best score + const fieldScores: Array<{ + field: string; + score: number; + uniqueCount: number; + }> = []; + + // Get all field keys from the first marker's data + const sampleData = markers[0].data; + if (!sampleData) { + return null; + } + + const fieldKeys = Object.keys(sampleData).filter((key) => { + // Exclude metadata fields + if (key === 'type' || key === 'cause') { + return false; + } + // Exclude fields that look like IDs (end with "ID" or "Id") + if (key.endsWith('ID') || key.endsWith('Id')) { + return false; + } + return true; + }); + + for (const fieldKey of fieldKeys) { + const uniqueValues = new Set(); + let validCount = 0; + + for (const marker of markers) { + const value = (marker.data as any)?.[fieldKey]; + if (value !== undefined && value !== null) { + uniqueValues.add(String(value)); + validCount++; + } + } + + const uniqueCount = uniqueValues.size; + + // Skip fields that don't appear frequently enough + if (validCount < markers.length * 0.8) { + continue; + } + + // Skip fields with too few unique values (< 3) + if (uniqueCount < 3) { + continue; + } + + // Calculate score based on how good this field is for grouping + // Prefer fields with 3-20 unique values (ideal range) + let score = 0; + if (uniqueCount >= 3 && uniqueCount <= 20) { + // Ideal range: score 100 + score = 100; + } else if (uniqueCount > 20 && uniqueCount <= 50) { + // Acceptable range: score decreases with more unique values + score = 100 - (uniqueCount - 20) * 2; + } else if (uniqueCount > 50) { + // Too many unique values (likely IDs): very low score + score = 10; + } + + // Boost score for fields that appear in all markers + if (validCount === markers.length) { + score += 10; + } + + // Boost score for semantically meaningful field names + const semanticFields = [ + 'eventType', + 'phase', + 'status', + 'operation', + 'category', + ]; + if (semanticFields.includes(fieldKey)) { + score += 20; + } + + fieldScores.push({ field: fieldKey, score, uniqueCount }); + } + + // Return the field with highest score + if (fieldScores.length === 0) { + return null; + } + + fieldScores.sort((a, b) => b.score - a.score); + return { field: fieldScores[0].field, variance: fieldScores[0].score / 100 }; +} + +/** + * Format marker groups hierarchically and append to the lines array. + */ +function formatMarkerGroups( + lines: string[], + groups: MarkerGroup[], + indentLevel: number, + maxGroups: number = 15 +): void { + const indent = ' '.repeat(indentLevel); + const topGroups = groups.slice(0, maxGroups); + + for (const group of topGroups) { + let line = `${indent}${group.groupName.padEnd(25)} ${group.count.toString().padStart(5)} markers`; + + if (group.durationStats) { + const { min, avg, max } = group.durationStats; + line += ` (interval: min=${formatDuration(min)}, avg=${formatDuration(avg)}, max=${formatDuration(max)})`; + } else if (group.isInterval) { + line += ' (interval)'; + } else { + line += ' (instant)'; + } + + lines.push(line); + + // Recursively format sub-groups + if (group.subGroups && group.subGroups.length > 0) { + formatMarkerGroups(lines, group.subGroups, indentLevel + 1, 10); + } + } + + if (groups.length > maxGroups) { + lines.push(`${indent}... (${groups.length - maxGroups} more groups)`); + } +} + +/** + * 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, + 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); + 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, + 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 type and compute statistics. + * Optionally applies auto-grouping or custom grouping. + */ +function aggregateMarkersByType( + markers: Marker[], + markerIndexes: MarkerIndex[], + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + categories: CategoryList, + autoGroup: boolean = false, + maxTopMarkers: number = 5 +): MarkerTypeStats[] { + // 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: MarkerTypeStats[] = []; + + 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); + if (fieldInfo) { + // Sub-group by the field with highest variance + subGroups = groupMarkers( + markerGroup, + [{ field: fieldInfo.field }], + categories, + 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. + */ +function aggregateMarkersByCategory( + markers: Marker[], + markerIndexes: MarkerIndex[], + categories: CategoryList +): Array<{ categoryName: string; count: number; percentage: number }> { + const groups = new Map(); + + for (const markerIndex of markerIndexes) { + const marker = markers[markerIndex]; + const categoryName = categories[marker.category]?.name ?? 'Unknown'; + + groups.set(categoryName, (groups.get(categoryName) ?? 0) + 1); + } + + const total = markerIndexes.length; + const stats = Array.from(groups.entries()) + .map(([categoryName, count]) => ({ + categoryName, + count, + percentage: (count / total) * 100, + })) + .sort((a, b) => b.count - a.count); + + return stats; +} + +/** + * Format the marker listing for a thread. + */ +export function formatThreadMarkers( + store: Store, + threadMap: ThreadMap, + markerMap: MarkerMap, + threadHandle?: string, + filterOptions: MarkerFilterOptions = {} +): string { + // 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' + ); + + const lines: string[] = []; + + // Generate thread handle for display + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + // Check if filters are active + const { minDuration, maxDuration, category, hasStack, limit } = + filterOptions; + const hasFilters = + !!searchString || + minDuration !== undefined || + maxDuration !== undefined || + category !== undefined || + hasStack || + limit !== undefined; + const filterSuffix = + hasFilters && filteredIndexes.length !== originalCount + ? ` (filtered from ${originalCount})` + : ''; + + lines.push( + `Markers in thread ${displayThreadHandle} (${friendlyThreadName}) — ${filteredIndexes.length} markers${filterSuffix}\n` + ); + + if (filteredIndexes.length === 0) { + if (hasFilters) { + lines.push('No markers match the specified filters.'); + } else { + lines.push('No markers in this thread.'); + } + return lines.join('\n'); + } + + const { groupBy, autoGroup, topN } = filterOptions; + const maxTopMarkers = topN ?? 5; + + // Handle custom grouping if groupBy is specified + 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, + threadIndexes, + markerMap, + getMarkerLabel, + 0, + maxTopMarkers + ); + + // Format and display hierarchical groups + formatMarkerGroups(lines, groups, 0); + } else { + // Default aggregation by type (with optional auto-grouping) + const typeStats = aggregateMarkersByType( + fullMarkerList, + filteredIndexes, + threadIndexes, + markerMap, + getMarkerLabel, + categories, + autoGroup || false, + maxTopMarkers + ); + + // Show top 15 marker names + lines.push('By Name (top 15):'); + const topTypes = typeStats.slice(0, 15); + for (const stats of topTypes) { + let line = ` ${stats.markerName.padEnd(25)} ${stats.count.toString().padStart(5)} 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 handleOnly = m.handle; + if (m.duration !== undefined) { + return `${handleOnly} (${formatDuration(m.duration)})`; + } + return handleOnly; + }) + .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}:`); + } + formatMarkerGroups(lines, stats.subGroups, 2); + } + } + + if (typeStats.length > 15) { + lines.push(` ... (${typeStats.length - 15} more types)`); + } + + lines.push(''); + + // Aggregate by category (using filtered indexes) + const categoryStats = aggregateMarkersByCategory( + fullMarkerList, + filteredIndexes, + categories + ); + + lines.push('By Category:'); + for (const stats of categoryStats) { + lines.push( + ` ${stats.categoryName.padEnd(25)} ${stats.count.toString().padStart(5)} markers (${stats.percentage.toFixed(1)}%)` + ); + } + + lines.push(''); + + // Frequency analysis for top types + lines.push('Frequency Analysis:'); + const topRateTypes = typeStats + .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.' + ); + + return lines.join('\n'); + } finally { + // Always clear the search string to avoid affecting other queries + if (searchString) { + store.dispatch(changeMarkersSearchString('')); + } + } +} + +/** + * 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, + threadIndexes, + markerMap, + getMarkerLabel, + 0, + maxTopMarkers + ); + + // Add markerIndex to topMarkers in groups + customGroups = addMarkerIndexToGroups(groups); + } + + // Aggregate by type (with optional auto-grouping) + const typeStats = aggregateMarkersByType( + fullMarkerList, + filteredIndexes, + threadIndexes, + markerMap, + getMarkerLabel, + categories, + autoGroup || false, + maxTopMarkers + ); + + // Convert typeStats to include markerIndex + const byType = typeStats.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: categories.findIndex( + (cat) => cat?.name === stats.categoryName + ), + 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; + + return { + type: 'thread-markers', + threadHandle: displayThreadHandle, + friendlyThreadName, + totalMarkerCount: originalCount, + filteredMarkerCount: filteredIndexes.length, + filters, + byType, + byCategory, + customGroups, + }; + } 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, + })); +} + +/** + * Format a marker's cause stack trace. + * Returns an array of formatted stack frame strings. + */ +function formatMarkerStack( + stackIndex: IndexIntoStackTable | null, + thread: Thread, + libs: Lib[], + maxFrames: number = 20 +): string[] { + if (stackIndex === null) { + return ['(no stack trace)']; + } + + const { stackTable, frameTable } = thread; + const frames: string[] = []; + + // Walk up the stack table to collect all frames + let currentStackIndex: IndexIntoStackTable | null = stackIndex; + while (currentStackIndex !== null) { + const frameIndex = stackTable.frame[currentStackIndex]; + const funcIndex = frameTable.func[frameIndex]; + const funcName = formatFunctionNameWithLibrary(funcIndex, thread, libs); + frames.push(funcName); + currentStackIndex = stackTable.prefix[currentStackIndex]; + } + + const lines: string[] = []; + const totalFrames = frames.length; + + if (totalFrames === 0) { + return ['(empty stack)']; + } + + // Show up to maxFrames, with ellipsis if there are more + const framesToShow = Math.min(totalFrames, maxFrames); + for (let i = 0; i < framesToShow; i++) { + const displayName = truncateFunctionName(frames[i], 100); + lines.push(` [${i + 1}] ${displayName}`); + } + + if (totalFrames > maxFrames) { + lines.push(` ... (${totalFrames - maxFrames} more frames)`); + } + + return lines; +} + +/** + * 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'] = []; + + // Walk up the stack table to collect all frames + let currentStackIndex: IndexIntoStackTable | null = stackIndex; + while (currentStackIndex !== null) { + const frameIndex = stackTable.frame[currentStackIndex]; + const funcIndex = frameTable.func[frameIndex]; + + // Get function name + const funcName = stringTable.getString(funcTable.name[funcIndex]); + + // Get library name if available + const resourceIndex = funcTable.resource[funcIndex]; + let library: string | undefined; + let nameWithLibrary = funcName; + + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; + if (libIndex !== null && libs) { + const lib = libs[libIndex]; + library = lib.name; + nameWithLibrary = `${library}!${funcName}`; + } else { + const resourceName = stringTable.getString( + resourceTable.name[resourceIndex] + ); + if (resourceName && resourceName !== funcName) { + nameWithLibrary = `${resourceName}!${funcName}`; + } + } + } + + 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, + }; +} + +/** + * Format a marker's full stack trace. + * Shows all frames without limit. + */ +export function formatMarkerStackFull( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): string { + 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 lines: string[] = []; + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + + lines.push(`Stack trace for marker ${markerHandle}: ${marker.name}\n`); + lines.push(`Thread: ${threadHandleDisplay} (${friendlyThreadName})`); + + // Check if marker has a stack trace + if (!marker.data || !('cause' in marker.data) || !marker.data.cause) { + lines.push('\n(This marker has no stack trace)'); + return lines.join('\n'); + } + + const cause = marker.data.cause; + const profile = getProfile(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = profile.libs; + + if (cause.time !== undefined) { + const causeTimeStr = formatTimestamp(cause.time); + lines.push(`Captured at: ${causeTimeStr}\n`); + } + + const stackLines = formatMarkerStack(cause.stack, thread, libs, Infinity); + lines.push(...stackLines); + + return lines.join('\n'); +} + +/** + * Format detailed information about a specific marker. + */ +export function formatMarkerInfo( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): string { + 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 lines: string[] = []; + 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); + + lines.push( + `Marker ${markerHandle}: ${marker.name}${tooltipLabel ? ` - ${tooltipLabel}` : ''}\n` + ); + + // Basic info + lines.push(`Type: ${marker.data?.type ?? 'None'}`); + lines.push(`Category: ${categories[marker.category]?.name ?? 'Unknown'}`); + + const startStr = formatTimestamp(marker.start); + if (marker.end !== null) { + const endStr = formatTimestamp(marker.end); + const duration = marker.end - marker.start; + lines.push(`Time: ${startStr} - ${endStr} (${formatDuration(duration)})`); + } else { + lines.push(`Time: ${startStr} (instant)`); + } + + lines.push(`Thread: ${threadHandleDisplay} (${friendlyThreadName})`); + + // Marker data fields + if (marker.data) { + const schema = markerSchemaByName[marker.data.type]; + if (schema && schema.fields.length > 0) { + lines.push('\nFields:'); + 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 + ); + lines.push(` ${field.label || field.key}: ${formattedValue}`); + } + } + } + + // Show description if available + if (schema?.description) { + lines.push(`\nDescription:`); + lines.push(` ${schema.description}`); + } + } + + // Show stack trace if available + 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; + + lines.push('\nStack trace:'); + if (cause.time !== undefined) { + const causeTimeStr = formatTimestamp(cause.time); + lines.push(` Captured at: ${causeTimeStr}`); + } + + const stackLines = formatMarkerStack(cause.stack, thread, libs, 20); + lines.push(...stackLines); + + if (stackLines.length > 21) { + lines.push( + `\nUse 'profiler-cli marker stack ${markerHandle}' for the full stack trace.` + ); + } + } + + return lines.join('\n'); +} + +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..115df097d5 --- /dev/null +++ b/src/profile-query/formatters/page-load.ts @@ -0,0 +1,502 @@ +/* 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 { jankLimit = 10 } = options; + + 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..9733a67b67 --- /dev/null +++ b/src/profile-query/formatters/thread-info.ts @@ -0,0 +1,485 @@ +/* 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 { printSliceTree, 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'; + +export function formatThreadInfo( + store: Store, + timestampManager: TimestampManager, + threadMap: ThreadMap, + threadHandle?: string +): string { + 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 cptuActivity = threadSelectors.getRangeFilteredActivitySlices(state); + const cpuActivityLines = + cptuActivity !== null ? printSliceTree(cptuActivity, timestampManager) : []; + + return `\ +Name: ${friendlyThreadName} +TID: ${thread.tid} +Created at: ${timestampManager.nameForTimestamp(thread.registerTime)} +Ended at: ${thread.unregisterTime !== null ? timestampManager.nameForTimestamp(thread.unregisterTime) : 'still alive at end of recording'} + +This thread contains ${thread.samples.length} samples and ${thread.markers.length} markers. + +CPU activity over time: +${cpuActivityLines.join('\n')} +`; +} + +/** + * 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, // Could extract from funcName if needed + })); + + 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, // Could extract from funcName if needed + })); + + // 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 + let invertedCallTree = null; + try { + 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; + invertedCallTree = collectCallTree(invertedTree, libs, callTreeOptions); + } catch (_e) { + // Inverted tree creation failed, leave as null + } + + 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) { + // Get the unfiltered thread to count total samples + const rawThread = threadSelectors.getRawThread(state); + fullProfileTotalSamples = rawThread.samples.length; + } + + // 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-list.ts b/src/profile-query/function-list.ts new file mode 100644 index 0000000000..cd48ebfa23 --- /dev/null +++ b/src/profile-query/function-list.ts @@ -0,0 +1,514 @@ +/* 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 } from 'firefox-profiler/types'; +import { getFunctionHandle } from './function-map'; + +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 === ')') { + flushText(); + + // Pop back to parent level + if (stack.length > 1) { + stack.pop(); + } + } 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 resourceIndex = thread.funcTable.resource[funcIndex]; + + // If there's no resource or it's -1, just return the function name + if (resourceIndex === -1) { + return funcName; + } + + // Get the resource name + const resourceName = thread.stringTable.getString( + thread.resourceTable.name[resourceIndex] + ); + + // Get the library name if available + const libIndex = thread.resourceTable.lib[resourceIndex]; + if (libIndex !== null && libs) { + const lib = libs[libIndex]; + // Use the library name (e.g., "nvoglv64.dll") rather than full path + const libName = lib.name; + return `${libName}!${funcName}`; + } + + // Fall back to resource name if no library + 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..55ea972ae2 --- /dev/null +++ b/src/profile-query/index.ts @@ -0,0 +1,1437 @@ +/* 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, + getSelectedThreadIndexes, + getTransformStack, + getCurrentSearchString, +} from 'firefox-profiler/selectors/url-state'; +import { + commitRange, + popCommittedRanges, + changeSelectedThreads, + addTransformToStack, + changeCallTreeSearchString, +} 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 { MarkerMap } from './marker-map'; +import { loadProfileFromFileOrUrl } 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 { FilterStack, pushSpecTransforms } from './filter-stack'; +import { + getStackLineInfo, + getLineTimings, +} from 'firefox-profiler/profile-logic/line-timings'; +import { + getStackAddressInfo, + getAddressTimings, +} from 'firefox-profiler/profile-logic/address-timings'; +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 { + AddressProof, + StartEndRange, + ThreadIndex, +} from 'firefox-profiler/types'; +import type { + StatusResult, + SessionContext, + WithContext, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + FunctionSourceAnnotation, + FunctionAsmAnnotation, + ViewRangeResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadNetworkResult, + ThreadFunctionsResult, + ThreadPageLoadResult, + ProfileLogsResult, + MarkerFilterOptions, + FunctionFilterOptions, + SampleFilterSpec, + FilterStackResult, +} from './types'; +import type { CallTreeCollectionOptions } from './formatters/call-tree'; + +import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; +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(); + +export class ProfileQuerier { + _store: Store; + _processIndexMap: Map; + _timestampManager: TimestampManager; + _threadMap: ThreadMap; + _markerMap: MarkerMap; + _filterStack: FilterStack; + _archiveCache: Map>; + + constructor(store: Store, rootRange: StartEndRange) { + this._store = store; + this._processIndexMap = new Map(); + this._timestampManager = new TimestampManager(rootRange); + this._threadMap = new ThreadMap(); + this._filterStack = new FilterStack(); + this._archiveCache = 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); + }); + } + + static async load(filePathOrUrl: string): Promise { + const { store, rootRange } = await loadProfileFromFileOrUrl(filePathOrUrl); + 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> { + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadSamples(this._store, this._threadMap, threadHandle); + const withIdle = activeOnly + ? () => this._withDroppedIdle(threadIndexes, 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._filterStack.list(getThreadsKey(threadIndexes)); + return { + ...result, + activeOnly, + search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + context: this._getContext(), + }; + } + + async threadSamplesTopDown( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadSamplesTopDown( + this._store, + this._threadMap, + threadHandle, + callTreeOptions + ); + const withIdle = activeOnly + ? () => this._withDroppedIdle(threadIndexes, 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._filterStack.list(getThreadsKey(threadIndexes)); + return { + ...result, + activeOnly, + search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + context: this._getContext(), + }; + } + + async threadSamplesBottomUp( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadSamplesBottomUp( + this._store, + this._threadMap, + threadHandle, + callTreeOptions + ); + const withIdle = activeOnly + ? () => this._withDroppedIdle(threadIndexes, 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._filterStack.list(getThreadsKey(threadIndexes)); + return { + ...result, + activeOnly, + search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + context: this._getContext(), + }; + } + + /** + * 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) { + throw new Error('No view ranges to clear'); + } + + // 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); + + if (threadIndexes.size === 1) { + const threadIndex = Array.from(threadIndexes)[0]; + const thread = profile.threads[threadIndex]; + return `Selected thread: ${threadHandle} (${thread.name})`; + } + + const names = Array.from(threadIndexes) + .map((idx) => profile.threads[idx].name) + .join(', '); + return `Selected ${threadIndexes.size} threads: ${threadHandle} (${names})`; + } + + /** + * Push a new filter onto the current thread's filter stack. + */ + 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 entry = this._filterStack.push(this._store, threadsKey, spec); + const filters = this._filterStack.list(threadsKey); + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'push', + message: `Pushed filter ${entry.index}: ${entry.description}`, + }; + } + + /** + * Pop the last `count` filters from the current thread's filter stack. + */ + 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 removed = this._filterStack.pop(this._store, threadsKey, count); + const filters = this._filterStack.list(threadsKey); + + let msg; + if (removed.length === 0) { + msg = 'No filters to pop'; + } else if (removed.length === 1) { + msg = `Popped filter: ${removed[0].description}`; + } else { + msg = `Popped ${removed.length} filters`; + } + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'pop', + message: msg, + }; + } + + /** + * Clear all filters from the current thread's filter 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 removed = this._filterStack.clear(this._store, threadsKey); + const msg = + removed.length === 0 + ? 'No filters to clear' + : `Cleared ${removed.length} filter(s)`; + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters: [], + action: 'clear', + message: msg, + }; + } + + /** + * List all active filters for the current thread. + */ + 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); + + const filters = this._filterStack.list(threadsKey); + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + }; + } + + /** + * 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; + + for (const spec of filters) { + pushSpecTransforms(this._store, threadsKey, spec); + } + + try { + return fn(); + } finally { + this._store.dispatch({ + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex: stackLengthBefore, + }); + } + } + + /** + * Apply a drop-category transform for the Idle category around a computation, + * then restore the transform stack to its previous state. + * If the profile has no Idle category, fn() is called without changes. + */ + private _withDroppedIdle(threadIndexes: Set, fn: () => T): T { + const state = this._store.getState(); + const profile = getProfile(state); + const idleCategoryIndex = + profile.meta.categories?.findIndex((c) => c.name === 'Idle') ?? -1; + + if (idleCategoryIndex === -1) { + return fn(); + } + + const threadsKey = getThreadsKey(threadIndexes); + const stackLengthBefore = getTransformStack(state, threadsKey).length; + + this._store.dispatch( + addTransformToStack(threadsKey, { + type: 'drop-category', + category: idleCategoryIndex, + }) + ); + + try { + return fn(); + } finally { + this._store.dispatch({ + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex: stackLengthBefore, + }); + } + } + + /** + * 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)); + } + } + + /** + * 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 profile = getProfile(state); + const rootRange = getProfileRootRange(state); + const committedRanges = getAllCommittedRanges(state); + const selectedThreadIndexes = getSelectedThreadIndexes(state); + + // Get selected threads info + const selectedThreadHandle = + selectedThreadIndexes.size > 0 + ? this._threadMap.handleForThreadIndexes(selectedThreadIndexes) + : null; + + const selectedThreads = Array.from(selectedThreadIndexes).map( + (threadIndex) => ({ + threadIndex, + name: profile.threads[threadIndex].name, + }) + ); + + // Get current (most recent) view range if any + const zeroAt = rootRange.start; + let currentViewRange = null; + if (committedRanges.length > 0) { + const range = committedRanges[committedRanges.length - 1]; + const absoluteStart = range.start + zeroAt; + const absoluteEnd = range.end + zeroAt; + const startName = this._timestampManager.nameForTimestamp(absoluteStart); + const endName = this._timestampManager.nameForTimestamp(absoluteEnd); + currentViewRange = { + start: absoluteStart, + startName, + end: absoluteEnd, + endName, + }; + } + + return { + selectedThreadHandle, + selectedThreads, + currentViewRange, + rootRange: { + start: rootRange.start, + end: rootRange.end, + }, + }; + } + + /** + * Get current session status including selected threads and view ranges. + */ + async getStatus(): Promise { + const state = this._store.getState(); + const profile = getProfile(state); + const rootRange = getProfileRootRange(state); + const committedRanges = getAllCommittedRanges(state); + const selectedThreadIndexes = getSelectedThreadIndexes(state); + + // Get selected threads info + const selectedThreadHandle = + selectedThreadIndexes.size > 0 + ? this._threadMap.handleForThreadIndexes(selectedThreadIndexes) + : null; + + const selectedThreads = Array.from(selectedThreadIndexes).map( + (threadIndex) => ({ + threadIndex, + name: profile.threads[threadIndex].name, + }) + ); + + // Collect view ranges + const zeroAt = rootRange.start; + const viewRanges = committedRanges.map((range) => { + const absoluteStart = range.start + zeroAt; + const absoluteEnd = range.end + zeroAt; + const startName = this._timestampManager.nameForTimestamp(absoluteStart); + const endName = this._timestampManager.nameForTimestamp(absoluteEnd); + return { + start: absoluteStart, + startName, + end: absoluteEnd, + endName, + }; + }); + + // Collect active filter stacks + const filterStacks = this._filterStack + .activeThreadsKeys() + .map((threadsKey) => ({ + threadsKey, + threadHandle: this._threadMap.handleForKey(threadsKey), + filters: this._filterStack.list(threadsKey), + })); + + return { + type: 'status', + selectedThreadHandle, + selectedThreads, + viewRanges, + rootRange: { + start: rootRange.start, + end: rootRange.end, + }, + 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 resourceIndex = funcTable.resource[funcIndex]; + + // Get library prefix if available + let library: string | undefined; + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; + if (libIndex !== null && libIndex !== undefined && profile.libs) { + const lib = profile.libs[libIndex]; + library = lib.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']; + let libraryName: string | undefined; + + // Add resource info if available + if (resourceIndex !== -1) { + const resourceName = stringArray[resourceTable.name[resourceIndex]]; + resource = { + name: resourceName, + index: resourceIndex, + }; + + const libIndex = resourceTable.lib[resourceIndex]; + if ( + libIndex !== null && + libIndex !== undefined && + libIndex >= 0 && + profile.libs + ) { + const lib = profile.libs[libIndex]; + libraryName = lib.name; + library = { + name: lib.name, + path: lib.path, + debugName: lib.debugName, + debugPath: lib.debugPath, + breakpadId: lib.breakpadId, + }; + } + } + + const fullName = libraryName ? `${libraryName}!${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 = activeOnly + ? () => this._withDroppedIdle(threadIndexes, collect) + : collect; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withIdle) + : withIdle(); + const activeFilters = this._filterStack.list(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 state = this._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 warnings: string[] = []; + + // Resolve library name for fullName + const resourceIndex = funcTable.resource[funcIndex]; + let libraryName: string | undefined; + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; + if ( + libIndex !== null && + libIndex !== undefined && + libIndex >= 0 && + profile.libs + ) { + libraryName = profile.libs[libIndex].name; + } + } + const fullName = libraryName ? `${libraryName}!${funcName}` : funcName; + + // Get selected thread + derived thread data (derived Thread has correct types for utilities) + const threadIndexes = getSelectedThreadIndexes(state); + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getFilteredThread(state); + const { + stackTable, + frameTable, + funcTable: threadFuncTable, + nativeSymbols: threadNativeSymbols, + } = thread; + const samples = thread.samples; + + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const threadHandle = this._threadMap.handleForThreadIndexes(threadIndexes); + + // Compute aggregate self/total for the header. + // Build a boolean lookup: does frame i belong to funcIndex? + const frameInFunc = new Uint8Array(frameTable.func.length); + for (let fi = 0; fi < frameTable.func.length; fi++) { + if (frameTable.func[fi] === funcIndex) { + frameInFunc[fi] = 1; + } + } + // Memoize bottom-up: does this stack contain any frame for funcIndex? + // stackTable entries are in topological order (prefix always has lower index). + const stackContainsFunc = new Int8Array(stackTable.length); + for (let si = 0; si < stackTable.length; si++) { + const frame = stackTable.frame[si]; + if (frameInFunc[frame]) { + stackContainsFunc[si] = 1; + } else { + const prefix = stackTable.prefix[si]; + stackContainsFunc[si] = + prefix !== null ? stackContainsFunc[prefix] : -1; + } + } + + let totalSelfSamples = 0; + let totalTotalSamples = 0; + for (let si = 0; si < samples.length; si++) { + const stackIndex = samples.stack[si]; + if (stackIndex === null) { + continue; + } + const weight = samples.weight ? samples.weight[si] : 1; + if (stackContainsFunc[stackIndex] === 1) { + totalTotalSamples += weight; + } + if (frameInFunc[stackTable.frame[stackIndex]]) { + totalSelfSamples += weight; + } + } + + // Source annotation + let srcAnnotation: FunctionSourceAnnotation | null = null; + if (mode === 'src' || mode === 'all') { + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + const filenameStrIndex = thread.sources.filename[sourceIndex]; + const filename = thread.stringTable.getString(filenameStrIndex); + const sourceUuid = thread.sources.id[sourceIndex]; + + // getStackLineInfo finds all frames belonging to this source file and + // computes per-line hit sets. getLineTimings aggregates into self/total maps. + const stackLineInfo = getStackLineInfo( + stackTable, + frameTable, + threadFuncTable, + sourceIndex + ); + const { totalLineHits, selfLineHits } = getLineTimings( + stackLineInfo, + samples + ); + + // Count samples with/without line number information + 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; + } + } + + // Build an addressProof from any native symbol for this function. + // This is used by fetchSource to query the /source/v1 API on local symbol servers. + let addressProof: AddressProof | null = null; + for (let fi = 0; fi < frameTable.func.length; fi++) { + if (frameTable.func[fi] === funcIndex) { + const ns = frameTable.nativeSymbol[fi]; + if (ns !== null) { + const libIndex = threadNativeSymbols.libIndex[ns]; + const lib = profile.libs[libIndex]; + if (lib.debugName && lib.breakpadId) { + addressProof = { + debugName: lib.debugName, + breakpadId: lib.breakpadId, + address: threadNativeSymbols.address[ns], + }; + } + break; + } + } + } + + // Fetch source using the same path as the profiler UI: + // tries /source/v1 on local symbol server, CORS download for Mercurial/crates.io, etc. + let fileLines: string[] | null = null; + let totalFileLines: number | null = null; + const fetchResult = await fetchSource( + filename, + sourceUuid, + symbolServerUrl, + addressProof, + this._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}` + ); + } + + // Determine which lines to show based on the context option + const annotatedLineNums = new Set([ + ...totalLineHits.keys(), + ...selfLineHits.keys(), + ]); + let linesToShow: Set; + let contextMode: string; + + if (contextOption === 'file') { + // Show the whole file + linesToShow = new Set(); + const last = totalFileLines ?? Math.max(...annotatedLineNums); + for (let ln = 1; ln <= last; ln++) { + linesToShow.add(ln); + } + contextMode = 'full file'; + } else { + // Treat as a number of context lines (default: 2) + 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); + srcAnnotation = { + 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, + })), + }; + } else if (mode === 'src') { + warnings.push( + `Function ${functionHandle} has no source index. Use --mode asm for assembly view.` + ); + } + } + + // Assembly annotation + const asmAnnotations: FunctionAsmAnnotation[] = []; + if (mode === 'asm' || mode === 'all') { + // Collect all native symbol indices for this funcIndex by scanning frames + const nativeSymbolsForFunc = new Set(); + for (let fi = 0; fi < frameTable.func.length; fi++) { + if (frameTable.func[fi] === funcIndex) { + const ns = frameTable.nativeSymbol[fi]; + if (ns !== null) { + nativeSymbolsForFunc.add(ns); + } + } + } + + if (nativeSymbolsForFunc.size === 0) { + warnings.push( + `Function ${functionHandle} has no native symbols — may be JS-only or not symbolicated.` + ); + } + + const nativeSymbolCount = nativeSymbolsForFunc.size; + let compilationIndex = 1; + + for (const nsIndex of nativeSymbolsForFunc) { + const symbolName = thread.stringTable.getString( + threadNativeSymbols.name[nsIndex] + ); + const symbolAddress = threadNativeSymbols.address[nsIndex]; + const functionSize = threadNativeSymbols.functionSize[nsIndex] ?? null; + const libIndex = threadNativeSymbols.libIndex[nsIndex]; + const lib = profile.libs[libIndex]; + + // Compute per-address timings using the existing utilities + const stackAddressInfo = getStackAddressInfo( + stackTable, + frameTable, + threadFuncTable, + nsIndex + ); + const { totalAddressHits, selfAddressHits } = getAddressTimings( + stackAddressInfo, + samples + ); + + const nativeSymbolInfo = { + name: symbolName, + address: symbolAddress, + functionSize: functionSize ?? 0, + functionSizeIsKnown: functionSize !== null, + libIndex, + }; + + let fetchError: string | null = null; + let instructions: FunctionAsmAnnotation['instructions'] = []; + + 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('; '); + warnings.push( + `Assembly fetch failed for ${symbolName}: ${fetchError}` + ); + } + } catch (e) { + fetchError = e instanceof Error ? e.message : String(e); + warnings.push( + `Assembly fetch threw for ${symbolName}: ${fetchError}` + ); + } + + asmAnnotations.push({ + compilationIndex, + symbolName, + symbolAddress, + functionSize, + nativeSymbolCount, + fetchError, + instructions, + }); + compilationIndex++; + } + } + + const annotateResult: FunctionAnnotateResult = { + type: 'function-annotate', + functionHandle, + funcIndex, + name: funcName, + fullName, + threadHandle, + friendlyThreadName, + totalSelfSamples, + totalTotalSamples, + mode, + srcAnnotation, + asmAnnotations, + warnings, + }; + + return { ...annotateResult, context: this._getContext() }; + } +} diff --git a/src/profile-query/loader.ts b/src/profile-query/loader.ts new file mode 100644 index 0000000000..d3b0cdbc69 --- /dev/null +++ b/src/profile-query/loader.ts @@ -0,0 +1,131 @@ +/* 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 { finalizeProfileView, loadProfile } from '../actions/receive-profile'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { + extractProfileUrlFromProfilerUrl, + fetchProfile, +} from '../utils/profile-fetch'; +import type { TemporaryError } from '../utils/errors'; +import type { Store } from '../types/store'; +import type { StartEndRange } from 'firefox-profiler/types'; + +/** + * Helper function to detect if the input is a URL + */ +function isUrl(input: string): boolean { + return input.startsWith('http://') || input.startsWith('https://'); +} + +/** + * Helper function to follow redirects and get the final URL. + * This is useful for short URLs like https://share.firefox.dev/4oLEjCw + */ +async function followRedirects(url: string): Promise { + const response = await fetch(url, { + method: 'HEAD', + redirect: 'follow', + }); + return response.url; +} + +export interface LoadResult { + store: Store; + rootRange: StartEndRange; +} + +/** + * Load a profile from a file path or URL. + * Returns a store and root range that can be used to construct a ProfileQuerier. + */ +export async function loadProfileFromFileOrUrl( + filePathOrUrl: string +): Promise { + const store = createStore(); + console.log(`Loading profile from ${filePathOrUrl}`); + + if (isUrl(filePathOrUrl)) { + // Handle URL input + let finalUrl = filePathOrUrl; + + // If it's a profiler.firefox.com URL (or short URL that redirects to one), + // extract the actual profile URL from it + if ( + filePathOrUrl.includes('profiler.firefox.com') || + filePathOrUrl.includes('share.firefox.dev') + ) { + // Follow redirects for short URLs + if (filePathOrUrl.includes('share.firefox.dev')) { + console.log('Following redirect from short URL...'); + finalUrl = await followRedirects(filePathOrUrl); + console.log(`Redirected to: ${finalUrl}`); + } + + // Extract the profile URL from the profiler.firefox.com URL + const profileUrl = extractProfileUrlFromProfilerUrl(finalUrl); + if (profileUrl) { + console.log(`Extracted profile URL: ${profileUrl}`); + finalUrl = profileUrl; + } else { + throw new Error( + `Unable to extract profile URL from profiler URL: ${finalUrl}` + ); + } + } + + // Fetch the profile using shared utility + console.log(`Fetching profile from ${finalUrl}`); + const result = await fetchProfile({ + url: finalUrl, + onTemporaryError: (e: TemporaryError) => { + if (e.attempt) { + console.log(`Retry ${e.attempt.count}/${e.attempt.total}...`); + } + }, + }); + + // Check if this is a zip file - not yet supported in CLI + if (result.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' + ); + } + + // Extract the profile data + const profile = await unserializeProfileOfArbitraryFormat(result.profile); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + + await store.dispatch(loadProfile(profile, {}, true)); + await store.dispatch(finalizeProfileView()); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + return { store, rootRange }; + } + + // Handle file path input + // Read the raw bytes from the file. It might be a JSON file, but it could also + // be a binary file, e.g. a .json.gz file, or any of the binary formats supported + // by our importers. + const bytes = fs.readFileSync(filePathOrUrl, null); + + // Load the profile. + const profile = await unserializeProfileOfArbitraryFormat(bytes); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + + await store.dispatch(loadProfile(profile, {}, true)); + await store.dispatch(finalizeProfileView()); + 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..ef464a860c --- /dev/null +++ b/src/profile-query/marker-map.ts @@ -0,0 +1,72 @@ +/* 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(); + _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 { + // Check if we already have a handle for this marker + const threadsKey = getThreadsKey(threadIndexes); + for (const [handle, markerId] of this._handleToMarker.entries()) { + if ( + markerId.threadsKey === threadsKey && + markerId.markerIndex === markerIndex + ) { + return handle; + } + } + + // Create a new handle + const handle = 'm-' + this._nextHandleId++; + this._handleToMarker.set(handle, { + threadIndexes, + threadsKey, + markerIndex, + }); + 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..7a28035468 --- /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..24d4d65dcf --- /dev/null +++ b/src/profile-query/timestamps.ts @@ -0,0 +1,312 @@ +/* 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(); + + 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 { + // Check cache first for exact matches. + for (const [name, cachedTs] of this._nameToTimestamp.entries()) { + if (cachedTs === ts) { + return name; + } + } + + 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-'); + } + + // Cache for reverse lookup. + this._nameToTimestamp.set(name, ts); + 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..88265f9152 --- /dev/null +++ b/src/profile-query/types.ts @@ -0,0 +1,657 @@ +/* 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. + */ + +// ===== 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) +}; + +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 }; + +export type FilterEntry = { + /** 1-based sequential index for display */ + index: number; + spec: SampleFilterSpec; + /** Human-readable description */ + description: string; + /** Number of Redux transforms this entry pushed (0 for querier-layer-only entries) */ + reduxTransformCount: number; +}; + +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 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 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[]; +}; + +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/thread.tsx b/src/selectors/per-thread/thread.tsx index 00158837aa..29a3d56bb7 100644 --- a/src/selectors/per-thread/thread.tsx +++ b/src/selectors/per-thread/thread.tsx @@ -50,6 +50,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 @@ -126,6 +128,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; @@ -187,6 +203,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 @@ -398,6 +433,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 7ac0ef4f2c..ddb6986553 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, @@ -708,6 +713,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) { + 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/components/CallNodeContextMenu.test.tsx b/src/test/components/CallNodeContextMenu.test.tsx index 0e032e73cf..85252b7e99 100644 --- a/src/test/components/CallNodeContextMenu.test.tsx +++ b/src/test/components/CallNodeContextMenu.test.tsx @@ -125,7 +125,8 @@ describe('calltree/CallNodeContextMenu', function () { { matcher: /Merge node only/, type: 'merge-call-node' }, { matcher: /Focus on subtree only/, type: 'focus-subtree' }, { matcher: /Focus on function/, type: 'focus-function' }, - { matcher: /Other/, type: 'focus-category' }, + { matcher: /Focus on category/, type: 'focus-category' }, + { matcher: /Drop samples with category/, type: 'drop-category' }, { matcher: /Collapse function/, type: 'collapse-function-subtree' }, { matcher: /XUL/, type: 'collapse-resource' }, { @@ -136,7 +137,7 @@ describe('calltree/CallNodeContextMenu', function () { matcher: /Collapse direct recursion/, type: 'collapse-direct-recursion', }, - { matcher: /Drop samples/, type: 'drop-function' }, + { matcher: /Drop samples with this function/, type: 'drop-function' }, ]; fixtures.forEach(({ matcher, type }) => { diff --git a/src/test/components/MenuButtons.test.tsx b/src/test/components/MenuButtons.test.tsx index 18b0d562ca..6b709bdbf1 100644 --- a/src/test/components/MenuButtons.test.tsx +++ b/src/test/components/MenuButtons.test.tsx @@ -681,7 +681,7 @@ describe('app/MenuButtons', function () { return setupResult; } - test('does not display the delete button if the profile is public but without uploaded data', async () => { + it('does not display the delete button if the profile is public but without uploaded data', async () => { const { getMetaInfoPanel } = await setupForDeletion(); // We wait a bit using the "find" flavor of the queries because this is // reached asynchronously. @@ -690,7 +690,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('displays the delete button if we have the uploaded data but no JWT token', async () => { + it('displays the delete button if we have the uploaded data but no JWT token', async () => { await addUploadedProfileInformation(); const { getMetaInfoPanel } = await setupForDeletion(); expect(await screen.findByText('Uploaded:')).toBeInTheDocument(); @@ -698,7 +698,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('displays the delete button if we have the uploaded data and some JWT token', async () => { + it('displays the delete button if we have the uploaded data and some JWT token', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { getMetaInfoPanel } = await setupForDeletion(); expect(await screen.findByText('Uploaded:')).toBeInTheDocument(); @@ -706,7 +706,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('clicking on the button shows the confirmation', async () => { + it('clicking on the button shows the confirmation', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { getMetaInfoPanel } = await setupForDeletion(); fireFullClick(await screen.findByText('Delete')); @@ -716,7 +716,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('clicking on the "cancel" button will move back to the profile information', async () => { + it('clicking on the "cancel" button will move back to the profile information', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); await setupForDeletion(); fireFullClick(await screen.findByText('Delete')); @@ -728,7 +728,7 @@ describe('app/MenuButtons', function () { expect(screen.getByText('Profile Information')).toBeInTheDocument(); }); - test('dismissing the panel will move back to the profile information when opened again', async () => { + it('dismissing the panel will move back to the profile information when opened again', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { displayMetaInfoPanel, waitForPanelToBeRemoved } = await setupForDeletion(); @@ -743,7 +743,7 @@ describe('app/MenuButtons', function () { expect(screen.getByText('Profile Information')).toBeInTheDocument(); }); - test('confirming the delete should delete on the server and in the db', async () => { + it('confirming the delete should delete on the server and in the db', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { getMetaInfoPanel, diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx index aefbcfbd4a..17d5f6464c 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -68,6 +68,15 @@ function testTransformKeyboardShortcuts(setup: () => TestSetup) { }); }); + it('handles drop-category', () => { + const { pressKey, getTransform, expectedCategory } = setup(); + pressKey({ key: 'G' }); + expect(getTransform()).toEqual({ + type: 'drop-category', + category: expectedCategory, + }); + }); + it('handles merge call node', () => { const { pressKey, getTransform, expectedCallNodePath } = setup(); pressKey({ key: 'M' }); diff --git a/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap b/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap index 083afe2a7b..5e950fb1a8 100644 --- a/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap +++ b/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap @@ -137,6 +137,30 @@ exports[`calltree/CallNodeContextMenu basic rendering renders a full context men g +
(c.name === 'Graphics' ? i : -1)) + .filter((i) => i !== -1)[0]; + + return { + threadIndex, + categoryIndex, + funcNamesDict, + ...storeWithProfile(profile), + }; + } + + describe('drops samples where the leaf frame is in the category, keeps others', function () { + // Sample 1: C (leaf) is Graphics -> entire sample dropped + // Sample 2: E (leaf) is Layout -> kept + const { threadIndex, categoryIndex, getState, dispatch } = setup(` + A[cat:Layout] A[cat:Layout] + B[cat:Layout] D[cat:Layout] + C[cat:Graphics] E[cat:Layout] + `); + const originalCallTree = selectedThreadSelectors.getCallTree(getState()); + + it('starts as an unfiltered call tree', function () { + expect(formatTree(originalCallTree)).toEqual([ + '- A (total: 2, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: 1)', + ' - D (total: 1, self: —)', + ' - E (total: 1, self: 1)', + ]); + }); + + it('drops the sample with a Graphics leaf, keeps the one without', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 1, self: —)', + ' - D (total: 1, self: —)', + ' - E (total: 1, self: 1)', + ]); + }); + }); + + describe('does not drop a sample when only the root frame is in the category', function () { + const { threadIndex, categoryIndex, getState, dispatch } = setup(` + A[cat:Graphics] + B[cat:Layout] + `); + + it('keeps the sample since the leaf is not in the category', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 1, self: —)', + ' - B (total: 1, self: 1)', + ]); + }); + }); + + describe('drops a sample when a leaf frame is in the category', function () { + const { threadIndex, categoryIndex, getState, dispatch } = setup(` + A[cat:Layout] + B[cat:Layout] + C[cat:Graphics] + `); + + it('results in an empty call tree', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([]); + }); + }); + + describe('selected call node path is cleared when the leaf is in the category', function () { + const { threadIndex, categoryIndex, getState, dispatch, funcNamesDict } = + setup(` + A[cat:Layout] A[cat:Layout] + B[cat:Layout] D[cat:Layout] + C[cat:Graphics] E[cat:Layout] + `); + + it('clears selected path when the leaf node is in the Graphics category', function () { + const { A, B, C } = funcNamesDict; + dispatch(changeSelectedCallNode(threadIndex, [A, B, C])); + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const selectedCallNodePath = + selectedThreadSelectors.getSelectedCallNodePath(getState()); + expect(selectedCallNodePath).toEqual([]); + }); + }); +}); + describe('"collapse-resource" transform', function () { describe('combined implementation', function () { /** @@ -2229,6 +2351,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.js b/src/test/unit/activity-slice-tree.test.js new file mode 100644 index 0000000000..d87eb512f0 --- /dev/null +++ b/src/test/unit/activity-slice-tree.test.js @@ -0,0 +1,75 @@ +/* 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/. */ + +// @flow + +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..6cd0a33b0d --- /dev/null +++ b/src/test/unit/profile-query/cpu-activity.test.ts @@ -0,0 +1,53 @@ +/* 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, + printSliceTree, +} 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('prints a fallback message when there are no slices', function () { + const tsManager = new TimestampManager({ start: 0, end: 10 }); + + expect(printSliceTree({ slices: [], time: [] }, tsManager)).toEqual([ + 'No significant activity.', + ]); + }); +}); 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..7cb3eb0bf6 --- /dev/null +++ b/src/test/unit/profile-query/function-list.test.ts @@ -0,0 +1,579 @@ +/* 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('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-info-formatters.test.ts b/src/test/unit/profile-query/marker-info-formatters.test.ts new file mode 100644 index 0000000000..1ef36d0f54 --- /dev/null +++ b/src/test/unit/profile-query/marker-info-formatters.test.ts @@ -0,0 +1,795 @@ +/* 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 { + collectMarkerInfo, + collectMarkerStack, + collectThreadMarkers, + collectThreadNetwork, + formatMarkerInfo, + formatMarkerStackFull, + formatThreadMarkers, +} 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 { + 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'; + +/** + * Sets up a store, threadMap, and markerMap for a single-thread profile with + * the given markers. Returns helpers to register marker handles. + */ +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('formatThreadMarkers', function () { + it('shows interval markers with duration stats', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + [ + 'DOMEvent', + 20, + 40, + { type: 'DOMEvent', eventType: 'click', latency: 5 }, + ], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap); + + expect(result).toContain('2 markers'); + expect(result).toContain('DOMEvent'); + // Interval markers should show duration stats, not "(instant)" + expect(result).toContain('interval'); + expect(result).not.toContain('(instant)'); + }); + + it('shows instant markers with (instant) label', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, null, { type: 'DOMEvent', eventType: 'click' }], + ['DOMEvent', 5, null, { type: 'DOMEvent', eventType: 'keydown' }], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap); + + expect(result).toContain('(instant)'); + }); + + it('shows filtered count annotation when search reduces marker count', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 5, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + [ + 'UserTiming', + 10, + 15, + { type: 'UserTiming', name: 'myMark', entryType: 'measure' }, + ], + [ + 'DOMEvent', + 20, + 25, + { type: 'DOMEvent', eventType: 'keydown', latency: 2 }, + ], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + searchString: 'DOMEvent', + }); + + // Should show 2 matches filtered from 3 total + expect(result).toContain('2 markers'); + expect(result).toContain('(filtered from 3)'); + }); + + it('shows nested sub-groups when groupBy specifies multiple keys', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 2, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + [ + 'DOMEvent', + 3, + 5, + { type: 'DOMEvent', eventType: 'keydown', latency: 1 }, + ], + ['DOMEvent', 6, 8, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + groupBy: 'type,field:eventType', + }); + + // Top-level group for DOMEvent, with sub-groups for click and keydown + expect(result).toContain('DOMEvent'); + expect(result).toContain('click'); + expect(result).toContain('keydown'); + // click sub-group appears before keydown because it has more markers + const clickPos = result.indexOf('click'); + const keydownPos = result.indexOf('keydown'); + expect(clickPos).toBeLessThan(keydownPos); + }); + + it('shows overflow notice when more than 15 groups exist', function () { + // Create 16 markers with distinct names so each forms its own group + const markers: Parameters[0] = Array.from( + { length: 16 }, + (_, i): [string, number, number, Record] => [ + `Marker${i}`, + i * 10, + i * 10 + 5, + { type: `Marker${i}` }, + ] + ); + const { store, threadMap, markerMap } = setupWithMarkers(markers); + + const result = formatThreadMarkers(store, threadMap, markerMap); + + expect(result).toContain('more types'); + }); +}); + +describe('formatMarkerInfo', function () { + it('shows schema fields for a marker with data', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ]); + const handle = registerMarker(0); + + const result = formatMarkerInfo(store, markerMap, threadMap, handle); + + expect(result).toContain('DOMEvent'); + // Schema field label: "Event Type" with value "click" + expect(result).toContain('Event Type'); + expect(result).toContain('click'); + // Schema field label: "Latency" with value for latency + expect(result).toContain('Latency'); + }); + + it('shows duration for interval markers and "instant" for instant markers', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + ['DOMEvent', 0, 20, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + ['DOMEvent', 30, null, { type: 'DOMEvent', eventType: 'hover' }], + ]); + const intervalHandle = registerMarker(0); + const instantHandle = registerMarker(1); + + const intervalResult = formatMarkerInfo( + store, + markerMap, + threadMap, + intervalHandle + ); + const instantResult = formatMarkerInfo( + store, + markerMap, + threadMap, + instantHandle + ); + + // Interval: shows duration in ms + expect(intervalResult).toContain('20.00ms'); + // Instant: shows "(instant)" label + expect(instantResult).toContain('(instant)'); + }); + + it('excludes hidden fields from output', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'MarkerWithHiddenField', + 0, + 5, + { type: 'MarkerWithHiddenField', hiddenString: 'secret-value' }, + ], + ]); + const handle = registerMarker(0); + + const result = formatMarkerInfo(store, markerMap, threadMap, handle); + + expect(result).not.toContain('secret-value'); + expect(result).not.toContain('Hidden string'); + }); + + it('shows schema description when available', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'UserTiming', + 0, + 10, + { type: 'UserTiming', name: 'myMeasure', entryType: 'measure' }, + ], + ]); + const handle = registerMarker(0); + + const result = formatMarkerInfo(store, markerMap, threadMap, handle); + + // UserTiming has a schema description + expect(result).toContain('performance.mark()'); + }); +}); + +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); + // Fields from schema + 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); + + // Hidden fields should not appear + 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('collectMarkerStack and formatMarkerStackFull', function () { + describe('for a marker without a cause', function () { + it('collectMarkerStack returns null stack', 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('formatMarkerStackFull reports no stack trace', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'DOMEvent', + 0, + 5, + { type: 'DOMEvent', eventType: 'click', latency: 1 }, + ], + ]); + const handle = registerMarker(0); + + const result = formatMarkerStackFull(store, markerMap, threadMap, handle); + + expect(result).toContain('DOMEvent'); + expect(result).toContain('no stack trace'); + }); + }); + + describe('for a marker with a cause stack', function () { + function setupWithCauseStack() { + const { profile } = getProfileFromTextSamples(` + rootFunc + leafFunc + `); + // profile.threads[0] has a stack table from the text samples + const thread = profile.threads[0]; + // samples.stack[0] is the stack index for the leaf frame of the first sample + const stackIndex = thread.samples.stack[0]; + + if (stackIndex === null || stackIndex === undefined) { + throw new Error('Expected a non-null stack index from text samples'); + } + + // Add a marker with a cause pointing to that stack + 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); + // The marker we added is at the end of the marker list, but after processing + // markers are time-sorted. Since our marker is at time 1 and there are no + // other markers, it will be at index 0. + const handle = markerMap.handleForMarker(new Set([0]), 0); + return { store, threadMap, markerMap, handle }; + } + + it('collectMarkerStack returns stack frames', function () { + const { store, threadMap, markerMap, handle } = setupWithCauseStack(); + + const result = collectMarkerStack(store, markerMap, threadMap, handle); + + expect(result.stack).not.toBeNull(); + expect(result.stack!.frames.length).toBeGreaterThan(0); + // The leaf frame should be "leafFunc" + expect(result.stack!.frames[0].name).toBe('leafFunc'); + }); + + it('formatMarkerStackFull shows numbered frames', function () { + const { store, threadMap, markerMap, handle } = setupWithCauseStack(); + + const result = formatMarkerStackFull(store, markerMap, threadMap, handle); + + expect(result).toContain('[1]'); + expect(result).toContain('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 () { + // getNetworkMarkers produces a START + STOP pair per entry + 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); + expect(result.summary.cacheUnknown).toBe(3); // all 3 counted, not just 2 + }); + + 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 }, // no cache → unknown + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.cacheHit).toBe(3); // Hit + MemoryHit + Prefetched + expect(result.summary.cacheMiss).toBe(2); // Miss + DiskStorage + expect(result.summary.cacheUnknown).toBe(1); // no cache field + }); + + it('extracts phase timings per request', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 100, + payload: { + domainLookupStart: 0, + domainLookupEnd: 5, // dns = 5 + connectStart: 5, + tcpConnectEnd: 15, // tcp = 10 + requestStart: 20, + responseStart: 50, // ttfb = 30 + responseEnd: 80, // download = 30; mainThread = 100 - 80 = 20 + }, + }, + ]); + + 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, // tls = 18 - 10 = 8 + }, + }, + ]); + + 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, // 0 means no TLS + 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 }, // ttfb = 8 + }, + { + id: 2, + startTime: 21, + fetchStart: 21, + endTime: 41, + payload: { requestStart: 21, responseStart: 33 }, // ttfb = 12 + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.phaseTotals.ttfb).toBe(20); // 8 + 12 + }); + + 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/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts new file mode 100644 index 0000000000..1213c4c660 --- /dev/null +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -0,0 +1,266 @@ +/* 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, + collectThreadMarkers, + formatDuration, +} 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 { getProfileWithMarkers } from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; + +import type { Marker } from 'firefox-profiler/types'; + +describe('marker-info utility functions', function () { + describe('formatDuration', function () { + it('formats microseconds correctly', function () { + expect(formatDuration(0.001)).toBe('1.00μs'); + expect(formatDuration(0.5)).toBe('500.00μs'); + expect(formatDuration(0.999)).toBe('999.00μs'); + }); + + it('formats milliseconds correctly', function () { + expect(formatDuration(1)).toBe('1.00ms'); + expect(formatDuration(10.5)).toBe('10.50ms'); + expect(formatDuration(999)).toBe('999.00ms'); + }); + + it('formats seconds correctly', function () { + expect(formatDuration(1000)).toBe('1.00s'); + expect(formatDuration(5500)).toBe('5.50s'); + expect(formatDuration(60000)).toBe('60.00s'); + }); + }); + + 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, + }), + ]); + }); + }); +}); 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/test/url-handling.test.ts b/src/test/url-handling.test.ts index 88ff994ee0..a046c24558 100644 --- a/src/test/url-handling.test.ts +++ b/src/test/url-handling.test.ts @@ -1308,7 +1308,7 @@ describe('url upgrading', function () { describe('URL serialization of the transform stack', function () { const transformString = - 'f-combined-0w2~mcn-combined-2w4~f-js-3w5-i~mf-6~ff-7~fg-42~cr-combined-8-9~' + + 'f-combined-0w2~mcn-combined-2w4~f-js-3w5-i~mf-6~ff-7~fg-42~dg-43~cr-combined-8-9~' + 'drec-combined-10~rec-11~df-12~cfs-13'; const { getState } = _getStoreWithURL({ search: '?transforms=' + transformString, @@ -1349,6 +1349,10 @@ describe('URL serialization of the transform stack', function () { type: 'focus-category', category: 42, }, + { + type: 'drop-category', + category: 43, + }, { type: 'collapse-resource', resourceIndex: 8, 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 8f5f5e5005..054559d0cf 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..28d9b3438b 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,37 @@ 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. + * Drop any samples whose leaf stack frame belongs to the specified category. + * Only the leaf (innermost) frame is checked — if a non-leaf frame belongs to + * the category the sample is kept. An example with 'function:category' as the + * node name, dropping the Native category: + * + * A:JS A:JS + * / \ \ + * v v Drop Native v + * B:JS E:JS -> E:JS + * | | | + * v v v + * C:Native B:JS B:JS + * | | + * v v + * D:JS D:JS + * + * The left branch (B:JS → C:Native) is removed because the leaf C:Native + * belongs to the Native category. The right branch is kept in full because + * its leaf D:JS is not Native, even though no Native frames appear there. + */ + 'drop-category': { + readonly type: 'drop-category'; + readonly category: IndexIntoCategoryList; + }; + + /** + * 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..d3a554666e --- /dev/null +++ b/src/utils/slice-tree.ts @@ -0,0 +1,206 @@ +/* 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; + } + + // assert(currentStartIndex < lastEndIndexWithAvgExceedingThreshold); + 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/types.ts b/src/utils/types.ts index 1d6df05f5e..006ab88fdc 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -86,6 +86,7 @@ export function convertToTransformType(type: string): TransformType | null { case 'focus-subtree': case 'focus-function': case 'focus-category': + case 'drop-category': case 'focus-self': case 'collapse-resource': case 'collapse-direct-recursion': 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 3ef98d3b9a..418a8ac2d8 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" @@ -10266,7 +10271,16 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10396,7 +10410,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10410,6 +10424,13 @@ strip-ansi@^0.3.0: dependencies: ansi-regex "^0.2.1" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -11783,7 +11804,16 @@ workbox-window@7.4.0, workbox-window@^7.4.0: "@types/trusted-types" "^2.0.2" workbox-core "7.4.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==