chromeTraceProfile.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. /**
  2. * The import code is very similar to speedscope's import code. The queue approach works well and allows us
  3. * to easily split the X events and handle them. There are some small differences when it comes to building
  4. * profiles where we opted to throw instead of closing a frame that was never opened.
  5. *
  6. * Overall, it seems that mostly typescript compiler uses this output, so we could possibly do a bit more
  7. * in order to detect if this is a tsc trace and mark the different compiler phases and give users the preference
  8. * to color encode by the program/bind/check/emit phases.
  9. */
  10. import {Frame} from 'sentry/utils/profiling/frame';
  11. import {Profile} from 'sentry/utils/profiling/profile/profile';
  12. import {wrapWithSpan} from 'sentry/utils/profiling/profile/utils';
  13. import {EventedProfile} from './eventedProfile';
  14. import {ImportOptions, ProfileGroup} from './importProfile';
  15. export class ChromeTraceProfile extends EventedProfile {}
  16. type ProcessId = number;
  17. type ThreadId = number;
  18. export function splitEventsByProcessAndThreadId(
  19. trace: ChromeTrace.ArrayFormat
  20. ): Map<ProcessId, Map<ThreadId, ChromeTrace.Event[]>> {
  21. const collections: Map<ProcessId, Map<ThreadId, ChromeTrace.Event[]>> = new Map();
  22. for (let i = 0; i < trace.length; i++) {
  23. const event = trace[i]!; // iterating over non empty array
  24. if (typeof event.pid !== 'number') {
  25. continue;
  26. }
  27. if (typeof event.tid !== 'number') {
  28. continue;
  29. }
  30. let processes = collections.get(event.pid);
  31. if (!processes) {
  32. processes = new Map();
  33. collections.set(event.pid, processes);
  34. }
  35. let threads = processes.get(event.tid);
  36. if (!threads) {
  37. threads = [];
  38. processes.set(event.tid, threads);
  39. }
  40. threads.push(event);
  41. }
  42. return collections;
  43. }
  44. function chronologicalSort(a: ChromeTrace.Event, b: ChromeTrace.Event): number {
  45. return a.ts - b.ts;
  46. }
  47. function reverseChronologicalSort(a: ChromeTrace.Event, b: ChromeTrace.Event): number {
  48. return b.ts - a.ts;
  49. }
  50. function getNextQueue(
  51. beginQueue: ChromeTrace.Event[],
  52. endQueue: ChromeTrace.Event[]
  53. ): 'B' | 'E' {
  54. if (!beginQueue.length && !endQueue.length) {
  55. throw new Error('Profile contains no events');
  56. }
  57. const nextBegin = beginQueue[beginQueue.length - 1];
  58. const nextEnd = endQueue[endQueue.length - 1];
  59. if (!nextEnd) {
  60. return 'B';
  61. }
  62. if (!nextBegin) {
  63. return 'E';
  64. }
  65. if (nextBegin.ts < nextEnd.ts) {
  66. return 'B';
  67. }
  68. if (nextEnd.ts < nextBegin.ts) {
  69. return 'E';
  70. }
  71. return 'B';
  72. }
  73. function buildProfile(
  74. processId: number,
  75. threadId: number,
  76. events: ChromeTrace.Event[]
  77. ): ChromeTraceProfile {
  78. let processName: string = `pid (${processId})`;
  79. let threadName: string = `tid (${threadId})`;
  80. // We dont care about other events besides begin, end, instant events and metadata events
  81. const timelineEvents = events.filter(
  82. e => e.ph === 'B' || e.ph === 'E' || e.ph === 'X' || e.ph === 'M'
  83. );
  84. const beginQueue: Array<ChromeTrace.Event> = [];
  85. const endQueue: Array<ChromeTrace.Event> = [];
  86. for (let i = 0; i < timelineEvents.length; i++) {
  87. const event = timelineEvents[i]!; // iterating over non empty array
  88. // M events are not pushed to the queue, we just store their information
  89. if (event.ph === 'M') {
  90. if (event.name === 'thread_name' && typeof event.args.name === 'string') {
  91. threadName = `${event.args.name} (${threadId})`;
  92. continue;
  93. }
  94. if (event.name === 'process_name' && typeof event.args.name === 'string') {
  95. processName = `${event.args.name} (${processId})`;
  96. continue;
  97. }
  98. }
  99. // B, E and X events are pushed to the timeline. We transform all X events into
  100. // B and E event, so that they can be pushed onto the queue and handled
  101. if (event.ph === 'B') {
  102. beginQueue.push(event);
  103. continue;
  104. }
  105. if (event.ph === 'E') {
  106. endQueue.push(event);
  107. continue;
  108. }
  109. if (event.ph === 'X') {
  110. if (typeof event.dur === 'number' || typeof event.tdur === 'number') {
  111. beginQueue.push({...event, ph: 'B'});
  112. endQueue.push({...event, ph: 'E', ts: event.ts + (event.dur ?? event.tdur ?? 0)});
  113. continue;
  114. }
  115. }
  116. }
  117. beginQueue.sort(reverseChronologicalSort);
  118. endQueue.sort(reverseChronologicalSort);
  119. if (!beginQueue.length) {
  120. throw new Error('Profile does not contain any frame events');
  121. }
  122. const firstTimestamp = beginQueue[beginQueue.length - 1].ts;
  123. const lastTimestamp = endQueue[0]?.ts ?? beginQueue[0].ts;
  124. if (typeof firstTimestamp !== 'number') {
  125. throw new Error('First begin event contains no timestamp');
  126. }
  127. if (typeof lastTimestamp !== 'number') {
  128. throw new Error('Last end event contains no timestamp');
  129. }
  130. const profile = new ChromeTraceProfile({
  131. duration: lastTimestamp - firstTimestamp,
  132. startedAt: firstTimestamp,
  133. endedAt: lastTimestamp,
  134. name: `${processName}: ${threadName}`,
  135. unit: 'microseconds', // the trace event format provides timestamps in microseconds
  136. threadId,
  137. });
  138. const stack: ChromeTrace.Event[] = [];
  139. const frameCache = new Map<string, Frame>();
  140. while (beginQueue.length > 0 || endQueue.length > 0) {
  141. const next = getNextQueue(beginQueue, endQueue);
  142. if (next === 'B') {
  143. const item = beginQueue.pop();
  144. if (!item) {
  145. throw new Error('Nothing to take from begin queue');
  146. }
  147. const frameInfo = createFrameInfoFromEvent(item);
  148. if (!frameCache.has(frameInfo.key)) {
  149. frameCache.set(frameInfo.key, new Frame(frameInfo));
  150. }
  151. const frame = frameCache.get(frameInfo.key)!;
  152. profile.enterFrame(frame, item.ts - firstTimestamp);
  153. stack.push(item);
  154. continue;
  155. }
  156. if (next === 'E') {
  157. const item = endQueue.pop()!;
  158. let frameInfo = createFrameInfoFromEvent(item);
  159. if (stack[stack.length - 1] === undefined) {
  160. throw new Error(
  161. `Unable to close frame from an empty stack, attempting to close ${JSON.stringify(
  162. item
  163. )}`
  164. );
  165. }
  166. const topFrameInfo = createFrameInfoFromEvent(stack[stack.length - 1]);
  167. // We check frames with the same ts and look for a match. We do this because
  168. // chronological sort will not break ties on frames that end at the same time,
  169. // but may not be in the same order as they were opened.
  170. for (let i = endQueue.length - 2; i > 0; i--) {
  171. if (endQueue[i].ts > endQueue[endQueue.length - 1].ts) {
  172. break;
  173. }
  174. const nextEndInfo = createFrameInfoFromEvent(endQueue[i]);
  175. if (topFrameInfo.key === nextEndInfo.key) {
  176. const tmp = endQueue[endQueue.length - 1];
  177. endQueue[endQueue.length - 1] = endQueue[i];
  178. endQueue[i] = tmp;
  179. frameInfo = nextEndInfo;
  180. break;
  181. }
  182. }
  183. if (!frameCache.has(frameInfo.key)) {
  184. throw new Error(
  185. `Cannot leave frame that was never entered, leaving ${frameInfo.key}`
  186. );
  187. }
  188. const frame = frameCache.get(frameInfo.key)!;
  189. profile.leaveFrame(frame, item.ts - firstTimestamp);
  190. stack.pop();
  191. continue;
  192. }
  193. }
  194. // Close the leftover frames in stack
  195. while (stack.length) {
  196. const item = stack.pop()!;
  197. const frameInfo = createFrameInfoFromEvent(item);
  198. const frame = frameCache.get(frameInfo.key);
  199. if (!frame) {
  200. throw new Error(
  201. `Cannot leave frame that was never entered, leaving ${frameInfo.key}`
  202. );
  203. }
  204. profile.leaveFrame(frame, frame.totalWeight);
  205. }
  206. return profile.build();
  207. }
  208. function createFrameInfoFromEvent(event: ChromeTrace.Event) {
  209. const key = JSON.stringify(event.args);
  210. return {
  211. key,
  212. name: `${event?.name || 'Unknown'} ${key}`.trim(),
  213. };
  214. }
  215. export function parseTypescriptChromeTraceArrayFormat(
  216. input: ChromeTrace.ArrayFormat,
  217. profileID: string,
  218. options?: ImportOptions
  219. ): ProfileGroup {
  220. const profiles: Profile[] = [];
  221. const eventsByProcessAndThreadID = splitEventsByProcessAndThreadId(input);
  222. for (const [processId, threads] of eventsByProcessAndThreadID) {
  223. for (const [threadId, events] of threads) {
  224. wrapWithSpan(
  225. options?.transaction,
  226. () => profiles.push(buildProfile(processId, threadId, events ?? [])),
  227. {
  228. op: 'profile.import',
  229. description: 'chrometrace',
  230. }
  231. );
  232. }
  233. }
  234. return {
  235. name: 'chrometrace',
  236. traceID: profileID,
  237. transactionID: null,
  238. activeProfileIndex: 0,
  239. profiles,
  240. metadata: {} as Profiling.Schema['metadata'],
  241. };
  242. }
  243. function isProfileEvent(event: ChromeTrace.Event): event is ChromeTrace.ProfileEvent {
  244. return event.ph === 'P' && event.name === 'Profile';
  245. }
  246. function isProfileChunk(
  247. event: ChromeTrace.Event
  248. ): event is ChromeTrace.ProfileChunkEvent {
  249. return event.ph === 'P' && event.name === 'ProfileChunk';
  250. }
  251. function isThreadmetaData(
  252. event: ChromeTrace.Event
  253. ): event is ChromeTrace.ThreadMetadataEvent {
  254. return event.ph === 'M' && event.name === 'Thread';
  255. }
  256. type Required<T> = {
  257. [P in keyof T]-?: T[P];
  258. };
  259. // This mostly follows what speedscope does for the Chrome Trace format, but we do minor adjustments (not sure if they are correct atm),
  260. // but the protocol format seems out of date and is not well documented, so this is a best effort.
  261. function collectEventsByProfile(input: ChromeTrace.ArrayFormat): {
  262. cpuProfiles: Map<string, Required<ChromeTrace.CpuProfile>>;
  263. threadNames: Map<string, string>;
  264. } {
  265. const sorted = input.sort(chronologicalSort);
  266. const threadNames = new Map<string, string>();
  267. const profileIdToProcessAndThreadIds = new Map<string, [number, number]>();
  268. const cpuProfiles = new Map<string, Required<ChromeTrace.CpuProfile>>();
  269. for (let i = 0; i < sorted.length; i++) {
  270. const event = sorted[i];
  271. if (isThreadmetaData(event)) {
  272. threadNames.set(`${event.pid}:${event.tid}`, event.args.name);
  273. continue;
  274. }
  275. // A profile entry will happen before we see any ProfileChunks, so the order here matters
  276. if (isProfileEvent(event)) {
  277. profileIdToProcessAndThreadIds.set(event.id, [event.pid, event.tid]);
  278. if (cpuProfiles.has(event.id)) {
  279. continue;
  280. }
  281. // Judging by https://github.com/v8/v8/blob/b8626ca445554b8376b5a01f651b70cb8c01b7dd/src/inspector/js_protocol.json#L1453,
  282. // the only optional properties of a profile event are the samples and the timeDelta, however looking at a few sample traces
  283. // this does not seem to be the case. For example, in our chrometrace/trace.json there is a profile entry where only startTime is present
  284. cpuProfiles.set(event.id, {
  285. samples: [],
  286. timeDeltas: [],
  287. // @ts-ignore
  288. startTime: 0,
  289. // @ts-ignore
  290. endTime: 0,
  291. // @ts-ignore
  292. nodes: [],
  293. ...event.args.data,
  294. });
  295. continue;
  296. }
  297. if (isProfileChunk(event)) {
  298. const profile = cpuProfiles.get(event.id);
  299. if (!profile) {
  300. throw new Error('No entry for Profile was found before ProfileChunk');
  301. }
  302. // If we have a chunk, then append our values to it. Eventually we end up with a single profile with all of the chunks and samples merged
  303. const cpuProfile = event.args.data.cpuProfile;
  304. if (cpuProfile.nodes) {
  305. profile.nodes = profile.nodes.concat(cpuProfile.nodes ?? []);
  306. }
  307. if (cpuProfile.samples) {
  308. profile.samples = profile.samples.concat(cpuProfile.samples ?? []);
  309. }
  310. if (cpuProfile.timeDeltas) {
  311. profile.timeDeltas = profile.timeDeltas.concat(cpuProfile.timeDeltas ?? []);
  312. }
  313. if (cpuProfile.startTime !== null) {
  314. // Make sure we dont overwrite the startTime if it is already set
  315. if (typeof profile.startTime === 'number') {
  316. profile.startTime = Math.min(profile.startTime, cpuProfile.startTime);
  317. } else {
  318. profile.startTime = cpuProfile.startTime;
  319. }
  320. }
  321. // Make sure we dont overwrite the endTime if it is already set
  322. if (cpuProfile.endTime !== null) {
  323. if (typeof profile.endTime === 'number') {
  324. profile.endTime = Math.max(profile.endTime, cpuProfile.endTime);
  325. } else {
  326. profile.endTime = cpuProfile.endTime;
  327. }
  328. }
  329. }
  330. continue;
  331. }
  332. return {cpuProfiles, threadNames};
  333. }
  334. function createFramesIndex(
  335. profile: ChromeTrace.CpuProfile
  336. ): Map<number, ChromeTrace.ProfileNode> {
  337. const frames: Map<number, ChromeTrace.ProfileNode> = new Map();
  338. for (let i = 0; i < profile.nodes.length; i++) {
  339. frames.set(profile.nodes[i].id, {...profile.nodes[i]});
  340. }
  341. for (let i = 0; i < profile.nodes.length; i++) {
  342. const profileNode = profile.nodes[i];
  343. if (typeof profileNode.parent === 'number') {
  344. const parent = frames.get(profileNode.parent);
  345. if (parent === undefined) {
  346. throw new Error('Missing frame parent in profile');
  347. }
  348. }
  349. if (!profileNode.children) {
  350. continue;
  351. }
  352. for (let j = 0; j < profileNode.children.length; j++) {
  353. const child = frames.get(profileNode.children[j]);
  354. if (child === undefined) {
  355. throw new Error('Missing frame child in profile');
  356. }
  357. child.parent = profileNode;
  358. }
  359. }
  360. return frames;
  361. }
  362. // Cpu profiles can often contain a lot of sequential samples that point to the same stack.
  363. // It's wasteful to process these one by one, we can instead collapse them and just update the time delta.
  364. // We should consider a similar approach for the backend sample storage. I expect we will remove
  365. // this code from the frontend once we have backend support and a unified format for these.
  366. // Effectively, samples like [1,1,2,1] and timedeltas [1,2,1,1] to sample [1,2,1] and timedeltas [3,1,1]
  367. export function collapseSamples(profile: ChromeTrace.CpuProfile): {
  368. sampleTimes: number[];
  369. samples: number[];
  370. } {
  371. const samples: number[] = [];
  372. const sampleTimes: number[] = [];
  373. // If we have no samples, then we can't collapse anything
  374. if (!profile.samples || !profile.samples.length) {
  375. throw new Error('Profile is missing samples');
  376. }
  377. // If we have no time deltas then the format may be corrupt
  378. if (!profile.timeDeltas || !profile.timeDeltas.length) {
  379. throw new Error('Profile is missing timeDeltas');
  380. }
  381. // If timedeltas does not match samples, then the format may be corrupt
  382. if (profile.timeDeltas.length !== profile.samples.length) {
  383. throw new Error("Profile's samples and timeDeltas don't match");
  384. }
  385. if (profile.samples.length === 1 && profile.timeDeltas.length === 1) {
  386. return {samples: [profile.samples[0]], sampleTimes: [profile.timeDeltas[0]]};
  387. }
  388. // First delta is relative to profile start
  389. // https://github.com/v8/v8/blob/44bd8fd7/src/inspector/js_protocol.json#L1485
  390. let elapsed: number = profile.timeDeltas[0];
  391. // This is quite significantly changed from speedscope's implementation.
  392. // We iterate over all samples and check if we can collapse them or not.
  393. // A sample should be collapsed when there are more that 2 consecutive samples
  394. // that are pointing to the same stack.
  395. for (let i = 0; i < profile.samples.length; i++) {
  396. const nodeId = profile.samples[i];
  397. // Initialize the delta to 0, so we can accumulate the deltas of any collapsed samples
  398. let delta = 0;
  399. // Start at i
  400. let j = i;
  401. // While we are not at the end and next sample is the same as current
  402. while (j < profile.samples.length && profile.samples[j + 1] === nodeId) {
  403. // Update the delta and advance j. In some cases, v8 reports deltas
  404. // as negative. We will just ignore these deltas and make sure that
  405. // we never go back in time when updating the delta.
  406. delta = Math.max(delta + profile.timeDeltas[j + 1], delta);
  407. j++;
  408. }
  409. // Check if we skipped more than 1 element
  410. if (j - i > 1) {
  411. // We skipped more than 1 element, so we should collapse the samples,
  412. // push the first element where we started with the elapsed time
  413. // and last element where we started with the elapsed time + delta
  414. samples.push(nodeId);
  415. sampleTimes.push(elapsed);
  416. samples.push(nodeId);
  417. sampleTimes.push(elapsed + delta);
  418. elapsed += delta;
  419. i = j;
  420. } else {
  421. // If we have not skipped samples, then we just push the sample and the delta to the list
  422. samples.push(nodeId);
  423. sampleTimes.push(elapsed);
  424. // In some cases, v8 reports deltas as negative. We will just ignore
  425. // these deltas and make sure that we never go back in time when updating the delta.
  426. elapsed = Math.max(elapsed + profile.timeDeltas[i + 1], elapsed);
  427. }
  428. }
  429. return {samples, sampleTimes};
  430. }
  431. export function parseChromeTraceFormat(
  432. input: ChromeTrace.ArrayFormat,
  433. profileID: string,
  434. _options?: ImportOptions
  435. ): ProfileGroup {
  436. const {cpuProfiles, threadNames: _threadNames} = collectEventsByProfile(input);
  437. for (const [_profileId, profile] of cpuProfiles.entries()) {
  438. // @ts-ignore
  439. // eslint-disable-next-line
  440. const index = createFramesIndex(profile);
  441. const {samples: _samples, sampleTimes: _sampleTimes} = collapseSamples(profile);
  442. }
  443. return {
  444. name: 'chrometrace',
  445. activeProfileIndex: 0,
  446. profiles: [],
  447. traceID: profileID,
  448. transactionID: null,
  449. metadata: {} as Profiling.Schema['metadata'],
  450. };
  451. }