From 045524adcebfef1b2cc2e3ca2263fcefdeec2b64 Mon Sep 17 00:00:00 2001 From: divdavem Date: Fri, 3 Aug 2018 03:44:40 +0200 Subject: [PATCH] [WIP] Add links to make values in tags or log properties clickable (#223) * Link patterns to make values in tags, processes and logs clickable Signed-off-by: David-Emmanuel Divernois * Fixing failing test Signed-off-by: David-Emmanuel Divernois * Moving icons closer to the value to make them more visible Signed-off-by: David-Emmanuel Divernois * Adding some tests Signed-off-by: David-Emmanuel Divernois * Fixing misplaced "// eslint-disable-next-line react/no-danger" comment Signed-off-by: David-Emmanuel Divernois * Using ub-inline-block instead of manipulating HTML in KeyValuesTable (as recommended by Joe Farro) Signed-off-by: David-Emmanuel Divernois * Adding tests for createTestFunction Signed-off-by: David-Emmanuel Divernois * Adding tests for getParameterInArray Signed-off-by: David-Emmanuel Divernois * Adding tests for getParameterInAncestor Signed-off-by: David-Emmanuel Divernois * Adding test for SpanDetailRow Signed-off-by: David-Emmanuel Divernois * Adding test for callTemplate Signed-off-by: David-Emmanuel Divernois * Adding test for computeLinks Signed-off-by: David-Emmanuel Divernois * Changes following code review Signed-off-by: David-Emmanuel Divernois * Adding tests for getLinks Signed-off-by: David-Emmanuel Divernois * Changes following code review Signed-off-by: David-Emmanuel Divernois * Using # instead of $ in link templates Signed-off-by: David-Emmanuel Divernois * Adding a reference to the parent span in the child spans Signed-off-by: David-Emmanuel Divernois * Merging addSpanReferences with transfromTraceData and making span optional in SpanReference Signed-off-by: David-Emmanuel Divernois --- .../SpanDetail/AccordianKeyValues.js | 10 +- .../SpanDetail/AccordianLogs.js | 6 +- .../SpanDetail/KeyValuesTable.css | 10 + .../SpanDetail/KeyValuesTable.js | 63 ++- .../SpanDetail/KeyValuesTable.test.js | 56 +++ .../TraceTimelineViewer/SpanDetail/index.js | 17 +- .../TraceTimelineViewer/SpanDetailRow.js | 9 +- .../TraceTimelineViewer/SpanDetailRow.test.js | 15 + .../VirtualizedTraceView.js | 6 +- .../jaeger-ui/src/constants/default-config.js | 1 + packages/jaeger-ui/src/model/link-patterns.js | 210 ++++++++++ .../jaeger-ui/src/model/link-patterns.test.js | 396 ++++++++++++++++++ packages/jaeger-ui/src/model/span.js | 27 ++ .../src/model/transform-trace-data.js | 28 +- packages/jaeger-ui/src/types/index.js | 9 +- 15 files changed, 828 insertions(+), 35 deletions(-) create mode 100644 packages/jaeger-ui/src/model/link-patterns.js create mode 100644 packages/jaeger-ui/src/model/link-patterns.test.js create mode 100644 packages/jaeger-ui/src/model/span.js diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js index d0eb11d797..94c46fe8d2 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js @@ -21,20 +21,22 @@ import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import * as markers from './AccordianKeyValues.markers'; import KeyValuesTable from './KeyValuesTable'; +import type { KeyValuePair, Link } from '../../../../types'; import './AccordianKeyValues.css'; type AccordianKeyValuesProps = { className?: ?string, - data: { key: string, value: any }[], + data: KeyValuePair[], highContrast?: boolean, isOpen: boolean, label: string, + linksGetter: ?(KeyValuePair[], number) => Link[], onToggle: () => void, }; // export for tests -export function KeyValuesSummary(props: { data?: { key: string, value: any }[] }) { +export function KeyValuesSummary(props: { data?: KeyValuePair[] }) { const { data } = props; if (!Array.isArray(data) || !data.length) { return null; @@ -59,7 +61,7 @@ KeyValuesSummary.defaultProps = { }; export default function AccordianKeyValues(props: AccordianKeyValuesProps) { - const { className, data, highContrast, isOpen, label, onToggle } = props; + const { className, data, highContrast, isOpen, label, linksGetter, onToggle } = props; const isEmpty = !Array.isArray(data) || !data.length; const iconCls = cx('u-align-icon', { 'AccordianKeyValues--emptyIcon': isEmpty }); return ( @@ -80,7 +82,7 @@ export default function AccordianKeyValues(props: AccordianKeyValuesProps) { {!isOpen && } - {isOpen && } + {isOpen && } ); } diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js index a3df52a8bc..5f103977a8 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js @@ -21,12 +21,13 @@ import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import AccordianKeyValues from './AccordianKeyValues'; import { formatDuration } from '../utils'; -import type { Log } from '../../../../types'; +import type { Log, KeyValuePair, Link } from '../../../../types'; import './AccordianLogs.css'; type AccordianLogsProps = { isOpen: boolean, + linksGetter: ?(KeyValuePair[], number) => Link[], logs: Log[], onItemToggle: Log => void, onToggle: () => void, @@ -35,7 +36,7 @@ type AccordianLogsProps = { }; export default function AccordianLogs(props: AccordianLogsProps) { - const { isOpen, logs, openedItems, onItemToggle, onToggle, timestamp } = props; + const { isOpen, linksGetter, logs, openedItems, onItemToggle, onToggle, timestamp } = props; return (
@@ -59,6 +60,7 @@ export default function AccordianLogs(props: AccordianLogsProps) { // compact highContrast isOpen={openedItems.has(log)} + linksGetter={linksGetter} data={log.fields || []} label={`${formatDuration(log.timestamp - timestamp)}`} onToggle={() => onItemToggle(log)} diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.css b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.css index 1add328273..826cd97a05 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.css +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.css @@ -39,3 +39,13 @@ limitations under the License. white-space: pre; width: 125px; } + +.KeyValueTable--body > tr > td { + padding: 0.25rem 0.5rem; + vertical-align: top; +} + +.KeyValueTable--linkIcon { + vertical-align: middle; + font-weight: bold; +} diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js index 8abffdeb78..42728dc685 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js @@ -14,8 +14,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react'; +import * as React from 'react'; import jsonMarkup from 'json-markup'; +import { Dropdown, Icon, Menu } from 'antd'; +import type { KeyValuePair, Link } from '../../../../types'; import './KeyValuesTable.css'; @@ -30,27 +32,70 @@ function parseIfJson(value) { return value; } +const LinkValue = (props: { href: string, title?: string, children: React.Node }) => ( + + {props.children} + +); + +const linkValueList = (links: Link[]) => ( + + {links.map(({ text, url }, index) => ( + // `index` is necessary in the key because url can repeat + // eslint-disable-next-line react/no-array-index-key + + {text} + + ))} + +); + type KeyValuesTableProps = { - data: { key: string, value: any }[], + data: KeyValuePair[], + linksGetter: ?(KeyValuePair[], number) => Link[], }; export default function KeyValuesTable(props: KeyValuesTableProps) { - const { data } = props; + const { data, linksGetter } = props; return (
{data.map((row, i) => { - const jsonTable = ( - // eslint-disable-next-line react/no-danger -
- ); + const markup = { + __html: jsonMarkup(parseIfJson(row.value)), + }; + // eslint-disable-next-line react/no-danger + const jsonTable =
; + const links = linksGetter ? linksGetter(data, i) : null; + let valueMarkup; + if (links && links.length === 1) { + valueMarkup = ( +
+ + {jsonTable} + +
+ ); + } else if (links && links.length > 1) { + valueMarkup = ( + + ); + } else { + valueMarkup = jsonTable; + } return ( // `i` is necessary in the key because row.key can repeat // eslint-disable-next-line react/no-array-index-key
- + ); })} @@ -59,3 +104,5 @@ export default function KeyValuesTable(props: KeyValuesTableProps) { ); } + +KeyValuesTable.LinkValue = LinkValue; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js index f82e062d1f..653c8104af 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { Dropdown } from 'antd'; import KeyValuesTable from './KeyValuesTable'; @@ -38,4 +39,59 @@ describe('', () => { expect(tr.find('.KeyValueTable--keyColumn').text()).toMatch(data[i].key); }); }); + + it('renders a single link correctly', () => { + wrapper.setProps({ + linksGetter: (array, i) => + array[i].key === 'span.kind' + ? [ + { + url: `http://example.com/?kind=${encodeURIComponent(array[i].value)}`, + text: `More info about ${array[i].value}`, + }, + ] + : [], + }); + + const anchor = wrapper.find(KeyValuesTable.LinkValue); + expect(anchor).toHaveLength(1); + expect(anchor.prop('href')).toBe('http://example.com/?kind=client'); + expect(anchor.prop('title')).toBe('More info about client'); + expect( + anchor + .closest('tr') + .find('td') + .first() + .text() + ).toBe('span.kind'); + }); + + it('renders multiple links correctly', () => { + wrapper.setProps({ + linksGetter: (array, i) => + array[i].key === 'span.kind' + ? [ + { url: `http://example.com/1?kind=${encodeURIComponent(array[i].value)}`, text: 'Example 1' }, + { url: `http://example.com/2?kind=${encodeURIComponent(array[i].value)}`, text: 'Example 2' }, + ] + : [], + }); + const dropdown = wrapper.find(Dropdown); + const menu = shallow(dropdown.prop('overlay')); + const anchors = menu.find(KeyValuesTable.LinkValue); + expect(anchors).toHaveLength(2); + const firstAnchor = anchors.first(); + expect(firstAnchor.prop('href')).toBe('http://example.com/1?kind=client'); + expect(firstAnchor.children().text()).toBe('Example 1'); + const secondAnchor = anchors.last(); + expect(secondAnchor.prop('href')).toBe('http://example.com/2?kind=client'); + expect(secondAnchor.children().text()).toBe('Example 2'); + expect( + dropdown + .closest('tr') + .find('td') + .first() + .text() + ).toBe('span.kind'); + }); }); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js index cec43234ad..f97995783b 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js @@ -22,12 +22,13 @@ import AccordianLogs from './AccordianLogs'; import DetailState from './DetailState'; import { formatDuration } from '../utils'; import LabeledList from '../../../common/LabeledList'; -import type { Log, Span } from '../../../../types'; +import type { Log, Span, KeyValuePair, Link } from '../../../../types'; import './index.css'; type SpanDetailProps = { detailState: DetailState, + linksGetter: ?(KeyValuePair[], number) => Link[], logItemToggle: (string, Log) => void, logsToggle: string => void, processToggle: string => void, @@ -37,7 +38,16 @@ type SpanDetailProps = { }; export default function SpanDetail(props: SpanDetailProps) { - const { detailState, logItemToggle, logsToggle, processToggle, span, tagsToggle, traceStartTime } = props; + const { + detailState, + linksGetter, + logItemToggle, + logsToggle, + processToggle, + span, + tagsToggle, + traceStartTime, + } = props; const { isTagsOpen, isProcessOpen, logs: logsState } = detailState; const { operationName, process, duration, relativeStartTime, spanID, logs, tags } = span; const overviewItems = [ @@ -73,6 +83,7 @@ export default function SpanDetail(props: SpanDetailProps) { tagsToggle(spanID)} /> @@ -81,6 +92,7 @@ export default function SpanDetail(props: SpanDetailProps) { className="ub-mb1" data={process.tags} label="Process" + linksGetter={linksGetter} isOpen={isProcessOpen} onToggle={() => processToggle(spanID)} /> @@ -89,6 +101,7 @@ export default function SpanDetail(props: SpanDetailProps) { {logs && logs.length > 0 && ( void, isFilteredOut: boolean, + linksGetter: ?(Span, KeyValuePair[], number) => Link[], logItemToggle: (string, Log) => void, logsToggle: string => void, processToggle: string => void, @@ -45,6 +46,11 @@ export default class SpanDetailRow extends React.PureComponent { + const { linksGetter, span } = this.props; + return linksGetter ? linksGetter(span, items, itemIndex) : []; + }; + render() { const { color, @@ -76,6 +82,7 @@ export default class SpanDetailRow extends React.PureComponent ', () => { columnDivision: 0.5, detailState: new DetailState(), onDetailToggled: jest.fn(), + linksGetter: jest.fn(), isFilteredOut: false, logItemToggle: jest.fn(), logsToggle: jest.fn(), @@ -40,6 +41,7 @@ describe('', () => { beforeEach(() => { props.onDetailToggled.mockReset(); + props.linksGetter.mockReset(); props.logItemToggle.mockReset(); props.logsToggle.mockReset(); props.processToggle.mockReset(); @@ -72,6 +74,7 @@ describe('', () => { const spanDetail = ( ', () => { ); expect(wrapper.contains(spanDetail)).toBe(true); }); + + it('adds span when calling linksGetter', () => { + const spanDetail = wrapper.find(SpanDetail); + const linksGetter = spanDetail.prop('linksGetter'); + const tags = [{ key: 'myKey', value: 'myValue' }]; + const linksGetterResponse = {}; + props.linksGetter.mockReturnValueOnce(linksGetterResponse); + const result = linksGetter(tags, 0); + expect(result).toBe(linksGetterResponse); + expect(props.linksGetter).toHaveBeenCalledTimes(1); + expect(props.linksGetter).toHaveBeenCalledWith(props.span, tags, 0); + }); }); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js index c19e87d099..cd3046df10 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js @@ -31,8 +31,9 @@ import { isErrorSpan, spanContainsErredSpan, } from './utils'; +import getLinks from '../../../model/link-patterns'; import type { Accessors } from '../ScrollManager'; -import type { Log, Span, Trace } from '../../../types'; +import type { Log, Span, Trace, KeyValuePair } from '../../../types'; import colorGenerator from '../../../utils/color-generator'; import './VirtualizedTraceView.css'; @@ -264,6 +265,8 @@ export class VirtualizedTraceViewImpl extends React.PureComponent getLinks(span, items, itemIndex); + renderRow = (key: string, style: Style, index: number, attrs: {}) => { const { isDetail, span, spanIndex } = this.rowStates[index]; return isDetail @@ -380,6 +383,7 @@ export class VirtualizedTraceViewImpl extends React.PureComponent string, +}; + +type ProcessedLinkPattern = { + object: any, + type: string => boolean, + key: string => boolean, + value: any => boolean, + url: ProcessedTemplate, + text: ProcessedTemplate, + parameters: string[], +}; + +function getParamNames(str) { + const names = new Set(); + str.replace(parameterRegExp, (match, name) => { + names.add(name); + return match; + }); + return Array.from(names); +} + +function stringSupplant(str, encodeFn: any => string, map) { + return str.replace(parameterRegExp, (_, name) => { + const value = map[name]; + return value == null ? '' : encodeFn(value); + }); +} + +export function processTemplate(template: any, encodeFn: any => string): ProcessedTemplate { + if (typeof template !== 'string') { + /* + + // kept on ice until #123 is implemented: + if (template && Array.isArray(template.parameters) && (typeof template.template === 'function')) { + return template; + } + + */ + throw new Error('Invalid template'); + } + return { + parameters: getParamNames(template), + template: stringSupplant.bind(null, template, encodeFn), + }; +} + +export function createTestFunction(entry: any) { + if (typeof entry === 'string') { + return (arg: any) => arg === entry; + } + if (Array.isArray(entry)) { + return (arg: any) => entry.indexOf(arg) > -1; + } + /* + + // kept on ice until #123 is implemented: + if (entry instanceof RegExp) { + return (arg: any) => entry.test(arg); + } + if (typeof entry === 'function') { + return entry; + } + + */ + if (entry == null) { + return () => true; + } + throw new Error(`Invalid value: ${entry}`); +} + +const identity = a => a; + +export function processLinkPattern(pattern: any): ?ProcessedLinkPattern { + try { + const url = processTemplate(pattern.url, encodeURIComponent); + const text = processTemplate(pattern.text, identity); + return { + object: pattern, + type: createTestFunction(pattern.type), + key: createTestFunction(pattern.key), + value: createTestFunction(pattern.value), + url, + text, + parameters: _uniq(url.parameters.concat(text.parameters)), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Ignoring invalid link pattern: ${error}`, pattern); + return null; + } +} + +export function getParameterInArray(name: string, array: KeyValuePair[]) { + if (array) { + return array.find(entry => entry.key === name); + } + return undefined; +} + +export function getParameterInAncestor(name: string, span: Span) { + let currentSpan = span; + while (currentSpan) { + const result = + getParameterInArray(name, currentSpan.tags) || getParameterInArray(name, currentSpan.process.tags); + if (result) { + return result; + } + currentSpan = getParent(currentSpan); + } + return undefined; +} + +function callTemplate(template, data) { + return template.template(data); +} + +export function computeLinks( + linkPatterns: ProcessedLinkPattern[], + span: Span, + items: KeyValuePair[], + itemIndex: number +) { + const item = items[itemIndex]; + let type = 'logs'; + const processTags = span.process.tags === items; + if (processTags) { + type = 'process'; + } + const spanTags = span.tags === items; + if (spanTags) { + type = 'tags'; + } + const result = []; + linkPatterns.forEach(pattern => { + if (pattern.type(type) && pattern.key(item.key) && pattern.value(item.value)) { + const parameterValues = {}; + const allParameters = pattern.parameters.every(parameter => { + let entry = getParameterInArray(parameter, items); + if (!entry && !processTags) { + // do not look in ancestors for process tags because the same object may appear in different places in the hierarchy + // and the cache in getLinks uses that object as a key + entry = getParameterInAncestor(parameter, span); + } + if (entry) { + parameterValues[parameter] = entry.value; + return true; + } + // eslint-disable-next-line no-console + console.warn( + `Skipping link pattern, missing parameter ${parameter} for key ${item.key} in ${type}.`, + pattern.object + ); + return false; + }); + if (allParameters) { + result.push({ + url: callTemplate(pattern.url, parameterValues), + text: callTemplate(pattern.text, parameterValues), + }); + } + } + }); + return result; +} + +export function createGetLinks(linkPatterns: ProcessedLinkPattern[], cache: WeakMap) { + return (span: Span, items: KeyValuePair[], itemIndex: number) => { + if (linkPatterns.length === 0) { + return []; + } + const item = items[itemIndex]; + let result = cache.get(item); + if (!result) { + result = computeLinks(linkPatterns, span, items, itemIndex); + cache.set(item, result); + } + return result; + }; +} + +export default createGetLinks( + (getConfigValue('linkPatterns') || []).map(processLinkPattern).filter(Boolean), + new WeakMap() +); diff --git a/packages/jaeger-ui/src/model/link-patterns.test.js b/packages/jaeger-ui/src/model/link-patterns.test.js new file mode 100644 index 0000000000..8694c8f0ff --- /dev/null +++ b/packages/jaeger-ui/src/model/link-patterns.test.js @@ -0,0 +1,396 @@ +// Copyright (c) 2017 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + processTemplate, + createTestFunction, + getParameterInArray, + getParameterInAncestor, + processLinkPattern, + computeLinks, + createGetLinks, +} from './link-patterns'; + +describe('processTemplate()', () => { + it('correctly replaces variables', () => { + const processedTemplate = processTemplate( + 'this is a test with #{oneVariable}#{anotherVariable} and the same #{oneVariable}', + a => a + ); + expect(processedTemplate.parameters).toEqual(['oneVariable', 'anotherVariable']); + expect(processedTemplate.template({ oneVariable: 'MYFIRSTVAR', anotherVariable: 'SECOND' })).toBe( + 'this is a test with MYFIRSTVARSECOND and the same MYFIRSTVAR' + ); + }); + + it('correctly uses the encoding function', () => { + const processedTemplate = processTemplate( + 'this is a test with #{oneVariable}#{anotherVariable} and the same #{oneVariable}', + e => `/${e}\\` + ); + expect(processedTemplate.parameters).toEqual(['oneVariable', 'anotherVariable']); + expect(processedTemplate.template({ oneVariable: 'MYFIRSTVAR', anotherVariable: 'SECOND' })).toBe( + 'this is a test with /MYFIRSTVAR\\/SECOND\\ and the same /MYFIRSTVAR\\' + ); + }); + + /* + // kept on ice until #123 is implemented: + + it('correctly returns the same object when passing an already processed template', () => { + const alreadyProcessed = { + parameters: ['b'], + template: data => `a${data.b}c`, + }; + const processedTemplate = processTemplate(alreadyProcessed, a => a); + expect(processedTemplate).toBe(alreadyProcessed); + }); + + */ + + it('reports an error when passing an object that does not look like an already processed template', () => { + expect(() => + processTemplate( + { + template: data => `a${data.b}c`, + }, + a => a + ) + ).toThrow(); + expect(() => + processTemplate( + { + parameters: ['b'], + }, + a => a + ) + ).toThrow(); + expect(() => processTemplate({}, a => a)).toThrow(); + }); +}); + +describe('createTestFunction()', () => { + it('accepts a string', () => { + const testFn = createTestFunction('myValue'); + expect(testFn('myValue')).toBe(true); + expect(testFn('myFirstValue')).toBe(false); + expect(testFn('mySecondValue')).toBe(false); + expect(testFn('otherValue')).toBe(false); + }); + + it('accepts an array', () => { + const testFn = createTestFunction(['myFirstValue', 'mySecondValue']); + expect(testFn('myValue')).toBe(false); + expect(testFn('myFirstValue')).toBe(true); + expect(testFn('mySecondValue')).toBe(true); + expect(testFn('otherValue')).toBe(false); + }); + + /* + // kept on ice until #123 is implemented: + + it('accepts a regular expression', () => { + const testFn = createTestFunction(/^my.*Value$/); + expect(testFn('myValue')).toBe(true); + expect(testFn('myFirstValue')).toBe(true); + expect(testFn('mySecondValue')).toBe(true); + expect(testFn('otherValue')).toBe(false); + }); + + it('accepts a function', () => { + const mockCallback = jest.fn(); + mockCallback + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + .mockReturnValue(false); + const testFn = createTestFunction(mockCallback); + expect(testFn('myValue')).toBe(true); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith('myValue'); + expect(testFn('myFirstValue')).toBe(false); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenCalledWith('myFirstValue'); + expect(testFn('mySecondValue')).toBe(true); + expect(mockCallback).toHaveBeenCalledTimes(3); + expect(mockCallback).toHaveBeenCalledWith('mySecondValue'); + expect(testFn('otherValue')).toBe(false); + expect(mockCallback).toHaveBeenCalledTimes(4); + expect(mockCallback).toHaveBeenCalledWith('otherValue'); + }); + + */ + + it('accepts undefined', () => { + const testFn = createTestFunction(); + expect(testFn('myValue')).toBe(true); + expect(testFn('myFirstValue')).toBe(true); + expect(testFn('mySecondValue')).toBe(true); + expect(testFn('otherValue')).toBe(true); + }); + + it('rejects unknown values', () => { + expect(() => createTestFunction({})).toThrow(); + expect(() => createTestFunction(true)).toThrow(); + expect(() => createTestFunction(false)).toThrow(); + expect(() => createTestFunction(0)).toThrow(); + expect(() => createTestFunction(5)).toThrow(); + }); +}); + +describe('getParameterInArray()', () => { + const data = [{ key: 'mykey', value: 'ok' }, { key: 'otherkey', value: 'v' }]; + + it('returns an entry that is present', () => { + expect(getParameterInArray('mykey', data)).toBe(data[0]); + expect(getParameterInArray('otherkey', data)).toBe(data[1]); + }); + + it('returns undefined when the entry cannot be found', () => { + expect(getParameterInArray('myotherkey', data)).toBeUndefined(); + }); + + it('returns undefined when there is no array', () => { + expect(getParameterInArray('otherkey')).toBeUndefined(); + expect(getParameterInArray('otherkey', null)).toBeUndefined(); + }); +}); + +describe('getParameterInAncestor()', () => { + const spans = [ + { + depth: 0, + process: { + tags: [ + { key: 'a', value: 'a7' }, + { key: 'b', value: 'b7' }, + { key: 'c', value: 'c7' }, + { key: 'd', value: 'd7' }, + { key: 'e', value: 'e7' }, + { key: 'f', value: 'f7' }, + { key: 'g', value: 'g7' }, + { key: 'h', value: 'h7' }, + ], + }, + tags: [ + { key: 'a', value: 'a6' }, + { key: 'b', value: 'b6' }, + { key: 'c', value: 'c6' }, + { key: 'd', value: 'd6' }, + { key: 'e', value: 'e6' }, + { key: 'f', value: 'f6' }, + { key: 'g', value: 'g6' }, + ], + }, + { + depth: 1, + process: { + tags: [ + { key: 'a', value: 'a5' }, + { key: 'b', value: 'b5' }, + { key: 'c', value: 'c5' }, + { key: 'd', value: 'd5' }, + { key: 'e', value: 'e5' }, + { key: 'f', value: 'f5' }, + ], + }, + tags: [ + { key: 'a', value: 'a4' }, + { key: 'b', value: 'b4' }, + { key: 'c', value: 'c4' }, + { key: 'd', value: 'd4' }, + { key: 'e', value: 'e4' }, + ], + }, + { + depth: 1, + process: { + tags: [ + { key: 'a', value: 'a3' }, + { key: 'b', value: 'b3' }, + { key: 'c', value: 'c3' }, + { key: 'd', value: 'd3' }, + ], + }, + tags: [{ key: 'a', value: 'a2' }, { key: 'b', value: 'b2' }, { key: 'c', value: 'c2' }], + }, + { + depth: 2, + process: { + tags: [{ key: 'a', value: 'a1' }, { key: 'b', value: 'b1' }], + }, + tags: [{ key: 'a', value: 'a0' }], + }, + ]; + spans[1].references = [ + { + refType: 'CHILD_OF', + span: spans[0], + }, + ]; + spans[2].references = [ + { + refType: 'CHILD_OF', + span: spans[0], + }, + ]; + spans[3].references = [ + { + refType: 'CHILD_OF', + span: spans[2], + }, + ]; + + it('uses current span tags', () => { + expect(getParameterInAncestor('a', spans[3])).toEqual({ key: 'a', value: 'a0' }); + expect(getParameterInAncestor('a', spans[2])).toEqual({ key: 'a', value: 'a2' }); + expect(getParameterInAncestor('a', spans[1])).toEqual({ key: 'a', value: 'a4' }); + expect(getParameterInAncestor('a', spans[0])).toEqual({ key: 'a', value: 'a6' }); + }); + + it('uses current span process tags', () => { + expect(getParameterInAncestor('b', spans[3])).toEqual({ key: 'b', value: 'b1' }); + expect(getParameterInAncestor('d', spans[2])).toEqual({ key: 'd', value: 'd3' }); + expect(getParameterInAncestor('f', spans[1])).toEqual({ key: 'f', value: 'f5' }); + expect(getParameterInAncestor('h', spans[0])).toEqual({ key: 'h', value: 'h7' }); + }); + + it('uses parent span tags', () => { + expect(getParameterInAncestor('c', spans[3])).toEqual({ key: 'c', value: 'c2' }); + expect(getParameterInAncestor('e', spans[2])).toEqual({ key: 'e', value: 'e6' }); + expect(getParameterInAncestor('f', spans[2])).toEqual({ key: 'f', value: 'f6' }); + expect(getParameterInAncestor('g', spans[2])).toEqual({ key: 'g', value: 'g6' }); + expect(getParameterInAncestor('g', spans[1])).toEqual({ key: 'g', value: 'g6' }); + }); + + it('uses parent span process tags', () => { + expect(getParameterInAncestor('d', spans[3])).toEqual({ key: 'd', value: 'd3' }); + expect(getParameterInAncestor('h', spans[2])).toEqual({ key: 'h', value: 'h7' }); + expect(getParameterInAncestor('h', spans[1])).toEqual({ key: 'h', value: 'h7' }); + }); + + it('uses grand-parent span tags', () => { + expect(getParameterInAncestor('e', spans[3])).toEqual({ key: 'e', value: 'e6' }); + expect(getParameterInAncestor('f', spans[3])).toEqual({ key: 'f', value: 'f6' }); + expect(getParameterInAncestor('g', spans[3])).toEqual({ key: 'g', value: 'g6' }); + }); + + it('uses grand-parent process tags', () => { + expect(getParameterInAncestor('h', spans[3])).toEqual({ key: 'h', value: 'h7' }); + }); + + it('returns undefined when the entry cannot be found', () => { + expect(getParameterInAncestor('i', spans[3])).toBeUndefined(); + }); + + it('does not break if some tags are not defined', () => { + const spansWithUndefinedTags = [ + { + depth: 0, + process: {}, + }, + ]; + expect(getParameterInAncestor('a', spansWithUndefinedTags[0])).toBeUndefined(); + }); +}); + +describe('computeLinks()', () => { + const linkPatterns = [ + { + type: 'tags', + key: 'myKey', + url: 'http://example.com/?myKey=#{myKey}', + text: 'first link (#{myKey})', + }, + { + key: 'myOtherKey', + url: 'http://example.com/?myKey=#{myOtherKey}&myKey=#{myKey}', + text: 'second link (#{myOtherKey})', + }, + ].map(processLinkPattern); + + const spans = [ + { depth: 0, process: {}, tags: [{ key: 'myKey', value: 'valueOfMyKey' }] }, + { depth: 1, process: {}, logs: [{ fields: [{ key: 'myOtherKey', value: 'valueOfMy+Other+Key' }] }] }, + ]; + spans[1].references = [ + { + refType: 'CHILD_OF', + span: spans[0], + }, + ]; + + it('correctly computes links', () => { + expect(computeLinks(linkPatterns, spans[0], spans[0].tags, 0)).toEqual([ + { + url: 'http://example.com/?myKey=valueOfMyKey', + text: 'first link (valueOfMyKey)', + }, + ]); + expect(computeLinks(linkPatterns, spans[1], spans[1].logs[0].fields, 0)).toEqual([ + { + url: 'http://example.com/?myKey=valueOfMy%2BOther%2BKey&myKey=valueOfMyKey', + text: 'second link (valueOfMy+Other+Key)', + }, + ]); + }); +}); + +describe('getLinks()', () => { + const linkPatterns = [ + { + key: 'mySpecialKey', + url: 'http://example.com/?mySpecialKey=#{mySpecialKey}', + text: 'special key link (#{mySpecialKey})', + }, + ].map(processLinkPattern); + const template = jest.spyOn(linkPatterns[0].url, 'template'); + + const span = { depth: 0, process: {}, tags: [{ key: 'mySpecialKey', value: 'valueOfMyKey' }] }; + + let cache; + + beforeEach(() => { + cache = new WeakMap(); + template.mockClear(); + }); + + it('does not access the cache if there is no link pattern', () => { + cache.get = jest.fn(); + const getLinks = createGetLinks([], cache); + expect(getLinks(span, span.tags, 0)).toEqual([]); + expect(cache.get).not.toHaveBeenCalled(); + }); + + it('returns the result from the cache', () => { + const result = []; + cache.set(span.tags[0], result); + const getLinks = createGetLinks(linkPatterns, cache); + expect(getLinks(span, span.tags, 0)).toBe(result); + expect(template).not.toHaveBeenCalled(); + }); + + it('adds the result to the cache', () => { + const getLinks = createGetLinks(linkPatterns, cache); + const result = getLinks(span, span.tags, 0); + expect(template).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { + url: 'http://example.com/?mySpecialKey=valueOfMyKey', + text: 'special key link (valueOfMyKey)', + }, + ]); + expect(cache.get(span.tags[0])).toBe(result); + }); +}); diff --git a/packages/jaeger-ui/src/model/span.js b/packages/jaeger-ui/src/model/span.js new file mode 100644 index 0000000000..370d5945cd --- /dev/null +++ b/packages/jaeger-ui/src/model/span.js @@ -0,0 +1,27 @@ +// @flow + +// Copyright (c) 2017 The Jaeger Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Span } from '../types'; + +/** + * Searches the span.references to find 'CHILD_OF' reference type or returns null. + * @param {Span} span The span whose parent is to be returned. + * @return {Span|null} The parent span if there is one, null otherwise. + */ +export function getParent(span: Span) { + const parentRef = span.references ? span.references.find(ref => ref.refType === 'CHILD_OF') : null; + return parentRef ? parentRef.span : null; +} diff --git a/packages/jaeger-ui/src/model/transform-trace-data.js b/packages/jaeger-ui/src/model/transform-trace-data.js index 4b1a5fe1ed..8595261b20 100644 --- a/packages/jaeger-ui/src/model/transform-trace-data.js +++ b/packages/jaeger-ui/src/model/transform-trace-data.js @@ -80,27 +80,23 @@ export default function transfromTraceData(data: TraceData & { spans: SpanWithPr if (spanID === '__root__') { return; } - const span: ?SpanWithProcess = spanMap.get(spanID); + const span: ?Span = (spanMap.get(spanID): any); if (!span) { return; } - spans.push({ - relativeStartTime: span.startTime - traceStartTime, - depth: depth - 1, - hasChildren: node.children.length > 0, - // spread fails with union types - duration: span.duration, - logs: span.logs, - operationName: span.operationName, - process: span.process, - processID: span.processID, - references: span.references, - spanID: span.spanID, - startTime: span.startTime, - tags: span.tags, - traceID: span.traceID, + span.relativeStartTime = span.startTime - traceStartTime; + span.depth = depth - 1; + span.hasChildren = node.children.length > 0; + span.references.forEach(ref => { + const refSpan: ?Span = (spanMap.get(ref.spanID): any); + if (refSpan) { + // eslint-disable-next-line no-param-reassign + ref.span = refSpan; + } }); + spans.push(span); }); + return { spans, traceID, diff --git a/packages/jaeger-ui/src/types/index.js b/packages/jaeger-ui/src/types/index.js index 55ca7d2028..09b6f100b1 100644 --- a/packages/jaeger-ui/src/types/index.js +++ b/packages/jaeger-ui/src/types/index.js @@ -18,11 +18,16 @@ * All timestamps are in microseconds */ -type KeyValuePair = { +export type KeyValuePair = { key: string, value: any, }; +export type Link = { + url: string, + text: string, +}; + export type Log = { timestamp: number, fields: Array, @@ -35,6 +40,8 @@ export type Process = { export type SpanReference = { refType: 'CHILD_OF' | 'FOLLOWS_FROM', + // eslint-disable-next-line no-use-before-define + span: ?Span, spanID: string, traceID: string, };
{row.key}{jsonTable}{valueMarkup}