From fb1c31e1b6c2464641ef726f4dfcaf56cf15c52e Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 14 Sep 2021 17:02:41 +0200 Subject: [PATCH] CloudWatch Logs: Add link to Xray data source for trace IDs in logs (#39135) * Refactor log query handling * Add link to config page * Change message about missing xray to alert * Add xrayTraceLinks * Fix typo in field name * Fix tests and lint * Move test * Add test for trace id link * lint --- .../src/components/DataSourcePicker.tsx | 1 + .../src/services/dataSourceSrv.ts | 7 +- .../cloudwatch/components/ConfigEditor.tsx | 62 ++++++--- .../components/MetricsQueryEditor.test.tsx | 53 -------- .../cloudwatch/components/XrayLinkConfig.tsx | 57 +++++++++ .../__snapshots__/ConfigEditor.test.tsx.snap | 15 +++ .../datasource/cloudwatch/datasource.test.ts | 120 ++++++++++++++++-- .../datasource/cloudwatch/datasource.ts | 118 ++++++++--------- .../cloudwatch/specs/datasource.test.ts | 53 -------- .../plugins/datasource/cloudwatch/types.ts | 3 + .../cloudwatch/utils/datalinks.test.ts | 95 ++++++++++++++ .../datasource/cloudwatch/utils/datalinks.ts | 88 +++++++++++++ 12 files changed, 469 insertions(+), 203 deletions(-) create mode 100644 public/app/plugins/datasource/cloudwatch/components/XrayLinkConfig.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts create mode 100644 public/app/plugins/datasource/cloudwatch/utils/datalinks.ts diff --git a/packages/grafana-runtime/src/components/DataSourcePicker.tsx b/packages/grafana-runtime/src/components/DataSourcePicker.tsx index e32cbdf3150..a95c9bf8a56 100644 --- a/packages/grafana-runtime/src/components/DataSourcePicker.tsx +++ b/packages/grafana-runtime/src/components/DataSourcePicker.tsx @@ -30,6 +30,7 @@ export interface DataSourcePickerProps { variables?: boolean; alerting?: boolean; pluginId?: string; + // If set to true and there is no value select will be empty, otherwise it will preselect default data source noDefault?: boolean; width?: number; filter?: (dataSource: DataSourceInstanceSettings) => boolean; diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index d167770f1f2..a1467f67ea7 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -10,10 +10,11 @@ import { ScopedVars, DataSourceApi, DataSourceInstanceSettings } from '@grafana/ */ export interface DataSourceSrv { /** - * @param name - name of the datasource plugin you want to use. + * Returns the requested dataSource. If it cannot be found it rejects the promise. + * @param nameOrUid - name or Uid of the datasource plugin you want to use. * @param scopedVars - variables used to interpolate a templated passed as name. */ - get(name?: string | null, scopedVars?: ScopedVars): Promise; + get(nameOrUid?: string | null, scopedVars?: ScopedVars): Promise; /** * Get a list of data sources @@ -28,7 +29,7 @@ export interface DataSourceSrv { /** @public */ export interface GetDataSourceListFilters { - /** Include mixed deta source by setting this to true */ + /** Include mixed data source by setting this to true */ mixed?: boolean; /** Only return data sources that support metrics response */ diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx index 061050dbc38..f59efe4102b 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx @@ -1,6 +1,10 @@ import React, { FC, useEffect, useState } from 'react'; import { Input, InlineField } from '@grafana/ui'; -import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOption } from '@grafana/data'; +import { + DataSourcePluginOptionsEditorProps, + onUpdateDatasourceJsonDataOption, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; import { ConnectionConfig } from '@grafana/aws-sdk'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -10,32 +14,15 @@ import { createWarningNotification } from 'app/core/copy/appNotification'; import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../types'; import { CloudWatchDatasource } from '../datasource'; +import { XrayLinkConfig } from './XrayLinkConfig'; export type Props = DataSourcePluginOptionsEditorProps; export const ConfigEditor: FC = (props: Props) => { - const [datasource, setDatasource] = useState(); const { options } = props; - const addWarning = (message: string) => { - store.dispatch(notifyApp(createWarningNotification('CloudWatch Authentication', message))); - }; - - useEffect(() => { - getDatasourceSrv() - .loadDatasource(options.name) - .then((datasource: CloudWatchDatasource) => setDatasource(datasource)); - - if (options.jsonData.authType === 'arn') { - addWarning('Since grafana 7.3 authentication type "arn" is deprecated, falling back to default SDK provider'); - } else if (options.jsonData.authType === 'credentials' && !options.jsonData.profile && !options.jsonData.database) { - addWarning( - 'As of grafana 7.3 authentication type "credentials" should be used only for shared file credentials. \ - If you don\'t have a credentials file, switch to the default SDK provider for extracting credentials \ - from environment variables or IAM roles' - ); - } - }, [options.jsonData.authType, options.jsonData.database, options.jsonData.profile, options.name]); + const datasource = useDatasource(options.name); + useAuthenticationWarning(options.jsonData); return ( <> @@ -55,6 +42,39 @@ export const ConfigEditor: FC = (props: Props) => { /> + + updateDatasourcePluginJsonDataOption(props, 'tracingDatasourceUid', uid)} + datasourceUid={options.jsonData.tracingDatasourceUid} + /> ); }; + +function useAuthenticationWarning(jsonData: CloudWatchJsonData) { + const addWarning = (message: string) => { + store.dispatch(notifyApp(createWarningNotification('CloudWatch Authentication', message))); + }; + + useEffect(() => { + if (jsonData.authType === 'arn') { + addWarning('Since grafana 7.3 authentication type "arn" is deprecated, falling back to default SDK provider'); + } else if (jsonData.authType === 'credentials' && !jsonData.profile && !jsonData.database) { + addWarning( + 'As of grafana 7.3 authentication type "credentials" should be used only for shared file credentials. \ + If you don\'t have a credentials file, switch to the default SDK provider for extracting credentials \ + from environment variables or IAM roles' + ); + } + }, [jsonData.authType, jsonData.database, jsonData.profile]); +} + +function useDatasource(datasourceName: string) { + const [datasource, setDatasource] = useState(); + useEffect(() => { + getDatasourceSrv() + .loadDatasource(datasourceName) + .then((datasource: CloudWatchDatasource) => setDatasource(datasource)); + }, [datasourceName]); + return datasource; +} diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx index 40a922baaeb..e236b34121e 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx @@ -2,7 +2,6 @@ import { interval, of, throwError } from 'rxjs'; import { DataFrame, DataQueryErrorType, - DataQueryResponse, DataSourceInstanceSettings, dateMath, getFrameDisplayName, @@ -176,58 +175,6 @@ describe('CloudWatchDatasource', () => { jest.spyOn(rxjsUtils, 'increasingInterval').mockImplementation(() => interval(100)); }); - it('should add data links to response', () => { - const { ds } = getTestContext(); - const mockResponse: DataQueryResponse = { - data: [ - { - fields: [ - { - config: { - links: [], - }, - }, - ], - refId: 'A', - }, - ], - }; - - const mockOptions: any = { - targets: [ - { - refId: 'A', - expression: 'stats count(@message) by bin(1h)', - logGroupNames: ['fake-log-group-one', 'fake-log-group-two'], - region: 'default', - }, - ], - }; - - const saturatedResponse = ds['addDataLinksToLogsResponse'](mockResponse, mockOptions); - expect(saturatedResponse).toMatchObject({ - data: [ - { - fields: [ - { - config: { - links: [ - { - url: - "https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#logs-insights:queryDetail=~(end~'2016-12-31T16*3a00*3a00.000Z~start~'2016-12-31T15*3a00*3a00.000Z~timeType~'ABSOLUTE~tz~'UTC~editorString~'stats*20count*28*40message*29*20by*20bin*281h*29~isLiveTail~false~source~(~'fake-log-group-one~'fake-log-group-two))", - title: 'View in CloudWatch console', - targetBlank: true, - }, - ], - }, - }, - ], - refId: 'A', - }, - ], - }); - }); - it('should stop querying when no more data received a number of times in a row', async () => { const { ds } = getTestContext(); const fakeFrames = genMockFrames(20); diff --git a/public/app/plugins/datasource/cloudwatch/components/XrayLinkConfig.tsx b/public/app/plugins/datasource/cloudwatch/components/XrayLinkConfig.tsx new file mode 100644 index 00000000000..1dad9384cb0 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/XrayLinkConfig.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { Alert, InlineField, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; + +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { DataSourcePicker } from '@grafana/runtime'; + +const getStyles = (theme: GrafanaTheme2) => ({ + infoText: css` + padding-bottom: ${theme.spacing(2)}; + color: ${theme.colors.text.secondary}; + `, +}); + +interface Props { + datasourceUid?: string; + onChange: (uid: string) => void; +} + +const xRayDsId = 'grafana-x-ray-datasource'; + +export function XrayLinkConfig({ datasourceUid, onChange }: Props) { + const hasXrayDatasource = Boolean(getDatasourceSrv().getList({ pluginId: xRayDsId }).length); + + const styles = useStyles2(getStyles); + + return ( + <> +

X-ray trace link

+ +
+ Grafana will automatically create a link to a trace in X-ray data source if logs contain @xrayTraceId field +
+ + {!hasXrayDatasource && ( + + )} + +
+ + onChange(ds.uid)} + current={datasourceUid} + noDefault={true} + /> + +
+ + ); +} diff --git a/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap index ce4e63e6c0a..2fe58d873ac 100644 --- a/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap +++ b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap @@ -62,6 +62,9 @@ exports[`Render should disable access key id field 1`] = ` /> + `; @@ -122,6 +125,9 @@ exports[`Render should render component 1`] = ` /> + `; @@ -187,6 +193,9 @@ exports[`Render should show access key and secret access key fields 1`] = ` /> + `; @@ -252,6 +261,9 @@ exports[`Render should show arn role field 1`] = ` /> + `; @@ -317,5 +329,8 @@ exports[`Render should show credentials profile name field 1`] = ` /> + `; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index 67b675a734d..7889238a67c 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -1,9 +1,10 @@ -import { of } from 'rxjs'; -import { setBackendSrv } from '@grafana/runtime'; -import { dateTime, getDefaultTimeRange } from '@grafana/data'; +import { from, lastValueFrom, of } from 'rxjs'; +import { setBackendSrv, setDataSourceSrv, setGrafanaLiveSrv } from '@grafana/runtime'; +import { ArrayVector, dataFrameToJSON, dateTime, Field, MutableDataFrame } from '@grafana/data'; import { TemplateSrv } from '../../../features/templating/template_srv'; import { CloudWatchDatasource } from './datasource'; +import { toArray } from 'rxjs/operators'; describe('datasource', () => { describe('query', () => { @@ -39,6 +40,41 @@ describe('datasource', () => { expect(response.data).toEqual([]); }); }); + + it('should add links to log queries', async () => { + const { datasource } = setupForLogs(); + const observable = datasource.query({ + targets: [ + { + queryMode: 'Logs', + logGroupNames: ['test'], + refId: 'a', + }, + ], + } as any); + + const emits = await lastValueFrom(observable.pipe(toArray())); + expect(emits).toHaveLength(1); + expect(emits[0].data[0].fields.find((f: Field) => f.name === '@xrayTraceId').config.links).toMatchObject([ + { + title: 'Xray', + url: '', + internal: { + query: { query: '${__value.raw}', region: 'us-west-1', queryType: 'getTrace' }, + datasourceUid: 'xray', + datasourceName: 'Xray', + }, + }, + ]); + + expect(emits[0].data[0].fields.find((f: Field) => f.name === '@message').config.links).toMatchObject([ + { + title: 'View in CloudWatch console', + url: + "https://us-west-1.console.aws.amazon.com/cloudwatch/home?region=us-west-1#logs-insights:queryDetail=~(end~'2020-12-31T19*3a00*3a00.000Z~start~'2020-12-31T19*3a00*3a00.000Z~timeType~'ABSOLUTE~tz~'UTC~editorString~'~isLiveTail~false~source~(~'test))", + }, + ]); + }); }); describe('performTimeSeriesQuery', () => { @@ -82,13 +118,81 @@ describe('datasource', () => { }); function setup({ data = [] }: { data?: any } = {}) { - const datasource = new CloudWatchDatasource({ jsonData: { defaultRegion: 'us-west-1' } } as any, new TemplateSrv(), { - timeRange() { - return getDefaultTimeRange(); - }, - } as any); + const datasource = new CloudWatchDatasource( + { jsonData: { defaultRegion: 'us-west-1', tracingDatasourceUid: 'xray' } } as any, + new TemplateSrv(), + { + timeRange() { + const time = dateTime('2021-01-01T01:00:00Z'); + const range = { + from: time.subtract(6, 'hour'), + to: time, + }; + + return { + ...range, + raw: range, + }; + }, + } as any + ); const fetchMock = jest.fn().mockReturnValue(of({ data })); setBackendSrv({ fetch: fetchMock } as any); return { datasource, fetchMock }; } + +function setupForLogs() { + const { datasource, fetchMock } = setup({ + data: { + results: { + a: { + refId: 'a', + frames: [dataFrameToJSON(new MutableDataFrame({ fields: [], meta: { custom: { channelName: 'test' } } }))], + }, + }, + }, + }); + const logsFrame = new MutableDataFrame({ + fields: [ + { + name: '@message', + values: new ArrayVector(['something']), + }, + { + name: '@timestamp', + values: new ArrayVector([1]), + }, + { + name: '@xrayTraceId', + values: new ArrayVector(['1-613f0d6b-3e7cb34375b60662359611bd']), + }, + ], + }); + setGrafanaLiveSrv({ + getStream() { + return from([ + { + type: 'message', + message: { + results: { + a: { + frames: [dataFrameToJSON(logsFrame)], + }, + }, + }, + }, + ]); + }, + } as any); + + setDataSourceSrv({ + async get() { + return { + name: 'Xray', + }; + }, + } as any); + + return { datasource, fetchMock }; +} diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 8e4cffb0759..ed62ca3f049 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -1,7 +1,7 @@ import React from 'react'; import angular from 'angular'; import { find, isEmpty, isString, set } from 'lodash'; -import { lastValueFrom, merge, Observable, of, throwError, zip } from 'rxjs'; +import { from, lastValueFrom, merge, Observable, of, throwError, zip } from 'rxjs'; import { catchError, concatMap, @@ -62,10 +62,10 @@ import { } from './types'; import { CloudWatchLanguageProvider } from './language_provider'; import { VariableWithMultiSupport } from 'app/features/variables/types'; -import { AwsUrl, encodeUrl } from './aws_url'; import { increasingInterval } from './utils/rxjs/increasingInterval'; import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse'; import config from 'app/core/config'; +import { addDataLinksToLogsResponse } from './utils/datalinks'; const DS_QUERY_ENDPOINT = '/api/ds/query'; @@ -94,6 +94,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend): Observable { @@ -128,11 +129,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend> = []; if (logQueries.length > 0) { - if (config.liveEnabled) { - dataQueryResponses.push(this.handleLiveLogQueries(logQueries, options)); - } else { - dataQueryResponses.push(this.handleLogQueries(logQueries, options)); - } + dataQueryResponses.push(this.handleLogQueries(logQueries, options)); } if (metricsQueries.length > 0) { @@ -150,7 +147,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend ): Observable => { @@ -164,7 +161,43 @@ export class CloudWatchDatasource extends DataSourceWithBackend ({ + const response = config.liveEnabled + ? this.handleLiveLogQueries(validLogQueries, options) + : this.handleLegacyLogQueries(validLogQueries, options); + + return response.pipe( + mergeMap((dataQueryResponse) => { + return from( + (async () => { + await addDataLinksToLogsResponse( + dataQueryResponse, + options, + this.timeSrv.timeRange(), + this.replace.bind(this), + this.getActualRegion.bind(this), + this.tracingDataSourceUid + ); + + return dataQueryResponse; + })() + ); + }) + ); + }; + + /** + * Handle log query using grafana live feature. This means the backend will return a websocket channel name and it + * will listen on it for partial responses until it's terminated. This should give quicker partial data to the user + * as the log query can be long running. This requires that config.liveEnabled === true as that controls whether + * websocket connections can be made. + * @param logQueries + * @param options + */ + private handleLiveLogQueries = ( + logQueries: CloudWatchLogsQuery[], + options: DataQueryRequest + ): Observable => { + const queryParams = logQueries.map((target: CloudWatchLogsQuery) => ({ intervalMs: 1, // dummy maxDataPoints: 1, // dummy datasourceId: this.id, @@ -207,7 +240,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend { if (err.data?.error) { @@ -219,21 +252,17 @@ export class CloudWatchDatasource extends DataSourceWithBackend ): Observable => { - const validLogQueries = logQueries.filter((item) => item.logGroupNames?.length); - if (logQueries.length > validLogQueries.length) { - return of({ data: [], error: { message: 'Log group is required' } }); - } - - // No valid targets, return the empty result to save a round trip. - if (isEmpty(validLogQueries)) { - return of({ data: [], state: LoadingState.Done }); - } - - const queryParams = validLogQueries.map((target: CloudWatchLogsQuery) => ({ + const queryParams = logQueries.map((target: CloudWatchLogsQuery) => ({ queryString: target.expression, refId: target.refId, logGroupNames: target.logGroupNames, @@ -251,8 +280,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend this.addDataLinksToLogsResponse(response, options)) + ) ); }; @@ -393,46 +421,6 @@ export class CloudWatchDatasource extends DataSourceWithBackend this.stopQueries()); } - private addDataLinksToLogsResponse(response: DataQueryResponse, options: DataQueryRequest) { - for (const dataFrame of response.data as DataFrame[]) { - const range = this.timeSrv.timeRange(); - const start = range.from.toISOString(); - const end = range.to.toISOString(); - - const curTarget = options.targets.find((target) => target.refId === dataFrame.refId) as CloudWatchLogsQuery; - const interpolatedGroups = - curTarget.logGroupNames?.map((logGroup: string) => - this.replace(logGroup, options.scopedVars, true, 'log groups') - ) ?? []; - const urlProps: AwsUrl = { - end, - start, - timeType: 'ABSOLUTE', - tz: 'UTC', - editorString: curTarget.expression ? this.replace(curTarget.expression, options.scopedVars, true) : '', - isLiveTail: false, - source: interpolatedGroups, - }; - - const encodedUrl = encodeUrl( - urlProps, - this.getActualRegion(this.replace(curTarget.region, options.scopedVars, true, 'region')) - ); - - for (const field of dataFrame.fields) { - field.config.links = [ - { - url: encodedUrl, - title: 'View in CloudWatch console', - targetBlank: true, - }, - ]; - } - } - - return response; - } - stopQueries() { if (Object.keys(this.logQueries).length > 0) { this.makeLogActionRequest( diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts index e77024870e2..2a863d609ce 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -2,7 +2,6 @@ import { interval, lastValueFrom, of, throwError } from 'rxjs'; import { DataFrame, DataQueryErrorType, - DataQueryResponse, DataSourceInstanceSettings, dateMath, getFrameDisplayName, @@ -176,58 +175,6 @@ describe('CloudWatchDatasource', () => { jest.spyOn(rxjsUtils, 'increasingInterval').mockImplementation(() => interval(100)); }); - it('should add data links to response', () => { - const { ds } = getTestContext(); - const mockResponse: DataQueryResponse = { - data: [ - { - fields: [ - { - config: { - links: [], - }, - }, - ], - refId: 'A', - }, - ], - }; - - const mockOptions: any = { - targets: [ - { - refId: 'A', - expression: 'stats count(@message) by bin(1h)', - logGroupNames: ['fake-log-group-one', 'fake-log-group-two'], - region: 'default', - }, - ], - }; - - const saturatedResponse = ds['addDataLinksToLogsResponse'](mockResponse, mockOptions); - expect(saturatedResponse).toMatchObject({ - data: [ - { - fields: [ - { - config: { - links: [ - { - url: - "https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#logs-insights:queryDetail=~(end~'2016-12-31T16*3a00*3a00.000Z~start~'2016-12-31T15*3a00*3a00.000Z~timeType~'ABSOLUTE~tz~'UTC~editorString~'stats*20count*28*40message*29*20by*20bin*281h*29~isLiveTail~false~source~(~'fake-log-group-one~'fake-log-group-two))", - title: 'View in CloudWatch console', - targetBlank: true, - }, - ], - }, - }, - ], - refId: 'A', - }, - ], - }); - }); - it('should stop querying when no more data received a number of times in a row', async () => { const { ds } = getTestContext(); const fakeFrames = genMockFrames(20); diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts index 14698d4ffb8..cb233cc26b9 100644 --- a/public/app/plugins/datasource/cloudwatch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -69,6 +69,9 @@ export interface CloudWatchJsonData extends AwsAuthDataSourceJsonData { database?: string; customMetricsNamespaces?: string; endpoint?: string; + + // Used to create links if logs contain traceId. + tracingDatasourceUid?: string; } export interface CloudWatchSecureJsonData extends AwsAuthDataSourceSecureJsonData { diff --git a/public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts b/public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts new file mode 100644 index 00000000000..7e18fcd6057 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts @@ -0,0 +1,95 @@ +import { DataQueryResponse, dateMath } from '@grafana/data'; +import { addDataLinksToLogsResponse } from './datalinks'; +import { setDataSourceSrv } from '@grafana/runtime'; + +describe('addDataLinksToLogsResponse', () => { + it('should add data links to response', async () => { + const mockResponse: DataQueryResponse = { + data: [ + { + fields: [ + { + name: '@message', + config: {}, + }, + { + name: '@xrayTraceId', + config: {}, + }, + ], + refId: 'A', + }, + ], + }; + + const mockOptions: any = { + targets: [ + { + refId: 'A', + expression: 'stats count(@message) by bin(1h)', + logGroupNames: ['fake-log-group-one', 'fake-log-group-two'], + region: 'us-east-1', + }, + ], + }; + + const time = { + from: dateMath.parse('2016-12-31 15:00:00Z', false)!, + to: dateMath.parse('2016-12-31 16:00:00Z', false)!, + }; + + setDataSourceSrv({ + async get() { + return { + name: 'Xray', + }; + }, + } as any); + + await addDataLinksToLogsResponse( + mockResponse, + mockOptions, + { ...time, raw: time }, + (s) => s ?? '', + (r) => r, + 'xrayUid' + ); + expect(mockResponse).toMatchObject({ + data: [ + { + fields: [ + { + name: '@message', + config: { + links: [ + { + url: + "https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#logs-insights:queryDetail=~(end~'2016-12-31T16*3a00*3a00.000Z~start~'2016-12-31T15*3a00*3a00.000Z~timeType~'ABSOLUTE~tz~'UTC~editorString~'stats*20count*28*40message*29*20by*20bin*281h*29~isLiveTail~false~source~(~'fake-log-group-one~'fake-log-group-two))", + title: 'View in CloudWatch console', + }, + ], + }, + }, + { + name: '@xrayTraceId', + config: { + links: [ + { + url: '', + title: 'Xray', + internal: { + query: { query: '${__value.raw}', region: 'us-east-1', queryType: 'getTrace' }, + datasourceUid: 'xrayUid', + datasourceName: 'Xray', + }, + }, + ], + }, + }, + ], + refId: 'A', + }, + ], + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts b/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts new file mode 100644 index 00000000000..e802baba157 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts @@ -0,0 +1,88 @@ +import { DataFrame, DataLink, DataQueryRequest, DataQueryResponse, ScopedVars, TimeRange } from '@grafana/data'; +import { CloudWatchLogsQuery, CloudWatchQuery } from '../types'; +import { AwsUrl, encodeUrl } from '../aws_url'; +import { getDataSourceSrv } from '@grafana/runtime'; + +type ReplaceFn = ( + target?: string, + scopedVars?: ScopedVars, + displayErrorIfIsMultiTemplateVariable?: boolean, + fieldName?: string +) => string; + +export async function addDataLinksToLogsResponse( + response: DataQueryResponse, + request: DataQueryRequest, + range: TimeRange, + replaceFn: ReplaceFn, + getRegion: (region: string) => string, + tracingDatasourceUid?: string +): Promise { + const replace = (target: string, fieldName?: string) => replaceFn(target, request.scopedVars, true, fieldName); + + for (const dataFrame of response.data as DataFrame[]) { + const curTarget = request.targets.find((target) => target.refId === dataFrame.refId) as CloudWatchLogsQuery; + const interpolatedRegion = getRegion(replace(curTarget.region, 'region')); + + for (const field of dataFrame.fields) { + if (field.name === '@xrayTraceId' && tracingDatasourceUid) { + getRegion(replace(curTarget.region, 'region')); + const xrayLink = await createInternalXrayLink(tracingDatasourceUid, interpolatedRegion); + if (xrayLink) { + field.config.links = [xrayLink]; + } + } else { + // Right now we add generic link to open the query in xray console to every field so it shows in the logs row + // details. Unfortunately this also creates link for all values inside table which look weird. + field.config.links = [createAwsConsoleLink(curTarget, range, interpolatedRegion, replace)]; + } + } + } +} + +async function createInternalXrayLink(datasourceUid: string, region: string) { + let ds; + try { + ds = await getDataSourceSrv().get(datasourceUid); + } catch (e) { + console.error('Could not load linked xray data source, it was probably deleted after it was linked', e); + return undefined; + } + + return { + title: ds.name, + url: '', + internal: { + query: { query: '${__value.raw}', queryType: 'getTrace', region: region }, + datasourceUid: datasourceUid, + datasourceName: ds.name, + }, + } as DataLink; +} + +function createAwsConsoleLink( + target: CloudWatchLogsQuery, + range: TimeRange, + region: string, + replace: (target: string, fieldName?: string) => string +) { + const interpolatedExpression = target.expression ? replace(target.expression) : ''; + const interpolatedGroups = target.logGroupNames?.map((logGroup: string) => replace(logGroup, 'log groups')) ?? []; + + const urlProps: AwsUrl = { + end: range.to.toISOString(), + start: range.from.toISOString(), + timeType: 'ABSOLUTE', + tz: 'UTC', + editorString: interpolatedExpression, + isLiveTail: false, + source: interpolatedGroups, + }; + + const encodedUrl = encodeUrl(urlProps, region); + return { + url: encodedUrl, + title: 'View in CloudWatch console', + targetBlank: true, + }; +}