diff --git a/static/app/utils/dashboards/issueFieldRenderers.tsx b/static/app/utils/dashboards/issueFieldRenderers.tsx index 72777ce29891a9..05f282db9dc36a 100644 --- a/static/app/utils/dashboards/issueFieldRenderers.tsx +++ b/static/app/utils/dashboards/issueFieldRenderers.tsx @@ -21,6 +21,8 @@ import {Container, FieldShortId, OverflowLink} from 'sentry/utils/discover/style import {SavedQueryDatasets} from 'sentry/utils/discover/types'; import {hasDatasetSelector} from 'sentry/views/dashboards/utils'; import {FieldKey} from 'sentry/views/dashboards/widgetBuilder/issueWidget/fields'; +import {QuickContextHoverWrapper} from 'sentry/views/discover/table/quickContext/quickContextWrapper'; +import {ContextType} from 'sentry/views/discover/table/quickContext/utils'; /** * Types, functions and definitions for rendering fields in discover results. @@ -79,9 +81,15 @@ const SPECIAL_FIELDS: SpecialFields = { return ( - - - + + + + + ); }, diff --git a/static/app/utils/discover/eventView.spec.tsx b/static/app/utils/discover/eventView.spec.tsx index 6d708f34e60996..4bb5199845c51d 100644 --- a/static/app/utils/discover/eventView.spec.tsx +++ b/static/app/utils/discover/eventView.spec.tsx @@ -1184,7 +1184,7 @@ describe('EventView.generateQueryStringObject()', function () { name: 'best query', fields: [ {field: 'count()', width: 123}, - {field: 'project.id', width: 456}, + {field: 'issue', width: 456}, ], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', @@ -1203,7 +1203,7 @@ describe('EventView.generateQueryStringObject()', function () { const expected = { id: '1234', name: 'best query', - field: ['count()', 'project.id'], + field: ['count()', 'issue'], widths: ['123', '456'], sort: '-count', query: 'event.type:error', @@ -1659,7 +1659,7 @@ describe('EventView.toNewQuery()', function () { name: 'best query', fields: [ {field: 'count()', width: 123}, - {field: 'project.id', width: 456}, + {field: 'issue', width: 456}, ], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', @@ -1681,7 +1681,7 @@ describe('EventView.toNewQuery()', function () { version: 2, id: '1234', name: 'best query', - fields: ['count()', 'project.id'], + fields: ['count()', 'issue'], widths: ['123', '456'], orderby: '-count', query: 'event.type:error', @@ -1713,7 +1713,7 @@ describe('EventView.toNewQuery()', function () { version: 2, id: '1234', name: 'best query', - fields: ['count()', 'project.id'], + fields: ['count()', 'issue'], widths: ['123', '456'], orderby: '-count', projects: [42], @@ -1744,7 +1744,7 @@ describe('EventView.toNewQuery()', function () { version: 2, id: '1234', name: 'best query', - fields: ['count()', 'project.id'], + fields: ['count()', 'issue'], widths: ['123', '456'], orderby: '-count', projects: [42], @@ -1765,7 +1765,7 @@ describe('EventView.isValid()', function () { it('event view is valid when there is at least one field', function () { const eventView = new EventView({ ...REQUIRED_CONSTRUCTOR_PROPS, - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: [], project: [], }); @@ -1791,7 +1791,7 @@ describe('EventView.getWidths()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, fields: [ {field: 'count()', width: COL_WIDTH_UNDEFINED}, - {field: 'project.id', width: 2020}, + {field: 'issue', width: 2020}, {field: 'title', width: COL_WIDTH_UNDEFINED}, {field: 'time', width: 420}, {field: 'lcp', width: 69}, @@ -1817,12 +1817,12 @@ describe('EventView.getFields()', function () { it('returns fields', function () { const eventView = new EventView({ ...REQUIRED_CONSTRUCTOR_PROPS, - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: [], project: [], }); - expect(eventView.getFields()).toEqual(['count()', 'project.id']); + expect(eventView.getFields()).toEqual(['count()', 'issue']); }); }); @@ -1832,7 +1832,7 @@ describe('EventView.numOfColumns()', function () { const eventView = new EventView({ ...REQUIRED_CONSTRUCTOR_PROPS, - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: [], project: [], }); @@ -1893,7 +1893,7 @@ describe('EventView.clone()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -1928,7 +1928,7 @@ describe('EventView.withColumns()', function () { name: 'best query', fields: [ {field: 'count()', width: 30}, - {field: 'project.id', width: 99}, + {field: 'issue', width: 99}, {field: 'failure_count()', width: 30}, ], yAxis: 'failure_count()', @@ -1946,7 +1946,7 @@ describe('EventView.withColumns()', function () { const newView = eventView.withColumns([ {kind: 'field', field: 'title'}, {kind: 'function', function: [AggregationKey.COUNT, '', undefined, undefined]}, - {kind: 'field', field: 'project.id'}, + {kind: 'field', field: 'issue'}, {kind: 'field', field: 'culprit'}, ]); // Views should be different. @@ -1954,7 +1954,7 @@ describe('EventView.withColumns()', function () { expect(newView.fields).toEqual([ {field: 'title', width: COL_WIDTH_UNDEFINED}, {field: 'count()', width: COL_WIDTH_UNDEFINED}, - {field: 'project.id', width: COL_WIDTH_UNDEFINED}, + {field: 'issue', width: COL_WIDTH_UNDEFINED}, {field: 'culprit', width: COL_WIDTH_UNDEFINED}, ]); }); @@ -1976,14 +1976,14 @@ describe('EventView.withColumns()', function () { it('inherits widths from existing columns when names match', function () { const newView = eventView.withColumns([ {kind: 'function', function: [AggregationKey.COUNT, '', undefined, undefined]}, - {kind: 'field', field: 'project.id'}, + {kind: 'field', field: 'issue'}, {kind: 'field', field: 'title'}, {kind: 'field', field: 'time'}, ]); expect(newView.fields).toEqual([ {field: 'count()', width: 30}, - {field: 'project.id', width: 99}, + {field: 'issue', width: 99}, {field: 'title', width: COL_WIDTH_UNDEFINED}, {field: 'time', width: COL_WIDTH_UNDEFINED}, ]); @@ -2016,12 +2016,12 @@ describe('EventView.withColumns()', function () { it('updates yAxis if column is dropped', function () { const newView = eventView.withColumns([ {kind: 'field', field: 'count()'}, - {kind: 'field', field: 'project.id'}, + {kind: 'field', field: 'issue'}, ]); expect(newView.fields).toEqual([ {field: 'count()', width: 30}, - {field: 'project.id', width: 99}, + {field: 'issue', width: 99}, ]); expect(eventView.yAxis).toBe('failure_count()'); @@ -2036,7 +2036,7 @@ describe('EventView.withNewColumn()', function () { name: 'best query', fields: [ {field: 'count()', width: 30}, - {field: 'project.id', width: 99}, + {field: 'issue', width: 99}, ], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', @@ -2118,7 +2118,7 @@ describe('EventView.withResizedColumn()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2146,7 +2146,7 @@ describe('EventView.withUpdatedColumn()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2335,7 +2335,7 @@ describe('EventView.withUpdatedColumn()', function () { // this column is expected to be non-sortable const newColumn: Column = { kind: 'field', - field: 'project.id', + field: 'issue', }; const eventView2 = eventView.withUpdatedColumn(0, newColumn, meta); @@ -2347,7 +2347,7 @@ describe('EventView.withUpdatedColumn()', function () { const nextState = { ...state, sorts: [{field: 'title', kind: 'desc'}], - fields: [{field: 'project.id'}, {field: 'title'}], + fields: [{field: 'issue'}, {field: 'title'}], }; expect(eventView2).toMatchObject(nextState); @@ -2364,7 +2364,7 @@ describe('EventView.withUpdatedColumn()', function () { // this column is expected to be non-sortable const newColumn: Column = { kind: 'field', - field: 'project.id', + field: 'issue', }; const eventView2 = eventView.withUpdatedColumn(0, newColumn, meta); @@ -2376,7 +2376,7 @@ describe('EventView.withUpdatedColumn()', function () { const nextState = { ...state, sorts: [], - fields: [{field: 'project.id'}], + fields: [{field: 'issue'}], }; expect(eventView2).toMatchObject(nextState); @@ -2389,7 +2389,7 @@ describe('EventView.withDeletedColumn()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2454,7 +2454,7 @@ describe('EventView.withDeletedColumn()', function () { const nextState = { ...state, - // we expect sorts to be empty since project.id is non-sortable + // we expect sorts to be empty since issue is non-sortable sorts: [], fields: [state.fields[1]], }; @@ -2465,7 +2465,7 @@ describe('EventView.withDeletedColumn()', function () { it('has a remaining sortable column', function () { const modifiedState: ConstructorParameters[0] = { ...state, - fields: [{field: 'count()'}, {field: 'project.id'}, {field: 'title'}], + fields: [{field: 'count()'}, {field: 'issue'}, {field: 'title'}], }; const eventView = new EventView(modifiedState); @@ -2478,7 +2478,7 @@ describe('EventView.withDeletedColumn()', function () { const nextState = { ...state, sorts: [{field: 'title', kind: 'desc'}], - fields: [{field: 'project.id'}, {field: 'title'}], + fields: [{field: 'issue'}, {field: 'title'}], }; expect(eventView2).toMatchObject(nextState); @@ -2539,7 +2539,7 @@ describe('EventView.getSorts()', function () { it('returns fields', function () { const eventView = new EventView({ ...REQUIRED_CONSTRUCTOR_PROPS, - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), project: [], }); @@ -2611,7 +2611,7 @@ describe('EventView.sortForField()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2638,7 +2638,7 @@ describe('EventView.sortForField()', function () { it('returns undefined when selected field is not sorted', function () { const field = { - field: 'project.id', + field: 'issue', }; expect(eventView.sortForField(field, meta)).toBeUndefined(); @@ -2646,7 +2646,7 @@ describe('EventView.sortForField()', function () { it('returns undefined when no meta is provided', function () { const field = { - field: 'project.id', + field: 'issue', }; expect(eventView.sortForField(field, undefined)).toBeUndefined(); @@ -2658,7 +2658,7 @@ describe('EventView.sortOnField()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2839,7 +2839,7 @@ describe('EventView.isEqualTo()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2870,7 +2870,7 @@ describe('EventView.isEqualTo()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2894,7 +2894,7 @@ describe('EventView.isEqualTo()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2910,7 +2910,7 @@ describe('EventView.isEqualTo()', function () { const differences = { id: '12', name: 'new query', - fields: [{field: 'project.id'}, {field: 'count()'}], + fields: [{field: 'issue'}, {field: 'count()'}], sorts: [{field: AggregationKey.COUNT, kind: 'asc'}], query: 'event.type:transaction', project: [24], @@ -2938,7 +2938,7 @@ describe('EventView.isEqualTo()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -2977,7 +2977,7 @@ describe('EventView.getResultsViewUrlTarget()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -3033,7 +3033,7 @@ describe('EventView.getResultsViewShortUrlTarget()', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -3098,7 +3098,7 @@ describe('EventView.getPerformanceTransactionEventsViewUrlTarget()', function () ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -3564,7 +3564,7 @@ describe('isAPIPayloadSimilar', function () { ...REQUIRED_CONSTRUCTOR_PROPS, id: '1234', name: 'best query', - fields: [{field: 'count()'}, {field: 'project.id'}], + fields: [{field: 'count()'}, {field: 'issue'}], sorts: generateSorts([AggregationKey.COUNT]), query: 'event.type:error', project: [42], @@ -3759,7 +3759,7 @@ describe('isAPIPayloadSimilar', function () { const equationField = {field: 'equation|failure_count() / count()'}; const otherEquationField = {field: 'equation|failure_count() / 2'}; state.fields = [ - {field: 'project.id'}, + {field: 'issue'}, {field: 'count()'}, equationField, otherEquationField, @@ -3770,7 +3770,7 @@ describe('isAPIPayloadSimilar', function () { state.fields = [ equationField, - {field: 'project.id'}, + {field: 'issue'}, {field: 'count()'}, otherEquationField, ]; @@ -3787,7 +3787,7 @@ describe('isAPIPayloadSimilar', function () { const equationField = {field: 'equation|failure_count() / count()'}; const otherEquationField = {field: 'equation|failure_count() / 2'}; state.fields = [ - {field: 'project.id'}, + {field: 'issue'}, {field: 'count()'}, equationField, otherEquationField, @@ -3797,7 +3797,7 @@ describe('isAPIPayloadSimilar', function () { const thisAPIPayload = thisEventView.getEventsAPIPayload(location); state.fields = [ - {field: 'project.id'}, + {field: 'issue'}, {field: 'count()'}, otherEquationField, equationField, diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index 824cc372299bee..7ab9251036d568 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -13,6 +13,7 @@ import {deviceNameMapper} from 'sentry/components/deviceName'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import Duration from 'sentry/components/duration'; +import {ContextIcon} from 'sentry/components/events/contexts/contextIcon'; import FileSize from 'sentry/components/fileSize'; import BadgeDisplayName from 'sentry/components/idBadge/badgeDisplayName'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; @@ -45,6 +46,7 @@ import { SPAN_OP_BREAKDOWN_FIELDS, SPAN_OP_RELATIVE_BREAKDOWN_FIELD, } from 'sentry/utils/discover/fields'; +import ViewReplayLink from 'sentry/utils/discover/viewReplayLink'; import {getShortEventId} from 'sentry/utils/events'; import {formatRate} from 'sentry/utils/formatters'; import getDynamicText from 'sentry/utils/getDynamicText'; @@ -60,15 +62,18 @@ import {ContextType} from 'sentry/views/discover/table/quickContext/utils'; import {PerformanceBadge} from 'sentry/views/insights/browser/webVitals/components/performanceBadge'; import {PercentChangeCell} from 'sentry/views/insights/common/components/tableCells/percentChangeCell'; import {ResponseStatusCodeCell} from 'sentry/views/insights/common/components/tableCells/responseStatusCodeCell'; +import {SpanDescriptionCell} from 'sentry/views/insights/common/components/tableCells/spanDescriptionCell'; import {StarredSegmentCell} from 'sentry/views/insights/common/components/tableCells/starredSegmentCell'; import {TimeSpentCell} from 'sentry/views/insights/common/components/tableCells/timeSpentCell'; -import {SpanFields, SpanMetricsField} from 'sentry/views/insights/types'; +import {ModuleName, SpanFields, SpanMetricsField} from 'sentry/views/insights/types'; import { filterToLocationQuery, SpanOperationBreakdownFilter, stringToFilter, } from 'sentry/views/performance/transactionSummary/filter'; +import {makeProjectsPathname} from 'sentry/views/projects/pathname'; import {ADOPTION_STAGE_LABELS} from 'sentry/views/releases/utils'; +import {makeReplaysPathname} from 'sentry/views/replays/pathnames'; import ArrayValue from './arrayValue'; import { @@ -77,6 +82,7 @@ import { FieldDateTime, FieldShortId, FlexContainer, + IconContainer, NumberContainer, OverflowFieldShortId, OverflowLink, @@ -134,7 +140,9 @@ const EmptyValueContainer = styled('span')` color: ${p => p.theme.subText}; `; const emptyValue = {t('(no value)')}; -const emptyStringValue = {t('(empty string)')}; +export const emptyStringValue = ( + {t('(empty string)')} +); const missingUserMisery = tct( 'We were unable to calculate User Misery. A likely cause of this is that the user was not set. [link:Read the docs]', { @@ -143,6 +151,9 @@ const missingUserMisery = tct( ), } ); +const userAgentLocking = t( + 'This operating system does not provide detailed version information in the User-Agent HTTP header. The exact operating system version is unknown.' +); export function nullableValue(value: string | null): string | React.ReactElement { switch (value) { @@ -367,35 +378,6 @@ type SpecialField = { sortField: string | null; }; -type SpecialFields = { - adoption_stage: SpecialField; - 'apdex()': SpecialField; - attachments: SpecialField; - 'count_unique(user)': SpecialField; - device: SpecialField; - 'error.handled': SpecialField; - id: SpecialField; - [SpanFields.IS_STARRED_TRANSACTION]: SpecialField; - issue: SpecialField; - 'issue.id': SpecialField; - minidump: SpecialField; - 'performance_score(measurements.score.total)': SpecialField; - 'profile.id': SpecialField; - project: SpecialField; - release: SpecialField; - replayId: SpecialField; - 'span.description': SpecialField; - 'span.status_code': SpecialField; - span_id: 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)}; `; @@ -409,7 +391,7 @@ const RightAlignedContainer = styled('span')` * "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 = { +const SPECIAL_FIELDS: Record = { // 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 'apdex()': { @@ -496,9 +478,8 @@ const SPECIAL_FIELDS: SpecialFields = { renderFunc: data => { const id: string | unknown = data?.id; if (typeof id !== 'string') { - return null; + return {emptyStringValue}; } - return {getShortEventId(id)}; }, }, @@ -516,7 +497,24 @@ const SPECIAL_FIELDS: SpecialFields = { 'span.description': { sortField: 'span.description', renderFunc: data => { - const value = data['span.description']; + const value = data[SpanFields.SPAN_DESCRIPTION]; + const op: string = data[SpanFields.SPAN_OP]; + const projectId = + typeof data[SpanFields.PROJECT_ID] === 'number' + ? data[SpanFields.PROJECT_ID] + : parseInt(data[SpanFields.PROJECT_ID], 10) || -1; + const spanGroup: string | undefined = data[SpanFields.SPAN_GROUP]; + + if (op === ModuleName.DB || op === ModuleName.RESOURCE) { + return ( + + ); + } return ( { + renderFunc: (data, {organization}) => { const replayId = data?.replayId; if (typeof replayId !== 'string' || !replayId) { return emptyValue; } - return {getShortEventId(replayId)}; + const target = makeReplaysPathname({ + path: `/${replayId}/`, + organization, + }); + + return ( + + + {getShortEventId(replayId)} + + + ); }, }, 'profile.id': { @@ -649,6 +658,21 @@ const SPECIAL_FIELDS: SpecialFields = { ); }, }, + // Two different project ID fields are being used right now. `project_id` is shared between all datasets, but `project.id` is the new one used in spans + project_id: { + sortField: 'project_id', + renderFunc: (data, baggage) => { + const projectId = data.project_id; + return getProjectIdLink(projectId, baggage); + }, + }, + 'project.id': { + sortField: 'project.id', + renderFunc: (data, baggage) => { + const projectId = data['project.id']; + return getProjectIdLink(projectId, baggage); + }, + }, user: { sortField: 'user', renderFunc: data => { @@ -795,6 +819,23 @@ const SPECIAL_FIELDS: SpecialFields = { ), }, + timestamp: { + sortField: 'timestamp', + renderFunc: data => { + const timestamp = data.timestamp; + if (!timestamp) { + return {emptyStringValue}; + } + const date = new Date(data.timestamp); + return ( + + + + + + ); + }, + }, 'timestamp.to_hour': { sortField: 'timestamp.to_hour', renderFunc: data => ( @@ -843,6 +884,126 @@ const SPECIAL_FIELDS: SpecialFields = { ); }, }, + 'browser.name': { + sortField: 'browser.name', + renderFunc: data => { + const browserName = data['browser.name']; + if (typeof browserName !== 'string') { + return {emptyStringValue}; + } + + return ( + + {getContextIcon(browserName, false)} + {browserName} + + ); + }, + }, + browser: { + sortField: 'browser', + renderFunc: data => { + const browser = data.browser; + if (typeof browser !== 'string') { + return {emptyStringValue}; + } + + return ( + + {getContextIcon(browser, true)} + {browser} + + ); + }, + }, + 'os.name': { + sortField: 'os.name', + renderFunc: data => { + const osName = data['os.name']; + if (typeof osName !== 'string') { + return {emptyStringValue}; + } + + return ( + + {getContextIcon(osName, false)} + {osName} + + ); + }, + }, + os: { + sortField: 'os', + renderFunc: data => { + const os = data.os; + if (typeof os !== 'string') { + return {emptyStringValue}; + } + + const hasUserAgentLocking = os.includes('>='); + + return ( + + {getContextIcon(os, true)} + {hasUserAgentLocking ? ( + + {os} + + ) : ( + os + )} + + ); + }, + }, +}; + +/** + * Returns an icon component for fields that use logo icons + * @param value the text to map to an icon + * @param dropVersion drops the last part of the value (the version) + */ +const getContextIcon = (value: string, dropVersion?: boolean) => { + const valueArray = value.split(' '); + + // Some fields have the number version attached, so it needs to be removed + if (dropVersion) { + valueArray.pop(); + } + + const formattedValue = valueArray.join('-').toLocaleLowerCase(); + + return ; +}; + +const getProjectIdLink = ( + projectId: number | string | undefined, + {organization}: RenderFunctionBaggage +) => { + const parsedId = typeof projectId === 'string' ? parseInt(projectId, 10) : projectId; + if (!parsedId || isNaN(parsedId)) { + return {emptyValue}; + } + + // TODO: Component has been deprecated in favour of hook, need to refactor this + return ( + + + {({projects}) => { + const project = projects.find(p => p.id === parsedId?.toString()); + if (!project) { + return emptyValue; + } + const target = makeProjectsPathname({ + path: `/${project?.slug}/?project=${parsedId}/`, + organization, + }); + + return {parsedId}; + }} + + + ); }; type SpecialFunctionFieldRenderer = ( @@ -951,8 +1112,8 @@ 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 (Object.hasOwn(SPECIAL_FIELDS, field)) { + return SPECIAL_FIELDS[field]!.sortField; } if (!tableMeta) { @@ -971,7 +1132,7 @@ export function getSortField( } const fieldType = tableMeta[field]; - if (FIELD_FORMATTERS.hasOwnProperty(fieldType)) { + if (Object.hasOwn(FIELD_FORMATTERS, fieldType)) { return FIELD_FORMATTERS[fieldType as keyof typeof FIELD_FORMATTERS].isSortable ? field : null; diff --git a/static/app/utils/discover/styles.tsx b/static/app/utils/discover/styles.tsx index 1ae2f3c4c159e6..31dff1513cbe18 100644 --- a/static/app/utils/discover/styles.tsx +++ b/static/app/utils/discover/styles.tsx @@ -62,3 +62,10 @@ export const UserIcon = styled(IconUser)` margin-left: ${space(1)}; color: ${p => p.theme.gray400}; `; + +export const IconContainer = styled('div')` + display: flex; + gap: ${space(1)}; + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/static/app/views/dashboards/datasetConfig/errorsAndTransactions.tsx b/static/app/views/dashboards/datasetConfig/errorsAndTransactions.tsx index 48c245e2376f33..2ce9a1a899a6e6 100644 --- a/static/app/views/dashboards/datasetConfig/errorsAndTransactions.tsx +++ b/static/app/views/dashboards/datasetConfig/errorsAndTransactions.tsx @@ -19,12 +19,12 @@ import {defined} from 'sentry/utils'; import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements'; import {getTimeStampFromTableDateField} from 'sentry/utils/dates'; import type {EventsTableData, TableData} from 'sentry/utils/discover/discoverQuery'; -import type {MetaType} from 'sentry/utils/discover/eventView'; +import type {EventData, MetaType} from 'sentry/utils/discover/eventView'; import type { FieldFormatterRenderFunctionPartial, RenderFunctionBaggage, } from 'sentry/utils/discover/fieldRenderers'; -import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; +import {emptyStringValue, getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import type {AggregationOutputType, QueryFieldValue} from 'sentry/utils/discover/fields'; import { errorsAndTransactionsAggregateFunctionOutputType, @@ -360,7 +360,7 @@ function getSeriesResultType( } export function renderEventIdAsLinkable( - data: any, + data: EventData, {eventView, organization}: RenderFunctionBaggage ) { const id: string | unknown = data?.id; @@ -387,12 +387,12 @@ export function renderEventIdAsLinkable( export function renderTraceAsLinkable(widget?: Widget) { return function ( - data: any, + data: EventData, {eventView, organization, location}: RenderFunctionBaggage ) { const id: string | unknown = data?.trace; if (!eventView || typeof id !== 'string') { - return null; + return emptyStringValue; } const dateSelection = eventView.normalizeDateSelection(location); const target = getTraceDetailsUrl({ diff --git a/static/app/views/dashboards/datasetConfig/spans.tsx b/static/app/views/dashboards/datasetConfig/spans.tsx index d6d9194c1c3aab..53b7ebf986c09a 100644 --- a/static/app/views/dashboards/datasetConfig/spans.tsx +++ b/static/app/views/dashboards/datasetConfig/spans.tsx @@ -2,6 +2,7 @@ import pickBy from 'lodash/pickBy'; import {doEventsRequest} from 'sentry/actionCreators/events'; import type {Client} from 'sentry/api'; +import {Link} from 'sentry/components/core/link'; import type {PageFilters} from 'sentry/types/core'; import type {TagCollection} from 'sentry/types/group'; import type { @@ -13,7 +14,9 @@ import type { import toArray from 'sentry/utils/array/toArray'; import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements'; import type {EventsTableData, TableData} from 'sentry/utils/discover/discoverQuery'; -import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; +import type {EventData} from 'sentry/utils/discover/eventView'; +import type {RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; +import {emptyStringValue, getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import type {Aggregation, QueryFieldValue} from 'sentry/utils/discover/fields'; import { type DiscoverQueryExtras, @@ -21,6 +24,8 @@ import { doDiscoverQuery, } from 'sentry/utils/discover/genericDiscoverQuery'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls'; +import {getShortEventId} from 'sentry/utils/events'; import { AggregationKey, ALLOWED_EXPLORE_VISUALIZE_AGGREGATES, @@ -35,6 +40,7 @@ import { import { getTableSortOptions, getTimeseriesSortOptions, + renderTraceAsLinkable, transformEventsResponseToTable, } from 'sentry/views/dashboards/datasetConfig/errorsAndTransactions'; import {getSeriesRequestData} from 'sentry/views/dashboards/datasetConfig/utils/getSeriesRequestData'; @@ -46,6 +52,7 @@ import type {FieldValueOption} from 'sentry/views/discover/table/queryField'; import {FieldValueKind} from 'sentry/views/discover/table/types'; import {generateFieldOptions} from 'sentry/views/discover/utils'; import type {SamplingMode} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHeader/breadcrumbs'; const DEFAULT_WIDGET_QUERY: WidgetQuery = { name: '', @@ -170,7 +177,13 @@ export const SpansConfig: DatasetConfig< transformTable: transformEventsResponseToTable, transformSeries: transformEventsResponseToSeries, filterAggregateParams, - getCustomFieldRenderer: (field, meta, _organization) => { + getCustomFieldRenderer: (field, meta, widget, _organization) => { + if (field === 'id') { + return renderEventInTraceView; + } + if (field === 'trace') { + return renderTraceAsLinkable(widget); + } return getFieldRenderer(field, meta, false); }, }; @@ -352,3 +365,24 @@ function filterSeriesSortOptions(columns: Set) { return columns.has(option.value.meta.name); }; } + +function renderEventInTraceView( + data: EventData, + {location, organization}: RenderFunctionBaggage +) { + const spanId = data.id; + if (!spanId || typeof spanId !== 'string') { + return emptyStringValue; + } + const target = generateLinkToEventInTraceView({ + traceSlug: data.trace, + timestamp: data.timestamp, + targetId: data['transaction.span_id'], + organization, + location, + spanId, + source: TraceViewSources.DASHBOARDS, + }); + + return {getShortEventId(spanId)}; +}