formatters.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. import {Release} from '@sentry/release-parser';
  2. import round from 'lodash/round';
  3. import type moment from 'moment';
  4. import {t, tn} from 'sentry/locale';
  5. import type {CommitAuthor, User} from 'sentry/types';
  6. import {RATE_UNIT_LABELS, RateUnit} from 'sentry/utils/discover/fields';
  7. export function userDisplayName(user: User | CommitAuthor, includeEmail = true): string {
  8. let displayName = String(user?.name ?? t('Unknown author')).trim();
  9. if (displayName.length <= 0) {
  10. displayName = t('Unknown author');
  11. }
  12. const email = String(user?.email ?? '').trim();
  13. if (email.length > 0 && email !== displayName && includeEmail) {
  14. displayName += ' (' + email + ')';
  15. }
  16. return displayName;
  17. }
  18. export const isSemverRelease = (rawVersion: string): boolean => {
  19. try {
  20. const parsedVersion = new Release(rawVersion);
  21. return !!parsedVersion.versionParsed;
  22. } catch {
  23. return false;
  24. }
  25. };
  26. export const formatVersion = (rawVersion: string, withPackage = false) => {
  27. try {
  28. const parsedVersion = new Release(rawVersion);
  29. const versionToDisplay = parsedVersion.describe();
  30. if (versionToDisplay.length) {
  31. return `${versionToDisplay}${
  32. withPackage && parsedVersion.package ? `, ${parsedVersion.package}` : ''
  33. }`;
  34. }
  35. return rawVersion;
  36. } catch {
  37. return rawVersion;
  38. }
  39. };
  40. function roundWithFixed(
  41. value: number,
  42. fixedDigits: number
  43. ): {label: string; result: number} {
  44. const label = value.toFixed(fixedDigits);
  45. const result = fixedDigits <= 0 ? Math.round(value) : value;
  46. return {label, result};
  47. }
  48. // in milliseconds
  49. export const MONTH = 2629800000;
  50. export const WEEK = 604800000;
  51. export const DAY = 86400000;
  52. export const HOUR = 3600000;
  53. export const MINUTE = 60000;
  54. export const SECOND = 1000;
  55. export const MILLISECOND = 1;
  56. export const MICROSECOND = 0.001;
  57. export const NANOSECOND = 0.000001;
  58. /**
  59. * Returns a human redable duration rounded to the largest unit.
  60. *
  61. * e.g. 2 days, or 3 months, or 25 seoconds
  62. *
  63. * Use `getExactDuration` for exact durations
  64. */
  65. const DURATION_LABELS = {
  66. mo: t('mo'),
  67. w: t('w'),
  68. wk: t('wk'),
  69. week: t('week'),
  70. weeks: t('weeks'),
  71. d: t('d'),
  72. day: t('day'),
  73. days: t('days'),
  74. h: t('h'),
  75. hr: t('hr'),
  76. hour: t('hour'),
  77. hours: t('hours'),
  78. m: t('m'),
  79. min: t('min'),
  80. minute: t('minute'),
  81. minutes: t('minutes'),
  82. s: t('s'),
  83. sec: t('sec'),
  84. secs: t('secs'),
  85. second: t('second'),
  86. seconds: t('seconds'),
  87. ms: t('ms'),
  88. millisecond: t('millisecond'),
  89. milliseconds: t('milliseconds'),
  90. };
  91. export function getDuration(
  92. seconds: number,
  93. fixedDigits: number = 0,
  94. abbreviation: boolean = false,
  95. extraShort: boolean = false,
  96. absolute: boolean = false
  97. ): string {
  98. const absValue = Math.abs(seconds * 1000);
  99. // value in milliseconds
  100. const msValue = absolute ? absValue : seconds * 1000;
  101. if (absValue >= MONTH && !extraShort) {
  102. const {label, result} = roundWithFixed(msValue / MONTH, fixedDigits);
  103. return `${label}${
  104. abbreviation ? DURATION_LABELS.mo : ` ${tn('month', 'months', result)}`
  105. }`;
  106. }
  107. if (absValue >= WEEK) {
  108. const {label, result} = roundWithFixed(msValue / WEEK, fixedDigits);
  109. if (extraShort) {
  110. return `${label}${DURATION_LABELS.w}`;
  111. }
  112. if (abbreviation) {
  113. return `${label}${DURATION_LABELS.wk}`;
  114. }
  115. return `${label} ${tn('week', 'weeks', result)}`;
  116. }
  117. if (absValue >= DAY) {
  118. const {label, result} = roundWithFixed(msValue / DAY, fixedDigits);
  119. if (extraShort || abbreviation) {
  120. return `${label}${DURATION_LABELS.d}`;
  121. }
  122. return `${label} ${tn('day', 'days', result)}`;
  123. }
  124. if (absValue >= HOUR) {
  125. const {label, result} = roundWithFixed(msValue / HOUR, fixedDigits);
  126. if (extraShort) {
  127. return `${label}${DURATION_LABELS.h}`;
  128. }
  129. if (abbreviation) {
  130. return `${label}${DURATION_LABELS.hr}`;
  131. }
  132. return `${label} ${tn('hour', 'hours', result)}`;
  133. }
  134. if (absValue >= MINUTE) {
  135. const {label, result} = roundWithFixed(msValue / MINUTE, fixedDigits);
  136. if (extraShort) {
  137. return `${label}${DURATION_LABELS.m}`;
  138. }
  139. if (abbreviation) {
  140. return `${label}${DURATION_LABELS.min}`;
  141. }
  142. return `${label} ${tn('minute', 'minutes', result)}`;
  143. }
  144. if (absValue >= SECOND) {
  145. const {label, result} = roundWithFixed(msValue / SECOND, fixedDigits);
  146. if (extraShort || abbreviation) {
  147. return `${label}${DURATION_LABELS.s}`;
  148. }
  149. return `${label} ${tn('second', 'seconds', result)}`;
  150. }
  151. const {label, result} = roundWithFixed(msValue, fixedDigits);
  152. if (extraShort || abbreviation) {
  153. return `${label}${DURATION_LABELS.ms}`;
  154. }
  155. return `${label} ${tn('millisecond', 'milliseconds', result)}`;
  156. }
  157. const SUFFIX_ABBR = {
  158. years: t('yr'),
  159. weeks: t('wk'),
  160. days: t('d'),
  161. hours: t('hr'),
  162. minutes: t('min'),
  163. seconds: t('s'),
  164. milliseconds: t('ms'),
  165. };
  166. /**
  167. * Returns a human readable exact duration.
  168. * 'precision' arg will truncate the results to the specified suffix
  169. *
  170. * e.g. 1 hour 25 minutes 15 seconds
  171. */
  172. export function getExactDuration(
  173. seconds: number,
  174. abbreviation: boolean = false,
  175. precision: keyof typeof SUFFIX_ABBR = 'milliseconds'
  176. ) {
  177. const minSuffix = ` ${precision}`;
  178. const convertDuration = (secs: number, abbr: boolean): string => {
  179. // value in milliseconds
  180. const msValue = round(secs * 1000);
  181. const value = round(Math.abs(secs * 1000));
  182. const divideBy = (time: number) => {
  183. return {
  184. quotient: msValue < 0 ? Math.ceil(msValue / time) : Math.floor(msValue / time),
  185. remainder: msValue % time,
  186. };
  187. };
  188. if (value >= WEEK || (value && minSuffix === ' weeks')) {
  189. const {quotient, remainder} = divideBy(WEEK);
  190. const suffix = abbr ? t('wk') : ` ${tn('week', 'weeks', quotient)}`;
  191. return `${quotient}${suffix} ${
  192. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  193. }`;
  194. }
  195. if (value >= DAY || (value && minSuffix === ' days')) {
  196. const {quotient, remainder} = divideBy(DAY);
  197. const suffix = abbr ? t('d') : ` ${tn('day', 'days', quotient)}`;
  198. return `${quotient}${suffix} ${
  199. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  200. }`;
  201. }
  202. if (value >= HOUR || (value && minSuffix === ' hours')) {
  203. const {quotient, remainder} = divideBy(HOUR);
  204. const suffix = abbr ? t('hr') : ` ${tn('hour', 'hours', quotient)}`;
  205. return `${quotient}${suffix} ${
  206. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  207. }`;
  208. }
  209. if (value >= MINUTE || (value && minSuffix === ' minutes')) {
  210. const {quotient, remainder} = divideBy(MINUTE);
  211. const suffix = abbr ? t('min') : ` ${tn('minute', 'minutes', quotient)}`;
  212. return `${quotient}${suffix} ${
  213. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  214. }`;
  215. }
  216. if (value >= SECOND || (value && minSuffix === ' seconds')) {
  217. const {quotient, remainder} = divideBy(SECOND);
  218. const suffix = abbr ? t('s') : ` ${tn('second', 'seconds', quotient)}`;
  219. return `${quotient}${suffix} ${
  220. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  221. }`;
  222. }
  223. if (value === 0) {
  224. return '';
  225. }
  226. const suffix = abbr ? t('ms') : ` ${tn('millisecond', 'milliseconds', value)}`;
  227. return `${msValue}${suffix}`;
  228. };
  229. const result = convertDuration(seconds, abbreviation).trim();
  230. if (result.length) {
  231. return result;
  232. }
  233. return `0${abbreviation ? SUFFIX_ABBR[precision] : minSuffix}`;
  234. }
  235. export const SEC_IN_WK = 604800;
  236. export const SEC_IN_DAY = 86400;
  237. export const SEC_IN_HR = 3600;
  238. export const SEC_IN_MIN = 60;
  239. type Level = [lvlSfx: moment.unitOfTime.DurationConstructor, denominator: number];
  240. type ParsedLargestSuffix = [val: number, suffix: moment.unitOfTime.DurationConstructor];
  241. /**
  242. * Given a length of time in seconds, provide me the largest divisible suffix and value for that time period.
  243. * eg. 60 -> [1, 'minutes']
  244. * eg. 7200 -> [2, 'hours']
  245. * eg. 7260 -> [121, 'minutes']
  246. *
  247. * @param seconds
  248. * @param maxSuffix determines the largest suffix we should pin the response to
  249. */
  250. export function parseLargestSuffix(
  251. seconds: number,
  252. maxSuffix: string = 'days'
  253. ): ParsedLargestSuffix {
  254. const levels: Level[] = [
  255. ['minutes', SEC_IN_MIN],
  256. ['hours', SEC_IN_HR],
  257. ['days', SEC_IN_DAY],
  258. ['weeks', SEC_IN_WK],
  259. ];
  260. let val = seconds;
  261. let suffix: moment.unitOfTime.DurationConstructor = 'seconds';
  262. if (val === 0) {
  263. return [val, suffix];
  264. }
  265. for (const [lvlSfx, denominator] of levels) {
  266. if (seconds % denominator) {
  267. break;
  268. }
  269. val = seconds / denominator;
  270. suffix = lvlSfx;
  271. if (lvlSfx === maxSuffix) {
  272. break;
  273. }
  274. }
  275. return [val, suffix];
  276. }
  277. export function formatSecondsToClock(
  278. seconds: number,
  279. {padAll}: {padAll: boolean} = {padAll: true}
  280. ) {
  281. if (seconds === 0 || isNaN(seconds)) {
  282. return padAll ? '00:00' : '0:00';
  283. }
  284. const divideBy = (msValue: number, time: number) => {
  285. return {
  286. quotient: msValue < 0 ? Math.ceil(msValue / time) : Math.floor(msValue / time),
  287. remainder: msValue % time,
  288. };
  289. };
  290. // value in milliseconds
  291. const absMSValue = round(Math.abs(seconds * 1000));
  292. const {quotient: hours, remainder: rMins} = divideBy(absMSValue, HOUR);
  293. const {quotient: minutes, remainder: rSeconds} = divideBy(rMins, MINUTE);
  294. const {quotient: secs, remainder: milliseconds} = divideBy(rSeconds, SECOND);
  295. const fill = (num: number) => (num < 10 ? `0${num}` : String(num));
  296. const parts = hours
  297. ? [padAll ? fill(hours) : hours, fill(minutes), fill(secs)]
  298. : [padAll ? fill(minutes) : minutes, fill(secs)];
  299. const ms = `000${milliseconds}`.slice(-3);
  300. return milliseconds ? `${parts.join(':')}.${ms}` : parts.join(':');
  301. }
  302. export function parseClockToSeconds(clock: string) {
  303. const [rest, milliseconds] = clock.split('.');
  304. const parts = rest.split(':');
  305. let seconds = 0;
  306. const progression = [MONTH, WEEK, DAY, HOUR, MINUTE, SECOND].slice(parts.length * -1);
  307. for (let i = 0; i < parts.length; i++) {
  308. const num = Number(parts[i]) || 0;
  309. const time = progression[i] / 1000;
  310. seconds += num * time;
  311. }
  312. const ms = Number(milliseconds) || 0;
  313. return seconds + ms / 1000;
  314. }
  315. export function formatFloat(number: number, places: number) {
  316. const multi = Math.pow(10, places);
  317. return parseInt((number * multi).toString(), 10) / multi;
  318. }
  319. /**
  320. * Format a value between 0 and 1 as a percentage
  321. */
  322. export function formatPercentage(
  323. value: number,
  324. places: number = 2,
  325. options: {
  326. minimumValue?: number;
  327. } = {}
  328. ) {
  329. if (value === 0) {
  330. return '0%';
  331. }
  332. const minimumValue = options.minimumValue ?? 0;
  333. if (Math.abs(value) <= minimumValue) {
  334. return `<${minimumValue * 100}%`;
  335. }
  336. return (
  337. round(value * 100, places).toLocaleString(undefined, {
  338. maximumFractionDigits: places,
  339. }) + '%'
  340. );
  341. }
  342. const numberFormatSteps = [
  343. [1_000_000_000, 'b'],
  344. [1_000_000, 'm'],
  345. [1_000, 'k'],
  346. ] as const;
  347. /**
  348. * Formats a number with an abbreviation e.g. 1000 -> 1k.
  349. *
  350. * @param number the number to format
  351. * @param maximumSignificantDigits the number of significant digits to include
  352. * @param includeDecimals when true, formatted number will always include non trailing zero decimal places
  353. */
  354. export function formatAbbreviatedNumber(
  355. number: number | string,
  356. maximumSignificantDigits?: number,
  357. includeDecimals?: boolean
  358. ): string {
  359. number = Number(number);
  360. const prefix = number < 0 ? '-' : '';
  361. const numAbsValue = Math.abs(number);
  362. for (const step of numberFormatSteps) {
  363. const [suffixNum, suffix] = step;
  364. const shortValue = Math.floor(numAbsValue / suffixNum);
  365. const fitsBound = numAbsValue % suffixNum === 0;
  366. if (shortValue <= 0) {
  367. continue;
  368. }
  369. const useShortValue = !includeDecimals && (shortValue > 10 || fitsBound);
  370. if (useShortValue) {
  371. if (maximumSignificantDigits === undefined) {
  372. return `${prefix}${shortValue}${suffix}`;
  373. }
  374. const formattedNumber = parseFloat(
  375. shortValue.toPrecision(maximumSignificantDigits)
  376. ).toString();
  377. return `${prefix}${formattedNumber}${suffix}`;
  378. }
  379. const formattedNumber = formatFloat(
  380. numAbsValue / suffixNum,
  381. maximumSignificantDigits || 1
  382. ).toLocaleString(undefined, {
  383. maximumSignificantDigits,
  384. });
  385. return `${prefix}${formattedNumber}${suffix}`;
  386. }
  387. return number.toLocaleString(undefined, {maximumSignificantDigits});
  388. }
  389. /**
  390. * Formats a number with an abbreviation and rounds to 2
  391. * decimal digits without forcing trailing zeros.
  392. * e. g. 1000 -> 1k, 1234 -> 1.23k
  393. */
  394. export function formatAbbreviatedNumberWithDynamicPrecision(
  395. value: number | string
  396. ): string {
  397. const number = Number(value);
  398. if (number === 0) {
  399. return '0';
  400. }
  401. const log10 = Math.log10(Math.abs(number));
  402. // numbers less than 1 will have a negative log10
  403. const numOfDigits = log10 < 0 ? 1 : Math.floor(log10) + 1;
  404. const maxStep = numberFormatSteps[0][0];
  405. // if the number is larger than the largest step, we determine the number of digits
  406. // by dividing the number by the largest step, otherwise the number of formatted
  407. // digits is the number of digits in the number modulo 3 (the number of zeroes between steps)
  408. const numOfFormattedDigits =
  409. number > maxStep
  410. ? Math.floor(Math.log10(number / maxStep))
  411. : Math.max(numOfDigits % 3 === 0 ? 3 : numOfDigits % 3, 0);
  412. const maximumSignificantDigits = numOfFormattedDigits + 2;
  413. return formatAbbreviatedNumber(value, maximumSignificantDigits, true);
  414. }
  415. /**
  416. * Rounds to specified number of decimal digits (defaults to 2) without forcing trailing zeros
  417. * Will preserve significant decimals for very small numbers
  418. * e.g. 0.0001234 -> 0.00012
  419. * @param value number to format
  420. */
  421. export function formatNumberWithDynamicDecimalPoints(
  422. value: number,
  423. maxFractionDigits = 2
  424. ): string {
  425. if ([0, Infinity, -Infinity, NaN].includes(value)) {
  426. return value.toLocaleString();
  427. }
  428. const exponent = Math.floor(Math.log10(Math.abs(value)));
  429. const maximumFractionDigits =
  430. exponent >= 0 ? maxFractionDigits : Math.abs(exponent) + 1;
  431. const numberFormat = {
  432. maximumFractionDigits,
  433. minimumFractionDigits: 0,
  434. };
  435. return value.toLocaleString(undefined, numberFormat);
  436. }
  437. export function formatRate(
  438. value: number,
  439. unit: RateUnit = RateUnit.PER_SECOND,
  440. options: {
  441. minimumValue?: number;
  442. significantDigits?: number;
  443. } = {}
  444. ) {
  445. // NOTE: `Intl` doesn't support unitless-per-unit formats (i.e.,
  446. // `"-per-minute"` is not valid) so we have to concatenate the unit manually, since our rates are usually just "/min" or "/s".
  447. // Because of this, the unit is not internationalized.
  448. // 0 is special!
  449. if (value === 0) {
  450. return `${0}${RATE_UNIT_LABELS[unit]}`;
  451. }
  452. const minimumValue = options.minimumValue ?? 0;
  453. const significantDigits = options.significantDigits ?? 3;
  454. const numberFormatOptions: ConstructorParameters<typeof Intl.NumberFormat>[1] = {
  455. notation: 'compact',
  456. compactDisplay: 'short',
  457. minimumSignificantDigits: significantDigits,
  458. maximumSignificantDigits: significantDigits,
  459. };
  460. if (value <= minimumValue) {
  461. return `<${minimumValue}${RATE_UNIT_LABELS[unit]}`;
  462. }
  463. return `${value.toLocaleString(undefined, numberFormatOptions)}${
  464. RATE_UNIT_LABELS[unit]
  465. }`;
  466. }
  467. export function formatSpanOperation(
  468. operation?: string,
  469. length: 'short' | 'long' = 'short'
  470. ) {
  471. if (length === 'long') {
  472. return getLongSpanOperationDescription(operation);
  473. }
  474. return getShortSpanOperationDescription(operation);
  475. }
  476. function getLongSpanOperationDescription(operation?: string) {
  477. if (operation?.startsWith('http')) {
  478. return t('URL request');
  479. }
  480. if (operation === 'db.redis') {
  481. return t('cache query');
  482. }
  483. if (operation?.startsWith('db')) {
  484. return t('database query');
  485. }
  486. if (operation?.startsWith('task')) {
  487. return t('application task');
  488. }
  489. if (operation?.startsWith('serialize')) {
  490. return t('serializer');
  491. }
  492. if (operation?.startsWith('middleware')) {
  493. return t('middleware');
  494. }
  495. if (operation === 'resource') {
  496. return t('resource');
  497. }
  498. if (operation === 'resource.script') {
  499. return t('JavaScript file');
  500. }
  501. if (operation === 'resource.css') {
  502. return t('stylesheet');
  503. }
  504. if (operation === 'resource.img') {
  505. return t('image');
  506. }
  507. return t('span');
  508. }
  509. function getShortSpanOperationDescription(operation?: string) {
  510. if (operation?.startsWith('http')) {
  511. return t('request');
  512. }
  513. if (operation?.startsWith('db')) {
  514. return t('query');
  515. }
  516. if (operation?.startsWith('task')) {
  517. return t('task');
  518. }
  519. if (operation?.startsWith('serialize')) {
  520. return t('serializer');
  521. }
  522. if (operation?.startsWith('middleware')) {
  523. return t('middleware');
  524. }
  525. if (operation?.startsWith('resource')) {
  526. return t('resource');
  527. }
  528. return t('span');
  529. }