import {InjectedRouter} from 'react-router'; import round from 'lodash/round'; import moment from 'moment'; import * as qs from 'query-string'; import { DateTimeObject, getDiffInMinutes, getInterval, } from 'sentry/components/charts/utils'; import {t} from 'sentry/locale'; import {MetricsApiResponse} from 'sentry/types'; import { MetricMeta, MetricsApiRequestMetric, MetricsApiRequestQuery, MetricsGroup, MetricType, MRI, UseCase, } from 'sentry/types/metrics'; import {defined, formatBytesBase2, formatBytesBase10} from 'sentry/utils'; import {formatPercentage, getDuration} from 'sentry/utils/formatters'; import {formatMRI, getUseCaseFromMRI, parseField} from 'sentry/utils/metrics/mri'; import {DateString, PageFilters} from '../../types/core'; export enum MetricDisplayType { LINE = 'line', AREA = 'area', BAR = 'bar', TABLE = 'table', } export const defaultMetricDisplayType = MetricDisplayType.LINE; export const getMetricDisplayType = (displayType: unknown): MetricDisplayType => { if ( [ MetricDisplayType.AREA, MetricDisplayType.BAR, MetricDisplayType.LINE, MetricDisplayType.TABLE, ].includes(displayType as MetricDisplayType) ) { return displayType as MetricDisplayType; } return MetricDisplayType.LINE; }; export type MetricTag = { key: string; }; export type SortState = { name: 'name' | 'avg' | 'min' | 'max' | 'sum' | undefined; order: 'asc' | 'desc'; }; export interface MetricWidgetQueryParams extends Pick { displayType: MetricDisplayType; focusedSeries?: string; position?: number; powerUserMode?: boolean; showSummaryTable?: boolean; sort?: SortState; } export interface DdmQueryParams { widgets: string; // stringified json representation of MetricWidgetQueryParams end?: DateString; environment?: string[]; project?: number[]; start?: DateString; statsPeriod?: string | null; utc?: boolean | null; } export type MetricsQuery = { datetime: PageFilters['datetime']; environments: PageFilters['environments']; mri: MRI; projects: PageFilters['projects']; groupBy?: string[]; op?: string; query?: string; }; export type MetricMetaCodeLocation = { frames: { absPath?: string; filename?: string; function?: string; lineNo?: number; module?: string; }[]; mri: string; timestamp: number; }; export function getDdmUrl( orgSlug: string, { widgets, start, end, statsPeriod, project, ...otherParams }: Omit & { widgets: MetricWidgetQueryParams[]; project?: (string | number)[]; } ) { const urlParams: Partial = { ...otherParams, project: project?.map(id => (typeof id === 'string' ? parseInt(id, 10) : id)), widgets: JSON.stringify(widgets), }; if (statsPeriod) { urlParams.statsPeriod = statsPeriod; } else { urlParams.start = start; urlParams.end = end; } return `/organizations/${orgSlug}/ddm/?${qs.stringify(urlParams)}`; } export function getMetricsApiRequestQuery( {field, query, groupBy}: MetricsApiRequestMetric, {projects, environments, datetime}: PageFilters, overrides: Partial ): MetricsApiRequestQuery { const {mri: mri} = parseField(field) ?? {}; const useCase = getUseCaseFromMRI(mri) ?? 'custom'; const interval = getMetricsInterval(datetime, useCase); const queryToSend = { ...getDateTimeParams(datetime), query, project: projects, environment: environments, field, useCase, interval, groupBy, allowPrivate: true, // TODO(ddm): reconsider before widening audience // max result groups per_page: 10, }; return {...queryToSend, ...overrides}; } // Wraps getInterval since other users of this function, and other metric use cases do not have support for 10s granularity export function getMetricsInterval(dateTimeObj: DateTimeObject, useCase: UseCase) { const interval = getInterval(dateTimeObj, 'metrics'); if (interval !== '1m') { return interval; } const diffInMinutes = getDiffInMinutes(dateTimeObj); if (diffInMinutes <= 60 && useCase === 'custom') { return '10s'; } return interval; } export function getDateTimeParams({start, end, period}: PageFilters['datetime']) { return period ? {statsPeriod: period} : {start: moment(start).toISOString(), end: moment(end).toISOString()}; } const metricTypeToReadable: Record = { c: t('counter'), g: t('gauge'), d: t('distribution'), s: t('set'), e: t('derived'), }; // Converts from "c" to "counter" export function getReadableMetricType(type?: string) { return metricTypeToReadable[type as MetricType] ?? t('unknown'); } // The metric units that we have support for in the UI // others will still be displayed, but will not have any effect on formatting export const formattingSupportedMetricUnits = [ 'none', 'nanosecond', 'microsecond', 'millisecond', 'second', 'minute', 'hour', 'day', 'week', 'ratio', 'percent', 'bit', 'byte', 'kibibyte', 'kilobyte', 'mebibyte', 'megabyte', 'gibibyte', 'gigabyte', 'tebibyte', 'terabyte', 'pebibyte', 'petabyte', 'exbibyte', 'exabyte', ] as const; type FormattingSupportedMetricUnit = (typeof formattingSupportedMetricUnits)[number]; export function formatMetricUsingUnit(value: number | null, unit: string) { if (!defined(value)) { return '\u2014'; } switch (unit as FormattingSupportedMetricUnit) { case 'nanosecond': return getDuration(value / 1000000000, 2, true); case 'microsecond': return getDuration(value / 1000000, 2, true); case 'millisecond': return getDuration(value / 1000, 2, true); case 'second': return getDuration(value, 2, true); case 'minute': return getDuration(value * 60, 2, true); case 'hour': return getDuration(value * 60 * 60, 2, true); case 'day': return getDuration(value * 60 * 60 * 24, 2, true); case 'week': return getDuration(value * 60 * 60 * 24 * 7, 2, true); case 'ratio': return formatPercentage(value, 2); case 'percent': return formatPercentage(value / 100, 2); case 'bit': return formatBytesBase2(value / 8); case 'byte': return formatBytesBase10(value); case 'kibibyte': return formatBytesBase2(value * 1024); case 'kilobyte': return formatBytesBase10(value, 1); case 'mebibyte': return formatBytesBase2(value * 1024 ** 2); case 'megabyte': return formatBytesBase10(value, 2); case 'gibibyte': return formatBytesBase2(value * 1024 ** 3); case 'gigabyte': return formatBytesBase10(value, 3); case 'tebibyte': return formatBytesBase2(value * 1024 ** 4); case 'terabyte': return formatBytesBase10(value, 4); case 'pebibyte': return formatBytesBase2(value * 1024 ** 5); case 'petabyte': return formatBytesBase10(value, 5); case 'exbibyte': return formatBytesBase2(value * 1024 ** 6); case 'exabyte': return formatBytesBase10(value, 6); case 'none': default: return value.toLocaleString(); } } const METRIC_UNIT_TO_SHORT: Record = { nanosecond: 'ns', microsecond: 'μs', millisecond: 'ms', second: 's', minute: 'min', hour: 'hr', day: 'day', week: 'wk', ratio: '%', percent: '%', bit: 'b', byte: 'B', kibibyte: 'KiB', kilobyte: 'KB', mebibyte: 'MiB', megabyte: 'MB', gibibyte: 'GiB', gigabyte: 'GB', tebibyte: 'TiB', terabyte: 'TB', pebibyte: 'PiB', petabyte: 'PB', exbibyte: 'EiB', exabyte: 'EB', none: '', }; const getShortMetricUnit = (unit: string): string => METRIC_UNIT_TO_SHORT[unit] ?? ''; export function formatMetricUsingFixedUnit(value: number | null, unit: string) { return value !== null ? `${round(value, 3).toLocaleString()}${getShortMetricUnit(unit)}`.trim() : '\u2015'; } export function formatMetricsUsingUnitAndOp( value: number | null, unit: string, operation?: string ) { if (operation === 'count') { // if the operation is count, we want to ignore the unit and always format the value as a number return value?.toLocaleString() ?? ''; } return formatMetricUsingUnit(value, unit); } export function isAllowedOp(op: string) { return !['max_timestamp', 'min_timestamp', 'histogram'].includes(op); } export function updateQuery(router: InjectedRouter, partialQuery: Record) { router.push({ ...router.location, query: { ...router.location.query, ...partialQuery, }, }); } export function clearQuery(router: InjectedRouter) { router.push({ ...router.location, query: {}, }); } // TODO(ddm): there has to be a nicer way to do this export function getSeriesName( group: MetricsGroup, isOnlyGroup = false, groupBy: MetricsQuery['groupBy'] ) { if (isOnlyGroup && !groupBy?.length) { const field = Object.keys(group.series)?.[0]; const {mri} = parseField(field) ?? {mri: field}; const name = formatMRI(mri as MRI); return name ?? '(none)'; } return Object.entries(group.by) .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`) .join(', '); } export function groupByOp(metrics: MetricMeta[]): Record { const uniqueOperations = [ ...new Set(metrics.flatMap(field => field.operations).filter(isAllowedOp)), ].sort(); const groupedByOp = uniqueOperations.reduce((result, op) => { result[op] = metrics.filter(field => field.operations.includes(op)); return result; }, {}); return groupedByOp; } // TODO(ddm): remove this and all of its usages once backend sends mri fields export function mapToMRIFields( data: MetricsApiResponse | undefined, fields: string[] ): void { if (!data) { return; } data.groups.forEach(group => { group.series = swapObjectKeys(group.series, fields); group.totals = swapObjectKeys(group.totals, fields); }); } function swapObjectKeys(obj: Record | undefined, newKeys: string[]) { if (!obj) { return {}; } return Object.keys(obj).reduce((acc, key, index) => { acc[newKeys[index]] = obj[key]; return acc; }, {}); }