mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
26e2faa779
commit
c8658f3ee8
@ -35,6 +35,8 @@ export interface FeatureToggles {
|
|||||||
live: boolean;
|
live: boolean;
|
||||||
expressions: boolean;
|
expressions: boolean;
|
||||||
ngalert: boolean;
|
ngalert: boolean;
|
||||||
|
// Just for demo at the moment
|
||||||
|
traceToLogs: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @remarks
|
* @remarks
|
||||||
|
@ -51,6 +51,7 @@ export type TraceSpanData = {
|
|||||||
traceID: string;
|
traceID: string;
|
||||||
processID: string;
|
processID: string;
|
||||||
operationName: string;
|
operationName: string;
|
||||||
|
// Times are in microseconds
|
||||||
startTime: number;
|
startTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
logs: TraceLog[];
|
logs: TraceLog[];
|
||||||
|
@ -26,8 +26,9 @@ export const DataLinkBuiltInVars = {
|
|||||||
valueCalc: '__value.calc',
|
valueCalc: '__value.calc',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// We inject these because we cannot import them directly as they reside inside grafana main package.
|
||||||
type Options = {
|
type Options = {
|
||||||
onClickFn?: (options: { datasourceUid: string; query: any }) => void;
|
onClickFn?: (options: { datasourceUid: string; query: any; range?: TimeRange }) => void;
|
||||||
replaceVariables: InterpolateFunction;
|
replaceVariables: InterpolateFunction;
|
||||||
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined;
|
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined;
|
||||||
};
|
};
|
||||||
@ -62,6 +63,7 @@ export function mapInternalLinkToExplore(
|
|||||||
onClickFn?.({
|
onClickFn?.({
|
||||||
datasourceUid: link.internal!.datasourceUid,
|
datasourceUid: link.internal!.datasourceUid,
|
||||||
query: interpolatedQuery,
|
query: interpolatedQuery,
|
||||||
|
range,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
@ -15,6 +15,6 @@ export { getMappedValue } from './valueMappings';
|
|||||||
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
||||||
export { locationUtil } from './location';
|
export { locationUtil } from './location';
|
||||||
export { urlUtil, UrlQueryMap, UrlQueryValue } from './url';
|
export { urlUtil, UrlQueryMap, UrlQueryValue } from './url';
|
||||||
export { DataLinkBuiltInVars } from './dataLinks';
|
export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks';
|
||||||
export { DocsId } from './docs';
|
export { DocsId } from './docs';
|
||||||
export { observableTester } from './tests/observableTester';
|
export { observableTester } from './tests/observableTester';
|
||||||
|
@ -56,6 +56,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
expressions: false,
|
expressions: false,
|
||||||
meta: false,
|
meta: false,
|
||||||
ngalert: false,
|
ngalert: false,
|
||||||
|
traceToLogs: false,
|
||||||
};
|
};
|
||||||
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
||||||
rendererAvailable = false;
|
rendererAvailable = false;
|
||||||
|
@ -19,6 +19,16 @@ export interface DataSourceSrv {
|
|||||||
* Returns metadata based on UID.
|
* Returns metadata based on UID.
|
||||||
*/
|
*/
|
||||||
getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined;
|
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;
|
let singletonInstance: DataSourceSrv;
|
||||||
|
@ -310,6 +310,9 @@ type SpanBarRowProps = {
|
|||||||
removeHoverIndentGuideId: (spanID: string) => void;
|
removeHoverIndentGuideId: (spanID: string) => void;
|
||||||
clippingLeft?: boolean;
|
clippingLeft?: boolean;
|
||||||
clippingRight?: 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,
|
clippingLeft,
|
||||||
clippingRight,
|
clippingRight,
|
||||||
theme,
|
theme,
|
||||||
|
createSpanLink,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const {
|
const {
|
||||||
duration,
|
duration,
|
||||||
@ -437,6 +441,32 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
|
|||||||
</span>
|
</span>
|
||||||
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
|
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
|
||||||
</a>
|
</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 && (
|
{span.references && span.references.length > 1 && (
|
||||||
<ReferencesButton
|
<ReferencesButton
|
||||||
references={span.references}
|
references={span.references}
|
||||||
|
@ -79,6 +79,9 @@ type TVirtualizedTraceViewOwnProps = {
|
|||||||
addHoverIndentGuideId: (spanID: string) => void;
|
addHoverIndentGuideId: (spanID: string) => void;
|
||||||
removeHoverIndentGuideId: (spanID: string) => void;
|
removeHoverIndentGuideId: (spanID: string) => void;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
createSpanLink?: (
|
||||||
|
span: TraceSpan
|
||||||
|
) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode };
|
||||||
};
|
};
|
||||||
|
|
||||||
type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
|
type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
|
||||||
@ -330,6 +333,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
addHoverIndentGuideId,
|
addHoverIndentGuideId,
|
||||||
removeHoverIndentGuideId,
|
removeHoverIndentGuideId,
|
||||||
theme,
|
theme,
|
||||||
|
createSpanLink,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
// to avert flow error
|
// to avert flow error
|
||||||
if (!trace) {
|
if (!trace) {
|
||||||
@ -379,6 +383,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
hoverIndentGuideIds={hoverIndentGuideIds}
|
hoverIndentGuideIds={hoverIndentGuideIds}
|
||||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||||
|
createSpanLink={createSpanLink}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -99,6 +99,9 @@ type TProps = TExtractUiFindFromStateReturn & {
|
|||||||
removeHoverIndentGuideId: (spanID: string) => void;
|
removeHoverIndentGuideId: (spanID: string) => void;
|
||||||
linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[];
|
linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[];
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
createSpanLink?: (
|
||||||
|
span: TraceSpan
|
||||||
|
) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode };
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DataSourceSrv } from '@grafana/runtime';
|
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 { ElasticsearchQuery } from '../../plugins/datasource/elasticsearch/types';
|
||||||
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
|
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
|
||||||
@ -23,6 +23,12 @@ describe('getAlertingValidationMessage', () => {
|
|||||||
const datasourceSrv: DataSourceSrv = {
|
const datasourceSrv: DataSourceSrv = {
|
||||||
get: getMock,
|
get: getMock,
|
||||||
getDataSourceSettingsByUid(): any {},
|
getDataSourceSettingsByUid(): any {},
|
||||||
|
getExternal(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getAll(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const targets: ElasticsearchQuery[] = [
|
const targets: ElasticsearchQuery[] = [
|
||||||
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
|
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
|
||||||
@ -60,6 +66,12 @@ describe('getAlertingValidationMessage', () => {
|
|||||||
return Promise.resolve(alertingDatasource);
|
return Promise.resolve(alertingDatasource);
|
||||||
},
|
},
|
||||||
getDataSourceSettingsByUid(): any {},
|
getDataSourceSettingsByUid(): any {},
|
||||||
|
getExternal(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getAll(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const targets: any[] = [
|
const targets: any[] = [
|
||||||
{ refId: 'A', query: 'some query', datasource: 'alertingDatasource' },
|
{ refId: 'A', query: 'some query', datasource: 'alertingDatasource' },
|
||||||
@ -84,6 +96,12 @@ describe('getAlertingValidationMessage', () => {
|
|||||||
const datasourceSrv: DataSourceSrv = {
|
const datasourceSrv: DataSourceSrv = {
|
||||||
get: getMock,
|
get: getMock,
|
||||||
getDataSourceSettingsByUid(): any {},
|
getDataSourceSettingsByUid(): any {},
|
||||||
|
getExternal(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getAll(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const targets: ElasticsearchQuery[] = [
|
const targets: ElasticsearchQuery[] = [
|
||||||
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
|
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
|
||||||
@ -110,6 +128,12 @@ describe('getAlertingValidationMessage', () => {
|
|||||||
const datasourceSrv: DataSourceSrv = {
|
const datasourceSrv: DataSourceSrv = {
|
||||||
get: getMock,
|
get: getMock,
|
||||||
getDataSourceSettingsByUid(): any {},
|
getDataSourceSettingsByUid(): any {},
|
||||||
|
getExternal(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getAll(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const targets: ElasticsearchQuery[] = [
|
const targets: ElasticsearchQuery[] = [
|
||||||
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
|
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
|
||||||
@ -136,6 +160,12 @@ describe('getAlertingValidationMessage', () => {
|
|||||||
const datasourceSrv: DataSourceSrv = {
|
const datasourceSrv: DataSourceSrv = {
|
||||||
get: getMock,
|
get: getMock,
|
||||||
getDataSourceSettingsByUid(): any {},
|
getDataSourceSettingsByUid(): any {},
|
||||||
|
getExternal(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getAll(): DataSourceInstanceSettings[] {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const targets: ElasticsearchQuery[] = [
|
const targets: ElasticsearchQuery[] = [
|
||||||
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
|
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
|
||||||
|
@ -101,6 +101,7 @@ const dummyProps: ExploreProps = {
|
|||||||
showLogs: true,
|
showLogs: true,
|
||||||
showTable: true,
|
showTable: true,
|
||||||
showTrace: true,
|
showTrace: true,
|
||||||
|
splitOpen: (() => {}) as any,
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupErrors = (hasRefId?: boolean) => {
|
const setupErrors = (hasRefId?: boolean) => {
|
||||||
|
@ -37,6 +37,7 @@ import {
|
|||||||
scanStart,
|
scanStart,
|
||||||
setQueries,
|
setQueries,
|
||||||
updateTimeRange,
|
updateTimeRange,
|
||||||
|
splitOpen,
|
||||||
} from './state/actions';
|
} from './state/actions';
|
||||||
|
|
||||||
import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types/explore';
|
import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types/explore';
|
||||||
@ -120,6 +121,7 @@ export interface ExploreProps {
|
|||||||
showTable: boolean;
|
showTable: boolean;
|
||||||
showLogs: boolean;
|
showLogs: boolean;
|
||||||
showTrace: boolean;
|
showTrace: boolean;
|
||||||
|
splitOpen: typeof splitOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ExploreDrawer {
|
enum ExploreDrawer {
|
||||||
@ -309,6 +311,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
|||||||
showTable,
|
showTable,
|
||||||
showLogs,
|
showLogs,
|
||||||
showTrace,
|
showTrace,
|
||||||
|
splitOpen,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { openDrawer } = this.state;
|
const { openDrawer } = this.state;
|
||||||
const exploreClass = split ? 'explore explore-split' : 'explore';
|
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
|
// 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
|
// If there is not data (like 404) we show a separate error so no need to show anything here
|
||||||
queryResponse.series[0] && (
|
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,
|
setQueries,
|
||||||
updateTimeRange,
|
updateTimeRange,
|
||||||
addQueryRow,
|
addQueryRow,
|
||||||
|
splitOpen,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default compose(
|
export default compose(
|
||||||
|
@ -5,7 +5,7 @@ import { TracePageHeader, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-c
|
|||||||
import { TraceSpanData, TraceData } from '@grafana/data';
|
import { TraceSpanData, TraceData } from '@grafana/data';
|
||||||
|
|
||||||
function renderTraceView() {
|
function renderTraceView() {
|
||||||
const wrapper = shallow(<TraceView trace={response} />);
|
const wrapper = shallow(<TraceView trace={response} splitOpenFn={() => {}} />);
|
||||||
return {
|
return {
|
||||||
timeline: wrapper.find(TraceTimelineViewer),
|
timeline: wrapper.find(TraceTimelineViewer),
|
||||||
header: wrapper.find(TracePageHeader),
|
header: wrapper.find(TracePageHeader),
|
||||||
|
@ -17,9 +17,11 @@ import { useDetailState } from './useDetailState';
|
|||||||
import { useHoverIndentGuide } from './useHoverIndentGuide';
|
import { useHoverIndentGuide } from './useHoverIndentGuide';
|
||||||
import { colors, useTheme } from '@grafana/ui';
|
import { colors, useTheme } from '@grafana/ui';
|
||||||
import { TraceData, TraceSpanData, Trace, TraceSpan, TraceKeyValuePair, TraceLink } from '@grafana/data';
|
import { TraceData, TraceSpanData, Trace, TraceSpan, TraceKeyValuePair, TraceLink } from '@grafana/data';
|
||||||
|
import { createSpanLinkFactory } from './createSpanLink';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
trace: TraceData & { spans: TraceSpanData[] };
|
trace: TraceData & { spans: TraceSpanData[] };
|
||||||
|
splitOpenFn: (options: { datasourceUid: string; query: any }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TraceView(props: Props) {
|
export function TraceView(props: Props) {
|
||||||
@ -77,6 +79,8 @@ export function TraceView(props: Props) {
|
|||||||
[childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, traceProp?.traceID]
|
[childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, traceProp?.traceID]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createSpanLink = useMemo(() => createSpanLinkFactory(props.splitOpenFn), [props.splitOpenFn]);
|
||||||
|
|
||||||
if (!traceProp) {
|
if (!traceProp) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -140,6 +144,7 @@ export function TraceView(props: Props) {
|
|||||||
[]
|
[]
|
||||||
)}
|
)}
|
||||||
uiFind={search}
|
uiFind={search}
|
||||||
|
createSpanLink={createSpanLink}
|
||||||
/>
|
/>
|
||||||
</UIElementsContext.Provider>
|
</UIElementsContext.Provider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
88
public/app/features/explore/TraceView/createSpanLink.test.ts
Normal file
88
public/app/features/explore/TraceView/createSpanLink.test.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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":""}]}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
92
public/app/features/explore/TraceView/createSpanLink.tsx
Normal file
92
public/app/features/explore/TraceView/createSpanLink.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
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
|
* Otherwise it copies the left state to be the right state. The copy keeps all query modifications but wipes the query
|
||||||
* results.
|
* 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) => {
|
return async (dispatch, getState) => {
|
||||||
// Clone left state to become the right state
|
// Clone left state to become the right state
|
||||||
const leftState: ExploreItemState = getState().explore[ExploreId.left];
|
const leftState: ExploreItemState = getState().explore[ExploreId.left];
|
||||||
@ -696,6 +700,10 @@ export function splitOpen<T extends DataQuery = any>(options?: { datasourceUid:
|
|||||||
rightState.queryKeys = [];
|
rightState.queryKeys = [];
|
||||||
urlState.queries = [];
|
urlState.queries = [];
|
||||||
rightState.urlState = urlState;
|
rightState.urlState = urlState;
|
||||||
|
if (options.range) {
|
||||||
|
urlState.range = options.range.raw;
|
||||||
|
rightState.range = options.range;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(splitOpenAction({ itemState: rightState }));
|
dispatch(splitOpenAction({ itemState: rightState }));
|
||||||
|
|
||||||
@ -707,6 +715,7 @@ export function splitOpen<T extends DataQuery = any>(options?: { datasourceUid:
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.datasourceUid);
|
const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.datasourceUid);
|
||||||
|
|
||||||
await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name));
|
await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name));
|
||||||
await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries }));
|
await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries }));
|
||||||
await dispatch(runQueries(ExploreId.right));
|
await dispatch(runQueries(ExploreId.right));
|
||||||
|
@ -57,7 +57,11 @@ describe('getFieldLinksForExplore', () => {
|
|||||||
links[0].onClick({});
|
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 = {
|
const range: TimeRange = {
|
||||||
from: dateTime(),
|
from: dateTime('2020-10-14T00:00:00'),
|
||||||
to: dateTime(),
|
to: dateTime('2020-10-14T01:00:00'),
|
||||||
raw: {
|
raw: {
|
||||||
from: 'now-1h',
|
from: 'now-1h',
|
||||||
to: 'now',
|
to: 'now',
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import memoizeOne from 'memoize-one';
|
import memoizeOne from 'memoize-one';
|
||||||
import { splitOpen } from '../state/actions';
|
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 { getLinkSrv } from '../../panel/panellinks/link_srv';
|
||||||
import { mapInternalLinkToExplore } from '@grafana/data/src/utils/dataLinks';
|
|
||||||
import { getDataSourceSrv, getTemplateSrv, getBackendSrv, config } from '@grafana/runtime';
|
import { getDataSourceSrv, getTemplateSrv, getBackendSrv, config } from '@grafana/runtime';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user