diff --git a/CHANGELOG.md b/CHANGELOG.md index 805af9186..6c2523cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,13 +35,14 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#120](https://github.com/kobsio/kobs/pull/120): Fix reconcilation of Flux resources. - [#123](https://github.com/kobsio/kobs/pull/123): Fix fields handling in ClickHouse and Elasticsearch plugin. - [#125](https://github.com/kobsio/kobs/pull/125): Fix missing `return` statement in ClickHouse panel. +- [#129](https://github.com/kobsio/kobs/pull/129): Fix handling of traces in the Jaeger plugin by using some function from the [jaegertracing/jaeger-ui](https://github.com/jaegertracing/jaeger-ui). ### Changed - [#106](https://github.com/kobsio/kobs/pull/106): :warning: *Breaking change:* :warning: Change Prometheus sparkline chart to allow the usage of labels. - [#107](https://github.com/kobsio/kobs/pull/107): Add new option for Prometheus chart legend and change formatting of values. - [#108](https://github.com/kobsio/kobs/pull/108): Improve tooltip position in all nivo charts. -- [#121](https://github.com/kobsio/kobs/pull/121): :warning: *Breaking change:* Allow multiple queries in the panel options for the Elasticsearch plugin. +- [#121](https://github.com/kobsio/kobs/pull/121): :warning: *Breaking change:* :warning: Allow multiple queries in the panel options for the Elasticsearch plugin. ## [v0.5.0](https://github.com/kobsio/kobs/releases/tag/v0.5.0) (2021-08-03) diff --git a/plugins/jaeger/package.json b/plugins/jaeger/package.json index 476e815a1..3a6b16842 100644 --- a/plugins/jaeger/package.json +++ b/plugins/jaeger/package.json @@ -23,6 +23,7 @@ "react-dropzone": "^11.3.4", "react-query": "^3.17.2", "react-router-dom": "^5.2.0", + "react-virtuoso": "^1.11.1", "typescript": "^4.3.4" } } diff --git a/plugins/jaeger/src/components/page/Trace.tsx b/plugins/jaeger/src/components/page/Trace.tsx index d970893dd..ef9a5ad7c 100644 --- a/plugins/jaeger/src/components/page/Trace.tsx +++ b/plugins/jaeger/src/components/page/Trace.tsx @@ -3,7 +3,8 @@ import React, { useEffect, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; import { ITrace } from '../../utils/interfaces'; -import TraceCompare from './TraceCompare'; +import TraceCompareData from './TraceCompareData'; +import TraceCompareID from './TraceCompareID'; import TraceSelect from './TraceSelect'; interface IJaegerPageCompareParams { @@ -40,7 +41,7 @@ const JaegerPageCompare: React.FunctionComponent = ({ n // handleUpload handles the upload of a JSON file, which contains a trace. When the file upload is finished we parse // the content of the file and set the uploadedTrace state. This state (trace) is then passed to the first - // TraceCompare so that the trace can be viewed. + // TraceCompareID so that the trace can be viewed. const handleUpload = (trace: ITrace): void => { setUploadedTrace(trace); history.push({ @@ -66,12 +67,16 @@ const JaegerPageCompare: React.FunctionComponent = ({ n return ( - + {uploadedTrace ? ( + + ) : ( + + )} {compareTrace ? ( - + ) : null} diff --git a/plugins/jaeger/src/components/page/TraceCompare.tsx b/plugins/jaeger/src/components/page/TraceCompare.tsx index 6601e4e9c..975c284ac 100644 --- a/plugins/jaeger/src/components/page/TraceCompare.tsx +++ b/plugins/jaeger/src/components/page/TraceCompare.tsx @@ -1,19 +1,6 @@ -import { - Alert, - AlertActionLink, - AlertVariant, - Grid, - GridItem, - PageSection, - PageSectionVariants, - Spinner, - Title, -} from '@patternfly/react-core'; -import { QueryObserverResult, useQuery } from 'react-query'; +import { Grid, GridItem, PageSection, PageSectionVariants, Title } from '@patternfly/react-core'; import React from 'react'; -import { useHistory } from 'react-router-dom'; -import { addColorForProcesses, getRootSpan } from '../../utils/helpers'; import { ITrace } from '../../utils/interfaces'; import Spans from '../panel/details/Spans'; import TraceActions from '../panel/details/TraceActions'; @@ -21,90 +8,21 @@ import TraceHeader from '../panel/details/TraceHeader'; interface ITraceCompareProps { name: string; - traceID: string; - trace?: ITrace; + trace: ITrace; } -const TraceCompare: React.FunctionComponent = ({ name, traceID, trace }: ITraceCompareProps) => { - const history = useHistory(); - - const { isError, isLoading, error, data, refetch } = useQuery( - ['jaeger/trace', name, traceID, trace], - async () => { - try { - if (trace && trace.traceID === traceID) { - return addColorForProcesses([trace])[0]; - } - - const response = await fetch(`/api/plugins/jaeger/trace/${name}?traceID=${traceID}`, { - method: 'get', - }); - const json = await response.json(); - - if (response.status >= 200 && response.status < 300) { - return addColorForProcesses(json.data)[0]; - } else { - if (json.error) { - throw new Error(json.error); - } else { - throw new Error('An unknown error occured'); - } - } - } catch (err) { - throw err; - } - }, - ); - - if (isLoading) { - return ( -
- -
- ); - } - - if (isError) { - return ( - - - history.push('/')}>Home - > => refetch()}> - Retry - - - } - > -

{error?.message}

-
-
- ); - } - - if (!data) { - return null; - } - - const rootSpan = data && data.spans.length > 0 ? getRootSpan(data.spans) : undefined; - if (!rootSpan) { - return null; - } - +const TraceCompare: React.FunctionComponent = ({ name, trace }: ITraceCompareProps) => { return ( - {data.processes[rootSpan.processID].serviceName}: {rootSpan.operationName} - <span className="pf-u-pl-sm pf-u-font-size-sm pf-u-color-400">{data.traceID}</span> + {trace.traceName} + <span className="pf-u-pl-sm pf-u-font-size-sm pf-u-color-400">{trace.traceID}</span>

- +

@@ -112,7 +30,7 @@ const TraceCompare: React.FunctionComponent = ({ name, trace
- +
@@ -120,7 +38,7 @@ const TraceCompare: React.FunctionComponent = ({ name, trace
- +
diff --git a/plugins/jaeger/src/components/page/TraceCompareData.tsx b/plugins/jaeger/src/components/page/TraceCompareData.tsx new file mode 100644 index 000000000..d1f0a882a --- /dev/null +++ b/plugins/jaeger/src/components/page/TraceCompareData.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { ITrace } from '../../utils/interfaces'; +import TraceCompare from './TraceCompare'; +import { addColorForProcesses } from '../../utils/colors'; +import { transformTraceData } from '../../utils/helpers'; + +interface ITraceCompareDataProps { + name: string; + traceData: ITrace; +} + +const TraceCompareData: React.FunctionComponent = ({ + name, + traceData, +}: ITraceCompareDataProps) => { + const trace = transformTraceData(addColorForProcesses([traceData])[0]); + + if (!trace) { + return null; + } + + return ; +}; + +export default TraceCompareData; diff --git a/plugins/jaeger/src/components/page/TraceCompareID.tsx b/plugins/jaeger/src/components/page/TraceCompareID.tsx new file mode 100644 index 000000000..e1c905dec --- /dev/null +++ b/plugins/jaeger/src/components/page/TraceCompareID.tsx @@ -0,0 +1,86 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + PageSection, + PageSectionVariants, + Spinner, +} from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { ITrace } from '../../utils/interfaces'; +import TraceCompare from './TraceCompare'; +import { addColorForProcesses } from '../../utils/colors'; +import { transformTraceData } from '../../utils/helpers'; + +interface ITraceCompareIDProps { + name: string; + traceID: string; +} + +const TraceCompareID: React.FunctionComponent = ({ name, traceID }: ITraceCompareIDProps) => { + const history = useHistory(); + + const { isError, isLoading, error, data, refetch } = useQuery( + ['jaeger/trace', name, traceID], + async () => { + try { + const response = await fetch(`/api/plugins/jaeger/trace/${name}?traceID=${traceID}`, { + method: 'get', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return transformTraceData(addColorForProcesses(json.data)[0]); + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + + history.push('/')}>Home + > => refetch()}> + Retry + +
+ } + > +

{error?.message}

+ + + ); + } + + if (!data) { + return null; + } + + return ; +}; + +export default TraceCompareID; diff --git a/plugins/jaeger/src/components/panel/Traces.tsx b/plugins/jaeger/src/components/panel/Traces.tsx index f17e72f13..0f77fa7d4 100644 --- a/plugins/jaeger/src/components/panel/Traces.tsx +++ b/plugins/jaeger/src/components/panel/Traces.tsx @@ -3,11 +3,12 @@ import { QueryObserverResult, useQuery } from 'react-query'; import React from 'react'; import { IOptions, ITrace } from '../../utils/interfaces'; -import { addColorForProcesses, encodeTags } from '../../utils/helpers'; +import { encodeTags, transformTraceData } from '../../utils/helpers'; import { PluginCard } from '@kobsio/plugin-core'; import TracesActions from './TracesActions'; import TracesChart from './TracesChart'; import TracesList from './TracesList'; +import { addColorForProcesses } from '../../utils/colors'; interface ITracesProps extends IOptions { name: string; @@ -46,7 +47,17 @@ const Traces: React.FunctionComponent = ({ const json = await response.json(); if (response.status >= 200 && response.status < 300) { - return addColorForProcesses(json.data); + const traceData = addColorForProcesses(json.data); + const traces: ITrace[] = []; + + for (const trace of traceData) { + const transformedTrace = transformTraceData(trace); + if (transformedTrace) { + traces.push(transformedTrace); + } + } + + return traces; } else { if (json.error) { throw new Error(json.error); diff --git a/plugins/jaeger/src/components/panel/TracesChart.tsx b/plugins/jaeger/src/components/panel/TracesChart.tsx index 8735fb84a..ce16845dd 100644 --- a/plugins/jaeger/src/components/panel/TracesChart.tsx +++ b/plugins/jaeger/src/components/panel/TracesChart.tsx @@ -5,7 +5,6 @@ import { SquareIcon } from '@patternfly/react-icons'; import { TooltipWrapper } from '@nivo/tooltip'; import Trace from './details/Trace'; -import { getDuration, getRootSpan } from '../../utils/helpers'; import { ITrace } from '../../utils/interfaces'; interface IDatum extends Datum { @@ -39,27 +38,13 @@ const TracesChart: React.FunctionComponent = ({ name, traces, maximalSpans = trace.spans.length; } - const rootSpan = getRootSpan(trace.spans); - if (!rootSpan) { - result.push({ - label: `${trace.traceID}`, - size: trace.spans.length, - trace, - x: new Date(Math.floor(trace.spans[0].startTime / 1000)), - y: getDuration(trace.spans), - }); - } else { - const rootSpanProcess = trace.processes[rootSpan.processID]; - const rootSpanService = rootSpanProcess.serviceName; - - result.push({ - label: `${rootSpanService}: ${rootSpan.operationName}`, - size: trace.spans.length, - trace, - x: new Date(Math.floor(trace.spans[0].startTime / 1000)), - y: getDuration(trace.spans), - }); - } + result.push({ + label: trace.traceName, + size: trace.spans.length, + trace, + x: new Date(Math.floor(trace.spans[0].startTime / 1000)), + y: trace.duration / 1000, + }); }); return { @@ -90,7 +75,7 @@ const TracesChart: React.FunctionComponent = ({ name, traces, enableGridX={false} enableGridY={false} margin={{ bottom: 25, left: 0, right: 0, top: 0 }} - nodeSize={{ key: 'size', sizes: [15, 20], values: [min, max] }} + nodeSize={{ key: 'size', sizes: [15, 75], values: [min, max] }} theme={{ background: '#ffffff', fontFamily: 'RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif', diff --git a/plugins/jaeger/src/components/panel/TracesListItem.tsx b/plugins/jaeger/src/components/panel/TracesListItem.tsx index 1158b27a9..0aed867bc 100644 --- a/plugins/jaeger/src/components/panel/TracesListItem.tsx +++ b/plugins/jaeger/src/components/panel/TracesListItem.tsx @@ -4,15 +4,10 @@ import React from 'react'; import { LinkWrapper } from '@kobsio/plugin-core'; -import { - doesTraceContainsError, - formatTraceTime, - getDuration, - getRootSpan, - getSpansPerServices, -} from '../../utils/helpers'; +import { doesTraceContainsError, formatTraceTime } from '../../utils/helpers'; import { ITrace } from '../../utils/interfaces'; import Trace from './details/Trace'; +import { getColorForService } from '../../utils/colors'; interface ITracesListItemProps { name: string; @@ -25,15 +20,6 @@ const TracesListItem: React.FunctionComponent = ({ trace, showDetails, }: ITracesListItemProps) => { - const rootSpan = getRootSpan(trace.spans); - if (!rootSpan) { - return null; - } - - const rootSpanProcess = trace.processes[rootSpan.processID]; - const rootSpanService = rootSpanProcess.serviceName; - const services = getSpansPerServices(trace); - const card = ( = ({ > - {rootSpanService}: {rootSpan.operationName} + {trace.traceName} {trace.traceID} {doesTraceContainsError(trace) ? ( @@ -59,7 +45,7 @@ const TracesListItem: React.FunctionComponent = ({ - {getDuration(trace.spans)}ms + {trace.duration / 1000}ms @@ -67,13 +53,17 @@ const TracesListItem: React.FunctionComponent = ({ {trace.spans.length} Spans - {Object.keys(services).map((name) => ( - - {services[name].service} ({services[name].spans}) + {trace.services.map((service, index) => ( + + {service.name} ({service.numberOfSpans}) ))} - {formatTraceTime(rootSpan.startTime)} + {formatTraceTime(trace.startTime)} ); diff --git a/plugins/jaeger/src/components/panel/details/Span.tsx b/plugins/jaeger/src/components/panel/details/Span.tsx index 0095e2bb4..92b495dfe 100644 --- a/plugins/jaeger/src/components/panel/details/Span.tsx +++ b/plugins/jaeger/src/components/panel/details/Span.tsx @@ -2,7 +2,7 @@ import { AccordionContent, AccordionItem, AccordionToggle } from '@patternfly/re import React, { useState } from 'react'; import { ExclamationIcon } from '@patternfly/react-icons'; -import { IProcesses, ISpan } from '../../../utils/interfaces'; +import { IProcess, ISpan } from '../../../utils/interfaces'; import SpanLogs from './SpanLogs'; import SpanTag from './SpanTag'; import { doesSpanContainsError } from '../../../utils/helpers'; @@ -12,13 +12,17 @@ const PADDING = 24; export interface ISpanProps { name: string; span: ISpan; - processes: IProcesses; - level: number; + duration: number; + startTime: number; + processes: Record; } -const Span: React.FunctionComponent = ({ name, span, processes, level }: ISpanProps) => { +const Span: React.FunctionComponent = ({ name, span, duration, startTime, processes }: ISpanProps) => { const [expanded, setExpanded] = useState(false); + const offset = ((span.startTime - startTime) / 1000 / (duration / 1000)) * 100; + const fill = (span.duration / 1000 / (duration / 1000)) * 100; + const time = ( = ({ name, span, processes, leve ? processes[span.processID].color : 'var(--pf-global--primary-color--100)', height: '5px', - left: `${span.offset}%`, + left: `${offset}%`, position: 'absolute', - width: `${span.fill}%`, + width: `${fill}%`, }} > ); const treeOffset = []; - for (let index = 0; index < level; index++) { + for (let index = 0; index < span.depth + 1; index++) { if (index > 0) { treeOffset.push( = ({ name, span, processes, leve } return ( - - - {treeOffset} - setExpanded(!expanded)} - isExpanded={expanded} - > - - {processes[span.processID].serviceName}: {span.operationName} - - - {span.spanID} - {doesSpanContainsError(span) ? ( - - ) : null} - - - {span.duration / 1000}ms - - {!expanded && span.fill !== undefined && span.offset !== undefined ? time : null} - - - -
- {processes[span.processID].tags.length > 0 ? ( -
- Process: - {processes[span.processID].tags.map((tag, index) => ( - - ))} -
- ) : null} - {span.tags.length > 0 ? ( -
- Tags: - {span.tags.map((tag, index) => ( - - ))} -
- ) : null} - {span.logs.length > 0 ? ( -
- Logs: - -
- ) : null} -
+ + {treeOffset} + setExpanded(!expanded)} + isExpanded={expanded} + > + + {processes[span.processID].serviceName}: {span.operationName} + + + {span.spanID} + {doesSpanContainsError(span) ? ( + + ) : null} + + + {span.duration / 1000}ms + + {!expanded && fill !== undefined && offset !== undefined ? time : null} + - {expanded && span.fill !== undefined && span.offset !== undefined ? time : null} -
-
+ +
+ {processes[span.processID].tags.length > 0 ? ( +
+ Process: + {processes[span.processID].tags.map((tag, index) => ( + + ))} +
+ ) : null} + {span.tags.length > 0 ? ( +
+ Tags: + {span.tags.map((tag, index) => ( + + ))} +
+ ) : null} + {span.logs.length > 0 ? ( +
+ Logs: + +
+ ) : null} + {span.warnings.length > 0 ? ( +
+ Warnings: + {span.warnings.map((warning, index) => ( +
+ + {warning} + +
+ ))} +
+ ) : null} +
- {span.childs - ? span.childs.map((span, index) => ( - - )) - : null} -
+ {expanded && fill !== undefined && offset !== undefined ? time : null} + + ); }; diff --git a/plugins/jaeger/src/components/panel/details/SpanTag.tsx b/plugins/jaeger/src/components/panel/details/SpanTag.tsx index fd16b9922..cd010f7fd 100644 --- a/plugins/jaeger/src/components/panel/details/SpanTag.tsx +++ b/plugins/jaeger/src/components/panel/details/SpanTag.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import { Tooltip } from '@patternfly/react-core'; -import { IKeyValue } from '../../../utils/interfaces'; +import { IKeyValuePair } from '../../../utils/interfaces'; interface ISpanTagProps { - tag: IKeyValue; + tag: IKeyValuePair; } const SpanTag: React.FunctionComponent = ({ tag }: ISpanTagProps) => { diff --git a/plugins/jaeger/src/components/panel/details/Spans.tsx b/plugins/jaeger/src/components/panel/details/Spans.tsx index b0b730dd5..342aa1e58 100644 --- a/plugins/jaeger/src/components/panel/details/Spans.tsx +++ b/plugins/jaeger/src/components/panel/details/Spans.tsx @@ -1,8 +1,8 @@ import { Accordion, Card, CardBody } from '@patternfly/react-core'; import React from 'react'; +import { Virtuoso } from 'react-virtuoso'; -import { ISpan, ITrace } from '../../../utils/interfaces'; -import { createSpansTree, getDuration, getRootSpan } from '../../../utils/helpers'; +import { ITrace } from '../../../utils/interfaces'; import Span from './Span'; import SpansChart from './SpansChart'; @@ -12,38 +12,50 @@ export interface ISpansProps { } const Spans: React.FunctionComponent = ({ name, trace }: ISpansProps) => { - const rootSpan = trace.spans.length > 0 ? getRootSpan(trace.spans) : undefined; - if (!rootSpan) { - return null; - } - - const duration = getDuration(trace.spans); - const spans: ISpan[] = createSpansTree(trace.spans, rootSpan.startTime, duration); - return ( - - -
20 ? 100 : trace.spans.length * 5}px`, position: 'relative' }}> - {spans.map((span, index) => ( - 20 ? 100 / trace.spans.length : 5} - /> - ))} -
-
-
+ {trace.spans.length <= 100 && ( +
+ + +
20 ? 100 : trace.spans.length * 5}px`, position: 'relative' }} + > + {trace.spans.map((span, index) => ( + 20 ? 100 / trace.spans.length : 5} + /> + ))} +
+
+
-

 

+

 

+
+ )} - {spans.map((span, index) => ( - - ))} +
+ ( + + )} + /> +
diff --git a/plugins/jaeger/src/components/panel/details/SpansChart.tsx b/plugins/jaeger/src/components/panel/details/SpansChart.tsx index 3b37dc7aa..b02b24e2c 100644 --- a/plugins/jaeger/src/components/panel/details/SpansChart.tsx +++ b/plugins/jaeger/src/components/panel/details/SpansChart.tsx @@ -1,21 +1,28 @@ import React from 'react'; -import { IProcesses, ISpan } from '../../../utils/interfaces'; +import { IProcess, ISpan } from '../../../utils/interfaces'; -export interface IJaegerSpansChartProps { +export interface ISpansChartProps { span: ISpan; - processes: IProcesses; + duration: number; + startTime: number; + processes: Record; height: number; } -// JaegerSpansChart is a single line in the chart to visualize the spans over time. The component requires a span, all +// SpansChart is a single line in the chart to visualize the spans over time. The component requires a span, all // processes to set the correct color for the line and a height for the line. The height is calculated by the container // height divided by the number of spans. -const JaegerSpansChart: React.FunctionComponent = ({ +const SpansChart: React.FunctionComponent = ({ span, + duration, + startTime, processes, height, -}: IJaegerSpansChartProps) => { +}: ISpansChartProps) => { + const offset = ((span.startTime - startTime) / 1000 / (duration / 1000)) * 100; + const fill = (span.duration / 1000 / (duration / 1000)) * 100; + return (
@@ -34,21 +41,15 @@ const JaegerSpansChart: React.FunctionComponent = ({ ? processes[span.processID].color : 'var(--pf-global--primary-color--100)', height: `${height}px`, - left: `${span.offset}%`, + left: `${offset}%`, position: 'absolute', - width: `${span.fill}%`, + width: `${fill}%`, }} >
- - {span.childs - ? span.childs.map((span, index) => ( - - )) - : null}
); }; -export default JaegerSpansChart; +export default SpansChart; diff --git a/plugins/jaeger/src/components/panel/details/Trace.tsx b/plugins/jaeger/src/components/panel/details/Trace.tsx index e85259947..fd478ccd5 100644 --- a/plugins/jaeger/src/components/panel/details/Trace.tsx +++ b/plugins/jaeger/src/components/panel/details/Trace.tsx @@ -12,7 +12,6 @@ import Spans from './Spans'; import { Title } from '@kobsio/plugin-core'; import TraceActions from './TraceActions'; import TraceHeader from './TraceHeader'; -import { getRootSpan } from '../../../utils/helpers'; export interface ITraceProps { name: string; @@ -21,18 +20,10 @@ export interface ITraceProps { } const Trace: React.FunctionComponent = ({ name, trace, close }: ITraceProps) => { - const rootSpan = getRootSpan(trace.spans); - if (!rootSpan) { - return null; - } - - const rootSpanProcess = trace.processes[rootSpan.processID]; - const rootSpanService = rootSpanProcess.serviceName; - return ( - + <Title title={trace.traceName} subtitle={trace.traceID} size="lg" /> <DrawerActions style={{ padding: 0 }}> <TraceActions name={name} trace={trace} /> <DrawerCloseButton onClose={close} /> @@ -40,7 +31,7 @@ const Trace: React.FunctionComponent<ITraceProps> = ({ name, trace, close }: ITr </DrawerHead> <DrawerPanelBody> - <TraceHeader trace={trace} rootSpan={rootSpan} /> + <TraceHeader trace={trace} /> <p> </p> <Spans name={name} trace={trace} /> <p> </p> diff --git a/plugins/jaeger/src/components/panel/details/TraceHeader.tsx b/plugins/jaeger/src/components/panel/details/TraceHeader.tsx index 31052bc1d..bd9a641ee 100644 --- a/plugins/jaeger/src/components/panel/details/TraceHeader.tsx +++ b/plugins/jaeger/src/components/panel/details/TraceHeader.tsx @@ -1,29 +1,26 @@ import React from 'react'; -import { ISpan, ITrace } from '../../../utils/interfaces'; -import { formatTraceTime, getDuration, getSpansPerServices } from '../../../utils/helpers'; +import { ITrace } from '../../../utils/interfaces'; +import { formatTraceTime } from '../../../utils/helpers'; export interface ITraceHeaderProps { trace: ITrace; - rootSpan: ISpan; } -const TraceHeader: React.FunctionComponent<ITraceHeaderProps> = ({ trace, rootSpan }: ITraceHeaderProps) => { - const services = getSpansPerServices(trace); - +const TraceHeader: React.FunctionComponent<ITraceHeaderProps> = ({ trace }: ITraceHeaderProps) => { return ( <React.Fragment> <span> <span className="pf-u-color-400">Trace Start: </span> - <b className="pf-u-pr-md">{formatTraceTime(rootSpan.startTime)}</b> + <b className="pf-u-pr-md">{formatTraceTime(trace.startTime)}</b> </span> <span> <span className="pf-u-color-400">Duration: </span> - <b className="pf-u-pr-md">{getDuration(trace.spans)}ms</b> + <b className="pf-u-pr-md">{trace.duration / 1000}ms</b> </span> <span> <span className="pf-u-color-400">Services: </span> - <b className="pf-u-pr-md">{Object.keys(services).length}</b> + <b className="pf-u-pr-md">{trace.services.length}</b> </span> <span> <span className="pf-u-color-400">Total Spans: </span> diff --git a/plugins/jaeger/src/utils/TreeNode.js b/plugins/jaeger/src/utils/TreeNode.js new file mode 100644 index 000000000..ab33a6777 --- /dev/null +++ b/plugins/jaeger/src/utils/TreeNode.js @@ -0,0 +1,120 @@ +// TreeNode is necessary to sort the spans, so children follow parents, and siblings are sorted by start time. +// See: https://github.com/jaegertracing/jaeger-ui/blob/master/packages/jaeger-ui/src/utils/TreeNode.js +// eslint-disable-next-line @typescript-eslint/naming-convention +export default class TreeNode { + static iterFunction(fn, depth = 0) { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + return (node) => fn(node.value, node, depth); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + static searchFunction(search) { + if (typeof search === 'function') { + return search; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + return (value, node) => (search instanceof TreeNode ? node === search : value === search); + } + + constructor(value, children = []) { + this.value = value; + this.children = children; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + get depth() { + return this.children.reduce((depth, child) => Math.max(child.depth + 1, depth), 1); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + get size() { + let i = 0; + this.walk(() => i++); + return i; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + addChild(child) { + this.children.push(child instanceof TreeNode ? child : new TreeNode(child)); + return this; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + find(search) { + const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); + if (searchFn(this)) { + return this; + } + for (let i = 0; i < this.children.length; i++) { + const result = this.children[i].find(search); + if (result) { + return result; + } + } + return null; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + getPath(search) { + const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const findPath = (currentNode, currentPath) => { + // skip if we already found the result + const attempt = currentPath.concat([currentNode]); + // base case: return the array when there is a match + if (searchFn(currentNode)) { + return attempt; + } + for (let i = 0; i < currentNode.children.length; i++) { + const child = currentNode.children[i]; + const match = findPath(child, attempt); + if (match) { + return match; + } + } + return null; + }; + + return findPath(this, []); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + walk(fn, depth = 0) { + const nodeStack = []; + let actualDepth = depth; + nodeStack.push({ depth: actualDepth, node: this }); + while (nodeStack.length) { + const { node, depth: nodeDepth } = nodeStack.pop(); + fn(node.value, node, nodeDepth); + actualDepth = nodeDepth + 1; + let i = node.children.length - 1; + while (i >= 0) { + nodeStack.push({ depth: actualDepth, node: node.children[i] }); + i--; + } + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + paths(fn) { + const stack = []; + stack.push({ childIndex: 0, node: this }); + const paths = []; + while (stack.length) { + const { node, childIndex } = stack[stack.length - 1]; + if (node.children.length >= childIndex + 1) { + stack[stack.length - 1].childIndex++; + stack.push({ childIndex: 0, node: node.children[childIndex] }); + } else { + if (node.children.length === 0) { + const path = stack.map((item) => item.node.value); + fn(path); + } + stack.pop(); + } + } + return paths; + } +} diff --git a/plugins/jaeger/src/utils/colors.ts b/plugins/jaeger/src/utils/colors.ts index 52beceacf..3f618a001 100644 --- a/plugins/jaeger/src/utils/colors.ts +++ b/plugins/jaeger/src/utils/colors.ts @@ -24,6 +24,8 @@ import chart_color_orange_300 from '@patternfly/react-tokens/dist/js/chart_color import chart_color_orange_400 from '@patternfly/react-tokens/dist/js/chart_color_orange_400'; import chart_color_orange_500 from '@patternfly/react-tokens/dist/js/chart_color_orange_500'; +import { IProcess, IProcessColors, ITrace } from './interfaces'; + // We are using the multi color ordered theme from Patternfly for the charts. // See: https://github.com/patternfly/patternfly-react/blob/main/packages/react-charts/src/components/ChartTheme/themes/light/multi-color-ordered-theme.ts export const COLOR_SCALE = [ @@ -59,3 +61,38 @@ export const COLOR_SCALE = [ export const getColor = (index: number): string => { return COLOR_SCALE[index % COLOR_SCALE.length]; }; + +// addColorForProcesses add a color to each process in all the given traces. If a former trace already uses a process, +// with the same service name we reuse the former color. +export const addColorForProcesses = (traces: ITrace[]): ITrace[] => { + const usedColors: IProcessColors = {}; + + for (let i = 0; i < traces.length; i++) { + const processes = Object.keys(traces[i].processes); + + for (let j = 0; j < processes.length; j++) { + const process = processes[j]; + + if (usedColors.hasOwnProperty(traces[i].processes[process].serviceName)) { + traces[i].processes[process].color = usedColors[traces[i].processes[process].serviceName]; + } else { + const color = getColor(j); + usedColors[traces[i].processes[process].serviceName] = color; + traces[i].processes[process].color = color; + } + } + } + + return traces; +}; + +// getColorForService returns the correct color for a given service from the processes. +export const getColorForService = (processes: Record<string, IProcess>, serviceName: string): string => { + for (const process in processes) { + if (processes[process].serviceName === serviceName) { + return processes[process].color || chart_color_blue_300.value; + } + } + + return chart_color_blue_300.value; +}; diff --git a/plugins/jaeger/src/utils/helpers.ts b/plugins/jaeger/src/utils/helpers.ts index 272e949b4..e1b0eafc6 100644 --- a/plugins/jaeger/src/utils/helpers.ts +++ b/plugins/jaeger/src/utils/helpers.ts @@ -1,6 +1,6 @@ -import { IOptions, ISpan, ITrace } from './interfaces'; +import { IDeduplicateTags, IKeyValuePair, IOptions, ISpan, ITrace } from './interfaces'; import { IPluginTimes, TTime, TTimeOptions, formatTime } from '@kobsio/plugin-core'; -import { getColor } from './colors'; +import TreeNode from './TreeNode'; // getOptionsFromSearch is used to get the Jaeger options from a given search location. export const getOptionsFromSearch = (search: string): IOptions => { @@ -57,19 +57,6 @@ export const encodeTags = (tags: string): string => { return encodeURIComponent(JSON.stringify(jsonTags)); }; -// getDuration returns the duration for a trace in milliseconds. -export const getDuration = (spans: ISpan[]): number => { - const startTimes: number[] = []; - const endTimes: number[] = []; - - for (const span of spans) { - startTimes.push(span.startTime); - endTimes.push(span.startTime + span.duration); - } - - return (Math.max(...endTimes) - Math.min(...startTimes)) / 1000; -}; - // formatAxisBottom calculates the format for the bottom axis based on the specified start and end time. export const formatAxisBottom = (times: IPluginTimes): string => { if (times.timeEnd - times.timeStart < 3600) { @@ -112,127 +99,193 @@ export const formatTraceTime = (time: number): string => { return formatTime(Math.floor(time / 1000000)); }; -// getRootSpan returns the first span of a trace. Normally this should be the first span, but sometime it can happen, -// that this isn't the case. So that we have to loop over the spans and then we return the first trace, which doesn't -// have a reference. -export const getRootSpan = (spans: ISpan[]): ISpan | undefined => { - for (const span of spans) { - if (span.references.length === 0 || span.references[0].refType !== 'CHILD_OF') { - return span; - } - } +// getTraceName returns the name of the trace. The name consists out of the first spans service name and operation name. +// See: https://github.com/jaegertracing/jaeger-ui/blob/master/packages/jaeger-ui/src/model/trace-viewer.tsx +const getTraceName = (spans: ISpan[]): string => { + let candidateSpan: ISpan | undefined; + const allIDs: Set<string> = new Set(spans.map(({ spanID }) => spanID)); - return undefined; -}; + for (let i = 0; i < spans.length; i++) { + const hasInternalRef = + spans[i].references && + spans[i].references.some(({ traceID, spanID }) => traceID === spans[i].traceID && allIDs.has(spanID)); + if (hasInternalRef) continue; + + if (!candidateSpan) { + candidateSpan = spans[i]; + continue; + } -// IService is the interface to get the number of spans per service. -export interface IService { - color: string; - service: string; - spans: number; -} + const thisRefLength = (spans[i].references && spans[i].references.length) || 0; + const candidateRefLength = (candidateSpan.references && candidateSpan.references.length) || 0; -export interface IServices { - [key: string]: IService; -} + if ( + thisRefLength < candidateRefLength || + (thisRefLength === candidateRefLength && spans[i].startTime < candidateSpan.startTime) + ) { + candidateSpan = spans[i]; + } + } -// getSpansPerServices returns the number of spans per service. -export const getSpansPerServices = (trace: ITrace): IServices => { - const services: IService[] = Object.keys(trace.processes).map((process) => { - return { - color: trace.processes[process].color - ? (trace.processes[process].color as string) - : 'var(--pf-global--primary-color--100)', - service: trace.processes[process].serviceName, - spans: trace.spans.filter((span) => span.processID === process).length, - }; - }); + return candidateSpan ? `${candidateSpan.process.serviceName}: ${candidateSpan.operationName}` : ''; +}; - const uniqueServices: IServices = {}; - for (const service of services) { - if (uniqueServices.hasOwnProperty(service.service)) { - uniqueServices[service.service] = { - ...uniqueServices[service.service], - spans: uniqueServices[service.service].spans + service.spans, - }; +// deduplicateTags deduplicates the tags of a given span. +// See: https://github.com/jaegertracing/jaeger-ui/blob/master/packages/jaeger-ui/src/model/transform-trace-data.tsx +const deduplicateTags = (spanTags: IKeyValuePair[]): IDeduplicateTags => { + const warningsHash: Map<string, string> = new Map<string, string>(); + const tags: IKeyValuePair[] = spanTags.reduce<IKeyValuePair[]>((uniqueTags, tag) => { + if (!uniqueTags.some((t) => t.key === tag.key && t.value === tag.value)) { + uniqueTags.push(tag); } else { - uniqueServices[service.service] = service; + warningsHash.set(`${tag.key}:${tag.value}`, `Duplicate tag "${tag.key}:${tag.value}"`); } - } - - return uniqueServices; + return uniqueTags; + }, []); + const warnings = Array.from(warningsHash.values()); + return { tags, warnings }; }; -// IMap is the interface for the map of spans in the createSpansTree function. -interface IMap { - [key: string]: number; -} +// getTraceSpanIdsAsTree returns a new TreeNode object, which is used to sort the spans of a trace. The tree is +// necessary to sort the spans, so children follow parents, and siblings are sorted by start time. +// See: https://github.com/jaegertracing/jaeger-ui/blob/master/packages/jaeger-ui/src/selectors/trace.js +const getTraceSpanIdsAsTree = (trace: ITrace): TreeNode => { + const nodesById = new Map(trace.spans.map((span) => [span.spanID, new TreeNode(span.spanID)])); + const spansById = new Map(trace.spans.map((span) => [span.spanID, span])); + const root = new TreeNode('__root__'); + + trace.spans.forEach((span) => { + const node = nodesById.get(span.spanID); + if (Array.isArray(span.references) && span.references.length) { + const { refType, spanID: parentID } = span.references[0]; + if (refType === 'CHILD_OF' || refType === 'FOLLOWS_FROM') { + const parent = nodesById.get(parentID) || root; + parent.children.push(node); + } else { + throw new Error(`Unrecognized ref type: ${refType}`); + } + } else { + root.children.push(node); + } + }); -// createSpansTree creates a tree of spans. This simplifies the frontend code in contrast to working with the flat array -// of spans. -export const createSpansTree = (spans: ISpan[], traceStartTime: number, duration: number): ISpan[] => { - const map: IMap = {}; - const roots: ISpan[] = []; + const comparator = (nodeA: TreeNode, nodeB: TreeNode): number => { + const a = spansById.get(nodeA.value); + const b = spansById.get(nodeB.value); + if (!a || !b) return -1; + return +(a.startTime > b.startTime) || +(a.startTime === b.startTime) - 1; + }; - spans.sort((a, b) => { - if (a.startTime < b.startTime) { - return -1; + trace.spans.forEach((span) => { + const node = nodesById.get(span.spanID); + if (node && node.children.length > 1) { + node.children.sort(comparator); } - - return 1; }); - for (let i = 0; i < spans.length; i++) { - map[spans[i].spanID] = i; - spans[i].childs = []; + root.children.sort(comparator); + return root; +}; - spans[i].offset = ((spans[i].startTime - traceStartTime) / 1000 / duration) * 100; - spans[i].fill = (spans[i].duration / 1000 / duration) * 100; +// transformTraceData transforms a given trace so we can used it within our ui. +// See: https://github.com/jaegertracing/jaeger-ui/blob/master/packages/jaeger-ui/src/model/transform-trace-data.tsx +export const transformTraceData = (data: ITrace): ITrace | null => { + let { traceID } = data; + if (!traceID) { + return null; } + traceID = traceID.toLowerCase(); - for (let i = 0; i < spans.length; i++) { - const span = spans[i]; + let traceEndTime = 0; + let traceStartTime = Number.MAX_SAFE_INTEGER; + const spanIdCounts = new Map(); + const spanMap = new Map<string, ISpan>(); - if (span.references && span.references.length > 0) { - const ref = span.references.filter((reference) => reference.refType === 'CHILD_OF'); + // filter out spans with empty start times + data.spans = data.spans.filter((span) => Boolean(span.startTime)); - if (ref.length > 0 && map.hasOwnProperty(ref[0].spanID)) { - spans[map[ref[0].spanID]].childs?.push(span); - } + const max = data.spans.length; + for (let i = 0; i < max; i++) { + const span: ISpan = data.spans[i] as ISpan; + const { startTime, duration, processID } = span; + + let spanID = span.spanID; + + // check for start / end time for the trace + if (startTime < traceStartTime) { + traceStartTime = startTime; + } + if (startTime + duration > traceEndTime) { + traceEndTime = startTime + duration; + } + + // make sure span IDs are unique + const idCount = spanIdCounts.get(spanID); + if (idCount != null) { + spanIdCounts.set(spanID, idCount + 1); + spanID = `${spanID}_${idCount}`; + span.spanID = spanID; } else { - roots.push(span); + spanIdCounts.set(spanID, 1); } + span.process = data.processes[processID]; + spanMap.set(spanID, span); } - return roots; -}; + const tree = getTraceSpanIdsAsTree(data); + const spans: ISpan[] = []; + const svcCounts: Record<string, number> = {}; -// IProcessColors is the interface we use to store a map of process and colors, so that we can reuse the color for -// processes with the same service name. -interface IProcessColors { - [key: string]: string; -} - -// addColorForProcesses add a color to each process in all the given traces. If a former trace already uses a process, -// with the same service name we reuse the former color. -export const addColorForProcesses = (traces: ITrace[]): ITrace[] => { - const usedColors: IProcessColors = {}; - - for (let i = 0; i < traces.length; i++) { - const processes = Object.keys(traces[i].processes); + tree.walk((spanID: string, node: TreeNode, depth = 0) => { + if (spanID === '__root__') { + return; + } + const span = spanMap.get(spanID) as ISpan; + if (!span) { + return; + } + const { serviceName } = span.process; + svcCounts[serviceName] = (svcCounts[serviceName] || 0) + 1; + span.relativeStartTime = span.startTime - traceStartTime; + span.depth = depth - 1; + span.hasChildren = node.children.length > 0; + span.warnings = span.warnings || []; + span.tags = span.tags || []; + span.references = span.references || []; + const tagsInfo = deduplicateTags(span.tags); + span.tags = tagsInfo.tags; + span.warnings = span.warnings.concat(tagsInfo.warnings); + span.references.forEach((ref, index) => { + const refSpan = spanMap.get(ref.spanID) as ISpan; + if (refSpan) { + ref.span = refSpan; + if (index > 0) { + // Don't take into account the parent, just other references. + refSpan.subsidiarilyReferencedBy = refSpan.subsidiarilyReferencedBy || []; + refSpan.subsidiarilyReferencedBy.push({ + refType: ref.refType, + span, + spanID, + traceID, + }); + } + } + }); - for (let j = 0; j < processes.length; j++) { - const process = processes[j]; + spans.push(span); + }); - if (usedColors.hasOwnProperty(traces[i].processes[process].serviceName)) { - traces[i].processes[process].color = usedColors[traces[i].processes[process].serviceName]; - } else { - const color = getColor(j); - usedColors[traces[i].processes[process].serviceName] = color; - traces[i].processes[process].color = color; - } - } - } + const traceName = getTraceName(spans); + const services = Object.keys(svcCounts).map((name) => ({ name, numberOfSpans: svcCounts[name] })); - return traces; + return { + duration: traceEndTime - traceStartTime, + endTime: traceEndTime, + processes: data.processes, + services, + spans, + startTime: traceStartTime, + traceID, + traceName, + }; }; diff --git a/plugins/jaeger/src/utils/interfaces.ts b/plugins/jaeger/src/utils/interfaces.ts index 0fe0b0e0c..864938f23 100644 --- a/plugins/jaeger/src/utils/interfaces.ts +++ b/plugins/jaeger/src/utils/interfaces.ts @@ -29,52 +29,86 @@ export interface IOperation { spanKind: string; } -// ITrace is the interface for a single trace as it is returned from the API. -export interface ITrace { - traceID: string; - spans: ISpan[]; - processes: IProcesses; +// The following interfaces are used to represent a trace like it returned from the API. +// See: https://github.com/jaegertracing/jaeger-ui/blob/master/packages/jaeger-ui/src/types/trace.tsx +export interface IKeyValuePair { + key: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; } -export interface IKeyValue { - key: string; - type: string; - value: string | boolean | number; +export interface ILink { + url: string; + text: string; } export interface ILog { timestamp: number; - fields: IKeyValue[]; + fields: IKeyValuePair[]; } export interface IProcess { serviceName: string; - tags: IKeyValue[]; + tags: IKeyValuePair[]; + // color is a custom field, which we add after the all traces are loaded from the API. It is used to simplify the + // coloring and to have a consistent color accross all traces. color?: string; } -export interface IProcesses { - [key: string]: IProcess; -} - -export interface IReference { - refType: string; +export interface ISpanReference { + refType: 'CHILD_OF' | 'FOLLOWS_FROM'; + // eslint-disable-next-line no-use-before-define + span: ISpan | null | undefined; spanID: string; traceID: string; } -export interface ISpan { - traceID: string; +export interface ISpanData { spanID: string; - flags: number; + traceID: string; + processID: string; operationName: string; - references: IReference[]; startTime: number; duration: number; - tags: IKeyValue[]; logs: ILog[]; - processID: string; - offset?: number; - fill?: number; - childs?: ISpan[]; + tags?: IKeyValuePair[]; + references?: ISpanReference[]; + warnings?: string[] | null; +} + +export interface ISpan extends ISpanData { + depth: number; + hasChildren: boolean; + process: IProcess; + relativeStartTime: number; + tags: NonNullable<ISpanData['tags']>; + references: NonNullable<ISpanData['references']>; + warnings: NonNullable<ISpanData['warnings']>; + subsidiarilyReferencedBy: ISpanReference[]; +} + +export interface ITraceData { + processes: Record<string, IProcess>; + traceID: string; +} + +export interface ITrace extends ITraceData { + duration: number; + endTime: number; + spans: ISpan[]; + startTime: number; + traceName: string; + services: { name: string; numberOfSpans: number }[]; +} + +// IProcessColors is the interface we use to store a map of process and colors, so that we can reuse the color for +// processes with the same service name. +export interface IProcessColors { + [key: string]: string; +} + +// IDeduplicateTags is the interface, which is returned by the deduplicateTags function. +export interface IDeduplicateTags { + tags: IKeyValuePair[]; + warnings: string[]; } diff --git a/yarn.lock b/yarn.lock index 9fec36ad4..bdf768adf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3272,6 +3272,18 @@ "@typescript-eslint/types" "4.29.1" eslint-visitor-keys "^2.0.0" +"@virtuoso.dev/react-urx@^0.2.5": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@virtuoso.dev/react-urx/-/react-urx-0.2.6.tgz#e1d8bc717723b2fc23d80ea4e07703dbc276448b" + integrity sha512-+PLQ2iWmSH/rW7WGPEf+Kkql+xygHFL43Jij5aREde/O9mE0OFFGqeetA2a6lry3LDVWzupPntvvWhdaYw0TyA== + dependencies: + "@virtuoso.dev/urx" "^0.2.6" + +"@virtuoso.dev/urx@^0.2.5", "@virtuoso.dev/urx@^0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@virtuoso.dev/urx/-/urx-0.2.6.tgz#0028c49e52037e673993900d32abea83262fbd53" + integrity sha512-EKJ0WvJgWaXIz6zKbh9Q63Bcq//p8OHXHbdz4Fy+ruhjJCyI8ADE8E5gwSqBoUchaiYlgwKrT+sX4L2h/H+hMg== + "@webassemblyjs/ast@1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" @@ -12394,6 +12406,15 @@ react-scripts@4.0.3: optionalDependencies: fsevents "^2.1.3" +react-virtuoso@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-1.11.1.tgz#350dc88866b3f42cb396bf8f40b90efc6daaadde" + integrity sha512-NH8eLLmoowdq0x7/AEfXxOY9OY4PDpLuPPmCc8F2IoBQLvIcMait3A2TnR0fT9UT+gl8n7GHoORzeGqF5Ka0MA== + dependencies: + "@virtuoso.dev/react-urx" "^0.2.5" + "@virtuoso.dev/urx" "^0.2.5" + resize-observer-polyfill "^1.5.1" + react@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"