TraceView: Allow span links defined on dataFrame (#40563)

* Align range to seconds in log queries

* Use default display processor if there is none in FieldDisplayProxy

* Allow links defined in dataframe

* Remove debug log

* Fix typings for span links

* Lint go

* Fix tests

* Update tests

* Add test for the display proxy

* Streamline the fallback for diplayProcessor
This commit is contained in:
Andrej Ocenas
2021-10-27 18:40:40 +02:00
committed by GitHub
parent 641a18b92e
commit 00ffe1a4fd
16 changed files with 337 additions and 342 deletions

View File

@@ -1,6 +1,6 @@
import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
import { applyFieldOverrides } from './fieldOverrides';
import { toDataFrame } from '../dataframe';
import { MutableDataFrame, toDataFrame } from '../dataframe';
import { createTheme } from '../themes';
describe('getFieldDisplayValuesProxy', () => {
@@ -81,4 +81,14 @@ describe('getFieldDisplayValuesProxy', () => {
expect(p.xyz).toBeUndefined();
expect(p[100]).toBeUndefined();
});
it('should use default display processor if display is not defined', () => {
const p = getFieldDisplayValuesProxy({
frame: new MutableDataFrame({ fields: [{ name: 'test', values: [1, 2] }] }),
rowIndex: 0,
});
expect(p.test.text).toBe('1');
expect(p.test.numeric).toBe(1);
expect(p.test.toString()).toBe('1');
});
});

View File

@@ -1,11 +1,14 @@
import { toNumber } from 'lodash';
import { DataFrame, DisplayValue, TimeZone } from '../types';
import { formattedValueToString } from '../valueFormats';
import { getDisplayProcessor } from './displayProcessor';
/**
* Creates a proxy object that allows accessing fields on dataFrame through various means and then returns it's
* display value. This is mainly useful for example in data links interpolation where you can easily create a scoped
* variable that will allow you to access dataFrame data with ${__data.fields.fieldName}.
* Allows accessing fields by name, index, displayName or 'name' label
*
* @param frame
* @param rowIndex
* @param options
* @internal
*/
@@ -15,7 +18,7 @@ export function getFieldDisplayValuesProxy(options: {
timeZone?: TimeZone;
}): Record<string, DisplayValue> {
return new Proxy({} as Record<string, DisplayValue>, {
get: (obj: any, key: string) => {
get: (obj: any, key: string): DisplayValue | undefined => {
// 1. Match the name
let field = options.frame.fields.find((f) => key === f.name);
if (!field) {
@@ -39,11 +42,11 @@ export function getFieldDisplayValuesProxy(options: {
if (!field) {
return undefined;
}
if (!field.display) {
throw new Error('Field missing display processor ' + field.name);
}
// TODO: we could supply the field here for the getDisplayProcessor fallback but we would also need theme which
// we do not have access to here
const displayProcessor = field.display ?? getDisplayProcessor();
const raw = field.values.get(options.rowIndex);
const disp = field.display(raw);
const disp = displayProcessor(raw);
disp.toString = () => formattedValueToString(disp);
return disp;
},

View File

@@ -27,7 +27,7 @@ import SpanTreeOffset from './SpanTreeOffset';
import SpanBar from './SpanBar';
import Ticks from './Ticks';
import { TNil } from '../types';
import { SpanLinkFunc, TNil } from '../types';
import { TraceSpan } from '../types/trace';
import { autoColor, createStyle, Theme, withTheme } from '../Theme';
@@ -294,9 +294,7 @@ type SpanBarRowProps = {
removeHoverIndentGuideId: (spanID: string) => void;
clippingLeft?: boolean;
clippingRight?: boolean;
createSpanLink?: (
span: TraceSpan
) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode };
createSpanLink?: SpanLinkFunc;
};
/**
@@ -443,27 +441,31 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
{createSpanLink &&
(() => {
const link = createSpanLink(span);
return (
<a
href={link.href}
// Needs to have target otherwise preventDefault would not work due to angularRouter.
target={'_blank'}
style={{ marginRight: '5px' }}
rel="noopener noreferrer"
onClick={
link.onClick
? (event) => {
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) {
event.preventDefault();
link.onClick(event);
if (link) {
return (
<a
href={link.href}
// Needs to have target otherwise preventDefault would not work due to angularRouter.
target={'_blank'}
style={{ marginRight: '5px' }}
rel="noopener noreferrer"
onClick={
link.onClick
? (event) => {
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) {
event.preventDefault();
link.onClick(event);
}
}
}
: undefined
}
>
{link.content}
</a>
);
: undefined
}
>
{link.content}
</a>
);
} else {
return null;
}
})()}
{span.references && span.references.length > 1 && (

View File

@@ -24,14 +24,13 @@ import { formatDuration } from '../utils';
import CopyIcon from '../../common/CopyIcon';
import LabeledList from '../../common/LabeledList';
import { TNil } from '../../types';
import { SpanLinkFunc, TNil } from '../../types';
import { TraceKeyValuePair, TraceLink, TraceLog, TraceSpan } from '../../types/trace';
import AccordianReferences from './AccordianReferences';
import { autoColor, createStyle, Theme, useTheme } from '../../Theme';
import { UIDivider } from '../../uiElementsContext';
import { ubFlex, ubFlexAuto, ubItemsCenter, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles';
import { DataLinkButton, TextArea } from '@grafana/ui';
import { CreateSpanLink } from '../types';
const getStyles = createStyle((theme: Theme) => {
return {
@@ -116,7 +115,7 @@ type SpanDetailProps = {
stackTracesToggle: (spanID: string) => void;
referencesToggle: (spanID: string) => void;
focusSpan: (uiFind: string) => void;
createSpanLink?: CreateSpanLink;
createSpanLink?: SpanLinkFunc;
};
export default function SpanDetail(props: SpanDetailProps) {

View File

@@ -22,7 +22,7 @@ import TimelineRow from './TimelineRow';
import { autoColor, createStyle, Theme, withTheme } from '../Theme';
import { TraceLog, TraceSpan, TraceKeyValuePair, TraceLink } from '../types/trace';
import { CreateSpanLink } from './types';
import { SpanLinkFunc } from '../types';
const getStyles = createStyle((theme: Theme) => {
return {
@@ -86,7 +86,7 @@ type SpanDetailRowProps = {
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: Theme;
createSpanLink?: CreateSpanLink;
createSpanLink?: SpanLinkFunc;
};
export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProps> {

View File

@@ -31,13 +31,12 @@ import {
} from './utils';
import { Accessors } from '../ScrollManager';
import { getColorByKey } from '../utils/color-generator';
import { TNil } from '../types';
import { SpanLinkFunc, TNil } from '../types';
import { TraceLog, TraceSpan, Trace, TraceKeyValuePair, TraceLink } from '../types/trace';
import TTraceTimeline from '../types/TTraceTimeline';
import { PEER_SERVICE } from '../constants/tag-keys';
import { createStyle, Theme, withTheme } from '../Theme';
import { CreateSpanLink } from './types';
type TExtractUiFindFromStateReturn = {
uiFind: string | undefined;
@@ -84,7 +83,7 @@ type TVirtualizedTraceViewOwnProps = {
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: Theme;
createSpanLink?: CreateSpanLink;
createSpanLink?: SpanLinkFunc;
scrollElement?: Element;
};

View File

@@ -20,7 +20,7 @@ import VirtualizedTraceView from './VirtualizedTraceView';
import { merge as mergeShortcuts } from '../keyboard-shortcuts';
import { Accessors } from '../ScrollManager';
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from './types';
import { TNil } from '../types';
import { SpanLinkFunc, TNil } from '../types';
import { TraceSpan, Trace, TraceLog, TraceKeyValuePair, TraceLink } from '../types/trace';
import TTraceTimeline from '../types/TTraceTimeline';
import { autoColor, createStyle, Theme, withTheme } from '../Theme';
@@ -99,9 +99,7 @@ type TProps = TExtractUiFindFromStateReturn & {
removeHoverIndentGuideId: (spanID: string) => void;
linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[];
theme: Theme;
createSpanLink?: (
span: TraceSpan
) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode };
createSpanLink?: SpanLinkFunc;
scrollElement?: Element;
};

View File

@@ -12,7 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { TraceSpan } from '../types/trace';
import { TNil } from '../types';
interface TimeCursorUpdate {
@@ -52,11 +51,3 @@ export interface ViewRangeTime {
export interface ViewRange {
time: ViewRangeTime;
}
export type CreateSpanLink = (
span: TraceSpan
) => {
href: string;
onClick?: (e: React.MouseEvent) => void;
content: React.ReactNode;
};

View File

@@ -16,7 +16,7 @@ import { ApiError } from './api-error';
import { Trace } from './trace';
export { TraceSpan, TraceResponse, Trace, TraceProcess, TraceKeyValuePair, TraceLink } from './trace';
export { SpanLinkFunc, SpanLinkDef } from './links';
export { default as TTraceTimeline } from './TTraceTimeline';
export { default as TNil } from './TNil';

View File

@@ -0,0 +1,10 @@
import { TraceSpan } from './trace';
import React from 'react';
export type SpanLinkDef = {
href: string;
onClick?: (event: any) => void;
content: React.ReactNode;
};
export type SpanLinkFunc = (span: TraceSpan) => SpanLinkDef | undefined;

View File

@@ -61,6 +61,7 @@ export type TraceSpanData = {
stackTraces?: string[];
flags: number;
errorIconColor?: string;
dataFrameRowIndex?: number;
};
export type TraceSpan = TraceSpanData & {

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math"
"sort"
"github.com/aws/aws-sdk-go/aws"
@@ -201,8 +202,13 @@ func (e *cloudWatchExecutor) executeStartQuery(ctx context.Context, logsClient c
logStreamIdentifierInternal + "|" + parameters.Get("queryString").MustString("")
startQueryInput := &cloudwatchlogs.StartQueryInput{
StartTime: aws.Int64(startTime.Unix()),
EndTime: aws.Int64(endTime.Unix()),
StartTime: aws.Int64(startTime.Unix()),
// Usually grafana time range allows only second precision, but you can create ranges with milliseconds
// for example when going from trace to logs for that trace and trace length is sub second. In that case
// StartTime is effectively floored while here EndTime is ceiled and so we should get the logs user wants
// and also a little bit more but as CW logs accept only seconds as integers there is not much to do about
// that.
EndTime: aws.Int64(int64(math.Ceil(float64(endTime.UnixNano()) / 1e9))),
LogGroupNames: aws.StringSlice(parameters.Get("logGroupNames").MustStringArray()),
QueryString: aws.String(modifiedQueryString),
}

View File

@@ -1,34 +1,31 @@
import React from 'react';
import { shallow } from 'enzyme';
import { render, prettyDOM } from '@testing-library/react';
import { render, prettyDOM, screen } from '@testing-library/react';
import { TraceView } from './TraceView';
import { TracePageHeader, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-components';
import { setDataSourceSrv } from '@grafana/runtime';
import { ExploreId } from 'app/types';
import { TraceData, TraceSpanData } from '@jaegertracing/jaeger-ui-components/src/types/trace';
import { MutableDataFrame } from '@grafana/data';
import { configureStore } from '../../../store/configureStore';
import { Provider } from 'react-redux';
import userEvent from '@testing-library/user-event';
jest.mock('react-redux', () => ({
useSelector: jest.fn(() => undefined),
connect: jest.fn((v) => v),
}));
function renderTraceView() {
const wrapper = shallow(<TraceView exploreId={ExploreId.left} dataFrames={[frameOld]} splitOpenFn={() => {}} />);
function renderTraceView(frames = [frameOld]) {
const store = configureStore();
const { container, baseElement } = render(
<Provider store={store}>
<TraceView exploreId={ExploreId.left} dataFrames={frames} splitOpenFn={() => {}} />
</Provider>
);
return {
timeline: wrapper.find(TraceTimelineViewer),
header: wrapper.find(TracePageHeader),
wrapper,
header: container.children[0],
timeline: container.children[1],
container,
baseElement,
};
}
function renderTraceViewNew() {
const wrapper = shallow(<TraceView exploreId={ExploreId.left} dataFrames={[frameNew]} splitOpenFn={() => {}} />);
return {
timeline: wrapper.find(TraceTimelineViewer),
header: wrapper.find(TracePageHeader),
wrapper,
};
return renderTraceView([frameNew]);
}
describe('TraceView', () => {
@@ -42,132 +39,83 @@ describe('TraceView', () => {
it('renders TraceTimelineViewer', () => {
const { timeline, header } = renderTraceView();
expect(timeline).toHaveLength(1);
expect(header).toHaveLength(1);
expect(timeline).toBeDefined();
expect(header).toBeDefined();
});
it('renders TraceTimelineViewer with new format', () => {
const { timeline, header } = renderTraceViewNew();
expect(timeline).toHaveLength(1);
expect(header).toHaveLength(1);
expect(timeline).toBeDefined();
expect(header).toBeDefined();
});
it('renders renders the same for old and new format', () => {
const { baseElement } = render(
<TraceView exploreId={ExploreId.left} dataFrames={[frameNew]} splitOpenFn={() => {}} />
);
const { baseElement: baseElementOld } = render(
<TraceView exploreId={ExploreId.left} dataFrames={[frameOld]} splitOpenFn={() => {}} />
);
const { baseElement } = renderTraceViewNew();
const { baseElement: baseElementOld } = renderTraceView();
expect(prettyDOM(baseElement)).toEqual(prettyDOM(baseElementOld));
});
it('does not render anything on missing trace', () => {
// Simulating Explore's access to empty response data
const { container } = render(<TraceView exploreId={ExploreId.left} dataFrames={[]} splitOpenFn={() => {}} />);
const { container } = renderTraceView([]);
expect(container.hasChildNodes()).toBeFalsy();
});
it('toggles detailState', () => {
let { timeline, wrapper } = renderTraceViewNew();
expect(timeline.props().traceTimeline.detailStates.size).toBe(0);
it('toggles detailState', async () => {
renderTraceViewNew();
expect(screen.queryByText(/Tags/)).toBeFalsy();
const spanView = screen.getAllByText('', { selector: 'div[data-test-id="span-view"]' })[0];
userEvent.click(spanView);
expect(screen.queryByText(/Tags/)).toBeTruthy();
timeline.props().detailToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.detailStates.size).toBe(1);
expect(timeline.props().traceTimeline.detailStates.get('1')).not.toBeUndefined();
timeline.props().detailToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.detailStates.size).toBe(0);
userEvent.click(spanView);
screen.debug(screen.queryAllByText(/Tags/));
expect(screen.queryByText(/Tags/)).toBeFalsy();
});
it('toggles children visibility', () => {
let { timeline, wrapper } = renderTraceViewNew();
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
renderTraceViewNew();
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3);
userEvent.click(screen.getAllByText('', { selector: 'span[data-test-id="SpanTreeOffset--indentGuide"]' })[0]);
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(1);
timeline.props().childrenToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(1);
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('1')).toBeTruthy();
timeline.props().childrenToggle('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
});
it('toggles adds and removes hover indent guides', () => {
let { timeline, wrapper } = renderTraceViewNew();
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(0);
timeline.props().addHoverIndentGuideId('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(1);
expect(timeline.props().traceTimeline.hoverIndentGuideIds.has('1')).toBeTruthy();
timeline.props().removeHoverIndentGuideId('1');
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.hoverIndentGuideIds.size).toBe(0);
userEvent.click(screen.getAllByText('', { selector: 'span[data-test-id="SpanTreeOffset--indentGuide"]' })[0]);
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3);
});
it('toggles collapses and expands one level of spans', () => {
let { timeline, wrapper } = renderTraceViewNew();
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
const spans = timeline.props().trace.spans;
timeline.props().collapseOne(spans);
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(1);
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy();
timeline.props().expandOne(spans);
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
renderTraceViewNew();
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3);
userEvent.click(screen.getByLabelText('Collapse +1'));
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(2);
userEvent.click(screen.getByLabelText('Expand +1'));
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3);
});
it('toggles collapses and expands all levels', () => {
let { timeline, wrapper } = renderTraceViewNew();
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
const spans = timeline.props().trace.spans;
timeline.props().collapseAll(spans);
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(2);
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('3fb050342773d333')).toBeTruthy();
expect(timeline.props().traceTimeline.childrenHiddenIDs.has('1ed38015486087ca')).toBeTruthy();
timeline.props().expandAll();
timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().traceTimeline.childrenHiddenIDs.size).toBe(0);
renderTraceViewNew();
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3);
userEvent.click(screen.getByLabelText('Collapse All'));
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(1);
userEvent.click(screen.getByLabelText('Expand All'));
expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3);
});
it('searches for spans', () => {
let { wrapper, header } = renderTraceViewNew();
header.props().onSearchValueChange('HTTP POST - api_prom_push');
const timeline = wrapper.find(TraceTimelineViewer);
expect(timeline.props().findMatchesIDs?.has('1ed38015486087ca')).toBeTruthy();
renderTraceViewNew();
userEvent.type(screen.getByPlaceholderText('Find...'), '1ed38015486087ca');
expect(
(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[0].parentNode! as HTMLElement).className
).toContain('rowMatchingFilter');
});
it('change viewRange', () => {
let { header, timeline, wrapper } = renderTraceViewNew();
const defaultRange = { time: { current: [0, 1] } };
expect(timeline.props().viewRange).toEqual(defaultRange);
expect(header.props().viewRange).toEqual(defaultRange);
header.props().updateViewRangeTime(0.2, 0.8);
let newRange = { time: { current: [0.2, 0.8] } };
timeline = wrapper.find(TraceTimelineViewer);
header = wrapper.find(TracePageHeader);
expect(timeline.props().viewRange).toEqual(newRange);
expect(header.props().viewRange).toEqual(newRange);
newRange = { time: { current: [0.3, 0.7] } };
timeline.props().updateViewRangeTime(0.3, 0.7);
timeline = wrapper.find(TraceTimelineViewer);
header = wrapper.find(TracePageHeader);
expect(timeline.props().viewRange).toEqual(newRange);
expect(header.props().viewRange).toEqual(newRange);
it('shows timeline ticks', () => {
renderTraceViewNew();
function ticks() {
return screen.getByText('', { selector: 'div[data-test-id="TimelineHeaderRow"]' }).children[1].children[1]
.textContent;
}
expect(ticks()).toBe('0μs274.5μs549μs823.5μs1.1ms');
});
});

View File

@@ -63,7 +63,9 @@ export function TraceView(props: Props) {
*/
const [slim, setSlim] = useState(false);
const traceProp = useMemo(() => transformDataFrames(props.dataFrames), [props.dataFrames]);
// At this point we only show single trace.
const frame = props.dataFrames[0];
const traceProp = useMemo(() => transformDataFrames(frame), [frame]);
const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans);
const dataSourceName = useSelector((state: StoreState) => state.explore[props.exploreId]?.datasourceInstance?.name);
const traceToLogsOptions = (getDatasourceSrv().getInstanceSettings(dataSourceName)?.jsonData as TraceToLogsData)
@@ -97,10 +99,10 @@ export function TraceView(props: Props) {
[childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, traceProp?.traceID]
);
const createSpanLink = useMemo(() => createSpanLinkFactory(props.splitOpenFn, traceToLogsOptions), [
props.splitOpenFn,
traceToLogsOptions,
]);
const createSpanLink = useMemo(
() => createSpanLinkFactory({ splitOpenFn: props.splitOpenFn, traceToLogsOptions, dataFrame: frame }),
[props.splitOpenFn, traceToLogsOptions, frame]
);
const scrollElement = document.getElementsByClassName('scrollbar-view')[0];
const onSlimViewClicked = useCallback(() => setSlim(!slim), [slim]);
@@ -173,9 +175,7 @@ export function TraceView(props: Props) {
);
}
function transformDataFrames(frames: DataFrame[]): Trace | null {
// At this point we only show single trace.
const frame = frames[0];
function transformDataFrames(frame?: DataFrame): Trace | null {
if (!frame) {
return null;
}
@@ -203,7 +203,7 @@ function transformTraceDataFrame(frame: DataFrame): TraceResponse {
return {
traceID: view.get(0).traceID,
processes,
spans: view.toArray().map((s) => {
spans: view.toArray().map((s, index) => {
return {
...s,
duration: s.duration * 1000,
@@ -212,6 +212,7 @@ function transformTraceDataFrame(frame: DataFrame): TraceResponse {
flags: 0,
references: s.parentSpanID ? [{ refType: 'CHILD_OF', spanID: s.parentSpanID, traceID: s.traceID }] : undefined,
logs: s.logs?.map((l) => ({ ...l, timestamp: l.timestamp * 1000 })) || [],
dataFrameRowIndex: index,
};
}),
};

View File

@@ -1,11 +1,15 @@
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data';
import { DataSourceInstanceSettings, MutableDataFrame } from '@grafana/data';
import { setDataSourceSrv, setTemplateSrv } from '@grafana/runtime';
import { createSpanLinkFactory } from './createSpanLink';
import { TraceSpan } from '@jaegertracing/jaeger-ui-components';
import { TraceToLogsOptions } from '../../../core/components/TraceToLogsSettings';
import { LinkSrv, setLinkSrv } from '../../../angular/panel/panellinks/link_srv';
import { TemplateSrv } from '../../templating/template_srv';
describe('createSpanLinkFactory', () => {
it('returns undefined if there is no data source uid', () => {
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn);
const createLink = createSpanLinkFactory({ splitOpenFn: splitOpenFn });
expect(createLink).not.toBeDefined();
});
@@ -13,52 +17,19 @@ describe('createSpanLinkFactory', () => {
beforeAll(() => {
setDataSourceSrv({
getInstanceSettings(uid: string): DataSourceInstanceSettings | undefined {
return {
uid: 'loki1',
name: 'loki1',
} as any;
return { uid: 'loki1', name: 'loki1' } as any;
},
} as any);
setTemplateSrv({
replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string {
return target!;
},
} as any);
setLinkSrv(new LinkSrv());
setTemplateSrv(new TemplateSrv());
});
it('with default keys when tags not configured', () => {
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn, { datasourceUid: 'lokiUid' });
const createLink = setupSpanLinkFactory();
expect(createLink).toBeDefined();
const linkDef = createLink!({
startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000,
duration: 1000 * 1000,
tags: [
{
key: 'host',
value: 'host',
},
],
process: {
tags: [
{
key: 'cluster',
value: 'cluster1',
},
{
key: 'hostname',
value: 'hostname1',
},
{
key: 'label2',
value: 'val2',
},
],
} as any,
} as any);
expect(linkDef.href).toBe(
const linkDef = createLink!(createTraceSpan());
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}'
)}`
@@ -66,33 +37,22 @@ describe('createSpanLinkFactory', () => {
});
it('with tags that passed in and without tags that are not in the span', () => {
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn, { datasourceUid: 'lokiUid', tags: ['ip', 'newTag'] });
const createLink = setupSpanLinkFactory({
tags: ['ip', 'newTag'],
});
expect(createLink).toBeDefined();
const linkDef = createLink!({
startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000,
duration: 1000 * 1000,
tags: [
{
key: 'host',
value: 'host',
const linkDef = createLink!(
createTraceSpan({
process: {
serviceName: 'service',
tags: [
{ key: 'hostname', value: 'hostname1' },
{ key: 'ip', value: '192.168.0.1' },
],
},
],
process: {
tags: [
{
key: 'hostname',
value: 'hostname1',
},
{
key: 'ip',
value: '192.168.0.1',
},
],
} as any,
} as any);
expect(linkDef.href).toBe(
})
);
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}]}'
)}`
@@ -100,36 +60,22 @@ describe('createSpanLinkFactory', () => {
});
it('from tags and process tags as well', () => {
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn, {
datasourceUid: 'lokiUid',
const createLink = setupSpanLinkFactory({
tags: ['ip', 'host'],
});
expect(createLink).toBeDefined();
const linkDef = createLink!({
startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000,
duration: 1000 * 1000,
tags: [
{
key: 'host',
value: 'host',
const linkDef = createLink!(
createTraceSpan({
process: {
serviceName: 'service',
tags: [
{ key: 'hostname', value: 'hostname1' },
{ key: 'ip', value: '192.168.0.1' },
],
},
],
process: {
tags: [
{
key: 'hostname',
value: 'hostname1',
},
{
key: 'ip',
value: '192.168.0.1',
},
],
} as any,
} as any);
expect(linkDef.href).toBe(
})
);
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}]}'
)}`
@@ -137,38 +83,23 @@ describe('createSpanLinkFactory', () => {
});
it('with adjusted start and end time', () => {
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn, {
datasourceUid: 'lokiUid',
const createLink = setupSpanLinkFactory({
spanStartTimeShift: '1m',
spanEndTimeShift: '1m',
});
expect(createLink).toBeDefined();
const linkDef = createLink!({
startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000,
duration: 1000 * 1000,
tags: [
{
key: 'host',
value: 'host',
const linkDef = createLink!(
createTraceSpan({
process: {
serviceName: 'service',
tags: [
{ key: 'hostname', value: 'hostname1' },
{ key: 'ip', value: '192.168.0.1' },
],
},
],
process: {
tags: [
{
key: 'hostname',
value: 'hostname1',
},
{
key: 'ip',
value: '192.168.0.1',
},
],
} as any,
} as any);
expect(linkDef.href).toBe(
})
);
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:01:00.000Z","to":"2020-10-14T01:01:01.000Z"},"datasource":"loki1","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}]}'
)}`
@@ -176,47 +107,89 @@ describe('createSpanLinkFactory', () => {
});
it('filters by trace and span ID', () => {
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn, {
datasourceUid: 'lokiUid',
const createLink = setupSpanLinkFactory({
filterBySpanID: true,
filterByTraceID: true,
});
expect(createLink).toBeDefined();
const linkDef = createLink!({
spanID: '6605c7b08e715d6c',
traceID: '7946b05c2e2e4e5a',
startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000,
duration: 1000 * 1000,
tags: [
{
key: 'host',
value: 'host',
},
],
process: {
tags: [
{
key: 'cluster',
value: 'cluster1',
},
{
key: 'hostname',
value: 'hostname1',
},
{
key: 'label2',
value: 'val2',
},
],
} as any,
} as any);
const linkDef = createLink!(createTraceSpan());
expect(linkDef.href).toBe(
expect(linkDef!.href).toBe(
`/explore?left=${encodeURIComponent(
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"} |=\\"7946b05c2e2e4e5a\\" |=\\"6605c7b08e715d6c\\"","refId":""}]}'
)}`
);
});
it('creates link from dataFrame', () => {
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory({
splitOpenFn,
dataFrame: new MutableDataFrame({
fields: [
{ name: 'traceID', values: ['testTraceId'] },
{
name: 'spanID',
config: { links: [{ title: 'link', url: '${__data.fields.spanID}' }] },
values: ['testSpanId'],
},
],
}),
});
expect(createLink).toBeDefined();
const linkDef = createLink!(createTraceSpan());
expect(linkDef!.href).toBe('testSpanId');
});
});
});
function setupSpanLinkFactory(options: Partial<TraceToLogsOptions> = {}) {
const splitOpenFn = jest.fn();
return createSpanLinkFactory({
splitOpenFn,
traceToLogsOptions: {
datasourceUid: 'lokiUid',
...options,
},
});
}
function createTraceSpan(overrides: Partial<TraceSpan> = {}): TraceSpan {
return {
spanID: '6605c7b08e715d6c',
traceID: '7946b05c2e2e4e5a',
processID: 'processId',
operationName: 'operation',
logs: [],
startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000,
duration: 1000 * 1000,
flags: 0,
hasChildren: false,
dataFrameRowIndex: 0,
tags: [
{
key: 'host',
value: 'host',
},
],
process: {
serviceName: 'test service',
tags: [
{
key: 'cluster',
value: 'cluster1',
},
{
key: 'hostname',
value: 'hostname1',
},
{
key: 'label2',
value: 'val2',
},
],
},
...overrides,
} as any;
}

View File

@@ -1,18 +1,68 @@
import { DataLink, dateTime, Field, mapInternalLinkToExplore, rangeUtil, SplitOpen, TimeRange } from '@grafana/data';
import {
DataFrame,
DataLink,
dateTime,
Field,
mapInternalLinkToExplore,
rangeUtil,
SplitOpen,
TimeRange,
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { Icon } from '@grafana/ui';
import { TraceSpan } from '@jaegertracing/jaeger-ui-components';
import { SpanLinkDef, SpanLinkFunc, TraceSpan } from '@jaegertracing/jaeger-ui-components';
import { TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import React from 'react';
import { LokiQuery } from '../../../plugins/datasource/loki/types';
import { getFieldLinksForExplore } from '../utils/links';
/**
* This is a factory for the link creator. It returns the function mainly so it can return undefined in which case
* the trace view won't create any links and to capture the datasource and split function making it easier to memoize
* with useMemo.
*/
export function createSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions?: TraceToLogsOptions) {
export function createSpanLinkFactory({
splitOpenFn,
traceToLogsOptions,
dataFrame,
}: {
splitOpenFn: SplitOpen;
traceToLogsOptions?: TraceToLogsOptions;
dataFrame?: DataFrame;
}): SpanLinkFunc | undefined {
if (!dataFrame || dataFrame.fields.length === 1 || !dataFrame.fields.some((f) => Boolean(f.config.links?.length))) {
// if the dataframe contains just a single blob of data (legacy format) or does not have any links configured,
// let's try to use the old legacy path.
return legacyCreateSpanLinkFactory(splitOpenFn, traceToLogsOptions);
} else {
return function (span: TraceSpan): SpanLinkDef | undefined {
// We should be here only if there are some links in the dataframe
const field = dataFrame.fields.find((f) => Boolean(f.config.links?.length))!;
try {
const links = getFieldLinksForExplore({
field,
rowIndex: span.dataFrameRowIndex!,
splitOpenFn,
range: getTimeRangeFromSpan(span),
dataFrame,
});
return {
href: links[0].href,
onClick: links[0].onClick,
content: <Icon name="gf-logs" title="Explore the logs for this in split view" />,
};
} catch (error) {
// It's fairly easy to crash here for example if data source defines wrong interpolation in the data link
console.error(error);
return undefined;
}
};
}
}
function legacyCreateSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions?: TraceToLogsOptions) {
// We should return if dataSourceUid is undefined otherwise getInstanceSettings would return testDataSource.
if (!traceToLogsOptions?.datasourceUid) {
return undefined;
@@ -24,7 +74,7 @@ export function createSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions
return undefined;
}
return function (span: TraceSpan): { href: string; onClick?: (event: any) => void; content: React.ReactNode } {
return function (span: TraceSpan): SpanLinkDef {
// This is reusing existing code from derived fields which may not be ideal match so some data is a bit faked at
// the moment. Issue is that the trace itself isn't clearly mapped to dataFrame (right now it's just a json blob
// inside a single field) so the dataLinks as config of that dataFrame abstraction breaks down a bit and we do
@@ -47,7 +97,12 @@ export function createSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions
link: dataLink,
internalLink: dataLink.internal!,
scopedVars: {},
range: getTimeRangeFromSpan(span, traceToLogsOptions),
range: getTimeRangeFromSpan(span, {
startMs: traceToLogsOptions.spanStartTimeShift
? rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift)
: 0,
endMs: traceToLogsOptions.spanEndTimeShift ? rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift) : 0,
}),
field: {} as Field,
onClickFn: splitOpenFn,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
@@ -91,15 +146,14 @@ function getLokiQueryFromSpan(span: TraceSpan, options: TraceToLogsOptions): str
/**
* Gets a time range from the span.
*/
function getTimeRangeFromSpan(span: TraceSpan, traceToLogsOptions?: TraceToLogsOptions): TimeRange {
const adjustedStartTime = traceToLogsOptions?.spanStartTimeShift
? Math.floor(span.startTime / 1000 + rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift))
: Math.floor(span.startTime / 1000);
function getTimeRangeFromSpan(
span: TraceSpan,
timeShift: { startMs: number; endMs: number } = { startMs: 0, endMs: 0 }
): TimeRange {
const adjustedStartTime = Math.floor(span.startTime / 1000 + timeShift.startMs);
const from = dateTime(adjustedStartTime);
const spanEndMs = (span.startTime + span.duration) / 1000;
let adjustedEndTime = traceToLogsOptions?.spanEndTimeShift
? Math.floor(spanEndMs + rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift))
: Math.floor(spanEndMs);
let adjustedEndTime = Math.floor(spanEndMs + timeShift.endMs);
// Because we can only pass milliseconds in the url we need to check if they equal.
// We need end time to be later than start time