From a414b07991c0447a662115b2fa655478a0909361 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Thu, 26 Jan 2023 16:37:16 +0100 Subject: [PATCH] Tempo: Trace to logs custom query with interpolation (#61702) --- .betterer.results | 20 +- .../src/field/fieldOverrides.test.ts | 2 +- .../grafana-data/src/utils/dataLinks.test.ts | 62 ++- packages/grafana-data/src/utils/dataLinks.ts | 52 +- .../TraceTimelineViewer/SpanDetail/index.tsx | 64 ++- .../jaeger-ui-components/src/types/links.ts | 3 + ...{KeyValueInput.tsx => TagMappingInput.tsx} | 31 +- .../TraceToLogs/TraceToLogsSettings.test.tsx | 123 +++++ .../TraceToLogs/TraceToLogsSettings.tsx | 345 ++++++------ .../TraceToMetrics/TraceToMetricsSettings.tsx | 8 +- .../features/explore/TraceView/TraceView.tsx | 7 +- .../explore/TraceView/createSpanLink.test.ts | 185 +++++-- .../explore/TraceView/createSpanLink.tsx | 489 +++++++++++------- .../app/features/explore/utils/links.test.ts | 8 +- public/app/features/explore/utils/links.ts | 6 + 15 files changed, 924 insertions(+), 481 deletions(-) rename public/app/core/components/TraceToLogs/{KeyValueInput.tsx => TagMappingInput.tsx} (81%) create mode 100644 public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx diff --git a/.betterer.results b/.betterer.results index 36836d9b169..376166f1291 100644 --- a/.betterer.results +++ b/.betterer.results @@ -697,13 +697,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"] ], - "packages/grafana-data/src/utils/dataLinks.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "packages/grafana-data/src/utils/dataLinks.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-data/src/utils/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -1717,10 +1712,6 @@ exports[`better eslint`] = { "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3894,14 +3885,7 @@ exports[`better eslint`] = { ], "public/app/features/explore/TraceView/createSpanLink.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/explore/spec/helper/setup.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index f830ed1271a..3d802346614 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -642,7 +642,7 @@ describe('getLinksSupplier', () => { ); const links = supplier({ valueRowIndex: 0 }); - const encodeURIParams = `{"datasource":"${datasourceUid}","queries":["12345"],"panelsState":{}}`; + const encodeURIParams = `{"datasource":"${datasourceUid}","queries":["12345"]}`; expect(links.length).toBe(1); expect(links[0]).toEqual( expect.objectContaining({ diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts index accaf0aab22..89784452605 100644 --- a/packages/grafana-data/src/utils/dataLinks.test.ts +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -1,4 +1,4 @@ -import { DataLink, FieldType } from '../types'; +import { DataLink, FieldType, TimeRange } from '../types'; import { ArrayVector } from '../vector'; import { mapInternalLinkToExplore } from './dataLinks'; @@ -19,7 +19,7 @@ describe('mapInternalLinkToExplore', () => { link: dataLink, internalLink: dataLink.internal, scopedVars: {}, - range: {} as any, + range: {} as unknown as TimeRange, field: { name: 'test', type: FieldType.number, @@ -32,9 +32,7 @@ describe('mapInternalLinkToExplore', () => { expect(link).toEqual( expect.objectContaining({ title: 'dsName', - href: `/explore?left=${encodeURIComponent( - '{"datasource":"uid","queries":[{"query":"12344"}],"panelsState":{}}' - )}`, + href: `/explore?left=${encodeURIComponent('{"datasource":"uid","queries":[{"query":"12344"}]}')}`, onClick: undefined, }) ); @@ -62,7 +60,7 @@ describe('mapInternalLinkToExplore', () => { link: dataLink, internalLink: dataLink.internal!, scopedVars: {}, - range: {} as any, + range: {} as unknown as TimeRange, field: { name: 'test', type: FieldType.number, @@ -82,4 +80,56 @@ describe('mapInternalLinkToExplore', () => { }) ); }); + + it('interpolates query correctly', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { + query: '$var $var', + // Should not interpolate keys + $var: 'foo', + nested: { + something: '$var', + }, + num: 1, + arr: ['$var', 'non var'], + }, + }, + }; + + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal, + scopedVars: { + var1: { text: '', value: 'val1' }, + }, + range: {} as unknown as TimeRange, + field: { + name: 'test', + type: FieldType.number, + config: {}, + values: new ArrayVector([2]), + }, + replaceVariables: (val, scopedVars) => val.replace(/\$var/g, scopedVars!['var1'].value), + }); + + expect(decodeURIComponent(link.href)).toEqual( + `/explore?left=${JSON.stringify({ + datasource: 'uid', + queries: [ + { + query: 'val1 val1', + $var: 'foo', + nested: { something: 'val1' }, + num: 1, + arr: ['val1', 'non var'], + }, + ], + })}` + ); + }); }); diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 4aadad9ac0c..b0b47b0e373 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -88,30 +88,38 @@ function generateInternalHref( ); } -function interpolateObject( - object: T | undefined, +function interpolateObject( + obj: T | undefined, + scopedVars: ScopedVars, + replaceVariables: InterpolateFunction +): T | undefined { + if (!obj) { + return obj; + } + if (typeof obj === 'string') { + // @ts-ignore this is complaining we are returning string, but we are checking if obj is a string so should be fine. + return replaceVariables(obj, scopedVars); + } + const copy = JSON.parse(JSON.stringify(obj)); + return interpolateObjectRecursive(copy, scopedVars, replaceVariables); +} + +function interpolateObjectRecursive( + obj: T, scopedVars: ScopedVars, replaceVariables: InterpolateFunction ): T { - let stringifiedQuery = ''; - try { - stringifiedQuery = JSON.stringify(object || {}); - } catch (err) { - // should not happen and not much to do about this, possibly something non stringifiable in the query - console.error(err); + for (const k of Object.keys(obj)) { + // Honestly not sure how to type this to make TS happy. + // @ts-ignore + if (typeof obj[k] === 'string') { + // @ts-ignore + obj[k] = replaceVariables(obj[k], scopedVars); + // @ts-ignore + } else if (typeof obj[k] === 'object' && obj[k] !== null) { + // @ts-ignore + obj[k] = interpolateObjectRecursive(obj[k], scopedVars, replaceVariables); + } } - - // Replace any variables inside the query. This may not be the safest as it can also replace keys etc so may not - // actually work with every datasource query right now. - stringifiedQuery = replaceVariables(stringifiedQuery, scopedVars); - - let replacedQuery = {} as T; - try { - replacedQuery = JSON.parse(stringifiedQuery); - } catch (err) { - // again should not happen and not much to do about this, probably some issue with how we replaced the variables. - console.error(stringifiedQuery, err); - } - - return replacedQuery; + return obj; } diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx index b08bca9d7dd..7cfee033a13 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx @@ -19,7 +19,7 @@ import IoLink from 'react-icons/lib/io/link'; import { dateTimeFormat, GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { DataLinkButton, TextArea, useStyles2 } from '@grafana/ui'; +import { Button, DataLinkButton, TextArea, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { Divider } from '../../common/Divider'; @@ -194,22 +194,48 @@ export default function SpanDetail(props: SpanDetailProps) { : []), ]; const styles = useStyles2(getStyles); - const links = createSpanLink?.(span); - const focusSpanLink = createFocusSpanLink(traceID, spanID); - const logLink = links?.logLinks?.[0] - ? { - ...links?.logLinks?.[0], - onClick: (event: React.MouseEvent) => { - reportInteraction('grafana_traces_trace_view_span_link_clicked', { - datasourceType: datasourceType, - type: 'log', - location: 'spanDetails', - }); - links?.logLinks?.[0].onClick!(event); - }, - } - : undefined; + let logLinkButton: JSX.Element | undefined = undefined; + if (createSpanLink) { + const links = createSpanLink(span); + if (links?.logLinks) { + logLinkButton = ( + { + reportInteraction('grafana_traces_trace_view_span_link_clicked', { + datasourceType: datasourceType, + type: 'log', + location: 'spanDetails', + }); + links?.logLinks?.[0].onClick?.(event); + }, + }} + buttonProps={{ icon: 'gf-logs' }} + /> + ); + } else { + logLinkButton = ( + + ); + } + } + + const focusSpanLink = createFocusSpanLink(traceID, spanID); return (
@@ -218,11 +244,7 @@ export default function SpanDetail(props: SpanDetailProps) {
- {links?.logLinks?.[0] ? ( - <> - - - ) : null} + {logLinkButton}
diff --git a/packages/jaeger-ui-components/src/types/links.ts b/packages/jaeger-ui-components/src/types/links.ts index bb4776bc9f6..0afb22985c4 100644 --- a/packages/jaeger-ui-components/src/types/links.ts +++ b/packages/jaeger-ui-components/src/types/links.ts @@ -1,5 +1,7 @@ import React from 'react'; +import { Field } from '@grafana/data'; + import { TraceSpan } from './trace'; export type SpanLinkDef = { @@ -7,6 +9,7 @@ export type SpanLinkDef = { onClick?: (event: any) => void; content: React.ReactNode; title?: string; + field: Field; }; export type SpanLinks = { diff --git a/public/app/core/components/TraceToLogs/KeyValueInput.tsx b/public/app/core/components/TraceToLogs/TagMappingInput.tsx similarity index 81% rename from public/app/core/components/TraceToLogs/KeyValueInput.tsx rename to public/app/core/components/TraceToLogs/TagMappingInput.tsx index bc30d96ea5e..8757260a45a 100644 --- a/public/app/core/components/TraceToLogs/KeyValueInput.tsx +++ b/public/app/core/components/TraceToLogs/TagMappingInput.tsx @@ -1,26 +1,16 @@ import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2, KeyValue } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { SegmentInput, useStyles2, InlineLabel, Icon } from '@grafana/ui'; -const EQ_WIDTH = 3; // = 24px in inline label - interface Props { - values: Array>; - onChange: (values: Array>) => void; + values: Array<{ key: string; value?: string }>; + onChange: (values: Array<{ key: string; value?: string }>) => void; id?: string; - keyPlaceholder?: string; - valuePlaceholder?: string; } -const KeyValueInput = ({ - values, - onChange, - id, - keyPlaceholder = 'Key', - valuePlaceholder = 'Value (optional)', -}: Props) => { +export const TagMappingInput = ({ values, onChange, id }: Props) => { const styles = useStyles2(getStyles); return ( @@ -30,7 +20,7 @@ const KeyValueInput = ({
{ onChange( @@ -43,13 +33,13 @@ const KeyValueInput = ({ ); }} /> - - = + + as { onChange( values.map((v, i) => { @@ -95,8 +85,6 @@ const KeyValueInput = ({ ); }; -export default KeyValueInput; - const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` display: flex; @@ -110,5 +98,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, operator: css` color: ${theme.v1.palette.orange}; + width: auto; `, }); diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx b/public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx new file mode 100644 index 00000000000..d3b5fb8254d --- /dev/null +++ b/public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourceSettings } from '@grafana/data'; +import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; + +import { TraceToLogsData, TraceToLogsSettings } from './TraceToLogsSettings'; + +const defaultOptionsOldFormat: DataSourceSettings = { + jsonData: { + tracesToLogs: { + datasourceUid: 'loki1_uid', + tags: ['someTag'], + mapTagNamesEnabled: false, + spanStartTimeShift: '1m', + spanEndTimeShift: '1m', + filterByTraceID: true, + filterBySpanID: true, + }, + }, +} as unknown as DataSourceSettings; + +const defaultOptionsNewFormat: DataSourceSettings = { + jsonData: { + tracesToLogsV2: { + datasourceUid: 'loki1_uid', + tags: [{ key: 'someTag', value: 'newName' }], + spanStartTimeShift: '1m', + spanEndTimeShift: '1m', + filterByTraceID: true, + filterBySpanID: true, + customQuery: true, + query: '{${__tags}}', + }, + }, +} as unknown as DataSourceSettings; + +const lokiSettings = { + uid: 'loki1_uid', + name: 'loki1', + type: 'loki', + meta: { info: { logos: { small: '' } } }, +} as unknown as DataSourceInstanceSettings; + +describe('TraceToLogsSettings', () => { + beforeAll(() => { + setDataSourceSrv({ + getList() { + return [lokiSettings]; + }, + getInstanceSettings() { + return lokiSettings; + }, + } as unknown as DataSourceSrv); + }); + + it('should render old format without error', () => { + expect(() => + render( {}} />) + ).not.toThrow(); + }); + + it('should render new format without error', () => { + expect(() => + render( {}} />) + ).not.toThrow(); + }); + + it('should render and transform data from old format correctly', () => { + render( {}} />); + expect(screen.getByText('someTag')).toBeInTheDocument(); + expect((screen.getByLabelText('Use custom query') as HTMLInputElement).checked).toBeFalsy(); + expect((screen.getByLabelText('Filter by trace ID') as HTMLInputElement).checked).toBeTruthy(); + expect((screen.getByLabelText('Filter by span ID') as HTMLInputElement).checked).toBeTruthy(); + }); + + it('renders old mapped tags correctly', () => { + const options = { + ...defaultOptionsOldFormat, + jsonData: { + ...defaultOptionsOldFormat.jsonData, + tracesToLogs: { + ...defaultOptionsOldFormat.jsonData.tracesToLogs, + tags: undefined, + mappedTags: [{ key: 'someTag', value: 'withNewName' }], + mapTagNamesEnabled: true, + }, + }, + }; + + render( {}} />); + expect(screen.getByText('someTag')).toBeInTheDocument(); + expect(screen.getByText('withNewName')).toBeInTheDocument(); + }); + + it('transforms old format to new on change', async () => { + const changeMock = jest.fn(); + render(); + const checkBox = screen.getByLabelText('Filter by trace ID'); + await userEvent.click(checkBox); + expect(changeMock.mock.calls[0]).toEqual([ + { + jsonData: { + tracesToLogs: undefined, + tracesToLogsV2: { + customQuery: false, + datasourceUid: 'loki1_uid', + filterBySpanID: true, + filterByTraceID: false, + spanEndTimeShift: '1m', + spanStartTimeShift: '1m', + tags: [ + { + key: 'someTag', + }, + ], + }, + }, + }, + ]); + }); +}); diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx index b80b7320ed6..a0af64be2e2 100644 --- a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx +++ b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx @@ -1,23 +1,22 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { DataSourceJsonData, DataSourceInstanceSettings, DataSourcePluginOptionsEditorProps, GrafanaTheme2, - KeyValue, - updateDatasourcePluginJsonDataOption, } from '@grafana/data'; import { DataSourcePicker } from '@grafana/runtime'; -import { InlineField, InlineFieldRow, Input, TagsInput, useStyles2, InlineSwitch } from '@grafana/ui'; +import { InlineField, InlineFieldRow, Input, useStyles2, InlineSwitch } from '@grafana/ui'; -import KeyValueInput from './KeyValueInput'; +import { TagMappingInput } from './TagMappingInput'; +// @deprecated use getTraceToLogsOptions to get the v2 version of this config from jsonData export interface TraceToLogsOptions { datasourceUid?: string; tags?: string[]; - mappedTags?: Array>; + mappedTags?: Array<{ key: string; value?: string }>; mapTagNamesEnabled?: boolean; spanStartTimeShift?: string; spanEndTimeShift?: string; @@ -26,8 +25,45 @@ export interface TraceToLogsOptions { lokiSearch?: boolean; // legacy } +export interface TraceToLogsOptionsV2 { + datasourceUid?: string; + tags?: Array<{ key: string; value?: string }>; + spanStartTimeShift?: string; + spanEndTimeShift?: string; + filterByTraceID?: boolean; + filterBySpanID?: boolean; + query?: string; + customQuery: boolean; +} + export interface TraceToLogsData extends DataSourceJsonData { tracesToLogs?: TraceToLogsOptions; + tracesToLogsV2?: TraceToLogsOptionsV2; +} + +/** + * Gets new version of the traceToLogs config from the json data either returning directly or transforming the old + * version to new and returning that. + */ +export function getTraceToLogsOptions(data?: TraceToLogsData): TraceToLogsOptionsV2 | undefined { + if (data?.tracesToLogsV2) { + return data.tracesToLogsV2; + } + if (!data?.tracesToLogs) { + return undefined; + } + const traceToLogs: TraceToLogsOptionsV2 = { + customQuery: false, + }; + traceToLogs.datasourceUid = data.tracesToLogs.datasourceUid; + traceToLogs.tags = data.tracesToLogs.mapTagNamesEnabled + ? data.tracesToLogs.mappedTags + : data.tracesToLogs.tags?.map((tag) => ({ key: tag })); + traceToLogs.filterByTraceID = data.tracesToLogs.filterByTraceID; + traceToLogs.filterBySpanID = data.tracesToLogs.filterBySpanID; + traceToLogs.spanStartTimeShift = data.tracesToLogs.spanStartTimeShift; + traceToLogs.spanEndTimeShift = data.tracesToLogs.spanEndTimeShift; + return traceToLogs; } interface Props extends DataSourcePluginOptionsEditorProps {} @@ -41,6 +77,31 @@ export function TraceToLogsSettings({ options, onOptionsChange }: Props) { 'grafana-opensearch-datasource', // external ]; + const traceToLogs = useMemo( + (): TraceToLogsOptionsV2 => getTraceToLogsOptions(options.jsonData) || { customQuery: false }, + [options.jsonData] + ); + const { query = '', tags, customQuery } = traceToLogs; + + const updateTracesToLogs = useCallback( + (value: Partial) => { + // Cannot use updateDatasourcePluginJsonDataOption here as we need to update 2 keys, and they would overwrite each + // other as updateDatasourcePluginJsonDataOption isn't synchronized + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + tracesToLogsV2: { + ...traceToLogs, + ...value, + }, + tracesToLogs: undefined, + }, + }); + }, + [onOptionsChange, options, traceToLogs] + ); + return (

Trace to logs

@@ -54,171 +115,143 @@ export function TraceToLogsSettings({ options, onOptionsChange }: Props) { supportedDataSourceTypes.includes(ds.type)} - current={options.jsonData.tracesToLogs?.datasourceUid} + current={traceToLogs.datasourceUid} noDefault={true} width={40} onChange={(ds: DataSourceInstanceSettings) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, + updateTracesToLogs({ datasourceUid: ds.uid, - tags: options.jsonData.tracesToLogs?.tags, }) } /> - {options.jsonData.tracesToLogs?.mapTagNamesEnabled ? ( - - - ({ key: tag })) ?? - [] - } - onChange={(v) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - mappedTags: v, - }) - } - /> - - - ) : ( - - - - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - tags: tags, - }) - } - /> - - + updateTracesToLogs({ spanStartTimeShift: val })} + /> + updateTracesToLogs({ spanEndTimeShift: val })} + /> + + + + updateTracesToLogs({ tags: v })} /> + + + + updateTracesToLogs({ filterByTraceID: val })} + /> + updateTracesToLogs({ filterBySpanID: val })} + /> + + + + ) => + updateTracesToLogs({ customQuery: event.currentTarget.checked }) + } + /> + + + + {customQuery && ( + + updateTracesToLogs({ query: e.currentTarget.value })} + /> + )} - - - - ) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - mapTagNamesEnabled: event.currentTarget.checked, - }) - } - /> - - - - - - - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - spanStartTimeShift: v.currentTarget.value, - }) - } - value={options.jsonData.tracesToLogs?.spanStartTimeShift || ''} - /> - - - - - - - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - spanEndTimeShift: v.currentTarget.value, - }) - } - value={options.jsonData.tracesToLogs?.spanEndTimeShift || ''} - /> - - - - - - ) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - filterByTraceID: event.currentTarget.checked, - }) - } - /> - - - - - - ) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - filterBySpanID: event.currentTarget.checked, - }) - } - /> - -
); } +interface IdFilterProps { + type: 'trace' | 'span'; + id: string; + value: boolean; + onChange: (val: boolean) => void; + disabled: boolean; +} +function IdFilter(props: IdFilterProps) { + return ( + + + ) => props.onChange(event.currentTarget.checked)} + /> + + + ); +} + +interface TimeRangeShiftProps { + type: 'start' | 'end'; + value: string; + onChange: (val: string) => void; +} +function TimeRangeShift(props: TimeRangeShiftProps) { + return ( + + + props.onChange(e.currentTarget.value)} + value={props.value} + /> + + + ); +} + const getStyles = (theme: GrafanaTheme2) => ({ infoText: css` padding-bottom: ${theme.spacing(2)}; diff --git a/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx b/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx index f51c90469d4..1198a85dce3 100644 --- a/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx +++ b/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx @@ -5,17 +5,16 @@ import { DataSourceJsonData, DataSourcePluginOptionsEditorProps, GrafanaTheme2, - KeyValue, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; import { DataSourcePicker } from '@grafana/runtime'; import { Button, InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui'; -import KeyValueInput from '../TraceToLogs/KeyValueInput'; +import { TagMappingInput } from '../TraceToLogs/TagMappingInput'; export interface TraceToMetricsOptions { datasourceUid?: string; - tags?: Array>; + tags?: Array<{ key: string; value: string }>; queries: TraceToMetricQuery[]; spanStartTimeShift?: string; spanEndTimeShift?: string; @@ -79,8 +78,7 @@ export function TraceToMetricsSettings({ options, onOptionsChange }: Props) { - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', { diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index dfa0221260d..b89588bd0e4 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -24,7 +24,7 @@ import { TraceTimelineViewer, TTraceTimeline, } from '@jaegertracing/jaeger-ui-components'; -import { TraceToLogsData } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; +import { getTraceToLogsOptions, TraceToLogsData } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsData } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getTimeZone } from 'app/features/profile/state/selectors'; @@ -121,7 +121,7 @@ export function TraceView(props: Props) { ); const instanceSettings = getDatasourceSrv().getInstanceSettings(datasource?.name); - const traceToLogsOptions = (instanceSettings?.jsonData as TraceToLogsData)?.tracesToLogs; + const traceToLogsOptions = getTraceToLogsOptions(instanceSettings?.jsonData as TraceToLogsData); const traceToMetricsOptions = (instanceSettings?.jsonData as TraceToMetricsData)?.tracesToMetrics; const spanBarOptions: SpanBarOptionsData | undefined = instanceSettings?.jsonData; @@ -133,8 +133,9 @@ export function TraceView(props: Props) { traceToMetricsOptions, dataFrame: props.dataFrames[0], createFocusSpanLink, + trace: traceProp, }), - [props.splitOpenFn, traceToLogsOptions, traceToMetricsOptions, props.dataFrames, createFocusSpanLink] + [props.splitOpenFn, traceToLogsOptions, traceToMetricsOptions, props.dataFrames, createFocusSpanLink, traceProp] ); const onSlimViewClicked = useCallback(() => setSlim(!slim), [slim]); const timeZone = useSelector((state) => getTimeZone(state.user)); diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 8a6ff0f10eb..40050a42cbe 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -1,19 +1,26 @@ import { DataSourceInstanceSettings, LinkModel, MutableDataFrame } from '@grafana/data'; import { DataSourceSrv, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime'; -import { TraceSpan } from '@jaegertracing/jaeger-ui-components'; +import { Trace, TraceSpan } from '@jaegertracing/jaeger-ui-components'; import { TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { TraceToLogsOptions } from '../../../core/components/TraceToLogs/TraceToLogsSettings'; +import { TraceToLogsOptionsV2 } from '../../../core/components/TraceToLogs/TraceToLogsSettings'; import { LinkSrv, setLinkSrv } from '../../panel/panellinks/link_srv'; import { TemplateSrv } from '../../templating/template_srv'; import { createSpanLinkFactory } from './createSpanLink'; +const dummyTraceData = { duration: 10, traceID: 'trace1', traceName: 'test trace' } as unknown as Trace; +const dummyDataFrame = new MutableDataFrame({ fields: [{ name: 'traceId', values: ['trace1'] }] }); + describe('createSpanLinkFactory', () => { it('returns no links if there is no data source uid', () => { const splitOpenFn = jest.fn(); - const createLink = createSpanLinkFactory({ splitOpenFn: splitOpenFn }); + const createLink = createSpanLinkFactory({ + splitOpenFn: splitOpenFn, + trace: dummyTraceData, + dataFrame: dummyDataFrame, + }); const links = createLink!(createTraceSpan()); expect(links?.logLinks).toBeUndefined(); expect(links?.metricLinks).toBeUndefined(); @@ -40,14 +47,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}' )}` ); }); it('with tags that passed in and without tags that are not in the span', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip', 'newTag'], + tags: [{ key: 'ip' }, { key: 'newTag' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -65,14 +72,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}]}' )}` ); }); it('from tags and process tags as well', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip', 'host'], + tags: [{ key: 'ip' }, { key: 'host' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -90,7 +97,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}]}' )}` ); }); @@ -116,7 +123,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:01:00.000Z","to":"2020-10-14T01:01:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:01:00.000Z","to":"2020-10-14T01:01:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}]}' )}` ); }); @@ -131,10 +138,18 @@ describe('createSpanLinkFactory', () => { const linkDef = links?.logLinks?.[0]; expect(linkDef).toBeDefined(); - expect(linkDef!.href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"} |=\\"7946b05c2e2e4e5a\\" |=\\"6605c7b08e715d6c\\"","refId":""}],"panelsState":{}}' - )}` + expect(decodeURIComponent(linkDef!.href)).toBe( + '/explore?left=' + + JSON.stringify({ + range: { from: '2020-10-14T01:00:00.000Z', to: '2020-10-14T01:00:01.000Z' }, + datasource: 'loki1_uid', + queries: [ + { + expr: '{cluster="cluster1", hostname="hostname1"} |="7946b05c2e2e4e5a" |="6605c7b08e715d6c"', + refId: '', + }, + ], + }) ); }); @@ -152,6 +167,7 @@ describe('createSpanLinkFactory', () => { }, ], }), + trace: dummyTraceData, }); expect(createLink).toBeDefined(); const links = createLink!(createTraceSpan()); @@ -163,8 +179,7 @@ describe('createSpanLinkFactory', () => { it('handles renamed tags', () => { const createLink = setupSpanLinkFactory({ - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: 'service' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -186,15 +201,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service=\\"serviceName\\", pod=\\"podName\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service=\\"serviceName\\", pod=\\"podName\\"}","refId":""}]}' )}` ); }); it('handles incomplete renamed tags', () => { const createLink = setupSpanLinkFactory({ - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: '' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -216,7 +230,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","refId":""}]}' )}` ); }); @@ -239,6 +253,16 @@ describe('createSpanLinkFactory', () => { ); expect(links?.logLinks).toBeUndefined(); }); + + it('interpolates span intrinsics', () => { + const createLink = setupSpanLinkFactory({ + tags: [{ key: 'name', value: 'spanName' }], + }); + expect(createLink).toBeDefined(); + const links = createLink!(createTraceSpan()); + expect(links?.logLinks).toBeDefined(); + expect(decodeURIComponent(links!.logLinks![0].href)).toContain('spanName=\\"operation\\"'); + }); }); describe('should return splunk link', () => { @@ -301,14 +325,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"cluster=\\"cluster1\\" hostname=\\"hostname1\\" \\"7946b05c2e2e4e5a\\" \\"6605c7b08e715d6c\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"cluster=\\"cluster1\\" hostname=\\"hostname1\\" \\"7946b05c2e2e4e5a\\" \\"6605c7b08e715d6c\\"","refId":""}]}' )}` ); }); it('should format one tag correctly', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip'], + tags: [{ key: 'ip' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -324,14 +348,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":""}]}' )}` ); }); it('should format multiple tags correctly', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip', 'hostname'], + tags: [{ key: 'ip' }, { key: 'hostname' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -350,15 +374,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"hostname=\\"hostname1\\" ip=\\"192.168.0.1\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"hostname=\\"hostname1\\" ip=\\"192.168.0.1\\"","refId":""}]}' )}` ); }); it('handles renamed tags', () => { const createLink = setupSpanLinkFactory({ - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: 'service' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -380,7 +403,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"service=\\"serviceName\\" pod=\\"podName\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"service=\\"serviceName\\" pod=\\"podName\\"","refId":""}]}' )}` ); }); @@ -406,6 +429,8 @@ describe('createSpanLinkFactory', () => { datasourceUid: 'prom1Uid', queries: [{ query: 'customQuery' }], }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -415,7 +440,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' )}` ); }); @@ -427,6 +452,8 @@ describe('createSpanLinkFactory', () => { traceToMetricsOptions: { datasourceUid: 'prom1', } as TraceToMetricsOptions, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -446,6 +473,8 @@ describe('createSpanLinkFactory', () => { { query: 'no_name_here' }, ], }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -458,7 +487,7 @@ describe('createSpanLinkFactory', () => { expect(namedLink!.title).toBe('Named Query'); expect(namedLink!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' )}` ); @@ -467,7 +496,7 @@ describe('createSpanLinkFactory', () => { expect(defaultLink!.title).toBe('defaultQuery'); expect(defaultLink!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"histogram_quantile(0.5, sum(rate(tempo_spanmetrics_latency_bucket{operation=\\"operation\\"}[5m])) by (le))","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"histogram_quantile(0.5, sum(rate(tempo_spanmetrics_latency_bucket{operation=\\"operation\\"}[5m])) by (le))","refId":"A"}]}' )}` ); @@ -476,7 +505,7 @@ describe('createSpanLinkFactory', () => { expect(unnamedQuery!.title).toBeUndefined(); expect(unnamedQuery!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"no_name_here","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"no_name_here","refId":"A"}]}' )}` ); }); @@ -491,6 +520,8 @@ describe('createSpanLinkFactory', () => { spanStartTimeShift: '-1h', spanEndTimeShift: '1h', }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -500,7 +531,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T00:00:00.000Z","to":"2020-10-14T02:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T00:00:00.000Z","to":"2020-10-14T02:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' )}` ); }); @@ -518,6 +549,8 @@ describe('createSpanLinkFactory', () => { { key: 'k8s.pod', value: 'pod' }, ], }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -535,7 +568,7 @@ describe('createSpanLinkFactory', () => { expect(links).toBeDefined(); expect(links!.metricLinks![0]!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"metric{job=\\"tns/app\\", pod=\\"sample-pod\\", job=\\"tns/app\\", pod=\\"sample-pod\\"}[5m]","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"metric{job=\\"tns/app\\", pod=\\"sample-pod\\", job=\\"tns/app\\", pod=\\"sample-pod\\"}[5m]","refId":"A"}]}' )}` ); }); @@ -620,10 +653,8 @@ describe('createSpanLinkFactory', () => { const linkDef = links?.logLinks?.[0]; expect(linkDef).toBeDefined(); - expect(linkDef!.href).toContain( - encodeURIComponent( - `datasource":"${searchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]` - ) + expect(decodeURIComponent(linkDef!.href)).toContain( + `datasource":"${searchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]` ); }); @@ -660,7 +691,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); @@ -686,17 +717,15 @@ describe('createSpanLinkFactory', () => { const linkDef = links?.logLinks?.[0]; expect(linkDef).toBeDefined(); - expect(linkDef!.href).toBe( - `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` - )}` + expect(decodeURIComponent(linkDef!.href)).toBe( + `/explore?left={"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` ); }); it('should format one tag correctly', () => { const createLink = setupSpanLinkFactory( { - tags: ['ip'], + tags: [{ key: 'ip' }], }, searchUID ); @@ -714,7 +743,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); @@ -722,7 +751,7 @@ describe('createSpanLinkFactory', () => { it('should format multiple tags correctly', () => { const createLink = setupSpanLinkFactory( { - tags: ['ip', 'hostname'], + tags: [{ key: 'ip' }, { key: 'hostname' }], }, searchUID ); @@ -743,7 +772,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); @@ -751,8 +780,7 @@ describe('createSpanLinkFactory', () => { it('handles renamed tags', () => { const createLink = setupSpanLinkFactory( { - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: 'service' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -776,18 +804,73 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); }); + + describe('custom query', () => { + beforeAll(() => { + setDataSourceSrv({ + getInstanceSettings() { + return { uid: 'loki1_uid', name: 'loki1', type: 'loki' } as unknown as DataSourceInstanceSettings; + }, + } as unknown as DataSourceSrv); + + setLinkSrv(new LinkSrv()); + setTemplateSrv(new TemplateSrv()); + }); + + it('interpolates custom query correctly', () => { + const createLink = setupSpanLinkFactory({ + tags: [ + { key: 'service.name', value: 'service' }, + { key: 'k8s.pod.name', value: 'pod' }, + ], + customQuery: true, + query: '{${__tags}} |="${__span.tags["service.name"]}" |="${__trace.traceId}"', + }); + expect(createLink).toBeDefined(); + const links = createLink!( + createTraceSpan({ + process: { + serviceName: 'service', + tags: [ + { key: 'service.name', value: 'serviceName' }, + { key: 'k8s.pod.name', value: 'podName' }, + ], + }, + }) + ); + + const linkDef = links?.logLinks?.[0]; + expect(linkDef).toBeDefined(); + expect(decodeURIComponent(linkDef!.href)).toContain( + '"queries":' + + JSON.stringify([{ expr: '{service="serviceName", pod="podName"} |="serviceName" |="trace1"', refId: '' }]) + ); + }); + + it('does not return a link if variables are not matched', () => { + const createLink = setupSpanLinkFactory({ + tags: [{ key: 'service.name', value: 'service' }], + customQuery: true, + query: '{${__tags}} |="${__span.tags["service.name"]}" |="${__trace.id}"', + }); + expect(createLink).toBeDefined(); + const links = createLink!(createTraceSpan()); + expect(links?.logLinks).toBeUndefined(); + }); + }); }); -function setupSpanLinkFactory(options: Partial = {}, datasourceUid = 'lokiUid') { +function setupSpanLinkFactory(options: Partial = {}, datasourceUid = 'lokiUid') { const splitOpenFn = jest.fn(); return createSpanLinkFactory({ splitOpenFn, traceToLogsOptions: { + customQuery: false, datasourceUid, ...options, }, @@ -796,6 +879,8 @@ function setupSpanLinkFactory(options: Partial = {}, datasou href: `${traceId}-${spanId}`, } as unknown as LinkModel; }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); } diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index b96c85ad190..4932b3bfeab 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -1,4 +1,5 @@ import { SpanLinks } from '@jaegertracing/jaeger-ui-components/src/types/links'; +import { property } from 'lodash'; import React from 'react'; import { @@ -8,23 +9,24 @@ import { DataSourceJsonData, dateTime, Field, - KeyValue, LinkModel, mapInternalLinkToExplore, rangeUtil, + ScopedVars, SplitOpen, TimeRange, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { Icon } from '@grafana/ui'; -import { SpanLinkFunc, TraceSpan } from '@jaegertracing/jaeger-ui-components'; -import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; +import { SpanLinkFunc, Trace, TraceSpan } from '@jaegertracing/jaeger-ui-components'; +import { TraceToLogsOptionsV2 } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricQuery, TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { LokiQuery } from '../../../plugins/datasource/loki/types'; +import { variableRegex } from '../../variables/utils'; import { getFieldLinksForExplore } from '../utils/links'; /** @@ -38,19 +40,44 @@ export function createSpanLinkFactory({ traceToMetricsOptions, dataFrame, createFocusSpanLink, + trace, }: { splitOpenFn: SplitOpen; - traceToLogsOptions?: TraceToLogsOptions; + traceToLogsOptions?: TraceToLogsOptionsV2; traceToMetricsOptions?: TraceToMetricsOptions; dataFrame?: DataFrame; createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel; + trace: Trace; }): SpanLinkFunc | undefined { - if (!dataFrame || dataFrame.fields.length === 1 || !dataFrame.fields.some((f) => Boolean(f.config.links?.length))) { + if (!dataFrame) { + return undefined; + } + + let scopedVars = scopedVarsFromTrace(trace); + const hasLinks = dataFrame.fields.some((f) => Boolean(f.config.links?.length)); + const legacyFormat = dataFrame.fields.length === 1; + + if (legacyFormat || !hasLinks) { // 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, traceToMetricsOptions, createFocusSpanLink); - } else { + // TODO: This was mainly a backward compatibility thing but at this point can probably be removed. + return legacyCreateSpanLinkFactory( + splitOpenFn, + // We need this to make the types happy but for this branch of code it does not matter which field we supply. + dataFrame.fields[0], + traceToLogsOptions, + traceToMetricsOptions, + createFocusSpanLink, + scopedVars + ); + } + + if (hasLinks) { return function SpanLink(span: TraceSpan): SpanLinks | undefined { + scopedVars = { + ...scopedVars, + ...scopedVarsFromSpan(span), + }; // 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 { @@ -60,6 +87,7 @@ export function createSpanLinkFactory({ splitOpenFn, range: getTimeRangeFromSpan(span), dataFrame, + vars: scopedVars, }); return { @@ -68,6 +96,7 @@ export function createSpanLinkFactory({ href: links[0].href, onClick: links[0].onClick, content: , + field: links[0].origin, }, ], }; @@ -78,13 +107,22 @@ export function createSpanLinkFactory({ } }; } + + return undefined; } +/** + * Default keys to use when there are no configured tags. + */ +const defaultKeys = ['cluster', 'hostname', 'namespace', 'pod'].map((k) => ({ key: k })); + function legacyCreateSpanLinkFactory( splitOpenFn: SplitOpen, - traceToLogsOptions?: TraceToLogsOptions, + field: Field, + traceToLogsOptions?: TraceToLogsOptionsV2, traceToMetricsOptions?: TraceToMetricsOptions, - createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel + createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel, + scopedVars?: ScopedVars ) { let logsDataSourceSettings: DataSourceInstanceSettings | undefined; if (traceToLogsOptions?.datasourceUid) { @@ -98,59 +136,89 @@ function legacyCreateSpanLinkFactory( } return function SpanLink(span: TraceSpan): SpanLinks { + scopedVars = { + ...scopedVars, + ...scopedVarsFromSpan(span), + }; const links: SpanLinks = { traceLinks: [] }; - // 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. - let dataLink: DataLink | undefined; + let query: DataQuery | undefined; + let tags = ''; - // Get logs link + // TODO: This should eventually move into specific data sources and added to the data frame as we no longer use the + // deprecated blob format and we can map the link easily in data frame. if (logsDataSourceSettings && traceToLogsOptions) { + const customQuery = traceToLogsOptions.customQuery ? traceToLogsOptions.query : undefined; + const tagsToUse = traceToLogsOptions.tags || defaultKeys; switch (logsDataSourceSettings?.type) { case 'loki': - dataLink = getLinkForLoki(span, traceToLogsOptions, logsDataSourceSettings); + tags = getFormattedTags(span, tagsToUse); + query = getQueryForLoki(span, traceToLogsOptions, tags, customQuery); break; case 'grafana-splunk-datasource': - dataLink = getLinkForSplunk(span, traceToLogsOptions, logsDataSourceSettings); + tags = getFormattedTags(span, tagsToUse, { joinBy: ' ' }); + query = getQueryForSplunk(span, traceToLogsOptions, tags, customQuery); break; case 'elasticsearch': - dataLink = getLinkForElasticsearchOrOpensearch(span, traceToLogsOptions, logsDataSourceSettings); - break; case 'grafana-opensearch-datasource': - dataLink = getLinkForElasticsearchOrOpensearch(span, traceToLogsOptions, logsDataSourceSettings); + tags = getFormattedTags(span, tagsToUse, { labelValueSign: ':', joinBy: ' AND ' }); + query = getQueryForElasticsearchOrOpensearch(span, traceToLogsOptions, tags, customQuery); break; } - if (dataLink) { - const link = mapInternalLinkToExplore({ - link: dataLink, - internalLink: dataLink.internal!, - scopedVars: {}, - range: getTimeRangeFromSpan( - span, - { - startMs: traceToLogsOptions.spanStartTimeShift - ? rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift) - : 0, - endMs: traceToLogsOptions.spanEndTimeShift - ? rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift) - : 0, - }, - isSplunkDS - ), - field: {} as Field, - onClickFn: splitOpenFn, - replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), - }); - - links.logLinks = [ - { - href: link.href, - onClick: link.onClick, - content: , + // query can be false in case the simple UI tag mapping is used but none of them are present in the span. + // For custom query, this is always defined and we check if the interpolation matched all variables later on. + if (query) { + const dataLink: DataLink = { + title: logsDataSourceSettings.name, + url: '', + internal: { + datasourceUid: logsDataSourceSettings.uid, + datasourceName: logsDataSourceSettings.name, + query, }, - ]; + }; + + scopedVars = { + ...scopedVars, + __tags: { + text: 'Tags', + value: tags, + }, + }; + + // Check if all variables are defined and don't show if they aren't. This is usually handled by the + // getQueryFor* functions but this is for case of custom query supplied by the user. + if (dataLinkHasAllVariablesDefined(dataLink.internal!.query, scopedVars)) { + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal!, + scopedVars: scopedVars, + range: getTimeRangeFromSpan( + span, + { + startMs: traceToLogsOptions.spanStartTimeShift + ? rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift) + : 0, + endMs: traceToLogsOptions.spanEndTimeShift + ? rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift) + : 0, + }, + isSplunkDS + ), + field: {} as Field, + onClickFn: splitOpenFn, + replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), + }); + + links.logLinks = [ + { + href: link.href, + onClick: link.onClick, + content: , + field, + }, + ]; + } } } @@ -158,7 +226,7 @@ function legacyCreateSpanLinkFactory( if (metricsDataSourceSettings && traceToMetricsOptions?.queries) { links.metricLinks = []; for (const query of traceToMetricsOptions.queries) { - const expr = buildMetricsQuery(query, traceToMetricsOptions?.tags, span); + const expr = buildMetricsQuery(query, traceToMetricsOptions?.tags || [], span); const dataLink: DataLink = { title: metricsDataSourceSettings.name, url: '', @@ -194,6 +262,7 @@ function legacyCreateSpanLinkFactory( href: link.href, onClick: link.onClick, content: , + field, }); } } @@ -213,6 +282,7 @@ function legacyCreateSpanLinkFactory( title: reference.span ? reference.span.operationName : 'View linked span', content: , onClick: link.onClick, + field: link.origin, }); } } @@ -226,6 +296,7 @@ function legacyCreateSpanLinkFactory( title: reference.span ? reference.span.operationName : 'View linked span', content: , onClick: link.onClick, + field: link.origin, }); } } @@ -234,56 +305,34 @@ function legacyCreateSpanLinkFactory( }; } -/** - * Default keys to use when there are no configured tags. - */ -const defaultKeys = ['cluster', 'hostname', 'namespace', 'pod']; -function getLinkForLoki(span: TraceSpan, options: TraceToLogsOptions, dataSourceSettings: DataSourceInstanceSettings) { - const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; +function getQueryForLoki( + span: TraceSpan, + options: TraceToLogsOptionsV2, + tags: string, + customQuery?: string +): LokiQuery | undefined { + const { filterByTraceID, filterBySpanID } = options; - // In order, try to use mapped tags -> tags -> default tags - const keysToCheck = mapTagNamesEnabled && mappedTags?.length ? mappedTags : keys?.length ? keys : defaultKeys; - // Build tag portion of query - const tags = [...span.process.tags, ...span.tags].reduce((acc, tag) => { - if (mapTagNamesEnabled) { - const keyValue = (keysToCheck as KeyValue[]).find((keyValue: KeyValue) => keyValue.key === tag.key); - if (keyValue) { - acc.push(`${keyValue.value ? keyValue.value : keyValue.key}="${tag.value}"`); - } - } else { - if ((keysToCheck as string[]).includes(tag.key)) { - acc.push(`${tag.key}="${tag.value}"`); - } - } - return acc; - }, [] as string[]); + if (customQuery) { + return { expr: customQuery, refId: '' }; + } - // If no tags found, return undefined to prevent an invalid Loki query - if (!tags.length) { + if (!tags) { return undefined; } - let expr = `{${tags.join(', ')}}`; + + let expr = '{${__tags}}'; if (filterByTraceID && span.traceID) { - expr += ` |="${span.traceID}"`; + expr += ' |="${__span.traceId}"'; } if (filterBySpanID && span.spanID) { - expr += ` |="${span.spanID}"`; + expr += ' |="${__span.spanId}"'; } - const dataLink: DataLink = { - title: dataSourceSettings.name, - url: '', - internal: { - datasourceUid: dataSourceSettings.uid, - datasourceName: dataSourceSettings.name, - query: { - expr: expr, - refId: '', - }, - }, + return { + expr: expr, + refId: '', }; - - return dataLink; } // we do not have access to the dataquery type for opensearch, @@ -296,114 +345,93 @@ interface ElasticsearchOrOpensearchQuery extends DataQuery { }>; } -function getLinkForElasticsearchOrOpensearch( +function getQueryForElasticsearchOrOpensearch( span: TraceSpan, - options: TraceToLogsOptions, - dataSourceSettings: DataSourceInstanceSettings -) { - const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; - const tags = [...span.process.tags, ...span.tags].reduce((acc: string[], tag) => { - if (mapTagNamesEnabled && mappedTags?.length) { - const keysToCheck = mappedTags; - const keyValue = keysToCheck.find((keyValue) => keyValue.key === tag.key); - if (keyValue) { - acc.push(`${keyValue.value ? keyValue.value : keyValue.key}:"${tag.value}"`); - } - } else { - const keysToCheck = keys?.length ? keys : defaultKeys; - if (keysToCheck.includes(tag.key)) { - acc.push(`${tag.key}:"${tag.value}"`); - } - } - return acc; - }, []); + options: TraceToLogsOptionsV2, + tags: string, + customQuery?: string +): ElasticsearchOrOpensearchQuery { + const { filterByTraceID, filterBySpanID } = options; + if (customQuery) { + return { + query: customQuery, + refId: '', + metrics: [{ id: '1', type: 'logs' }], + }; + } let queryArr = []; if (filterBySpanID && span.spanID) { - queryArr.push(`"${span.spanID}"`); + queryArr.push('"${__span.spanId}"'); } if (filterByTraceID && span.traceID) { - queryArr.push(`"${span.traceID}"`); + queryArr.push('"${__span.traceId}"'); } - if (tags.length > 0) { - for (const tag of tags) { - queryArr.push(tag); - } + if (tags) { + queryArr.push('${__tags}'); } - const dataLink: DataLink = { - title: dataSourceSettings.name, - url: '', - internal: { - datasourceUid: dataSourceSettings.uid, - datasourceName: dataSourceSettings.name, - query: { - query: queryArr.join(' AND '), - refId: '', - metrics: [ - { - id: '1', - type: 'logs', - }, - ], - }, - }, + return { + query: queryArr.join(' AND '), + refId: '', + metrics: [{ id: '1', type: 'logs' }], }; - - return dataLink; } -function getLinkForSplunk( - span: TraceSpan, - options: TraceToLogsOptions, - dataSourceSettings: DataSourceInstanceSettings -) { - const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; +function getQueryForSplunk(span: TraceSpan, options: TraceToLogsOptionsV2, tags: string, customQuery?: string) { + const { filterByTraceID, filterBySpanID } = options; - // In order, try to use mapped tags -> tags -> default tags - const keysToCheck = mapTagNamesEnabled && mappedTags?.length ? mappedTags : keys?.length ? keys : defaultKeys; - // Build tag portion of query - const tags = [...span.process.tags, ...span.tags].reduce((acc, tag) => { - if (mapTagNamesEnabled) { - const keyValue = (keysToCheck as KeyValue[]).find((keyValue: KeyValue) => keyValue.key === tag.key); - if (keyValue) { - acc.push(`${keyValue.value ? keyValue.value : keyValue.key}="${tag.value}"`); - } - } else { - if ((keysToCheck as string[]).includes(tag.key)) { - acc.push(`${tag.key}="${tag.value}"`); - } - } - return acc; - }, [] as string[]); + if (customQuery) { + return { query: customQuery, refId: '' }; + } let query = ''; - if (tags.length > 0) { - query += `${tags.join(' ')}`; + if (tags) { + query += '${__tags}'; } if (filterByTraceID && span.traceID) { - query += ` "${span.traceID}"`; + query += ' "${__span.traceId}"'; } if (filterBySpanID && span.spanID) { - query += ` "${span.spanID}"`; + query += ' "${__span.spanId}"'; } - const dataLink: DataLink = { - title: dataSourceSettings.name, - url: '', - internal: { - datasourceUid: dataSourceSettings.uid, - datasourceName: dataSourceSettings.name, - query: { - query: query, - refId: '', - }, - }, - } as DataLink; + return { + query: query, + refId: '', + }; +} - return dataLink; +/** + * Creates a string representing all the tags already formatted for use in the query. The tags are filtered so that + * only intersection of tags that exist in a span and tags that you want are serialized into the string. + */ +function getFormattedTags( + span: TraceSpan, + tags: Array<{ key: string; value?: string }>, + { labelValueSign = '=', joinBy = ', ' }: { labelValueSign?: string; joinBy?: string } = {} +) { + // In order, try to use mapped tags -> tags -> default tags + // Build tag portion of query + return [ + ...span.process.tags, + ...span.tags, + { key: 'spanId', value: span.spanID }, + { key: 'traceId', value: span.traceID }, + { key: 'name', value: span.operationName }, + { key: 'duration', value: span.duration }, + ] + .map((tag) => { + const keyValue = tags.find((keyValue) => keyValue.key === tag.key); + if (keyValue) { + return `${keyValue.value ? keyValue.value : keyValue.key}${labelValueSign}"${tag.value}"`; + } + return undefined; + }) + .filter((v) => Boolean(v)) + .join(joinBy); } /** @@ -442,7 +470,11 @@ function getTimeRangeFromSpan( } // Interpolates span attributes into trace to metric query, or returns default query -function buildMetricsQuery(query: TraceToMetricQuery, tags: Array> = [], span: TraceSpan): string { +function buildMetricsQuery( + query: TraceToMetricQuery, + tags: Array<{ key: string; value?: string }> = [], + span: TraceSpan +): string { if (!query.query) { return `histogram_quantile(0.5, sum(rate(tempo_spanmetrics_latency_bucket{operation="${span.operationName}"}[5m])) by (le))`; } @@ -464,3 +496,112 @@ function buildMetricsQuery(query: TraceToMetricQuery, tags: Array(query: T, scopedVars: ScopedVars): boolean { + const vars = getVariablesMapInTemplate(getStringsFromObject(query), scopedVars); + return Object.values(vars).every((val) => val !== undefined); +} + +function getStringsFromObject(obj: T): string { + let acc = ''; + for (const k of Object.keys(obj)) { + // Honestly not sure how to type this to make TS happy. + // @ts-ignore + if (typeof obj[k] === 'string') { + // @ts-ignore + acc += ' ' + obj[k]; + // @ts-ignore + } else if (typeof obj[k] === 'object' && obj[k] !== null) { + // @ts-ignore + acc += ' ' + getStringsFromObject(obj[k]); + } + } + return acc; +} + +function getVariablesMapInTemplate(target: string, scopedVars: ScopedVars): Record { + const regex = new RegExp(variableRegex); + const values: Record = {}; + + target.replace(regex, (match, var1, var2, fmt2, var3, fieldPath) => { + const variableName = var1 || var2 || var3; + values[variableName] = getVariableValue(variableName, fieldPath, scopedVars); + + // Don't care about the result anyway + return ''; + }); + + return values; +} + +function getVariableValue(variableName: string, fieldPath: string | undefined, scopedVars: ScopedVars): VarValue { + const scopedVar = scopedVars[variableName]; + if (!scopedVar) { + return undefined; + } + + if (fieldPath) { + // @ts-ignore ScopedVars are typed in way that I don't think this is possible to type correctly. + return property(fieldPath)(scopedVar.value); + } + + return scopedVar.value; +} diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 1c60871ed87..3d111b947f9 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -147,7 +147,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' )}` ); }); @@ -166,7 +166,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' )}` ); }); @@ -194,7 +194,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' )}` ); }); @@ -236,7 +236,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}]}' )}` ); }); diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index a8e507a0b59..6c50fb4e312 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -24,6 +24,12 @@ const dataLinkHasRequiredPermissions = (link: DataLink) => { return !link.internal || contextSrv.hasAccessToExplore(); }; +/** + * Check if every variable in the link has a value. If not this returns false. If there are no variables in the link + * this will return true. + * @param link + * @param scopedVars + */ const dataLinkHasAllVariablesDefined = (link: DataLink, scopedVars: ScopedVars) => { let hasAllRequiredVarDefined = true;