opsBreakdown.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import styled from '@emotion/styled';
  2. import isFinite from 'lodash/isFinite';
  3. import {SectionHeading} from 'sentry/components/charts/styles';
  4. import type {ActiveOperationFilter} from 'sentry/components/events/interfaces/spans/filter';
  5. import type {
  6. RawSpanType,
  7. TraceContextType,
  8. } from 'sentry/components/events/interfaces/spans/types';
  9. import {getSpanOperation} from 'sentry/components/events/interfaces/spans/utils';
  10. import {pickBarColor} from 'sentry/components/performance/waterfall/utils';
  11. import QuestionTooltip from 'sentry/components/questionTooltip';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {
  15. AggregateEventTransaction,
  16. EntrySpans,
  17. Event,
  18. EventTransaction,
  19. } from 'sentry/types/event';
  20. import {EntryType} from 'sentry/types/event';
  21. type StartTimestamp = number;
  22. type EndTimestamp = number;
  23. type Duration = number;
  24. type TimeWindowSpan = [StartTimestamp, EndTimestamp];
  25. const OtherOperation = Symbol('Other');
  26. type OperationName = string | typeof OtherOperation;
  27. // mapping an operation name to a disjoint set of time intervals (start/end timestamp).
  28. // this is an intermediary data structure to help calculate the coverage of an operation name
  29. // with respect to the root transaction span's operation lifetime
  30. type OperationNameIntervals = Record<OperationName, Array<TimeWindowSpan>>;
  31. type OperationNameCoverage = Record<OperationName, Duration>;
  32. type OpStats = {
  33. name: OperationName;
  34. percentage: number;
  35. totalInterval: number;
  36. };
  37. const TOP_N_SPANS = 4;
  38. type OpBreakdownType = OpStats[];
  39. type Props = {
  40. event: Event | AggregateEventTransaction;
  41. operationNameFilters: ActiveOperationFilter;
  42. hideHeader?: boolean;
  43. topN?: number;
  44. };
  45. export function generateStats(
  46. transactionEvent: EventTransaction | AggregateEventTransaction,
  47. operationNameFilters: ActiveOperationFilter,
  48. topN?: number
  49. ): OpBreakdownType {
  50. if (!transactionEvent) {
  51. return [];
  52. }
  53. const traceContext: TraceContextType | undefined = transactionEvent?.contexts?.trace;
  54. if (!traceContext) {
  55. return [];
  56. }
  57. const spanEntry = transactionEvent.entries.find(
  58. (entry: EntrySpans | any): entry is EntrySpans => {
  59. return entry.type === EntryType.SPANS;
  60. }
  61. );
  62. let spans: RawSpanType[] = spanEntry?.data ?? [];
  63. const rootSpan = {
  64. op: traceContext.op,
  65. timestamp: transactionEvent.endTimestamp,
  66. start_timestamp: transactionEvent.startTimestamp,
  67. trace_id: traceContext.trace_id || '',
  68. span_id: traceContext.span_id || '',
  69. data: {},
  70. };
  71. spans =
  72. spans.length > 0
  73. ? spans
  74. : // if there are no descendent spans, then use the transaction root span
  75. [rootSpan];
  76. // Filter spans by operation name
  77. if (operationNameFilters.type === 'active_filter') {
  78. spans = [...spans, rootSpan];
  79. spans = spans.filter(span => {
  80. const operationName = getSpanOperation(span);
  81. const shouldFilterOut =
  82. typeof operationName === 'string' &&
  83. !operationNameFilters.operationNames.has(operationName);
  84. return !shouldFilterOut;
  85. });
  86. }
  87. const operationNameIntervals = spans.reduce(
  88. (intervals: Partial<OperationNameIntervals>, span: RawSpanType) => {
  89. let startTimestamp = span.start_timestamp;
  90. const endTimestamp = span.timestamp;
  91. if (!span.exclusive_time) {
  92. return intervals;
  93. }
  94. if (endTimestamp < startTimestamp) {
  95. // reverse timestamps
  96. startTimestamp = span.timestamp;
  97. }
  98. // invariant: startTimestamp <= endTimestamp
  99. let operationName = span.op;
  100. if (typeof operationName !== 'string') {
  101. // a span with no operation name is considered an 'unknown' op
  102. operationName = 'unknown';
  103. }
  104. const cover: TimeWindowSpan = [
  105. startTimestamp,
  106. startTimestamp + span.exclusive_time / 1000,
  107. ];
  108. const operationNameInterval = intervals[operationName];
  109. if (!Array.isArray(operationNameInterval)) {
  110. intervals[operationName] = [cover];
  111. return intervals;
  112. }
  113. operationNameInterval.push(cover);
  114. intervals[operationName] = mergeInterval(operationNameInterval);
  115. return intervals;
  116. },
  117. {}
  118. ) as OperationNameIntervals;
  119. const operationNameCoverage = Object.entries(operationNameIntervals).reduce(
  120. (
  121. acc: Partial<OperationNameCoverage>,
  122. [operationName, intervals]: [OperationName, TimeWindowSpan[]]
  123. ) => {
  124. const duration = intervals.reduce((sum: number, [start, end]) => {
  125. return sum + Math.abs(end - start);
  126. }, 0);
  127. acc[operationName] = duration;
  128. return acc;
  129. },
  130. {}
  131. ) as OperationNameCoverage;
  132. const sortedOpsBreakdown = Object.entries(operationNameCoverage).sort(
  133. (first: [OperationName, Duration], second: [OperationName, Duration]) => {
  134. const firstDuration = first[1];
  135. const secondDuration = second[1];
  136. if (firstDuration === secondDuration) {
  137. return 0;
  138. }
  139. if (firstDuration < secondDuration) {
  140. // sort second before first
  141. return 1;
  142. }
  143. // otherwise, sort first before second
  144. return -1;
  145. }
  146. );
  147. const breakdown = sortedOpsBreakdown
  148. .slice(0, topN)
  149. .map(([operationName, duration]: [OperationName, Duration]): OpStats => {
  150. return {
  151. name: operationName,
  152. // percentage to be recalculated after the ops breakdown group is decided
  153. percentage: 0,
  154. totalInterval: duration,
  155. };
  156. });
  157. const other = sortedOpsBreakdown.slice(topN).reduce(
  158. (accOther: OpStats, [_operationName, duration]: [OperationName, Duration]) => {
  159. accOther.totalInterval += duration;
  160. return accOther;
  161. },
  162. {
  163. name: OtherOperation,
  164. // percentage to be recalculated after the ops breakdown group is decided
  165. percentage: 0,
  166. totalInterval: 0,
  167. }
  168. );
  169. if (other.totalInterval > 0) {
  170. breakdown.push(other);
  171. }
  172. // calculate breakdown total duration
  173. const total = breakdown.reduce((sum: number, operationNameGroup) => {
  174. return sum + operationNameGroup.totalInterval;
  175. }, 0);
  176. // recalculate percentage values
  177. breakdown.forEach(operationNameGroup => {
  178. operationNameGroup.percentage = operationNameGroup.totalInterval / total;
  179. });
  180. return breakdown;
  181. }
  182. function OpsBreakdown({
  183. event,
  184. operationNameFilters,
  185. hideHeader = false,
  186. topN = TOP_N_SPANS,
  187. }: Props) {
  188. const transactionEvent =
  189. event.type === 'transaction' || event.type === 'aggregateTransaction'
  190. ? event
  191. : undefined;
  192. if (!transactionEvent) {
  193. return null;
  194. }
  195. const breakdown = generateStats(transactionEvent, operationNameFilters, topN);
  196. const contents = breakdown.map(currOp => {
  197. const {name, percentage, totalInterval} = currOp;
  198. const isOther = name === OtherOperation;
  199. const operationName = typeof name === 'string' ? name : t('Other');
  200. const durLabel = Math.round(totalInterval * 1000 * 100) / 100;
  201. const pctLabel = isFinite(percentage) ? Math.round(percentage * 100) : '∞';
  202. const opsColor: string = pickBarColor(operationName);
  203. return (
  204. <OpsLine key={operationName}>
  205. <OpsNameContainer>
  206. <OpsDot style={{backgroundColor: isOther ? 'transparent' : opsColor}} />
  207. <OpsName>{operationName}</OpsName>
  208. </OpsNameContainer>
  209. <OpsContent>
  210. <Dur>{durLabel}ms</Dur>
  211. <Pct>{pctLabel}%</Pct>
  212. </OpsContent>
  213. </OpsLine>
  214. );
  215. });
  216. if (!hideHeader) {
  217. return (
  218. <StyledBreakdown>
  219. <SectionHeading>
  220. {t('Operation Breakdown')}
  221. <QuestionTooltip
  222. position="top"
  223. size="sm"
  224. containerDisplayMode="block"
  225. title={t(
  226. 'Span durations are summed over the course of an entire transaction. Any overlapping spans are only counted once. Percentages are calculated by dividing the summed span durations by the total of all span durations.'
  227. )}
  228. />
  229. </SectionHeading>
  230. {contents}
  231. </StyledBreakdown>
  232. );
  233. }
  234. return <StyledBreakdownNoHeader>{contents}</StyledBreakdownNoHeader>;
  235. }
  236. const StyledBreakdown = styled('div')`
  237. font-size: ${p => p.theme.fontSizeMedium};
  238. margin-bottom: ${space(4)};
  239. `;
  240. const StyledBreakdownNoHeader = styled('div')`
  241. font-size: ${p => p.theme.fontSizeMedium};
  242. margin: ${space(2)} ${space(3)};
  243. `;
  244. export const OpsLine = styled('div')`
  245. display: flex;
  246. justify-content: space-between;
  247. margin-bottom: ${space(0.5)};
  248. * + * {
  249. margin-left: ${space(0.5)};
  250. }
  251. `;
  252. export const OpsDot = styled('div')`
  253. content: '';
  254. display: block;
  255. width: 8px;
  256. min-width: 8px;
  257. height: 8px;
  258. margin-right: ${space(1)};
  259. border-radius: 100%;
  260. `;
  261. const OpsContent = styled('div')`
  262. display: flex;
  263. align-items: center;
  264. `;
  265. const OpsNameContainer = styled(OpsContent)`
  266. overflow: hidden;
  267. `;
  268. const OpsName = styled('div')`
  269. white-space: nowrap;
  270. overflow: hidden;
  271. text-overflow: ellipsis;
  272. `;
  273. const Dur = styled('div')`
  274. color: ${p => p.theme.gray300};
  275. font-variant-numeric: tabular-nums;
  276. `;
  277. const Pct = styled('div')`
  278. min-width: 40px;
  279. text-align: right;
  280. font-variant-numeric: tabular-nums;
  281. `;
  282. function mergeInterval(intervals: TimeWindowSpan[]): TimeWindowSpan[] {
  283. // sort intervals by start timestamps
  284. intervals.sort((first: TimeWindowSpan, second: TimeWindowSpan) => {
  285. if (first[0] < second[0]) {
  286. // sort first before second
  287. return -1;
  288. }
  289. if (second[0] < first[0]) {
  290. // sort second before first
  291. return 1;
  292. }
  293. return 0;
  294. });
  295. // array of disjoint intervals
  296. const merged: TimeWindowSpan[] = [];
  297. for (const currentInterval of intervals) {
  298. if (merged.length === 0) {
  299. merged.push(currentInterval);
  300. continue;
  301. }
  302. const lastInterval = merged[merged.length - 1];
  303. const lastIntervalEnd = lastInterval[1];
  304. const [currentIntervalStart, currentIntervalEnd] = currentInterval;
  305. if (lastIntervalEnd < currentIntervalStart) {
  306. // if currentInterval does not overlap with lastInterval,
  307. // then add currentInterval
  308. merged.push(currentInterval);
  309. continue;
  310. }
  311. // currentInterval and lastInterval overlaps; so we merge these intervals
  312. // invariant: lastIntervalStart <= currentIntervalStart
  313. lastInterval[1] = Math.max(lastIntervalEnd, currentIntervalEnd);
  314. }
  315. return merged;
  316. }
  317. export default OpsBreakdown;