Commit c8658f3e by Andrej Ocenas Committed by GitHub

Explore: Add link to logs from trace span (#28229)

* Add trace to logs link

* Do a bit of refactor and allow for custom time range in split

* Add margin and noopener to the link

* Fix tests

* Fix tests
parent 26e2faa7
......@@ -35,6 +35,8 @@ export interface FeatureToggles {
live: boolean;
expressions: boolean;
ngalert: boolean;
// Just for demo at the moment
traceToLogs: boolean;
/**
* @remarks
......
......@@ -51,6 +51,7 @@ export type TraceSpanData = {
traceID: string;
processID: string;
operationName: string;
// Times are in microseconds
startTime: number;
duration: number;
logs: TraceLog[];
......
......@@ -26,8 +26,9 @@ export const DataLinkBuiltInVars = {
valueCalc: '__value.calc',
};
// We inject these because we cannot import them directly as they reside inside grafana main package.
type Options = {
onClickFn?: (options: { datasourceUid: string; query: any }) => void;
onClickFn?: (options: { datasourceUid: string; query: any; range?: TimeRange }) => void;
replaceVariables: InterpolateFunction;
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined;
};
......@@ -62,6 +63,7 @@ export function mapInternalLinkToExplore(
onClickFn?.({
datasourceUid: link.internal!.datasourceUid,
query: interpolatedQuery,
range,
});
}
: undefined,
......
......@@ -15,6 +15,6 @@ export { getMappedValue } from './valueMappings';
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
export { locationUtil } from './location';
export { urlUtil, UrlQueryMap, UrlQueryValue } from './url';
export { DataLinkBuiltInVars } from './dataLinks';
export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks';
export { DocsId } from './docs';
export { observableTester } from './tests/observableTester';
......@@ -56,6 +56,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
expressions: false,
meta: false,
ngalert: false,
traceToLogs: false,
};
licenseInfo: LicenseInfo = {} as LicenseInfo;
rendererAvailable = false;
......
......@@ -19,6 +19,16 @@ export interface DataSourceSrv {
* Returns metadata based on UID.
*/
getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined;
/**
* Get all data sources
*/
getAll(): DataSourceInstanceSettings[];
/**
* Get all data sources except for internal ones that usually should not be listed like mixed data source.
*/
getExternal(): DataSourceInstanceSettings[];
}
let singletonInstance: DataSourceSrv;
......
......@@ -310,6 +310,9 @@ type SpanBarRowProps = {
removeHoverIndentGuideId: (spanID: string) => void;
clippingLeft?: boolean;
clippingRight?: boolean;
createSpanLink?: (
span: TraceSpan
) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode };
};
/**
......@@ -356,6 +359,7 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
clippingLeft,
clippingRight,
theme,
createSpanLink,
} = this.props;
const {
duration,
......@@ -437,6 +441,32 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
</span>
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
</a>
{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);
}
}
: undefined
}
>
{link.content}
</a>
);
})()}
{span.references && span.references.length > 1 && (
<ReferencesButton
references={span.references}
......
......@@ -79,6 +79,9 @@ type TVirtualizedTraceViewOwnProps = {
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: Theme;
createSpanLink?: (
span: TraceSpan
) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode };
};
type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
......@@ -330,6 +333,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
addHoverIndentGuideId,
removeHoverIndentGuideId,
theme,
createSpanLink,
} = this.props;
// to avert flow error
if (!trace) {
......@@ -379,6 +383,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
createSpanLink={createSpanLink}
/>
</div>
);
......
......@@ -99,6 +99,9 @@ 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 };
};
type State = {
......
import { DataSourceSrv } from '@grafana/runtime';
import { DataSourceApi, PluginMeta, DataTransformerConfig } from '@grafana/data';
import { DataSourceApi, PluginMeta, DataTransformerConfig, DataSourceInstanceSettings } from '@grafana/data';
import { ElasticsearchQuery } from '../../plugins/datasource/elasticsearch/types';
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
......@@ -23,6 +23,12 @@ describe('getAlertingValidationMessage', () => {
const datasourceSrv: DataSourceSrv = {
get: getMock,
getDataSourceSettingsByUid(): any {},
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
return [];
},
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
......@@ -60,6 +66,12 @@ describe('getAlertingValidationMessage', () => {
return Promise.resolve(alertingDatasource);
},
getDataSourceSettingsByUid(): any {},
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
return [];
},
};
const targets: any[] = [
{ refId: 'A', query: 'some query', datasource: 'alertingDatasource' },
......@@ -84,6 +96,12 @@ describe('getAlertingValidationMessage', () => {
const datasourceSrv: DataSourceSrv = {
get: getMock,
getDataSourceSettingsByUid(): any {},
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
return [];
},
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
......@@ -110,6 +128,12 @@ describe('getAlertingValidationMessage', () => {
const datasourceSrv: DataSourceSrv = {
get: getMock,
getDataSourceSettingsByUid(): any {},
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
return [];
},
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
......@@ -136,6 +160,12 @@ describe('getAlertingValidationMessage', () => {
const datasourceSrv: DataSourceSrv = {
get: getMock,
getDataSourceSettingsByUid(): any {},
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
return [];
},
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
......
......@@ -101,6 +101,7 @@ const dummyProps: ExploreProps = {
showLogs: true,
showTable: true,
showTrace: true,
splitOpen: (() => {}) as any,
};
const setupErrors = (hasRefId?: boolean) => {
......
......@@ -37,6 +37,7 @@ import {
scanStart,
setQueries,
updateTimeRange,
splitOpen,
} from './state/actions';
import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types/explore';
......@@ -120,6 +121,7 @@ export interface ExploreProps {
showTable: boolean;
showLogs: boolean;
showTrace: boolean;
splitOpen: typeof splitOpen;
}
enum ExploreDrawer {
......@@ -309,6 +311,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
showTable,
showLogs,
showTrace,
splitOpen,
} = this.props;
const { openDrawer } = this.state;
const exploreClass = split ? 'explore explore-split' : 'explore';
......@@ -405,7 +408,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
// We expect only one trace at the moment to be in the dataframe
// If there is not data (like 404) we show a separate error so no need to show anything here
queryResponse.series[0] && (
<TraceView trace={queryResponse.series[0].fields[0].values.get(0) as any} />
<TraceView
trace={queryResponse.series[0].fields[0].values.get(0) as any}
splitOpenFn={splitOpen}
/>
)}
</>
)}
......@@ -505,6 +511,7 @@ const mapDispatchToProps: Partial<ExploreProps> = {
setQueries,
updateTimeRange,
addQueryRow,
splitOpen,
};
export default compose(
......
......@@ -5,7 +5,7 @@ import { TracePageHeader, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-c
import { TraceSpanData, TraceData } from '@grafana/data';
function renderTraceView() {
const wrapper = shallow(<TraceView trace={response} />);
const wrapper = shallow(<TraceView trace={response} splitOpenFn={() => {}} />);
return {
timeline: wrapper.find(TraceTimelineViewer),
header: wrapper.find(TracePageHeader),
......
......@@ -17,9 +17,11 @@ import { useDetailState } from './useDetailState';
import { useHoverIndentGuide } from './useHoverIndentGuide';
import { colors, useTheme } from '@grafana/ui';
import { TraceData, TraceSpanData, Trace, TraceSpan, TraceKeyValuePair, TraceLink } from '@grafana/data';
import { createSpanLinkFactory } from './createSpanLink';
type Props = {
trace: TraceData & { spans: TraceSpanData[] };
splitOpenFn: (options: { datasourceUid: string; query: any }) => void;
};
export function TraceView(props: Props) {
......@@ -77,6 +79,8 @@ export function TraceView(props: Props) {
[childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, traceProp?.traceID]
);
const createSpanLink = useMemo(() => createSpanLinkFactory(props.splitOpenFn), [props.splitOpenFn]);
if (!traceProp) {
return null;
}
......@@ -140,6 +144,7 @@ export function TraceView(props: Props) {
[]
)}
uiFind={search}
createSpanLink={createSpanLink}
/>
</UIElementsContext.Provider>
</ThemeProvider>
......
import { createSpanLinkFactory } from './createSpanLink';
import { config, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime';
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data';
describe('createSpanLinkFactory', () => {
beforeAll(() => {
config.featureToggles.traceToLogs = true;
});
afterAll(() => {
config.featureToggles.traceToLogs = false;
});
it('returns undefined if there is no loki data source', () => {
setDataSourceSrv({
getExternal() {
return [
{
meta: {
id: 'not loki',
},
} as DataSourceInstanceSettings,
];
},
} as any);
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn);
expect(createLink).not.toBeDefined();
});
it('creates correct link', () => {
setDataSourceSrv({
getExternal() {
return [
{
name: 'loki1',
uid: 'lokiUid',
meta: {
id: 'loki',
},
} as DataSourceInstanceSettings,
];
},
getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined {
if (uid === 'lokiUid') {
return {
name: 'Loki1',
} as any;
}
return undefined;
},
} as any);
setTemplateSrv({
replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string {
return target!;
},
} as any);
const splitOpenFn = jest.fn();
const createLink = createSpanLinkFactory(splitOpenFn);
expect(createLink).toBeDefined();
const linkDef = createLink!({
startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000,
duration: 1000 * 1000,
process: {
tags: [
{
key: 'cluster',
value: 'cluster1',
},
{
key: 'hostname',
value: 'hostname1',
},
{
key: 'label2',
value: 'val2',
},
],
} as any,
} as any);
expect(linkDef.href).toBe(
`/explore?left={"range":{"from":"20201014T005955","to":"20201014T020001"},"datasource":"Loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}`
);
});
});
import React from 'react';
import { config, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime';
import { DataLink, dateTime, Field, mapInternalLinkToExplore, TimeRange, TraceSpan } from '@grafana/data';
import { LokiQuery } from '../../../plugins/datasource/loki/types';
import { Icon } from '@grafana/ui';
/**
* 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: (options: { datasourceUid: string; query: any }) => void) {
if (!config.featureToggles.traceToLogs) {
return undefined;
}
// Right now just hardcoded for first loki DS we can find
const lokiDs = getDataSourceSrv()
.getExternal()
.find(ds => ds.meta.id === 'loki');
if (!lokiDs) {
return undefined;
}
return function(span: TraceSpan): { href: string; onClick?: (event: any) => void; content: React.ReactNode } {
// 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
// it manually here instead of leaving it for the data source to supply the config.
const dataLink: DataLink<LokiQuery> = {
title: lokiDs.name,
url: '',
internal: {
datasourceUid: lokiDs.uid,
query: {
expr: getLokiQueryFromSpan(span),
refId: '',
},
},
};
const link = mapInternalLinkToExplore(dataLink, {}, getTimeRangeFromSpan(span), {} as Field, {
onClickFn: splitOpenFn,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
getDataSourceSettingsByUid: getDataSourceSrv().getDataSourceSettingsByUid.bind(getDataSourceSrv()),
});
return {
href: link.href,
onClick: link.onClick,
content: <Icon name="file-alt" title="Show logs" />,
};
};
}
/**
* Right now this is just hardcoded and later will probably be part of some user configuration.
*/
const allowedKeys = ['cluster', 'hostname', 'namespace', 'pod'];
function getLokiQueryFromSpan(span: TraceSpan): string {
const tags = span.process.tags.reduce((acc, tag) => {
if (allowedKeys.includes(tag.key)) {
acc.push(`${tag.key}="${tag.value}"`);
}
return acc;
}, [] as string[]);
return `{${tags.join(', ')}}`;
}
/**
* Gets a time range from the span. Naively this could be just start and end time of the span but we also want some
* buffer around that just so we do not miss some logs which may not have timestamps aligned with the span. Right
* now the buffers are hardcoded which may be a bit weird for very short spans but at the same time, fractional buffers
* with very short spans could mean microseconds and that could miss some logs relevant to that spans. In the future
* something more intelligent should probably be implemented
*/
function getTimeRangeFromSpan(span: TraceSpan): TimeRange {
const from = dateTime(span.startTime / 1000 - 5 * 1000);
const spanEndMs = (span.startTime + span.duration) / 1000;
const to = dateTime(spanEndMs + 1000 * 60 * 60);
return {
from,
to,
// Weirdly Explore does not handle ISO string which would have been the default stringification if passed as object
// and we have to use this custom format :( .
raw: {
from: from.format('YYYYMMDDTHHmmss'),
to: to.format('YYYYMMDDTHHmmss'),
},
};
}
......@@ -678,7 +678,11 @@ export function splitClose(itemId: ExploreId): ThunkResult<void> {
* Otherwise it copies the left state to be the right state. The copy keeps all query modifications but wipes the query
* results.
*/
export function splitOpen<T extends DataQuery = any>(options?: { datasourceUid: string; query: T }): ThunkResult<void> {
export function splitOpen<T extends DataQuery = any>(options?: {
datasourceUid: string;
query: T;
range?: TimeRange;
}): ThunkResult<void> {
return async (dispatch, getState) => {
// Clone left state to become the right state
const leftState: ExploreItemState = getState().explore[ExploreId.left];
......@@ -696,6 +700,10 @@ export function splitOpen<T extends DataQuery = any>(options?: { datasourceUid:
rightState.queryKeys = [];
urlState.queries = [];
rightState.urlState = urlState;
if (options.range) {
urlState.range = options.range.raw;
rightState.range = options.range;
}
dispatch(splitOpenAction({ itemState: rightState }));
......@@ -707,6 +715,7 @@ export function splitOpen<T extends DataQuery = any>(options?: { datasourceUid:
];
const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.datasourceUid);
await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name));
await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries }));
await dispatch(runQueries(ExploreId.right));
......
......@@ -57,7 +57,11 @@ describe('getFieldLinksForExplore', () => {
links[0].onClick({});
}
expect(splitfn).toBeCalledWith({ datasourceUid: 'uid_1', query: { query: 'query_1' } });
expect(splitfn).toBeCalledWith({
datasourceUid: 'uid_1',
query: { query: 'query_1' },
range,
});
});
});
......@@ -100,8 +104,8 @@ function setup(link: DataLink) {
};
const range: TimeRange = {
from: dateTime(),
to: dateTime(),
from: dateTime('2020-10-14T00:00:00'),
to: dateTime('2020-10-14T01:00:00'),
raw: {
from: 'now-1h',
to: 'now',
......
import memoizeOne from 'memoize-one';
import { splitOpen } from '../state/actions';
import { Field, LinkModel, TimeRange } from '@grafana/data';
import { Field, LinkModel, TimeRange, mapInternalLinkToExplore } from '@grafana/data';
import { getLinkSrv } from '../../panel/panellinks/link_srv';
import { mapInternalLinkToExplore } from '@grafana/data/src/utils/dataLinks';
import { getDataSourceSrv, getTemplateSrv, getBackendSrv, config } from '@grafana/runtime';
/**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment