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 (
}
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);
}