Files
2024-02-15 17:01:50 +00:00

569 lines
15 KiB
TypeScript

import {Release} from '@sentry/release-parser';
import round from 'lodash/round';
import type moment from 'moment';
import {t, tn} from 'sentry/locale';
import type {CommitAuthor, User} from 'sentry/types';
import {RATE_UNIT_LABELS, RateUnit} from 'sentry/utils/discover/fields';
export function userDisplayName(user: User | CommitAuthor, includeEmail = true): string {
let displayName = String(user?.name ?? t('Unknown author')).trim();
if (displayName.length <= 0) {
displayName = t('Unknown author');
}
const email = String(user?.email ?? '').trim();
if (email.length > 0 && email !== displayName && includeEmail) {
displayName += ' (' + email + ')';
}
return displayName;
}
export const isSemverRelease = (rawVersion: string): boolean => {
try {
const parsedVersion = new Release(rawVersion);
return !!parsedVersion.versionParsed;
} catch {
return false;
}
};
export const formatVersion = (rawVersion: string, withPackage = false) => {
try {
const parsedVersion = new Release(rawVersion);
const versionToDisplay = parsedVersion.describe();
if (versionToDisplay.length) {
return `${versionToDisplay}${
withPackage && parsedVersion.package ? `, ${parsedVersion.package}` : ''
}`;
}
return rawVersion;
} catch {
return rawVersion;
}
};
function roundWithFixed(
value: number,
fixedDigits: number
): {label: string; result: number} {
const label = value.toFixed(fixedDigits);
const result = fixedDigits <= 0 ? Math.round(value) : value;
return {label, result};
}
// in milliseconds
export const MONTH = 2629800000;
export const WEEK = 604800000;
export const DAY = 86400000;
export const HOUR = 3600000;
export const MINUTE = 60000;
export const SECOND = 1000;
/**
* Returns a human redable duration rounded to the largest unit.
*
* e.g. 2 days, or 3 months, or 25 seoconds
*
* Use `getExactDuration` for exact durations
*/
const DURATION_LABELS = {
mo: t('mo'),
w: t('w'),
wk: t('wk'),
week: t('week'),
weeks: t('weeks'),
d: t('d'),
day: t('day'),
days: t('days'),
h: t('h'),
hr: t('hr'),
hour: t('hour'),
hours: t('hours'),
m: t('m'),
min: t('min'),
minute: t('minute'),
minutes: t('minutes'),
s: t('s'),
sec: t('sec'),
secs: t('secs'),
second: t('second'),
seconds: t('seconds'),
ms: t('ms'),
millisecond: t('millisecond'),
milliseconds: t('milliseconds'),
};
export function getDuration(
seconds: number,
fixedDigits: number = 0,
abbreviation: boolean = false,
extraShort: boolean = false,
absolute: boolean = false
): string {
const absValue = Math.abs(seconds * 1000);
// value in milliseconds
const msValue = absolute ? absValue : seconds * 1000;
if (absValue >= MONTH && !extraShort) {
const {label, result} = roundWithFixed(msValue / MONTH, fixedDigits);
return `${label}${
abbreviation ? DURATION_LABELS.mo : ` ${tn('month', 'months', result)}`
}`;
}
if (absValue >= WEEK) {
const {label, result} = roundWithFixed(msValue / WEEK, fixedDigits);
if (extraShort) {
return `${label}${DURATION_LABELS.w}`;
}
if (abbreviation) {
return `${label}${DURATION_LABELS.wk}`;
}
return `${label} ${tn('week', 'weeks', result)}`;
}
if (absValue >= DAY) {
const {label, result} = roundWithFixed(msValue / DAY, fixedDigits);
if (extraShort || abbreviation) {
return `${label}${DURATION_LABELS.d}`;
}
return `${label} ${tn('day', 'days', result)}`;
}
if (absValue >= HOUR) {
const {label, result} = roundWithFixed(msValue / HOUR, fixedDigits);
if (extraShort) {
return `${label}${DURATION_LABELS.h}`;
}
if (abbreviation) {
return `${label}${DURATION_LABELS.hr}`;
}
return `${label} ${tn('hour', 'hours', result)}`;
}
if (absValue >= MINUTE) {
const {label, result} = roundWithFixed(msValue / MINUTE, fixedDigits);
if (extraShort) {
return `${label}${DURATION_LABELS.m}`;
}
if (abbreviation) {
return `${label}${DURATION_LABELS.min}`;
}
return `${label} ${tn('minute', 'minutes', result)}`;
}
if (absValue >= SECOND) {
const {label, result} = roundWithFixed(msValue / SECOND, fixedDigits);
if (extraShort || abbreviation) {
return `${label}${DURATION_LABELS.s}`;
}
return `${label} ${tn('second', 'seconds', result)}`;
}
const {label, result} = roundWithFixed(msValue, fixedDigits);
if (extraShort || abbreviation) {
return `${label}${DURATION_LABELS.ms}`;
}
return `${label} ${tn('millisecond', 'milliseconds', result)}`;
}
const SUFFIX_ABBR = {
years: t('yr'),
weeks: t('wk'),
days: t('d'),
hours: t('hr'),
minutes: t('min'),
seconds: t('s'),
milliseconds: t('ms'),
};
/**
* Returns a human readable exact duration.
* 'precision' arg will truncate the results to the specified suffix
*
* e.g. 1 hour 25 minutes 15 seconds
*/
export function getExactDuration(
seconds: number,
abbreviation: boolean = false,
precision: keyof typeof SUFFIX_ABBR = 'milliseconds'
) {
const minSuffix = ` ${precision}`;
const convertDuration = (secs: number, abbr: boolean): string => {
// value in milliseconds
const msValue = round(secs * 1000);
const value = round(Math.abs(secs * 1000));
const divideBy = (time: number) => {
return {
quotient: msValue < 0 ? Math.ceil(msValue / time) : Math.floor(msValue / time),
remainder: msValue % time,
};
};
if (value >= WEEK || (value && minSuffix === ' weeks')) {
const {quotient, remainder} = divideBy(WEEK);
const suffix = abbr ? t('wk') : ` ${tn('week', 'weeks', quotient)}`;
return `${quotient}${suffix} ${
minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
}`;
}
if (value >= DAY || (value && minSuffix === ' days')) {
const {quotient, remainder} = divideBy(DAY);
const suffix = abbr ? t('d') : ` ${tn('day', 'days', quotient)}`;
return `${quotient}${suffix} ${
minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
}`;
}
if (value >= HOUR || (value && minSuffix === ' hours')) {
const {quotient, remainder} = divideBy(HOUR);
const suffix = abbr ? t('hr') : ` ${tn('hour', 'hours', quotient)}`;
return `${quotient}${suffix} ${
minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
}`;
}
if (value >= MINUTE || (value && minSuffix === ' minutes')) {
const {quotient, remainder} = divideBy(MINUTE);
const suffix = abbr ? t('min') : ` ${tn('minute', 'minutes', quotient)}`;
return `${quotient}${suffix} ${
minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
}`;
}
if (value >= SECOND || (value && minSuffix === ' seconds')) {
const {quotient, remainder} = divideBy(SECOND);
const suffix = abbr ? t('s') : ` ${tn('second', 'seconds', quotient)}`;
return `${quotient}${suffix} ${
minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
}`;
}
if (value === 0) {
return '';
}
const suffix = abbr ? t('ms') : ` ${tn('millisecond', 'milliseconds', value)}`;
return `${msValue}${suffix}`;
};
const result = convertDuration(seconds, abbreviation).trim();
if (result.length) {
return result;
}
return `0${abbreviation ? SUFFIX_ABBR[precision] : minSuffix}`;
}
export const SEC_IN_WK = 604800;
export const SEC_IN_DAY = 86400;
export const SEC_IN_HR = 3600;
export const SEC_IN_MIN = 60;
type Level = [lvlSfx: moment.unitOfTime.DurationConstructor, denominator: number];
type ParsedLargestSuffix = [val: number, suffix: moment.unitOfTime.DurationConstructor];
/**
* Given a length of time in seconds, provide me the largest divisible suffix and value for that time period.
* eg. 60 -> [1, 'minutes']
* eg. 7200 -> [2, 'hours']
* eg. 7260 -> [121, 'minutes']
*
* @param seconds
* @param maxSuffix determines the largest suffix we should pin the response to
*/
export function parseLargestSuffix(
seconds: number,
maxSuffix: string = 'days'
): ParsedLargestSuffix {
const levels: Level[] = [
['minutes', SEC_IN_MIN],
['hours', SEC_IN_HR],
['days', SEC_IN_DAY],
['weeks', SEC_IN_WK],
];
let val = seconds;
let suffix: moment.unitOfTime.DurationConstructor = 'seconds';
if (val === 0) {
return [val, suffix];
}
for (const [lvlSfx, denominator] of levels) {
if (seconds % denominator) {
break;
}
val = seconds / denominator;
suffix = lvlSfx;
if (lvlSfx === maxSuffix) {
break;
}
}
return [val, suffix];
}
export function formatSecondsToClock(
seconds: number,
{padAll}: {padAll: boolean} = {padAll: true}
) {
if (seconds === 0 || isNaN(seconds)) {
return padAll ? '00:00' : '0:00';
}
const divideBy = (msValue: number, time: number) => {
return {
quotient: msValue < 0 ? Math.ceil(msValue / time) : Math.floor(msValue / time),
remainder: msValue % time,
};
};
// value in milliseconds
const absMSValue = round(Math.abs(seconds * 1000));
const {quotient: hours, remainder: rMins} = divideBy(absMSValue, HOUR);
const {quotient: minutes, remainder: rSeconds} = divideBy(rMins, MINUTE);
const {quotient: secs, remainder: milliseconds} = divideBy(rSeconds, SECOND);
const fill = (num: number) => (num < 10 ? `0${num}` : String(num));
const parts = hours
? [padAll ? fill(hours) : hours, fill(minutes), fill(secs)]
: [padAll ? fill(minutes) : minutes, fill(secs)];
const ms = `000${milliseconds}`.slice(-3);
return milliseconds ? `${parts.join(':')}.${ms}` : parts.join(':');
}
export function parseClockToSeconds(clock: string) {
const [rest, milliseconds] = clock.split('.');
const parts = rest.split(':');
let seconds = 0;
const progression = [MONTH, WEEK, DAY, HOUR, MINUTE, SECOND].slice(parts.length * -1);
for (let i = 0; i < parts.length; i++) {
const num = Number(parts[i]) || 0;
const time = progression[i] / 1000;
seconds += num * time;
}
const ms = Number(milliseconds) || 0;
return seconds + ms / 1000;
}
export function formatFloat(number: number, places: number) {
const multi = Math.pow(10, places);
return parseInt((number * multi).toString(), 10) / multi;
}
/**
* Format a value between 0 and 1 as a percentage
*/
export function formatPercentage(value: number, places: number = 2) {
if (value === 0) {
return '0%';
}
return (
round(value * 100, places).toLocaleString(undefined, {
maximumFractionDigits: places,
}) + '%'
);
}
const numberFormats = [
[1000000000, 'b'],
[1000000, 'm'],
[1000, 'k'],
] as const;
/**
* Formats a number to a string with a suffix
*
* @param number the number to format
* @param precision the number of significant digits to include
*/
export function formatAbbreviatedNumber(
number: number | string,
precision?: number
): string {
number = Number(number);
let lookup: (typeof numberFormats)[number];
// eslint-disable-next-line no-cond-assign
for (let i = 0; (lookup = numberFormats[i]); i++) {
const [suffixNum, suffix] = lookup;
const shortValue = Math.floor(number / suffixNum);
const fitsBound = number % suffixNum;
if (shortValue <= 0) {
continue;
}
const formattedNumber =
shortValue / 10 > 1 || !fitsBound
? precision === undefined
? shortValue
: parseFloat(shortValue.toPrecision(precision)).toString()
: formatFloat(number / suffixNum, precision || 1).toLocaleString(undefined, {
maximumSignificantDigits: precision,
});
return `${formattedNumber}${suffix}`;
}
return number.toLocaleString(undefined, {maximumSignificantDigits: precision});
}
/**
* Rounds to 2 decimal digits without forcing trailing zeros
* Will preserve significant decimals for very small numbers
* e.g. 0.0001234 -> 0.00012
* @param value number to format
*/
export function formatNumberWithDynamicDecimalPoints(value: number): string {
if ([0, Infinity, -Infinity, NaN].includes(value)) {
return value.toLocaleString();
}
const exponent = Math.floor(Math.log10(Math.abs(value)));
const maxFractionDigits = exponent >= 0 ? 2 : Math.abs(exponent) + 1;
const numberFormat = {
maximumFractionDigits: maxFractionDigits,
minimumFractionDigits: 0,
};
return value.toLocaleString(undefined, numberFormat);
}
export function formatRate(
value: number,
unit: RateUnit = RateUnit.PER_SECOND,
options: {
minimumValue?: number;
significantDigits?: number;
} = {}
) {
// NOTE: `Intl` doesn't support unitless-per-unit formats (i.e.,
// `"-per-minute"` is not valid) so we have to concatenate the unit manually, since our rates are usually just "/min" or "/s".
// Because of this, the unit is not internationalized.
// 0 is special!
if (value === 0) {
return `${0}${RATE_UNIT_LABELS[unit]}`;
}
const minimumValue = options.minimumValue ?? 0;
const significantDigits = options.significantDigits ?? 3;
const numberFormatOptions: ConstructorParameters<typeof Intl.NumberFormat>[1] = {
notation: 'compact',
compactDisplay: 'short',
minimumSignificantDigits: significantDigits,
maximumSignificantDigits: significantDigits,
};
if (value <= minimumValue) {
return `<${minimumValue}${RATE_UNIT_LABELS[unit]}`;
}
return `${value.toLocaleString(undefined, numberFormatOptions)}${
RATE_UNIT_LABELS[unit]
}`;
}
export function formatSpanOperation(
operation?: string,
length: 'short' | 'long' = 'short'
) {
if (length === 'long') {
return getLongSpanOperationDescription(operation);
}
return getShortSpanOperationDescription(operation);
}
function getLongSpanOperationDescription(operation?: string) {
if (operation?.startsWith('http')) {
return t('URL request');
}
if (operation === 'db.redis') {
return t('cache query');
}
if (operation?.startsWith('db')) {
return t('database query');
}
if (operation?.startsWith('task')) {
return t('application task');
}
if (operation?.startsWith('serialize')) {
return t('serializer');
}
if (operation?.startsWith('middleware')) {
return t('middleware');
}
if (operation === 'resource') {
return t('resource');
}
if (operation === 'resource.script') {
return t('JavaScript file');
}
if (operation === 'resource.css') {
return t('stylesheet');
}
if (operation === 'resource.img') {
return t('image');
}
return t('span');
}
function getShortSpanOperationDescription(operation?: string) {
if (operation?.startsWith('http')) {
return t('request');
}
if (operation?.startsWith('db')) {
return t('query');
}
if (operation?.startsWith('task')) {
return t('task');
}
if (operation?.startsWith('serialize')) {
return t('serializer');
}
if (operation?.startsWith('middleware')) {
return t('middleware');
}
if (operation?.startsWith('resource')) {
return t('resource');
}
return t('span');
}