Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add links to make values in tags or log properties clickable #223

Merged
merged 18 commits into from
Aug 3, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Changes following code review
Signed-off-by: David-Emmanuel Divernois <david-emmanuel.divernois@amadeus.com>
  • Loading branch information
divdavem committed Jul 25, 2018
commit dd795d24a3e10573946652d3be485bf403a74469
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// 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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file looks great! Awesome.

(Not sure how to add a comment to a file instead of a line...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your encouraging feedback!


Expand All @@ -31,6 +31,24 @@ function parseIfJson(value) {
return value;
}

const LinkValue = (props: { href: string, title?: string, children: React.Node }) => (
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer">
{props.children} <Icon className="KeyValueTable--linkIcon" type="export" />
</a>
);

const linkValueList = (links: { url: string, text: string }[]) => (
<Menu>
{links.map(({ text, url }, index) => (
// `index` is necessary in the key because url can repeat
// eslint-disable-next-line react/no-array-index-key
<Menu.Item key={`${url}-${index}`}>
<LinkValue href={url}>{text}</LinkValue>
</Menu.Item>
))}
</Menu>
);

type KeyValuesTableProps = {
data: { key: string, value: any }[],
linksGetter: ?({ key: string, value: any }[], number) => { url: string, text: string }[],
Expand All @@ -53,31 +71,15 @@ export default function KeyValuesTable(props: KeyValuesTableProps) {
if (links && links.length === 1) {
valueMarkup = (
<div>
<a href={links[0].url} title={links[0].text} target="_blank" rel="noopener noreferrer">
{jsonTable} <Icon className="KeyValueTable--linkIcon" type="export" />
</a>
<LinkValue href={links[0].url} title={links[0].text}>
{jsonTable}
</LinkValue>
</div>
);
} else if (links && links.length > 1) {
const menuItems = (
<Menu>
{links.map((link, index) => {
const { text, url } = link;
return (
// `index` is necessary in the key because url can repeat
// eslint-disable-next-line react/no-array-index-key
<Menu.Item key={`${url}-${index}`}>
<a href={url} target="_blank" rel="noopener noreferrer">
{text}
</a>
</Menu.Item>
);
})}
</Menu>
);
valueMarkup = (
<div>
<Dropdown overlay={menuItems} placement="bottomRight" trigger={['click']}>
<Dropdown overlay={linkValueList(links)} placement="bottomRight" trigger={['click']}>
<a>
{jsonTable} <Icon className="KeyValueTable--linkIcon" type="profile" />
</a>
Expand All @@ -101,3 +103,5 @@ export default function KeyValuesTable(props: KeyValuesTableProps) {
</div>
);
}

KeyValuesTable.LinkValue = LinkValue;
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import React from 'react';
import { shallow } from 'enzyme';
import { Dropdown, Icon } from 'antd';
import { Dropdown } from 'antd';

import KeyValuesTable from './KeyValuesTable';

Expand Down Expand Up @@ -53,11 +53,10 @@ describe('<KeyValuesTable>', () => {
: [],
});

const anchor = wrapper.find('a');
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.find(Icon)).toHaveLength(1);
expect(
anchor
.closest('tr')
Expand All @@ -79,14 +78,14 @@ describe('<KeyValuesTable>', () => {
});
const dropdown = wrapper.find(Dropdown);
const menu = shallow(dropdown.prop('overlay'));
const anchors = menu.find('a');
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.text()).toBe('Example 1');
expect(firstAnchor.children().text()).toBe('Example 1');
const secondAnchor = anchors.last();
expect(secondAnchor.prop('href')).toBe('http://example.com/2?kind=client');
expect(secondAnchor.text()).toBe('Example 2');
expect(secondAnchor.children().text()).toBe('Example 2');
expect(
dropdown
.closest('tr')
Expand Down
123 changes: 78 additions & 45 deletions packages/jaeger-ui/src/model/link-patterns.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// @flow

// Copyright (c) 2017 The Jaeger Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
Expand All @@ -14,58 +16,67 @@

import _uniq from 'lodash/uniq';
import { getConfigValue } from '../utils/config/get-config';
import type { Span, Trace } from '../types';

const parameterRegExp = /\$\{([^{}]*)\}/g;

type ProcessedTemplate = {
parameters: string[],
template: (data: { [parameter: string]: any }) => string,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be

template: { [string]: any } => string,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, would mixed fit the bill instead of any?

https://flow.org/en/docs/types/mixed/

Copy link
Contributor Author

@divdavem divdavem Aug 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! I do not know flow very well. I mostly use Typescript.

};

function getParamNames(str) {
const names = [];
str.replace(parameterRegExp, (match, name) => {
names.push(name);
return match;
});
return _uniq(names);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could maybe use a Set?

const names = new Set();
// ...
return Array.from(names);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

}

const parameterRegExp = /\$\{([^{}]*)\}/;
function stringSupplant(str, encodeFn: any => string, map) {
return str.replace(parameterRegExp, (_, name) => encodeFn(map[name]));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encodeUriComponent will stringify null (and undefined?) values, so maybe it makes sense to guard the encode function invocation?

encodeURIComponent(null)
// -> "null"

fn = () => null;
'abbc'.replace(/b/g, fn)
// -> "anullnullc"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. As links are not displayed if any parameter is missing, this problem only happens when tags are explicitly added with a null or undefined value. In this case, it is true that is better to guard the encode function invocation and have an empty string rather than the strange "undefined" or "null" strings. I have updated the code.

}

export function processTemplate(template, encodeFn) {
export function processTemplate(
template: any,
encodeFn: any => string
): ProcessedTemplate {
if (typeof template !== 'string') {
if (!template || !Array.isArray(template.parameters) || !(template.template instanceof Function)) {
throw new Error('Invalid template');
}
return template;
}
const templateSplit = template.split(parameterRegExp);
const templateSplitLength = templateSplit.length;
const parameters = [];
// odd indexes contain variable names
for (let i = 1; i < templateSplitLength; i += 2) {
const param = templateSplit[i];
let paramIndex = parameters.indexOf(param);
if (paramIndex === -1) {
paramIndex = parameters.length;
parameters.push(param);
/*

// kept on ice until #123 is implemented:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

if (template && Array.isArray(template.parameters) && (typeof template.template === 'function')) {
return template;
}
templateSplit[i] = paramIndex;

*/
throw new Error('Invalid template');
}
return {
parameters,
template: (...args) => {
let text = '';
for (let i = 0; i < templateSplitLength; i++) {
if (i % 2 === 0) {
text += templateSplit[i];
} else {
text += encodeFn(args[templateSplit[i]]);
}
}
return text;
},
parameters: getParamNames(template),
template: stringSupplant.bind(null, template, encodeFn),
};
}

export function createTestFunction(entry) {
export function createTestFunction(entry: any) {
if (typeof entry === 'string') {
return arg => arg === entry;
return (arg: any) => arg === entry;
}
if (Array.isArray(entry)) {
return arg => entry.indexOf(arg) > -1;
return (arg: any) => entry.indexOf(arg) > -1;
}
/*

// kept on ice until #123 is implemented:
if (entry instanceof RegExp) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Planning ahead in preparation for the JS config file (#123) is awesome but it's tough to validate functionality that relies on or is impacted by the JS config before it's implemented. That increases the risks when switching to using the JS config. Maybe the regular expression and function variants of the test function should be kept on ice until a PR for #128 is merged?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now commented the parts of the code that rely on #123.

return arg => entry.test(arg);
return (arg: any) => entry.test(arg);
}
if (typeof entry === 'function') {
return entry;
}

*/
if (entry == null) {
return () => true;
}
Expand All @@ -74,7 +85,17 @@ export function createTestFunction(entry) {

const identity = a => a;

export function processLinkPattern(pattern) {
type ProcessedLinkPattern = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please move type definitions to the top of the file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok!

object: any,
type: string => boolean,
key: string => boolean,
value: any => boolean,
url: ProcessedTemplate,
text: ProcessedTemplate,
parameters: string[],
};

export function processLinkPattern(pattern: any): ?ProcessedLinkPattern {
try {
const url = processTemplate(pattern.url, encodeURIComponent);
const text = processTemplate(pattern.text, identity);
Expand All @@ -94,16 +115,16 @@ export function processLinkPattern(pattern) {
}
}

export function getParameterInArray(name, array) {
export function getParameterInArray(name: string, array: { key: string, value: any }[]) {
if (array) {
return array.find(entry => entry.key === name);
}
return undefined;
}

export function getParameterInAncestor(name, spans, startSpanIndex) {
export function getParameterInAncestor(name: string, spans: Span[], startSpanIndex: number) {
let currentSpan = { depth: spans[startSpanIndex].depth + 1 };
for (let spanIndex = startSpanIndex; spanIndex >= 0; spanIndex--) {
for (let spanIndex = startSpanIndex; spanIndex >= 0 && currentSpan.depth > 0; spanIndex--) {
const nextSpan = spans[spanIndex];
if (nextSpan.depth < currentSpan.depth) {
currentSpan = nextSpan;
Expand All @@ -117,11 +138,17 @@ export function getParameterInAncestor(name, spans, startSpanIndex) {
return undefined;
}

export function callTemplate(template, data) {
return template.template(...template.parameters.map(param => data[param]));
function callTemplate(template, data) {
return template.template(data);
}

export function computeLinks(linkPatterns, trace, spanIndex, items, itemIndex) {
export function computeLinks(
linkPatterns: ProcessedLinkPattern[],
trace: Trace,
spanIndex: number,
items: { key: string, value: any }[],
itemIndex: number
) {
const item = items[itemIndex];
const span = trace.spans[spanIndex];
let type = 'logs';
Expand All @@ -135,15 +162,16 @@ export function computeLinks(linkPatterns, trace, spanIndex, items, itemIndex) {
}
const result = [];
linkPatterns.forEach(pattern => {
if (pattern.type(type) && pattern.key(item.key, item.value, type) && pattern.value(item.value)) {
if (pattern.type(type) && pattern.key(item.key) && pattern.value(item.value)) {
let parameterValues = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nicely done. A consideration from a reader's perspective, using the return value of the .every() instead of setting parameterValues to null might be easier to follow.

const ok = pattern.parameters.every(parameter => {
  // ...
});

if (ok) {
  result.push(...);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I did not think about using the return value, but that is a good idea, thank you!

pattern.parameters.every(parameter => {
let entry = getParameterInArray(parameter, items);
if (!entry && !processTags) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe ancestors are also being searched when type === 'log', which seems to contradict your comment on the PR description?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, ancestors are also being searched when type === 'log', and this is on purpose.
Maybe I should update the PR description if it is ambiguous about this.

// do not look in ancestors for process tags because the same object may appear in different places in the hierarchy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this comment, why are process tags always searched when looking through ancestors? Seems like the only process tags that are skipped are those on the current span.

Also, why is it relevant that process tags can be repeated? A process will match the first time it's encountered or it will fail every time it's tested?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getLinks function caches results in the alreadyComputed weak map.
The key used in that map is the object (with the key and value properties) that will be displayed as a link (if a link has to be displayed).
If that object is reused at different places in the hierarchy (which is the case for process tags, as the same process object can be reused for multiple spans), a call to getLinks with an object that is already in the cache could get the result of a previous call to getLinks from a different place in the hierarchy. If a process tag could use values from an ancestor, the ancestor from which the value could be found and the corresponding value could be different between the two places in the hierarchy, which would make getLinks return an invalid value.
Another solution (instead of preventing process tags links from using values from ancestors) would be to have a cache that also stores the current span (so we would need a cache with 2 keys), but that would be more complex and I am not sure if it is actually needed to refer to ancestor values from a process tag.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, having trouble following... Seems like items are only added to the cache in the link getter, at the outer level, and the cache is only checked at that level, too. So, when searching ancestors, previously calculated values that are cached are a non-issue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will explain in Javascript:

// Let's say there is one tag like this:

let myProcessTag = {
  key: 'myProcessInfo',
  value: 'myProcessValue'
};

// The tag is used in a process:

let myProcess = {
  tags: [myProcessTag]
};

// The same process reference is used in multiple spans in the trace hierarchy:
let trace = {
  spans: [{
    depth: 0
  }, {
    depth: 1,
    tags: [{
      key: 'myParentKey',
      value: 'parentValue1'
    }]
  }, {
    depth: 2,
    process: myProcess
  }, {
    depth: 1,
    tags: [{
      key: 'myParentKey',
      value: 'parentValue2'
    }]
  }, {
    depth: 2,
    process: myProcess
  }]
};

// So myProcess is used in trace.spans[2], which is the child of trace.spans[1]
// and myProcess is also used in trace.spans[4], which is the child of trace.spans[3]

// Now, let's suppose that it is allowed for a link of a process tag to refer to tags from ancestors
// and someone defined the configuration like this:
JAEGER_CONFIG = {
  "linkPatterns": [{
    "key": "myProcessInfo",
    "url": "http://example.org/?myProcessValue=#{myProcessInfo}&myParentValue=#{myParentKey}",
    "text": "Where will this link go?"
  }]
};

// This link on the process tag refers to a tag from an ancestor
// (which is the thing I am not allowing in the current code).

// Then, if we call computeLinks with trace.spans[2] and myProcessTag, we should get:
[{
  "url": "http://example.org/?myProcessValue=myProcessValue&myParentValue=parentValue1",
  "text": "Where will this link go?"
}]

// Now, if we call computeLinks with trace.spans[4] and the same myProcessTag, we should get:
[{
  "url": "http://example.org/?myProcessValue=myProcessValue&myParentValue=parentValue2",
  "text": "Where will this link go?"
}]

// These two values are different because the same tag is at two different places in the spans hierarchy,
// and the value for myParentKey is taken
// - in the first case from trace.spans[1].tags[0]
// - in the second case from trace.spans[3].tags[0]

// Now the getLink function returned by createGetLinks uses the tag object as a key in the cache.
// In our case, the tag object is myProcessTag.

// So if we first call the getLink function returned by createGetLinks for trace.spans[2] and myProcessTag :
result = cache.get(myProcessTag); // result is undefined
result = computeLinks(...);
// which means:
result = [{
  "url": "http://example.org/?myProcessValue=myProcessValue&myParentValue=parentValue1",
  "text": "Where will this link go?"
}];
cache.set(myProcessTag, result); // result is cached

// now if we call the getLink function for trace.spans[4] and myProcessTag :
result = cache.get(myProcessTag); // result is already computed
return [{
  "url": "http://example.org/?myProcessValue=myProcessValue&myParentValue=parentValue1",
  "text": "Where will this link go?"
}]; // now the return value is wrong for trace.spans[4]

I hope this explanation is clearer!
I do not have the requirement of having links on process tags that use values from ancestor spans, so I chose the simplest solution of not allowing it. Do you think it would be better (for consistency or some use cases that I don't know) to allow this and then to use as the key in the cache both the span and the tag object (for example, with a cache based on nested WeakMaps)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great explanation, I think I've got it... Don't look in ancestors for process tags because the same process object, and therefore tags, can be used for many spans. In this case, searching a span's ancestors could yield different results (if they weren't cached). But, as the cache is keyed on the initial, shared, process tag, all spans with that process would end up getting the same results even if they have different ancestors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly!

// and the cache in getLinks uses that object as a key
entry = getParameterInAncestor(parameter, trace.spans, spanIndex);
}
if (entry) {
if (entry && parameterValues) {
parameterValues[parameter] = entry.value;
return true;
}
Expand All @@ -166,10 +194,15 @@ export function computeLinks(linkPatterns, trace, spanIndex, items, itemIndex) {
return result;
}

const linkPatterns = (getConfigValue('linkPatterns') || []).map(processLinkPattern).filter(value => !!value);
const linkPatterns = (getConfigValue('linkPatterns') || []).map(processLinkPattern).filter(Boolean);
const alreadyComputed = new WeakMap();

export default function getLinks(trace, spanIndex, items, itemIndex) {
export default function getLinks(
trace: Trace,
spanIndex: number,
items: { key: string, value: any }[],
itemIndex: number
) {
if (linkPatterns.length === 0) {
return [];
}
Expand Down
Loading