Repository: firefox-devtools/profiler (2025-07-11T13:37:45Z)

Issue:
The line numbers in marker stacks (shown in the tooltip or in the sidebar) should be the line number of the frame, not the line number where the function starts
Here is an example profile: https://perfht.ml/2LwnxbS

In the cause for the "reflow" marker, the line number `24` is where the function has been defined, instead of the actual line inside this function that triggers this synchronous reflow.

This profile was taken from the testcase in https://everlong.org/mozilla/testcase-reflow-sync.html.



┆Issue is synchronized with this [Jira Task](https://mozilla-hub.atlassian.net/browse/FP-58)



Discussion:
We can make use of the frameTable's `line` column here. This column is otherwise unused in perf.html at the moment. It contains the line number of the frame's executed line of JavaScript for JS interpreter frames, and is `null` otherwise. [Bug 1441689](https://bugzilla.mozilla.org/show_bug.cgi?id=1441689) tracks adding this information for JS JIT frames as well.
Yeah, the current code is:
https://github.com/devtools-html/perf.html/blob/9934bc950d25e5baf88ebb5179d5d5363288ff4e/src/components/shared/Backtrace.js#L42-L47

We currently use the funcTable to get the information in:
https://github.com/devtools-html/perf.html/blob/9934bc950d25e5baf88ebb5179d5d5363288ff4e/src/profile-logic/profile-data.js#L1614-L1617



Patch:
diff --git a/src/components/shared/Backtrace.js b/src/components/shared/Backtrace.js
index 3a4bcce046..86d8f76b92 100644
--- a/src/components/shared/Backtrace.js
+++ b/src/components/shared/Backtrace.js
@@ -6,11 +6,7 @@
 
 import React from 'react';
 import classNames from 'classnames';
-import { filterCallNodeAndCategoryPathByImplementation } from 'firefox-profiler/profile-logic/transforms';
-import {
-  getFuncNamesAndOriginsForPath,
-  convertStackToCallNodeAndCategoryPath,
-} from 'firefox-profiler/profile-logic/profile-data';
+import { getBacktraceItemsForStack } from 'firefox-profiler/profile-logic/transforms';
 
 import type {
   CategoryList,
@@ -34,15 +30,11 @@ type Props = {|
 export function Backtrace(props: Props) {
   const { stackIndex, thread, implementationFilter, maxStacks, categories } =
     props;
-  const callNodePath = filterCallNodeAndCategoryPathByImplementation(
-    thread,
+  const funcNamesAndOrigins = getBacktraceItemsForStack(
+    stackIndex,
     implementationFilter,
-    convertStackToCallNodeAndCategoryPath(thread, stackIndex)
-  );
-  const funcNamesAndOrigins = getFuncNamesAndOriginsForPath(
-    callNodePath,
     thread
-  ).reverse();
+  );
 
   if (funcNamesAndOrigins.length) {
     return (
diff --git a/src/components/shared/MarkerContextMenu.js b/src/components/shared/MarkerContextMenu.js
index a6318580c2..8915eeecfd 100644
--- a/src/components/shared/MarkerContextMenu.js
+++ b/src/components/shared/MarkerContextMenu.js
@@ -42,11 +42,7 @@ import type {
 import type { ConnectedProps } from 'firefox-profiler/utils/connect';
 import { getImplementationFilter } from 'firefox-profiler/selectors/url-state';
 
-import { filterCallNodeAndCategoryPathByImplementation } from 'firefox-profiler/profile-logic/transforms';
-import {
-  convertStackToCallNodeAndCategoryPath,
-  getFuncNamesAndOriginsForPath,
-} from 'firefox-profiler/profile-logic/profile-data';
+import { getBacktraceItemsForStack } from 'firefox-profiler/profile-logic/transforms';
 import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors/per-thread';
 
 import './MarkerContextMenu.css';
@@ -161,13 +157,12 @@ class MarkerContextMenuImpl extends PureComponent<Props> {
       return '';
     }
 
-    const path = filterCallNodeAndCategoryPathByImplementation(
-      thread,
+    const funcNamesAndOrigins = getBacktraceItemsForStack(
+      stack,
       implementationFilter,
-      convertStackToCallNodeAndCategoryPath(thread, stack)
-    );
+      thread
+    ).reverse();
 
-    const funcNamesAndOrigins = getFuncNamesAndOriginsForPath(path, thread);
     return funcNamesAndOrigins
       .map(({ funcName, origin }) => `${funcName} [${origin}]`)
       .join('\n');
diff --git a/src/components/shared/SampleTooltipContents.js b/src/components/shared/SampleTooltipContents.js
index cd7c6a247c..39d2cd24b0 100644
--- a/src/components/shared/SampleTooltipContents.js
+++ b/src/components/shared/SampleTooltipContents.js
@@ -9,8 +9,7 @@ import { Backtrace } from './Backtrace';
 import { TooltipDetailSeparator } from '../tooltip/TooltipDetails';
 import {
   getCategoryPairLabel,
-  getFuncNamesAndOriginsForPath,
-  convertStackToCallNodeAndCategoryPath,
+  isSampleWithNonEmptyStack,
 } from 'firefox-profiler/profile-logic/profile-data';
 import { getFormattedTimelineValue } from 'firefox-profiler/profile-logic/committed-ranges';
 import {
@@ -26,7 +25,6 @@ import type {
   Milliseconds,
 } from 'firefox-profiler/types';
 import type { CpuRatioInTimeRange } from './thread/ActivityGraphFills';
-import { ensureExists } from '../../utils/flow';
 
 type CPUProps = CpuRatioInTimeRange;
 
@@ -134,21 +132,10 @@ export class SampleTooltipContents extends React.PureComponent<Props> {
     let hasStack = false;
     let formattedSampleTime = null;
     if (sampleIndex !== null) {
-      const { samples, stackTable } = rangeFilteredThread;
+      const { samples } = rangeFilteredThread;
       const sampleTime = samples.time[sampleIndex];
-      const stackIndex = samples.stack[sampleIndex];
-      const hasSamples = samples.length > 0 && stackTable.length > 1;
-
-      if (hasSamples) {
-        const stack = getFuncNamesAndOriginsForPath(
-          convertStackToCallNodeAndCategoryPath(
-            rangeFilteredThread,
-            ensureExists(stackIndex)
-          ),
-          rangeFilteredThread
-        );
-        hasStack = stack.length > 1 || stack[0].funcName !== '(root)';
-      }
+
+      hasStack = isSampleWithNonEmptyStack(sampleIndex, rangeFilteredThread);
 
       formattedSampleTime = getFormattedTimelineValue(
         sampleTime - zeroAt,
diff --git a/src/profile-logic/profile-data.js b/src/profile-logic/profile-data.js
index bbba49027a..4ae6f0a4d9 100644
--- a/src/profile-logic/profile-data.js
+++ b/src/profile-logic/profile-data.js
@@ -71,7 +71,6 @@ import type {
   PageList,
   CallNodeTable,
   CallNodePath,
-  CallNodeAndCategoryPath,
   IndexIntoCallNodeTable,
   AccumulatedCounterSamples,
   SamplesLikeTable,
@@ -2148,30 +2147,6 @@ export function processEventDelays(
   };
 }
 
-/**
- * This function converts a stack information into a call node and
- * category path structure.
- */
-export function convertStackToCallNodeAndCategoryPath(
-  thread: Thread,
-  stack: IndexIntoStackTable
-): CallNodeAndCategoryPath {
-  const { stackTable, frameTable } = thread;
-  const path = [];
-  for (
-    let stackIndex = stack;
-    stackIndex !== null;
-    stackIndex = stackTable.prefix[stackIndex]
-  ) {
-    const index = stackTable.frame[stackIndex];
-    path.push({
-      category: stackTable.category[stackIndex],
-      func: frameTable.func[index],
-    });
-  }
-  return path.reverse();
-}
-
 /**
  * Compute maximum depth of call stack for a given thread, and return maxDepth+1.
  * This value can be used as the length for any per-depth arrays.
@@ -2883,34 +2858,32 @@ export function reserveFunctionsInThread(
 }
 
 /**
- * From a valid call node path, this function returns a list of information
- * about each function in this path: their names and their origins.
+ * Returns whether the given sample has a stack which is non-null and not just
+ * a single function with the name '(root)'.
  */
-export function getFuncNamesAndOriginsForPath(
-  path: CallNodeAndCategoryPath,
+export function isSampleWithNonEmptyStack(
+  sampleIndex: IndexIntoSamplesTable,
   thread: Thread
-): Array<{
-  funcName: string,
-  category: IndexIntoCategoryList,
-  isFrameLabel: boolean,
-  origin: string,
-}> {
-  const { funcTable, stringTable, resourceTable } = thread;
+): boolean {
+  const { samples, stackTable, frameTable, funcTable, stringTable } = thread;
 
-  return path.map((frame) => {
-    const { category, func } = frame;
-    return {
-      funcName: stringTable.getString(funcTable.name[func]),
-      category: category,
-      isFrameLabel: funcTable.resource[func] === -1,
-      origin: getOriginAnnotationForFunc(
-        func,
-        funcTable,
-        resourceTable,
-        stringTable
-      ),
-    };
-  });
+  const stackIndex = samples.stack[sampleIndex];
+  if (stackIndex === null) {
+    return false;
+  }
+
+  if (stackTable.prefix[stackIndex] !== null) {
+    // Stack contains at least two frames.
+    return true;
+  }
+
+  // Stack is only a single frame. Is it the '(root)' frame that Firefox puts
+  // in its profiles?
+  const frameIndex = stackTable.frame[stackIndex];
+  const funcIndex = frameTable.func[frameIndex];
+  const funcNameStringIndex = funcTable.name[funcIndex];
+  const funcName = stringTable.getString(funcNameStringIndex);
+  return funcName !== '(root)';
 }
 
 /**
diff --git a/src/profile-logic/transforms.js b/src/profile-logic/transforms.js
index a61e7f1ee4..70b77424f4 100644
--- a/src/profile-logic/transforms.js
+++ b/src/profile-logic/transforms.js
@@ -12,6 +12,7 @@ import {
   updateThreadStacks,
   updateThreadStacksByGeneratingNewStackColumns,
   getMapStackUpdater,
+  getOriginAnnotationForFunc,
 } from './profile-data';
 import { timeCode } from '../utils/time-code';
 import { assertExhaustiveCheck, convertToTransformType } from '../utils/flow';
@@ -32,7 +33,6 @@ import type {
   IndexIntoStackTable,
   IndexIntoResourceTable,
   CallNodePath,
-  CallNodeAndCategoryPath,
   CallNodeTable,
   StackType,
   ImplementationFilter,
@@ -1563,15 +1563,72 @@ export function filterCallNodePathByImplementation(
   );
 }
 
-export function filterCallNodeAndCategoryPathByImplementation(
-  thread: Thread,
+// User-facing properties about a stack frame.
+export type BacktraceItem = {|
+  // The function name of the stack frame.
+  funcName: string,
+  // The frame category of the stack frame.
+  category: IndexIntoCategoryList,
+  // Whether this frame is a label frame.
+  isFrameLabel: boolean,
+  // A string which is usually displayed after the function name, and which
+  // describes, in some way, where this function or frame came from.
+  // If known, this contains the file name of the function, and the line and
+  // column number of the frame, i.e. the spot within the function that was
+  // being executed.
+  // If the source file name is not known, this might be the name of a native
+  // library instead.
+  // May also be empty.
+  origin: string,
+|};
+
+/**
+ * Convert the stack into an array of "backtrace items" for each stack frame.
+ * The returned array is ordered from callee-most to caller-most, i.e. the root
+ * caller is at the end.
+ */
+export function getBacktraceItemsForStack(
+  stack: IndexIntoStackTable,
   implementationFilter: ImplementationFilter,
-  path: CallNodeAndCategoryPath
-): CallNodeAndCategoryPath {
+  thread: Thread
+): BacktraceItem[] {
+  const { funcTable, stringTable, resourceTable } = thread;
+
+  const { stackTable, frameTable } = thread;
+  const unfilteredPath = [];
+  for (
+    let stackIndex = stack;
+    stackIndex !== null;
+    stackIndex = stackTable.prefix[stackIndex]
+  ) {
+    const frameIndex = stackTable.frame[stackIndex];
+    unfilteredPath.push({
+      category: stackTable.category[stackIndex],
+      funcIndex: frameTable.func[frameIndex],
+      frameLine: frameTable.line[frameIndex],
+      frameColumn: frameTable.column[frameIndex],
+    });
+  }
+
   const funcMatchesImplementation = FUNC_MATCHES[implementationFilter];
-  return path.filter((funcIndex) =>
-    funcMatchesImplementation(thread, funcIndex.func)
+  const path = unfilteredPath.filter(({ funcIndex }) =>
+    funcMatchesImplementation(thread, funcIndex)
   );
+  return path.map(({ category, funcIndex, frameLine, frameColumn }) => {
+    return {
+      funcName: stringTable.getString(funcTable.name[funcIndex]),
+      category: category,
+      isFrameLabel: funcTable.resource[funcIndex] === -1,
+      origin: getOriginAnnotationForFunc(
+        funcIndex,
+        funcTable,
+        resourceTable,
+        stringTable,
+        frameLine,
+        frameColumn
+      ),
+    };
+  });
 }
 
 /**
diff --git a/src/types/profile-derived.js b/src/types/profile-derived.js
index 5f9474a5dc..16875cb875 100644
--- a/src/types/profile-derived.js
+++ b/src/types/profile-derived.js
@@ -431,13 +431,6 @@ export type AddressProof = {|
  */
 export type CallNodePath = IndexIntoFuncTable[];
 
-export type CallNodeAndCategory = {|
-  func: IndexIntoFuncTable,
-  category: IndexIntoCategoryList,
-|};
-
-export type CallNodeAndCategoryPath = CallNodeAndCategory[];
-
 /**
  * This type contains the first derived `Marker[]` information, plus an IndexedArray
  * to get back to the RawMarkerTable.


Test patch:
diff --git a/src/test/unit/profile-data.test.js b/src/test/unit/profile-data.test.js
index f4ce4ed64f..50f64a2a99 100644
--- a/src/test/unit/profile-data.test.js
+++ b/src/test/unit/profile-data.test.js
@@ -16,7 +16,6 @@ import {
   getInvertedCallNodeInfo,
   filterThreadByImplementation,
   getSampleIndexClosestToStartTime,
-  convertStackToCallNodeAndCategoryPath,
   getSampleIndexToCallNodeIndex,
   getTreeOrderComparator,
   getSamplesSelectedStates,
@@ -46,6 +45,7 @@ import {
 import {
   funcHasDirectRecursiveCall,
   funcHasRecursiveCall,
+  getBacktraceItemsForStack,
 } from '../../profile-logic/transforms';
 
 import type { Thread, IndexIntoStackTable } from 'firefox-profiler/types';
@@ -949,24 +949,6 @@ describe('funcHasDirectRecursiveCall and funcHasRecursiveCall', function () {
   });
 });
 
-describe('convertStackToCallNodeAndCategoryPath', function () {
-  it('correctly returns a call node path for a stack', function () {
-    const profile = getCallNodeProfile();
-    const { derivedThreads } = getProfileWithDicts(profile);
-    const [thread] = derivedThreads;
-    const stack1 = thread.samples.stack[0];
-    const stack2 = thread.samples.stack[1];
-    if (stack1 === null || stack2 === null) {
-      // Makes flow happy
-      throw new Error("stack shouldn't be null");
-    }
-    let callNodePath = convertStackToCallNodeAndCategoryPath(thread, stack1);
-    expect(callNodePath.map((f) => f.func)).toEqual([0, 1, 2, 3, 4]);
-    callNodePath = convertStackToCallNodeAndCategoryPath(thread, stack2);
-    expect(callNodePath.map((f) => f.func)).toEqual([0, 1, 2, 3, 5]);
-  });
-});
-
 describe('getSamplesSelectedStates', function () {
   function setup(textSamples) {
     const {
@@ -1566,3 +1548,43 @@ describe('getNativeSymbolInfo', function () {
     });
   });
 });
+
+describe('getBacktraceItemsForStack', function () {
+  function getBacktraceString(thread, sampleIndex): string {
+    return getBacktraceItemsForStack(
+      ensureExists(thread.samples.stack[sampleIndex]),
+      'combined',
+      thread
+    )
+      .map(({ funcName, origin }) => `${funcName} ${origin}`)
+      .join('\n');
+  }
+
+  it('returns backtrace items in the right order and with frame line numbers', function () {
+    const { derivedThreads } = getProfileFromTextSamples(`
+      A[file:one.js][line:20]  A[file:one.js][line:21]  A[file:one.js][line:20]
+      B[file:one.js][line:30]  D[file:one.js][line:50]  B[file:one.js][line:31]
+      C[file:two.js][line:10]  C[file:two.js][line:11]  C[file:two.js][line:12]
+                                                        D[file:one.js][line:51]
+    `);
+
+    const [thread] = derivedThreads;
+
+    expect(getBacktraceString(thread, 0)).toMatchInlineSnapshot(`
+     "C two.js:10
+     B one.js:30
+     A one.js:20"
+    `);
+    expect(getBacktraceString(thread, 1)).toMatchInlineSnapshot(`
+     "C two.js:11
+     D one.js:50
+     A one.js:21"
+    `);
+    expect(getBacktraceString(thread, 2)).toMatchInlineSnapshot(`
+     "D one.js:51
+     C two.js:12
+     B one.js:31
+     A one.js:20"
+    `);
+  });
+});