11import { Fragment } from 'react' ;
22import { useTheme } from '@emotion/react' ;
3+ import styled from '@emotion/styled' ;
34import type { Location } from 'history' ;
45
56import { LinkButton } from '@sentry/scraps/button' ;
67import { Link } from '@sentry/scraps/link' ;
8+ import { Tooltip } from '@sentry/scraps/tooltip' ;
79
10+ import { Duration } from 'sentry/components/duration' ;
811import { usePageFilters } from 'sentry/components/pageFilters/usePageFilters' ;
912import { Pagination , type CursorHandler } from 'sentry/components/pagination' ;
13+ import { RowRectangle } from 'sentry/components/performance/waterfall/rowBar' ;
14+ import { pickBarColor } from 'sentry/components/performance/waterfall/utils' ;
15+ import { QuestionTooltip } from 'sentry/components/questionTooltip' ;
1016import {
1117 COL_WIDTH_UNDEFINED ,
1218 GridEditable ,
@@ -17,6 +23,11 @@ import {t, tct} from 'sentry/locale';
1723import type { Organization } from 'sentry/types/organization' ;
1824import type { EventsMetaType , EventView } from 'sentry/utils/discover/eventView' ;
1925import { getFieldRenderer } from 'sentry/utils/discover/fieldRenderers' ;
26+ import {
27+ getSpanOperationName ,
28+ SPAN_OP_BREAKDOWN_FIELDS ,
29+ } from 'sentry/utils/discover/fields' ;
30+ import { toPercent } from 'sentry/utils/number/toPercent' ;
2031import { decodeScalar , decodeSorts } from 'sentry/utils/queryString' ;
2132import { projectSupportsReplay } from 'sentry/utils/replays/projectSupportsReplay' ;
2233import type { Theme } from 'sentry/utils/theme' ;
@@ -36,6 +47,8 @@ import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';
3647
3748const LIMIT = 50 ;
3849
50+ const SPAN_OPS_BREAKDOWN_COLUMN_KEY = 'span_ops_breakdown.relative' ;
51+
3952const BASE_FIELDS : SpanProperty [ ] = [
4053 'span_id' ,
4154 'user.id' ,
@@ -46,11 +59,17 @@ const BASE_FIELDS: SpanProperty[] = [
4659 'span.duration' ,
4760 'trace' ,
4861 'timestamp' ,
62+ 'replayId' ,
4963 'profile.id' ,
5064 'profiler.id' ,
5165 'thread.id' ,
5266 'precise.start_ts' ,
5367 'precise.finish_ts' ,
68+ 'spans.browser' ,
69+ 'spans.db' ,
70+ 'spans.http' ,
71+ 'spans.resource' ,
72+ 'spans.ui' ,
5473] ;
5574
5675type OverviewSpansColumn = GridColumnHeader <
@@ -62,12 +81,18 @@ type OverviewSpansColumn = GridColumnHeader<
6281 | 'timestamp'
6382 | 'replayId'
6483 | 'profile.id'
84+ | typeof SPAN_OPS_BREAKDOWN_COLUMN_KEY
6585> ;
6686
6787const BASE_COLUMN_ORDER : OverviewSpansColumn [ ] = [
6888 { key : 'span_id' , name : t ( 'Span ID' ) , width : COL_WIDTH_UNDEFINED } ,
6989 { key : 'user.display' , name : t ( 'User' ) , width : COL_WIDTH_UNDEFINED } ,
7090 { key : 'request.method' , name : t ( 'HTTP Method' ) , width : COL_WIDTH_UNDEFINED } ,
91+ {
92+ key : SPAN_OPS_BREAKDOWN_COLUMN_KEY ,
93+ name : t ( 'Operation Duration' ) ,
94+ width : COL_WIDTH_UNDEFINED ,
95+ } ,
7196 { key : 'span.duration' , name : t ( 'Total Duration' ) , width : COL_WIDTH_UNDEFINED } ,
7297 { key : 'trace' , name : t ( 'Trace ID' ) , width : COL_WIDTH_UNDEFINED } ,
7398 { key : 'timestamp' , name : t ( 'Timestamp' ) , width : COL_WIDTH_UNDEFINED } ,
@@ -106,10 +131,6 @@ export function OverviewSpansTable({eventView, transactionName}: Props) {
106131 project !== undefined &&
107132 projectSupportsReplay ( project ) ;
108133
109- const fields : SpanProperty [ ] = showReplayColumn
110- ? BASE_FIELDS . concat ( 'replayId' )
111- : BASE_FIELDS ;
112-
113134 const columnOrder : OverviewSpansColumn [ ] = [
114135 ...BASE_COLUMN_ORDER ,
115136 ...( showReplayColumn ? [ REPLAY_COLUMN ] : [ ] ) ,
@@ -159,7 +180,7 @@ export function OverviewSpansTable({eventView, transactionName}: Props) {
159180 } = useSpans (
160181 {
161182 search : defaultQuery ,
162- fields,
183+ fields : BASE_FIELDS ,
163184 sorts : [ sort ] ,
164185 limit : LIMIT ,
165186 cursor,
@@ -193,13 +214,28 @@ export function OverviewSpansTable({eventView, transactionName}: Props) {
193214 columnOrder = { columnOrder }
194215 columnSortBy = { [ { key : sort . field , order : sort . kind } ] }
195216 grid = { {
196- renderHeadCell : column =>
197- renderHeadCell ( {
217+ renderHeadCell : column => {
218+ if ( column . key === SPAN_OPS_BREAKDOWN_COLUMN_KEY ) {
219+ return (
220+ < Fragment >
221+ < span > { column . name } </ span >
222+ < StyledQuestionTooltip
223+ size = "xs"
224+ position = "top"
225+ title = { t (
226+ 'Span durations are summed over the course of an entire transaction. Any overlapping spans are only counted once.'
227+ ) }
228+ />
229+ </ Fragment >
230+ ) ;
231+ }
232+ return renderHeadCell ( {
198233 column,
199234 sort,
200235 location,
201236 sortParameterName : QueryParameterNames . SPANS_SORT ,
202- } ) ,
237+ } ) ;
238+ } ,
203239 renderBodyCell : ( column , row ) =>
204240 renderBodyCell ( column , row , meta , projectSlug , location , organization , theme ) ,
205241 } }
@@ -303,6 +339,10 @@ function renderBodyCell(
303339 ) ;
304340 }
305341
342+ if ( column . key === SPAN_OPS_BREAKDOWN_COLUMN_KEY ) {
343+ return renderOperationDurationCell ( row , theme ) ;
344+ }
345+
306346 if ( ! meta || ! meta ?. fields ) {
307347 return row [ column . key ] ;
308348 }
@@ -318,3 +358,97 @@ function renderBodyCell(
318358
319359 return rendered ;
320360}
361+
362+ function renderOperationDurationCell ( row : Record < string , any > , theme : Theme ) {
363+ const sumOfSpanTime = SPAN_OP_BREAKDOWN_FIELDS . reduce (
364+ ( prev , curr ) =>
365+ curr in row && typeof row [ curr ] === 'number' ? prev + row [ curr ] : prev ,
366+ 0
367+ ) ;
368+ const cumulativeSpanOpBreakdown = Math . max ( sumOfSpanTime , row [ 'span.duration' ] ?? 0 ) ;
369+
370+ if (
371+ SPAN_OP_BREAKDOWN_FIELDS . every (
372+ field => ! ( field in row ) || typeof row [ field ] !== 'number'
373+ ) ||
374+ cumulativeSpanOpBreakdown === 0
375+ ) {
376+ return (
377+ < Duration
378+ seconds = { ( row [ 'span.duration' ] ?? 0 ) / 1000 }
379+ fixedDigits = { 2 }
380+ abbreviation
381+ />
382+ ) ;
383+ }
384+
385+ let otherPercentage = 1 ;
386+
387+ return (
388+ < RelativeOpsBreakdown data-test-id = "relative-ops-breakdown" >
389+ { SPAN_OP_BREAKDOWN_FIELDS . map ( field => {
390+ if ( ! ( field in row ) || typeof row [ field ] !== 'number' ) {
391+ return null ;
392+ }
393+
394+ const operationName = getSpanOperationName ( field ) ?? 'op' ;
395+ const spanOpDuration = row [ field ] ;
396+ const widthPercentage = spanOpDuration / cumulativeSpanOpBreakdown ;
397+ otherPercentage = otherPercentage - widthPercentage ;
398+ if ( widthPercentage === 0 ) {
399+ return null ;
400+ }
401+ return (
402+ < div key = { operationName } style = { { width : toPercent ( widthPercentage || 0 ) } } >
403+ < Tooltip
404+ title = {
405+ < div >
406+ < div > { operationName } </ div >
407+ < div >
408+ < Duration
409+ seconds = { spanOpDuration / 1000 }
410+ fixedDigits = { 2 }
411+ abbreviation
412+ />
413+ </ div >
414+ </ div >
415+ }
416+ containerDisplayMode = "block"
417+ >
418+ < RectangleRelativeOpsBreakdown
419+ style = { {
420+ backgroundColor : pickBarColor ( operationName , theme ) ,
421+ } }
422+ />
423+ </ Tooltip >
424+ </ div >
425+ ) ;
426+ } ) }
427+ < div key = "other" style = { { width : toPercent ( otherPercentage || 0 ) } } >
428+ < Tooltip title = { < div > { t ( 'Other' ) } </ div > } containerDisplayMode = "block" >
429+ < OtherRelativeOpsBreakdown />
430+ </ Tooltip >
431+ </ div >
432+ </ RelativeOpsBreakdown >
433+ ) ;
434+ }
435+
436+ const StyledQuestionTooltip = styled ( QuestionTooltip ) `
437+ position: relative;
438+ top: 1px;
439+ left: 4px;
440+ ` ;
441+
442+ const RelativeOpsBreakdown = styled ( 'div' ) `
443+ position: relative;
444+ display: flex;
445+ ` ;
446+
447+ const RectangleRelativeOpsBreakdown = styled ( RowRectangle ) `
448+ position: relative;
449+ width: 100%;
450+ ` ;
451+
452+ const OtherRelativeOpsBreakdown = styled ( RectangleRelativeOpsBreakdown ) `
453+ background-color: ${ p => p . theme . colors . gray100 } ;
454+ ` ;
0 commit comments