import {Fragment} from 'react'; import {browserHistory} from 'react-router'; import styled from '@emotion/styled'; import {Location} from 'history'; import partial from 'lodash/partial'; import Button from 'sentry/components/button'; import Count from 'sentry/components/count'; import DropdownMenuControl from 'sentry/components/dropdownMenuControl'; import {MenuItemProps} from 'sentry/components/dropdownMenuItem'; import Duration from 'sentry/components/duration'; import FileSize from 'sentry/components/fileSize'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import UserBadge from 'sentry/components/idBadge/userBadge'; import ExternalLink from 'sentry/components/links/externalLink'; import {RowRectangle} from 'sentry/components/performance/waterfall/rowBar'; import {pickBarColor, toPercent} from 'sentry/components/performance/waterfall/utils'; import Tooltip from 'sentry/components/tooltip'; import UserMisery from 'sentry/components/userMisery'; import Version from 'sentry/components/version'; import {IconDownload, IconPlay} from 'sentry/icons'; import {t} from 'sentry/locale'; import space from 'sentry/styles/space'; import {AvatarProject, IssueAttachment, Organization, Project} from 'sentry/types'; import {defined, isUrl} from 'sentry/utils'; import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent'; import EventView, {EventData, MetaType} from 'sentry/utils/discover/eventView'; import { AGGREGATIONS, getAggregateAlias, getSpanOperationName, isEquation, isRelativeSpanOperationBreakdownField, SPAN_OP_BREAKDOWN_FIELDS, SPAN_OP_RELATIVE_BREAKDOWN_FIELD, } from 'sentry/utils/discover/fields'; import {getShortEventId} from 'sentry/utils/events'; import {formatFloat, formatPercentage} from 'sentry/utils/formatters'; import getDynamicText from 'sentry/utils/getDynamicText'; import Projects from 'sentry/utils/projects'; import { ContextType, QuickContextHoverWrapper, } from 'sentry/views/eventsV2/table/quickContext'; import { filterToLocationQuery, SpanOperationBreakdownFilter, stringToFilter, } from 'sentry/views/performance/transactionSummary/filter'; import {decodeScalar} from '../queryString'; import ArrayValue from './arrayValue'; import { BarContainer, Container, FieldDateTime, FieldShortId, FlexContainer, NumberContainer, OverflowLink, UserIcon, VersionContainer, } from './styles'; import TeamKeyTransactionField from './teamKeyTransactionField'; /** * Types, functions and definitions for rendering fields in discover results. */ export type RenderFunctionBaggage = { location: Location; organization: Organization; eventView?: EventView; projectId?: string; unit?: string; }; type RenderFunctionOptions = { enableOnClick?: boolean; }; type FieldFormatterRenderFunction = ( field: string, data: EventData, baggage?: RenderFunctionBaggage ) => React.ReactNode; type FieldFormatterRenderFunctionPartial = ( data: EventData, baggage: RenderFunctionBaggage ) => React.ReactNode; type FieldFormatter = { isSortable: boolean; renderFunc: FieldFormatterRenderFunction; }; type FieldFormatters = { array: FieldFormatter; boolean: FieldFormatter; date: FieldFormatter; duration: FieldFormatter; integer: FieldFormatter; number: FieldFormatter; percentage: FieldFormatter; size: FieldFormatter; string: FieldFormatter; }; export type FieldTypes = keyof FieldFormatters; const EmptyValueContainer = styled('span')` color: ${p => p.theme.gray300}; `; const emptyValue = {t('(no value)')}; const emptyStringValue = {t('(empty string)')}; export function nullableValue(value: string | null): string | React.ReactElement { switch (value) { case null: return emptyValue; case '': return emptyStringValue; default: return value; } } export const SIZE_UNITS = { bit: 1 / 8, byte: 1, kibibyte: 1024, mebibyte: 1024 ** 2, gibibyte: 1024 ** 3, tebibyte: 1024 ** 4, pebibyte: 1024 ** 5, exbibyte: 1024 ** 6, kilobyte: 1000, megabyte: 1000 ** 2, gigabyte: 1000 ** 3, terabyte: 1000 ** 4, petabyte: 1000 ** 5, exabyte: 1000 ** 6, }; export const ABYTE_UNITS = [ 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte', 'exabyte', ]; export const DURATION_UNITS = { nanosecond: 1 / 1000 ** 2, microsecond: 1 / 1000, millisecond: 1, second: 1000, minute: 1000 * 60, hour: 1000 * 60 * 60, day: 1000 * 60 * 60 * 24, week: 1000 * 60 * 60 * 24 * 7, }; export const PERCENTAGE_UNITS = ['ratio', 'percent']; /** * A mapping of field types to their rendering function. * This mapping is used when a field is not defined in SPECIAL_FIELDS * and the field is not being coerced to a link. * * This mapping should match the output sentry.utils.snuba:get_json_type */ export const FIELD_FORMATTERS: FieldFormatters = { boolean: { isSortable: true, renderFunc: (field, data) => { const value = data[field] ? t('true') : t('false'); return {value}; }, }, date: { isSortable: true, renderFunc: (field, data, baggage) => ( {data[field] ? getDynamicText({ value: ( ), fixed: 'timestamp', }) : emptyValue} ), }, duration: { isSortable: true, renderFunc: (field, data, baggage) => { const {unit} = baggage ?? {}; return ( {typeof data[field] === 'number' ? ( ) : ( emptyValue )} ); }, }, integer: { isSortable: true, renderFunc: (field, data) => ( {typeof data[field] === 'number' ? : emptyValue} ), }, number: { isSortable: true, renderFunc: (field, data) => ( {typeof data[field] === 'number' ? formatFloat(data[field], 4) : emptyValue} ), }, percentage: { isSortable: true, renderFunc: (field, data) => ( {typeof data[field] === 'number' ? formatPercentage(data[field]) : emptyValue} ), }, size: { isSortable: true, renderFunc: (field, data, baggage) => { const {unit} = baggage ?? {}; return ( {unit && SIZE_UNITS[unit] && typeof data[field] === 'number' ? ( ) : ( emptyValue )} ); }, }, string: { isSortable: true, renderFunc: (field, data) => { // Some fields have long arrays in them, only show the tail of the data. const value = Array.isArray(data[field]) ? data[field].slice(-1) : defined(data[field]) ? data[field] : emptyValue; if (isUrl(value)) { return ( {value} ); } return {nullableValue(value)}; }, }, array: { isSortable: true, renderFunc: (field, data) => { const value = Array.isArray(data[field]) ? data[field] : [data[field]]; return ; }, }, }; type SpecialFieldRenderFunc = ( data: EventData, baggage: RenderFunctionBaggage ) => React.ReactNode; type SpecialField = { renderFunc: SpecialFieldRenderFunc; sortField: string | null; }; type SpecialFields = { attachments: SpecialField; 'count_unique(user)': SpecialField; 'error.handled': SpecialField; id: SpecialField; issue: SpecialField; 'issue.id': SpecialField; minidump: SpecialField; project: SpecialField; release: SpecialField; replayId: SpecialField; team_key_transaction: SpecialField; 'timestamp.to_day': SpecialField; 'timestamp.to_hour': SpecialField; trace: SpecialField; 'trend_percentage()': SpecialField; user: SpecialField; 'user.display': SpecialField; }; const DownloadCount = styled('span')` padding-left: ${space(0.75)}; `; const RightAlignedContainer = styled('span')` margin-left: auto; margin-right: 0; `; /** * "Special fields" either do not map 1:1 to an single column in the event database, * or they require custom UI formatting that can't be handled by the datatype formatters. */ const SPECIAL_FIELDS: SpecialFields = { // This is a custom renderer for a field outside discover // TODO - refactor code and remove from this file or add ability to query for attachments in Discover attachments: { sortField: null, renderFunc: (data, {organization, projectId}) => { const attachments: Array = data.attachments; const items: MenuItemProps[] = attachments .filter(attachment => attachment.type !== 'event.minidump') .map(attachment => ({ key: attachment.id, label: attachment.name, onAction: () => window.open( `/api/0/projects/${organization.slug}/${projectId}/events/${attachment.event_id}/attachments/${attachment.id}/?download=1` ), })); return ( {items.length} ), }} items={items} /> ); }, }, minidump: { sortField: null, renderFunc: (data, {organization, projectId}) => { const attachments: Array = data.attachments; const minidump = attachments.find( attachment => attachment.type === 'event.minidump' ); return ( ); }, }, id: { sortField: 'id', renderFunc: data => { const id: string | unknown = data?.id; if (typeof id !== 'string') { return null; } return {getShortEventId(id)}; }, }, trace: { sortField: 'trace', renderFunc: data => { const id: string | unknown = data?.trace; if (typeof id !== 'string') { return emptyValue; } return {getShortEventId(id)}; }, }, 'issue.id': { sortField: 'issue.id', renderFunc: (data, {organization}) => { const target = { pathname: `/organizations/${organization.slug}/issues/${data['issue.id']}/`, }; return ( {data['issue.id']} ); }, }, replayId: { sortField: 'replayId', renderFunc: data => { const replayId = data?.replayId; if (typeof replayId !== 'string' || !replayId) { return emptyValue; } return ( ); }, }, issue: { sortField: null, renderFunc: (data, {organization}) => { const issueID = data['issue.id']; if (!issueID) { return ( ); } const target = { pathname: `/organizations/${organization.slug}/issues/${issueID}/`, }; return ( {organization.features.includes('discover-quick-context') ? ( ) : ( )} ); }, }, project: { sortField: 'project', renderFunc: (data, {organization}) => { let slugs: string[] | undefined = undefined; let projectIds: number[] | undefined = undefined; if (typeof data.project === 'number') { projectIds = [data.project]; } else { slugs = [data.project]; } return ( {({projects}) => { let project: Project | AvatarProject | undefined; if (typeof data.project === 'number') { project = projects.find(p => p.id === data.project.toString()); } else { project = projects.find(p => p.slug === data.project); } return ( ); }} ); }, }, user: { sortField: 'user', renderFunc: data => { if (data.user) { const [key, value] = data.user.split(':'); const userObj = { id: '', name: '', email: '', username: '', ip_address: '', }; userObj[key] = value; const badge = ; return {badge}; } return {emptyValue}; }, }, 'user.display': { sortField: 'user.display', renderFunc: data => { if (data['user.display']) { const userObj = { id: '', name: data['user.display'], email: '', username: '', ip_address: '', }; const badge = ; return {badge}; } return {emptyValue}; }, }, 'count_unique(user)': { sortField: 'count_unique(user)', renderFunc: data => { const count = data.count_unique_user ?? data['count_unique(user)']; if (typeof count === 'number') { return ( ); } return {emptyValue}; }, }, release: { sortField: 'release', renderFunc: data => data.release ? ( ) : ( {emptyValue} ), }, 'error.handled': { sortField: 'error.handled', renderFunc: data => { const values = data['error.handled']; // Transactions will have null, and default events have no handled attributes. if (values === null || values?.length === 0) { return {emptyValue}; } const value = Array.isArray(values) ? values : [values]; return ( {value.every(v => [1, null].includes(v)) ? 'true' : 'false'} ); }, }, team_key_transaction: { sortField: null, renderFunc: (data, {organization}) => ( ), }, 'trend_percentage()': { sortField: 'trend_percentage()', renderFunc: data => ( {typeof data.trend_percentage === 'number' ? formatPercentage(data.trend_percentage - 1) : emptyValue} ), }, 'timestamp.to_hour': { sortField: 'timestamp.to_hour', renderFunc: data => ( {getDynamicText({ value: , fixed: 'timestamp.to_hour', })} ), }, 'timestamp.to_day': { sortField: 'timestamp.to_day', renderFunc: data => ( {getDynamicText({ value: , fixed: 'timestamp.to_day', })} ), }, }; type SpecialFunctionFieldRenderer = ( fieldName: string ) => (data: EventData, baggage: RenderFunctionBaggage) => React.ReactNode; type SpecialFunctions = { user_misery: SpecialFunctionFieldRenderer; }; /** * "Special functions" are functions whose values either do not map 1:1 to a single column, * or they require custom UI formatting that can't be handled by the datatype formatters. */ const SPECIAL_FUNCTIONS: SpecialFunctions = { user_misery: fieldName => data => { const userMiseryField = fieldName; if (!(userMiseryField in data)) { return {emptyValue}; } const userMisery = data[userMiseryField]; if (userMisery === null || isNaN(userMisery)) { return {emptyValue}; } const projectThresholdConfig = 'project_threshold_config'; let countMiserableUserField: string = ''; let miseryLimit: number | undefined = parseInt( userMiseryField.split('(').pop()?.slice(0, -1) || '', 10 ); if (isNaN(miseryLimit)) { countMiserableUserField = 'count_miserable(user)'; if (projectThresholdConfig in data) { miseryLimit = data[projectThresholdConfig][1]; } else { miseryLimit = undefined; } } else { countMiserableUserField = `count_miserable(user,${miseryLimit})`; } const uniqueUsers = data['count_unique(user)']; let miserableUsers: number | undefined; if (countMiserableUserField in data) { const countMiserableMiseryLimit = parseInt( userMiseryField.split('(').pop()?.slice(0, -1) || '', 10 ); miserableUsers = countMiserableMiseryLimit === miseryLimit || (isNaN(countMiserableMiseryLimit) && projectThresholdConfig) ? data[countMiserableUserField] : undefined; } return ( ); }, }; /** * Get the sort field name for a given field if it is special or fallback * to the generic type formatter. */ export function getSortField( field: string, tableMeta: MetaType | undefined ): string | null { if (SPECIAL_FIELDS.hasOwnProperty(field)) { return SPECIAL_FIELDS[field as keyof typeof SPECIAL_FIELDS].sortField; } if (!tableMeta) { return field; } if (isEquation(field)) { return field; } for (const alias in AGGREGATIONS) { if (field.startsWith(alias)) { return AGGREGATIONS[alias].isSortable ? field : null; } } const fieldType = tableMeta[field]; if (FIELD_FORMATTERS.hasOwnProperty(fieldType)) { return FIELD_FORMATTERS[fieldType as keyof typeof FIELD_FORMATTERS].isSortable ? field : null; } return null; } const isDurationValue = (data: EventData, field: string): boolean => { return field in data && typeof data[field] === 'number'; }; export const spanOperationRelativeBreakdownRenderer = ( data: EventData, {location, organization, eventView}: RenderFunctionBaggage, options?: RenderFunctionOptions ): React.ReactNode => { const {enableOnClick = true} = options ?? {}; const sumOfSpanTime = SPAN_OP_BREAKDOWN_FIELDS.reduce( (prev, curr) => (isDurationValue(data, curr) ? prev + data[curr] : prev), 0 ); const cumulativeSpanOpBreakdown = Math.max(sumOfSpanTime, data['transaction.duration']); if ( SPAN_OP_BREAKDOWN_FIELDS.every(field => !isDurationValue(data, field)) || cumulativeSpanOpBreakdown === 0 ) { return FIELD_FORMATTERS.duration.renderFunc(SPAN_OP_RELATIVE_BREAKDOWN_FIELD, data); } let otherPercentage = 1; let orderedSpanOpsBreakdownFields; const sortingOnField = eventView?.sorts?.[0]?.field; if (sortingOnField && (SPAN_OP_BREAKDOWN_FIELDS as string[]).includes(sortingOnField)) { orderedSpanOpsBreakdownFields = [ sortingOnField, ...SPAN_OP_BREAKDOWN_FIELDS.filter(op => op !== sortingOnField), ]; } else { orderedSpanOpsBreakdownFields = SPAN_OP_BREAKDOWN_FIELDS; } return ( {orderedSpanOpsBreakdownFields.map(field => { if (!isDurationValue(data, field)) { return null; } const operationName = getSpanOperationName(field) ?? 'op'; const spanOpDuration: number = data[field]; const widthPercentage = spanOpDuration / cumulativeSpanOpBreakdown; otherPercentage = otherPercentage - widthPercentage; if (widthPercentage === 0) { return null; } return (
{operationName}
} containerDisplayMode="block" > { if (!enableOnClick) { return; } event.stopPropagation(); const filter = stringToFilter(operationName); if (filter === SpanOperationBreakdownFilter.None) { return; } trackAdvancedAnalyticsEvent( 'performance_views.relative_breakdown.selection', { action: filter, organization, } ); browserHistory.push({ pathname: location.pathname, query: { ...location.query, ...filterToLocationQuery(filter), }, }); }} /> ); })}
{t('Other')}
} containerDisplayMode="block">
); }; const RelativeOpsBreakdown = styled('div')` position: relative; display: flex; `; const RectangleRelativeOpsBreakdown = styled(RowRectangle)` position: relative; width: 100%; `; const OtherRelativeOpsBreakdown = styled(RectangleRelativeOpsBreakdown)` background-color: ${p => p.theme.gray100}; `; /** * Get the field renderer for the named field and metadata * * @param {String} field name * @param {object} metadata mapping. * @param {boolean} isAlias convert the name with getAggregateAlias * @returns {Function} */ export function getFieldRenderer( field: string, meta: MetaType, isAlias: boolean = true ): FieldFormatterRenderFunctionPartial { if (SPECIAL_FIELDS.hasOwnProperty(field)) { return SPECIAL_FIELDS[field].renderFunc; } if (isRelativeSpanOperationBreakdownField(field)) { return spanOperationRelativeBreakdownRenderer; } const fieldName = isAlias ? getAggregateAlias(field) : field; const fieldType = meta[fieldName]; for (const alias in SPECIAL_FUNCTIONS) { if (fieldName.startsWith(alias)) { return SPECIAL_FUNCTIONS[alias](fieldName); } } if (FIELD_FORMATTERS.hasOwnProperty(fieldType)) { return partial(FIELD_FORMATTERS[fieldType].renderFunc, fieldName); } return partial(FIELD_FORMATTERS.string.renderFunc, fieldName); } type FieldTypeFormatterRenderFunctionPartial = ( data: EventData, baggage?: RenderFunctionBaggage ) => React.ReactNode; /** * Get the field renderer for the named field only based on its type from the given * metadata. * * @param {String} field name * @param {object} metadata mapping. * @param {boolean} isAlias convert the name with getAggregateAlias * @returns {Function} */ export function getFieldFormatter( field: string, meta: MetaType, isAlias: boolean = true ): FieldTypeFormatterRenderFunctionPartial { const fieldName = isAlias ? getAggregateAlias(field) : field; const fieldType = meta[fieldName]; if (FIELD_FORMATTERS.hasOwnProperty(fieldType)) { return partial(FIELD_FORMATTERS[fieldType].renderFunc, fieldName); } return partial(FIELD_FORMATTERS.string.renderFunc, fieldName); }