From 7e8a088ece9f2f67198ebc7e740d9065c98edb2e Mon Sep 17 00:00:00 2001 From: Stas Date: Wed, 17 Aug 2022 20:51:00 +0300 Subject: [PATCH 1/3] feat: added sub-second units support for trace visualization --- .../src/format/format.spec.ts | 8 ++- .../pyroscope-flamegraph/src/format/format.ts | 72 ++++++++++++++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/pyroscope-flamegraph/src/format/format.spec.ts b/packages/pyroscope-flamegraph/src/format/format.spec.ts index 286f6fbbfe..af109d3454 100644 --- a/packages/pyroscope-flamegraph/src/format/format.spec.ts +++ b/packages/pyroscope-flamegraph/src/format/format.spec.ts @@ -41,11 +41,15 @@ describe('format', () => { const df = getFormatter(80, 2, 'trace_samples'); expect(df.format(0, 100)).toBe('0.00 seconds'); - expect(df.format(0.001, 100)).toBe('< 0.01 seconds'); + expect(df.format(0.001, 100)).toBe('10.00 μs'); + expect(df.format(0.01555, 100)).toBe('155.50 μs'); + expect(df.format(0.1, 100)).toBe('1.00 ms'); + expect(df.format(9.3, 100)).toBe('93.00 ms'); expect(df.format(100, 100)).toBe('1.00 second'); expect(df.format(2000, 100)).toBe('20.00 seconds'); - expect(df.format(2012.3, 100)).toBe('20.12 seconds'); + expect(df.format(2012.3, 100)).toBe('20123.00 ms'); expect(df.format(8000, 100)).toBe('80.00 seconds'); + expect(df.format(374.12, 100)).toBe('3741200.00 μs'); }); it('correctly formats duration when maxdur = 80', () => { diff --git a/packages/pyroscope-flamegraph/src/format/format.ts b/packages/pyroscope-flamegraph/src/format/format.ts index 84b93e8fa4..a2bc8ab4e3 100644 --- a/packages/pyroscope-flamegraph/src/format/format.ts +++ b/packages/pyroscope-flamegraph/src/format/format.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Units } from '@pyroscope/models/src'; +import _last from 'lodash/last'; export function numberWithCommas(x: number): string { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); @@ -29,7 +30,7 @@ export function getFormatter(max: number, sampleRate: number, unit: Units) { case 'lock_samples': return new ObjectsFormatter(max); case 'trace_samples': - return new DurationFormatter(max / sampleRate); + return new SubSecondDurationFormatter(max / sampleRate); default: console.warn(`Unsupported unit: '${unit}'. Defaulting to ''`); return new DurationFormatter(max / sampleRate, ' '); @@ -74,7 +75,7 @@ class DurationFormatter { } } - format(samples: number, sampleRate: number) { + format(samples: number, sampleRate: number): string { const n = samples / sampleRate / this.divider; let nStr = n.toFixed(2); @@ -90,6 +91,73 @@ class DurationFormatter { } } +// this is a class and not a function because we can save some time by +// precalculating divider and suffix and not doing it on each iteration +class SubSecondDurationFormatter { + divider = 1; + + suffix = 'second'; + + durations: [number, string][] = [ + [60, 'minute'], + [60, 'hour'], + [24, 'day'], + [30, 'month'], + [12, 'year'], + ]; + + subSecondDurations: [number, string][] = [ + [1000, 'ms'], + [1000, 'μs'], + ]; + + units = ''; + + constructor(maxDur: number, units?: string) { + this.units = units || ''; + // eslint-disable-next-line no-plusplus + for (let i = 0; i < this.durations.length; i++) { + const level = this.durations[i]; + if (!level) { + console.warn('Could not calculate level'); + break; + } + + if (maxDur >= level[0]) { + this.divider *= level[0]; + maxDur /= level[0]; + // eslint-disable-next-line prefer-destructuring + this.suffix = level[1]; + } else { + break; + } + } + } + + format(samples: number, sampleRate: number): string { + let n = samples / sampleRate / this.divider; + + if (n && !Number.isInteger(n) && this.divider === 1) { + // n is float and we are in the seconds + // eslint-disable-next-line no-plusplus + for (let i = 0; i < this.subSecondDurations.length; i++) { + const [multiplier, suffix] = this.subSecondDurations[i]; + // floating math is broken https://stackoverflow.com/questions/588004/is-floating-point-math-broken so we use this workaround + n = Number((n * multiplier).toPrecision(15)); + if (Number.isInteger(n)) return `${n}.00 ${this.units || suffix}`; + } + const lastSubSecDuration = _last(this.subSecondDurations) as [ + number, + string + ]; + return `${n.toFixed(2)} ${this.units || `${lastSubSecDuration[1]}`}`; + } + + const nStr = n.toFixed(2); + return `${nStr} ${this.units || `${this.suffix}${n === 1 ? '' : 's'}`}`; + } +} + // this is a class and not a function because we can save some time by // precalculating divider and suffix and not doing it on each iteration class NanosecondsFormatter { From 361b3e99e5e920509f21ae99fcb5a8b25ee295e7 Mon Sep 17 00:00:00 2001 From: Stas Date: Thu, 18 Aug 2022 15:01:08 +0300 Subject: [PATCH 2/3] fix: DurationFormatter logic updated, more tests added --- .../src/format/format.spec.ts | 75 +++++++++++++++-- .../pyroscope-flamegraph/src/format/format.ts | 82 +++---------------- 2 files changed, 78 insertions(+), 79 deletions(-) diff --git a/packages/pyroscope-flamegraph/src/format/format.spec.ts b/packages/pyroscope-flamegraph/src/format/format.spec.ts index af109d3454..6f11dd1de1 100644 --- a/packages/pyroscope-flamegraph/src/format/format.spec.ts +++ b/packages/pyroscope-flamegraph/src/format/format.spec.ts @@ -40,16 +40,11 @@ describe('format', () => { it('correctly formats trace samples', () => { const df = getFormatter(80, 2, 'trace_samples'); - expect(df.format(0, 100)).toBe('0.00 seconds'); - expect(df.format(0.001, 100)).toBe('10.00 μs'); - expect(df.format(0.01555, 100)).toBe('155.50 μs'); - expect(df.format(0.1, 100)).toBe('1.00 ms'); - expect(df.format(9.3, 100)).toBe('93.00 ms'); + expect(df.format(0.001, 100)).toBe('< 0.01 seconds'); expect(df.format(100, 100)).toBe('1.00 second'); expect(df.format(2000, 100)).toBe('20.00 seconds'); - expect(df.format(2012.3, 100)).toBe('20123.00 ms'); + expect(df.format(2012.3, 100)).toBe('20.12 seconds'); expect(df.format(8000, 100)).toBe('80.00 seconds'); - expect(df.format(374.12, 100)).toBe('3741200.00 μs'); }); it('correctly formats duration when maxdur = 80', () => { @@ -61,6 +56,72 @@ describe('format', () => { expect(df.format(2012.3, 100)).toBe('0.34 minutes'); expect(df.format(8000, 100)).toBe('1.33 minutes'); }); + + it('correctly formats trace_samples duration when maxdur is less than second', () => { + const df = getFormatter(10, 100, 'trace_samples'); + + expect(df.format(55, 100)).toBe('550.00 ms'); + expect(df.format(100, 100)).toBe('1000.00 ms'); + expect(df.format(1.001, 100)).toBe('10.01 ms'); + expect(df.format(9999, 100)).toBe('99990.00 ms'); + expect(df.format(0.331, 100)).toBe('3.31 ms'); + expect(df.format(0.0001, 100)).toBe('< 0.01 ms'); + }); + + it('correctly formats trace_samples duration when maxdur is less than ms', () => { + const df = getFormatter(1, 10000, 'trace_samples'); + + expect(df.format(0.012, 100)).toBe('120.00 μs'); + expect(df.format(0, 100)).toBe('0.00 μs'); + expect(df.format(0.0091, 100)).toBe('91.00 μs'); + expect(df.format(1.005199, 100)).toBe('10051.99 μs'); + expect(df.format(1.1, 100)).toBe('11000.00 μs'); + }); + + it('correctly formats trace_samples duration when maxdur is hour', () => { + const hour = 3600; + let df = getFormatter(hour, 1, 'trace_samples'); + + expect(df.format(0, 100)).toBe('0.00 hours'); + expect(df.format(hour * 100, 100)).toBe('1.00 hour'); + expect(df.format(0.6 * hour * 100, 100)).toBe('0.60 hours'); + expect(df.format(0.02 * hour * 100, 100)).toBe('0.02 hours'); + expect(df.format(0.001 * hour * 100, 100)).toBe('< 0.01 hours'); + expect(df.format(42.1 * hour * 100, 100)).toBe('42.10 hours'); + }); + + it('correctly formats trace_samples duration when maxdur is day', () => { + const day = 24 * 60 * 60; + const df = getFormatter(day, 1, 'trace_samples'); + + expect(df.format(day * 100, 100)).toBe('1.00 day'); + expect(df.format(12 * day * 100, 100)).toBe('12.00 days'); + expect(df.format(2.29 * day * 100, 100)).toBe('2.29 days'); + expect(df.format(0.11 * day * 100, 100)).toBe('0.11 days'); + expect(df.format(0.001 * day * 100, 100)).toBe('< 0.01 days'); + }); + + it('correctly formats trace_samples duration when maxdur = month', () => { + const month = 30 * 24 * 60 * 60; + const df = getFormatter(month, 1, 'trace_samples'); + + expect(df.format(month * 100, 100)).toBe('1.00 month'); + expect(df.format(44 * month * 100, 100)).toBe('44.00 months'); + expect(df.format(5.142 * month * 100, 100)).toBe('5.14 months'); + expect(df.format(0.88 * month * 100, 100)).toBe('0.88 months'); + expect(df.format(0.008 * month * 100, 100)).toBe('< 0.01 months'); + }); + + it('correctly formats trace_samples duration when maxdur = year', () => { + const year = 12 * 30 * 24 * 60 * 60; + const df = getFormatter(year, 1, 'trace_samples'); + + expect(df.format(year * 100, 100)).toBe('1.00 year'); + expect(df.format(12 * year * 100, 100)).toBe('12.00 years'); + expect(df.format(3.414 * year * 100, 100)).toBe('3.41 years'); + expect(df.format(0.12 * year * 100, 100)).toBe('0.12 years'); + expect(df.format(0.008 * year * 100, 100)).toBe('< 0.01 years'); + }); }); describe('ObjectsFormatter', () => { diff --git a/packages/pyroscope-flamegraph/src/format/format.ts b/packages/pyroscope-flamegraph/src/format/format.ts index a2bc8ab4e3..dd8e15003c 100644 --- a/packages/pyroscope-flamegraph/src/format/format.ts +++ b/packages/pyroscope-flamegraph/src/format/format.ts @@ -1,6 +1,5 @@ /* eslint-disable max-classes-per-file */ import { Units } from '@pyroscope/models/src'; -import _last from 'lodash/last'; export function numberWithCommas(x: number): string { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); @@ -30,7 +29,7 @@ export function getFormatter(max: number, sampleRate: number, unit: Units) { case 'lock_samples': return new ObjectsFormatter(max); case 'trace_samples': - return new SubSecondDurationFormatter(max / sampleRate); + return new DurationFormatter(max / sampleRate); default: console.warn(`Unsupported unit: '${unit}'. Defaulting to ''`); return new DurationFormatter(max / sampleRate, ' '); @@ -42,9 +41,11 @@ export function getFormatter(max: number, sampleRate: number, unit: Units) { class DurationFormatter { divider = 1; - suffix = 'second'; + suffix = 'μs'; durations: [number, string][] = [ + [1000, 'ms'], + [1000, 'second'], [60, 'minute'], [60, 'hour'], [24, 'day'], @@ -55,6 +56,7 @@ class DurationFormatter { units = ''; constructor(maxDur: number, units?: string) { + maxDur *= 1e6; // Converting seconds to μs this.units = units || ''; // eslint-disable-next-line no-plusplus for (let i = 0; i < this.durations.length; i++) { @@ -76,7 +78,7 @@ class DurationFormatter { } format(samples: number, sampleRate: number): string { - const n = samples / sampleRate / this.divider; + const n = samples / (sampleRate / 1e6) / this.divider; let nStr = n.toFixed(2); if (n === 0) { @@ -87,74 +89,10 @@ class DurationFormatter { nStr = '< 0.01'; } - return `${nStr} ${this.units || `${this.suffix}${n === 1 ? '' : 's'}`}`; - } -} - -// this is a class and not a function because we can save some time by -// precalculating divider and suffix and not doing it on each iteration -class SubSecondDurationFormatter { - divider = 1; - - suffix = 'second'; - - durations: [number, string][] = [ - [60, 'minute'], - [60, 'hour'], - [24, 'day'], - [30, 'month'], - [12, 'year'], - ]; - - subSecondDurations: [number, string][] = [ - [1000, 'ms'], - [1000, 'μs'], - ]; - - units = ''; - - constructor(maxDur: number, units?: string) { - this.units = units || ''; - // eslint-disable-next-line no-plusplus - for (let i = 0; i < this.durations.length; i++) { - const level = this.durations[i]; - if (!level) { - console.warn('Could not calculate level'); - break; - } - - if (maxDur >= level[0]) { - this.divider *= level[0]; - maxDur /= level[0]; - // eslint-disable-next-line prefer-destructuring - this.suffix = level[1]; - } else { - break; - } - } - } - - format(samples: number, sampleRate: number): string { - let n = samples / sampleRate / this.divider; - - if (n && !Number.isInteger(n) && this.divider === 1) { - // n is float and we are in the seconds - // eslint-disable-next-line no-plusplus - for (let i = 0; i < this.subSecondDurations.length; i++) { - const [multiplier, suffix] = this.subSecondDurations[i]; - // floating math is broken https://stackoverflow.com/questions/588004/is-floating-point-math-broken so we use this workaround - n = Number((n * multiplier).toPrecision(15)); - if (Number.isInteger(n)) return `${n}.00 ${this.units || suffix}`; - } - const lastSubSecDuration = _last(this.subSecondDurations) as [ - number, - string - ]; - return `${n.toFixed(2)} ${this.units || `${lastSubSecDuration[1]}`}`; - } - - const nStr = n.toFixed(2); - return `${nStr} ${this.units || `${this.suffix}${n === 1 ? '' : 's'}`}`; + return `${nStr} ${ + this.units || + `${this.suffix}${n === 1 || this.suffix.length === 2 ? '' : 's'}` + }`; } } From 5362965f39fe9b788e4cb0a71ebb07a41c824015 Mon Sep 17 00:00:00 2001 From: Stas Date: Wed, 24 Aug 2022 15:18:21 +0300 Subject: [PATCH 3/3] fix(flamegraph): added enableSubsecondPrecision prop --- .../pyroscope-flamegraph/src/format/format.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/pyroscope-flamegraph/src/format/format.ts b/packages/pyroscope-flamegraph/src/format/format.ts index dd8e15003c..eb225cfc8e 100644 --- a/packages/pyroscope-flamegraph/src/format/format.ts +++ b/packages/pyroscope-flamegraph/src/format/format.ts @@ -29,7 +29,7 @@ export function getFormatter(max: number, sampleRate: number, unit: Units) { case 'lock_samples': return new ObjectsFormatter(max); case 'trace_samples': - return new DurationFormatter(max / sampleRate); + return new DurationFormatter(max / sampleRate, '', true); default: console.warn(`Unsupported unit: '${unit}'. Defaulting to ''`); return new DurationFormatter(max / sampleRate, ' '); @@ -41,11 +41,11 @@ export function getFormatter(max: number, sampleRate: number, unit: Units) { class DurationFormatter { divider = 1; - suffix = 'μs'; + enableSubsecondPrecision = false; + + suffix = 'second'; durations: [number, string][] = [ - [1000, 'ms'], - [1000, 'second'], [60, 'minute'], [60, 'hour'], [24, 'day'], @@ -55,8 +55,17 @@ class DurationFormatter { units = ''; - constructor(maxDur: number, units?: string) { - maxDur *= 1e6; // Converting seconds to μs + constructor( + maxDur: number, + units?: string, + enableSubsecondPrecision?: boolean + ) { + if (enableSubsecondPrecision) { + this.enableSubsecondPrecision = enableSubsecondPrecision; + this.durations = [[1000, 'ms'], [1000, 'second'], ...this.durations]; + this.suffix = `μs`; + maxDur *= 1e6; // Converting seconds to μs + } this.units = units || ''; // eslint-disable-next-line no-plusplus for (let i = 0; i < this.durations.length; i++) { @@ -78,7 +87,10 @@ class DurationFormatter { } format(samples: number, sampleRate: number): string { - const n = samples / (sampleRate / 1e6) / this.divider; + if (this.enableSubsecondPrecision) { + sampleRate /= 1e6; + } + const n = samples / sampleRate / this.divider; let nStr = n.toFixed(2); if (n === 0) {