From ea6070e9ce9c6b5e006d6c3b64fa69c5b88b1829 Mon Sep 17 00:00:00 2001 From: Alexander Wert Date: Mon, 19 Apr 2021 11:30:30 +0200 Subject: [PATCH] Implemented Compressed Spans --- .../Waterfall/WaterfallItem.tsx | 47 ++- .../waterfall_helpers/waterfall_helpers.ts | 11 +- .../WaterfallContainer/compress-spans.ts | 304 ++++++++++++++++++ .../WaterfallContainer/index.tsx | 55 +++- 4 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/compress-spans.ts diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx index f3e1547c4b8b8..5dec9bddd331b 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx @@ -7,18 +7,19 @@ import React, { ReactNode } from 'react'; -import { EuiIcon, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui'; +import { EuiIcon, EuiText, EuiTitle, EuiToolTip, EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; import { asDuration } from '../../../../../../../common/utils/formatters'; import { isRumAgentName } from '../../../../../../../common/agent_name'; import { px, unit, units } from '../../../../../../style/variables'; import { ErrorCount } from '../../ErrorCount'; -import { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers'; +import { IWaterfallItem, IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers'; import { ErrorOverviewLink } from '../../../../../shared/Links/apm/ErrorOverviewLink'; import { TRACE_ID } from '../../../../../../../common/elasticsearch_fieldnames'; import { SyncBadge } from './SyncBadge'; import { Margins } from '../../../../../shared/charts/Timeline'; +import { useTheme } from '../../../../../../hooks/use_theme'; type ItemType = 'transaction' | 'span' | 'error'; @@ -159,6 +160,26 @@ function NameLabel({ item }: { item: IWaterfallSpanOrTransaction }) { } } +function compressedSpanStyle(item: IWaterfallSpanOrTransaction, width: number, left: number): React.CSSProperties { + var itemBarStyle = { left: `${left}%`, width: `${width}%` }; + + if (item.count !== undefined && item.durationSum !== undefined) { + const percNumItems = 100.0 / item.count; + const percDuration = percNumItems * (item.durationSum / item.duration); + + itemBarStyle = { + ...itemBarStyle, + ...{ + backgroundImage: `repeating-linear-gradient(90deg, transparent, transparent max(${percDuration}%,1.5px),` + + ` rgba(255,255,255,1) max(${percDuration}%,1.5px),` + + ` rgba(255,255,255,1) max(${percNumItems}%,3px))` + } + }; + } + + return itemBarStyle +} + export function WaterfallItem({ timelineMargins, totalDuration, @@ -168,6 +189,7 @@ export function WaterfallItem({ errorCount, onClick, }: IWaterfallItemProps) { + const theme = useTheme(); if (!totalDuration) { return null; } @@ -184,6 +206,8 @@ export function WaterfallItem({ } ); + var itemBarStyle = compressedSpanStyle(item, width, left); + return ( - +
+ +
+ {item.nPlusOne && + + + N+1 pattern! + + + } {errorCount > 0 && item.docType === 'transaction' ? ( diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts index 9ace59fae6320..53eb9ccaf08c6 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts @@ -38,6 +38,7 @@ export interface IWaterfall { errorsCount: number; legends: IWaterfallLegend[]; errorItems: IWaterfallError[]; + antipatternDetected?: boolean; } interface IWaterfallSpanItemBase @@ -46,6 +47,9 @@ interface IWaterfallSpanItemBase * Latency in us */ duration: number; + durationSum?: number; + count?: number; + nPlusOne?: boolean; legendValues: Record; } @@ -56,6 +60,7 @@ interface IWaterfallItemBase { parent?: IWaterfallSpanOrTransaction; parentId?: string; color: string; + /** * offset from first item in us */ @@ -274,7 +279,7 @@ const getWaterfallItems = (items: TraceAPIResponse['trace']['items']) => } }); -function reparentSpans(waterfallItems: IWaterfallSpanOrTransaction[]) { +export function reparentSpans(waterfallItems: IWaterfallSpanOrTransaction[]) { // find children that needs to be re-parented and map them to their correct parent id const childIdToParentIdMapping = Object.fromEntries( flatten( @@ -302,9 +307,7 @@ function reparentSpans(waterfallItems: IWaterfallSpanOrTransaction[]) { }); } -const getChildrenGroupedByParentId = ( - waterfallItems: IWaterfallSpanOrTransaction[] -) => +export const getChildrenGroupedByParentId = (waterfallItems: IWaterfallSpanOrTransaction[]) => groupBy(waterfallItems, (item) => (item.parentId ? item.parentId : ROOT_ID)); const getEntryWaterfallTransaction = ( diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/compress-spans.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/compress-spans.ts new file mode 100644 index 0000000000000..6ad0ad6501df1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/compress-spans.ts @@ -0,0 +1,304 @@ +import { IWaterfall, IWaterfallItem, IWaterfallSpan, getChildrenGroupedByParentId, getOrderedWaterfallItems, reparentSpans, IWaterfallTransaction, IWaterfallSpanOrTransaction } from './Waterfall/waterfall_helpers/waterfall_helpers'; + +import { Span } from '../../../../../../typings/es_schemas/ui/span'; +import uuid from 'uuid'; + +export function doCompressSpans(waterfall: IWaterfall, nPlusOneThreshold: number, durationThresholdMs: number): IWaterfall { + const itemsToProcess: IWaterfallItem[] = []; + + const transformedItems: IWaterfallItem[] = []; + + itemsToProcess.push(waterfall.entryWaterfallTransaction as IWaterfallItem); + + while (itemsToProcess.length > 0) { + const nextItemToProcess = itemsToProcess.shift(); + if (nextItemToProcess !== undefined) { + const children = waterfall.childrenByParentId[nextItemToProcess.id]; + if (children !== undefined) { + transformedItems.push(nextItemToProcess); + transformChildren(children, transformedItems, itemsToProcess, + waterfall.childrenByParentId, durationThresholdMs, nPlusOneThreshold); + } + } + } + + return createWaterfall(waterfall, transformedItems); +} + +function transformChildren(children: IWaterfallItem[], transformedItems: IWaterfallItem[], itemsToProcess: IWaterfallItem[], childrenByParentId: Record, durationThresholdMs: number, nPlusOneThreshold: number) { + var prevItem: IWaterfallSpan | undefined = undefined; + var state: AlgorithmState = { compressedSpan: undefined, prevCompressedSpan: undefined }; + const durationThreshold = durationThresholdMs * 1000; + var i; + for (i = 0; i < children.length; i++) { + const item = children[i] as IWaterfallSpan; + const hasSubChildren = childrenByParentId[item.id] !== undefined; + const isSpan = item.docType === 'span'; + + if (hasSubChildren || !isSpan) { + // current item is either a parent or not a span + itemsToProcess.push(item); + reportCSpan(transformedItems, state, durationThreshold, nPlusOneThreshold); + } else { + processExitSpan(transformedItems, item, prevItem, state, durationThreshold, nPlusOneThreshold); + } + + prevItem = item; + } + + while (state.prevCompressedSpan || state.compressedSpan) { + reportCSpan(transformedItems, state, durationThreshold, nPlusOneThreshold); + } +} + +function processExitSpan(transformedItems: IWaterfallItem[], item: IWaterfallSpan, prevItem: IWaterfallSpan | undefined, state: AlgorithmState, durationThreshold: number, nPlusOneThreshold: number) { + const isShort = item.duration < durationThreshold; + const sameDestination = state.compressedSpan && item.doc.span.type === state.compressedSpan.type + && item.doc.span.subtype === state.compressedSpan.subtype + && item.doc.span.destination !== undefined + && item.doc.span.destination?.service.resource === state.compressedSpan.destination; + + const sameQueryAsPrev = prevItem && prevItem.doc.span.db && sameDestination && item.doc.span.db && item.doc.span.db?.statement === prevItem.doc.span.db?.statement; + const sameQueryAsCompressedSpan = sameDestination && item.doc.span.db && state.compressedSpan && item.doc.span.db?.statement === state.compressedSpan.query; + const cSpanContainsLongCall = state.compressedSpan && state.compressedSpan.maxDuration >= durationThreshold; + const cSpanIsNPlusOneCandidate = state.compressedSpan && state.compressedSpan.query; + + if (isShort && !sameQueryAsPrev) { + // current span is short but with different query as previous + if (sameDestination && !cSpanContainsLongCall && !cSpanIsNPlusOneCandidate) { + extendCSpan(state, item); + } else { + reportCSpan(transformedItems, state, durationThreshold, nPlusOneThreshold); + state.compressedSpan = newCSpan(item); + } + } else if (sameQueryAsPrev) { + // current span is long and has the same query as previous + if (sameQueryAsCompressedSpan) { + extendCSpan(state, item); + } else { + correctCSpan(state, item); + reportCSpan(transformedItems, state, durationThreshold, nPlusOneThreshold); + state.compressedSpan = newCSpan(prevItem as IWaterfallSpan); + extendCSpan(state, item); + } + } else { + // current span is long and does not have the same query as previous + reportCSpan(transformedItems, state, durationThreshold, nPlusOneThreshold); + state.compressedSpan = newCSpan(item); + } +} + +function newCSpan(span: IWaterfallSpan): ICompressedSpan { + return { + count: 1, + destination: span.doc.span.destination ? span.doc.span.destination.service.resource : (span.doc.span.type + "/" + (span.doc.span.subtype ? span.doc.span.subtype : "")), + parentId: span.parentId as string, + parent: span.parent, + duration: span.duration, + maxDuration: span.duration, + type: span.doc.span.type, + query: span.doc.span.db?.statement, + subtype: span.doc.span.subtype, + originSpan: span, + start: span.doc.timestamp.us, + end: span.doc.timestamp.us + span.duration, + prevEnd: span.doc.timestamp.us + }; +} + +function extendCSpan(state: AlgorithmState, item: IWaterfallSpan) { + if (!state.compressedSpan) { + state.compressedSpan = newCSpan(item); + } else { + state.compressedSpan.duration += item.duration; + if (item.duration > state.compressedSpan.maxDuration) { + state.compressedSpan.maxDuration = item.duration; + } + state.compressedSpan.count += 1; + state.compressedSpan.prevEnd = state.compressedSpan.end; + state.compressedSpan.end = item.doc.timestamp.us + item.duration; + if (state.compressedSpan.query !== item.doc.span.db?.statement) { + state.compressedSpan.query = undefined; + } + } +} + +function correctCSpan(state: AlgorithmState, item: IWaterfallSpan) { + if (state.compressedSpan) { + state.compressedSpan.duration -= item.duration; + if (item.duration > state.compressedSpan.maxDuration) { + state.compressedSpan.maxDuration = item.duration; + } + state.compressedSpan.count -= 1; + state.compressedSpan.end = state.compressedSpan.prevEnd; + } +} + +function reportCSpan(transformedItems: IWaterfallItem[], state: AlgorithmState, durationThreshold: number, nPlusOneThreshold: number) { + const cSpan = state.compressedSpan; + const prevCSpan = state.prevCompressedSpan; + var itemToReport: IWaterfallSpan | undefined = undefined; + if (!cSpan && prevCSpan) { + itemToReport = getSpanItem(prevCSpan, nPlusOneThreshold); + state.prevCompressedSpan = state.compressedSpan; + } else if (cSpan && prevCSpan) { + const sameDestination = cSpan.destination === prevCSpan.destination; + const sameQuery = cSpan.query && prevCSpan.query && cSpan.query === prevCSpan.query; + const bothShort = cSpan.maxDuration < durationThreshold && prevCSpan.maxDuration < durationThreshold; + const currentIsFullNPlusOne = cSpan.query && cSpan.count >= nPlusOneThreshold; + const prevIsFullNPlusOne = prevCSpan.query && prevCSpan.count >= nPlusOneThreshold; + const noneIsNPlusOne = !currentIsFullNPlusOne && !prevIsFullNPlusOne; + if (sameDestination && ((bothShort && noneIsNPlusOne) || sameQuery)) { + // Merge current compressed span with previous + prevCSpan.duration += cSpan.duration; + if (cSpan.maxDuration > prevCSpan.maxDuration) { + prevCSpan.maxDuration = cSpan.maxDuration; + } + prevCSpan.count += cSpan.count; + prevCSpan.prevEnd = cSpan.prevEnd; + prevCSpan.end = cSpan.end; + if (prevCSpan.query !== cSpan.query) { + prevCSpan.query = undefined; + } + } else { + itemToReport = getSpanItem(prevCSpan, nPlusOneThreshold); + state.prevCompressedSpan = state.compressedSpan; + } + } else { + state.prevCompressedSpan = state.compressedSpan; + } + + if (itemToReport) { + transformedItems.push(itemToReport); + } + + state.compressedSpan = undefined; +} + +function getSpanItem(compressedSpan: ICompressedSpan, nPlusOneThreshold: number): IWaterfallSpan { + if (compressedSpan.count === 1) { + return compressedSpan.originSpan; + } + + const iriginDoc = compressedSpan.originSpan.doc; + var antiPattern = false; + var newSpanName = iriginDoc.span.name; + if (compressedSpan.query && compressedSpan.count >= nPlusOneThreshold) { + newSpanName = '(' + compressedSpan.count + 'x) ' + iriginDoc.span.name; + antiPattern = true; + } else if (compressedSpan.query) { + newSpanName = '(' + compressedSpan.count + 'x) ' + iriginDoc.span.name; + } else { + newSpanName = compressedSpan.count + 'x calls to ' + compressedSpan.destination; + } + const duration = compressedSpan.end - compressedSpan.start; + const doc = newSpanDoc(iriginDoc, newSpanName, duration, compressedSpan.query); + + return { + docType: 'span', + doc: doc, + id: doc.span.id, + parentId: compressedSpan.parentId, + duration: duration, + durationSum: compressedSpan.duration, + count: compressedSpan.count, + nPlusOne: antiPattern, + offset: 0, + skew: 0, + legendValues: compressedSpan.originSpan.legendValues, + color: compressedSpan.originSpan.color + }; +} + +function newSpanDoc(origin: Span, name: string, duration: number, query?: string): Span { + const db = !query ? origin.span.db : { + statement: query, + type: origin.span.db?.type, + }; + const newId = uuid.v4(); + const spanDoc: Span = { + agent: origin.agent, + processor: origin.processor, + trace: origin.trace, + service: origin.service, + timestamp: origin.timestamp, + '@timestamp': origin['@timestamp'], + span: { + destination: origin.span.destination, + duration: { us: duration }, + id: newId, + name: name, + subtype: origin.span.subtype, + type: origin.span.type, + sync: origin.span.sync, + http: origin.span.http, + db: db, + message: origin.span.message, + } + }; + + return spanDoc; +} + +interface AlgorithmState { + compressedSpan?: ICompressedSpan; + prevCompressedSpan?: ICompressedSpan; +} + +interface ICompressedSpan { + count: number; + type: string; + subtype?: string + destination: string; + query?: string; + maxDuration: number; + + parentId: string; + parent?: IWaterfallItem; + + start: number; + end: number; + prevEnd: number; + /** + * Latency in us + */ + duration: number; + + originSpan: IWaterfallSpan; +} + +function createWaterfall(origin: IWaterfall, transformedItems: IWaterfallItem[]): IWaterfall { + const modifiedWaterfall: IWaterfall = { + entryWaterfallTransaction: origin.entryWaterfallTransaction, + rootTransaction: origin.rootTransaction, + duration: origin.duration, + items: [], + childrenByParentId: {}, + errorsPerTransaction: origin.errorsPerTransaction, + errorsCount: origin.errorsCount, + errorItems: origin.errorItems, + antipatternDetected: false, + legends: origin.legends, + }; + + const childrenByParentId = getChildrenGroupedByParentId( + reparentSpans(getWaterfallItems(transformedItems)) + ); + + const items = getOrderedWaterfallItems( + childrenByParentId, + modifiedWaterfall.entryWaterfallTransaction + ); + + modifiedWaterfall.antipatternDetected = items.find(item => item.nPlusOne) !== undefined; + + modifiedWaterfall.items = items; + modifiedWaterfall.childrenByParentId = getChildrenGroupedByParentId(items); + + return modifiedWaterfall; +} + +function getWaterfallItems(items: IWaterfallItem[]): IWaterfallSpanOrTransaction[] { + return items.filter(item => item.docType === 'span' || item.docType === 'transaction') + .map((item: IWaterfallItem) => item as IWaterfallSpanOrTransaction); +} \ No newline at end of file diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/index.tsx index 11de1ac85c434..01b312c5ed56d 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/index.tsx @@ -5,8 +5,9 @@ * 2.0. */ +import { EuiFlexItem, EuiCheckbox, EuiFieldNumber, EuiBadge } from '@elastic/eui'; import { Location } from 'history'; -import React from 'react'; +import React, { useState } from 'react'; import { keyBy } from 'lodash'; import { useParams } from 'react-router-dom'; import { IUrlParams } from '../../../../../context/url_params_context/types'; @@ -16,6 +17,9 @@ import { } from './Waterfall/waterfall_helpers/waterfall_helpers'; import { Waterfall } from './Waterfall'; import { WaterfallLegends } from './WaterfallLegends'; +import { doCompressSpans } from './compress-spans'; +import { useTheme } from '../../../../../hooks/use_theme'; + interface Props { urlParams: IUrlParams; @@ -32,6 +36,10 @@ export function WaterfallContainer({ }: Props) { const { serviceName } = useParams<{ serviceName: string }>(); + const [compressSpans, setCompressSpans] = useState(false); + const [durationThreshold, setDurationThreshold] = useState(5); + const [nPlusOneThreshold, setNPlusOneThreshold] = useState(10); + const theme = useTheme(); if (!waterfall) { return null; } @@ -80,13 +88,56 @@ export function WaterfallContainer({ return { ...legend, value: !legend.value ? serviceName : legend.value }; }); + const waterfallToRender = compressSpans ? doCompressSpans(waterfall, nPlusOneThreshold, durationThreshold) : waterfall; + return (
+
+ {waterfallToRender.antipatternDetected && + + + N+1 pattern detected! + + + } + +
+ setDurationThreshold(parseInt(e.target.value)) } + /> +
+
+ { setNPlusOneThreshold(parseInt(e.target.value)) }} + /> +
+
+ setCompressSpans(!compressSpans)} + /> +
+
+
+