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
pull/39198/head
Andrej Ocenas 4 years ago committed by GitHub
parent 3c433dc36d
commit fb1c31e1b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-runtime/src/components/DataSourcePicker.tsx
  2. 7
      packages/grafana-runtime/src/services/dataSourceSrv.ts
  3. 62
      public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx
  4. 53
      public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx
  5. 57
      public/app/plugins/datasource/cloudwatch/components/XrayLinkConfig.tsx
  6. 15
      public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap
  7. 120
      public/app/plugins/datasource/cloudwatch/datasource.test.ts
  8. 118
      public/app/plugins/datasource/cloudwatch/datasource.ts
  9. 53
      public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
  10. 3
      public/app/plugins/datasource/cloudwatch/types.ts
  11. 95
      public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts
  12. 88
      public/app/plugins/datasource/cloudwatch/utils/datalinks.ts

@ -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;

@ -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<DataSourceApi>;
get(nameOrUid?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi>;
/**
* 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 */

@ -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<CloudWatchJsonData, CloudWatchSecureJsonData>;
export const ConfigEditor: FC<Props> = (props: Props) => {
const [datasource, setDatasource] = useState<CloudWatchDatasource>();
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: Props) => {
/>
</InlineField>
</ConnectionConfig>
<XrayLinkConfig
onChange={(uid) => 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<CloudWatchDatasource>();
useEffect(() => {
getDatasourceSrv()
.loadDatasource(datasourceName)
.then((datasource: CloudWatchDatasource) => setDatasource(datasource));
}, [datasourceName]);
return datasource;
}

@ -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);

@ -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 (
<>
<h3 className="page-heading">X-ray trace link</h3>
<div className={styles.infoText}>
Grafana will automatically create a link to a trace in X-ray data source if logs contain @xrayTraceId field
</div>
{!hasXrayDatasource && (
<Alert
title={
'There is no X-ray datasource to link to. First add an X-ray data source and then link it to Cloud Watch. '
}
severity="info"
/>
)}
<div className="gf-form-group">
<InlineField label="Data source" labelWidth={28} tooltip="X-ray data source containing traces">
<DataSourcePicker
pluginId={xRayDsId}
onChange={(ds) => onChange(ds.uid)}
current={datasourceUid}
noDefault={true}
/>
</InlineField>
</div>
</>
);
}

@ -62,6 +62,9 @@ exports[`Render should disable access key id field 1`] = `
/>
</InlineField>
</ConnectionConfig>
<XrayLinkConfig
onChange={[Function]}
/>
</Fragment>
`;
@ -122,6 +125,9 @@ exports[`Render should render component 1`] = `
/>
</InlineField>
</ConnectionConfig>
<XrayLinkConfig
onChange={[Function]}
/>
</Fragment>
`;
@ -187,6 +193,9 @@ exports[`Render should show access key and secret access key fields 1`] = `
/>
</InlineField>
</ConnectionConfig>
<XrayLinkConfig
onChange={[Function]}
/>
</Fragment>
`;
@ -252,6 +261,9 @@ exports[`Render should show arn role field 1`] = `
/>
</InlineField>
</ConnectionConfig>
<XrayLinkConfig
onChange={[Function]}
/>
</Fragment>
`;
@ -317,5 +329,8 @@ exports[`Render should show credentials profile name field 1`] = `
/>
</InlineField>
</ConnectionConfig>
<XrayLinkConfig
onChange={[Function]}
/>
</Fragment>
`;

@ -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 };
}

@ -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<CloudWatchQuery,
defaultRegion: any;
datasourceName: string;
languageProvider: CloudWatchLanguageProvider;
tracingDataSourceUid?: string;
type = 'cloudwatch';
standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'];
@ -116,8 +117,8 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
this.proxyUrl = instanceSettings.url;
this.defaultRegion = instanceSettings.jsonData.defaultRegion;
this.datasourceName = instanceSettings.name;
this.languageProvider = new CloudWatchLanguageProvider(this);
this.tracingDataSourceUid = instanceSettings.jsonData.tracingDatasourceUid;
}
query(options: DataQueryRequest<CloudWatchQuery>): Observable<DataQueryResponse> {
@ -128,11 +129,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
const dataQueryResponses: Array<Observable<DataQueryResponse>> = [];
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<CloudWatchQuery,
return merge(...dataQueryResponses);
}
handleLiveLogQueries = (
handleLogQueries = (
logQueries: CloudWatchLogsQuery[],
options: DataQueryRequest<CloudWatchQuery>
): Observable<DataQueryResponse> => {
@ -164,7 +161,43 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
return of({ data: [], state: LoadingState.Done });
}
const queryParams = validLogQueries.map((target: CloudWatchLogsQuery) => ({
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<CloudWatchQuery>
): Observable<DataQueryResponse> => {
const queryParams = logQueries.map((target: CloudWatchLogsQuery) => ({
intervalMs: 1, // dummy
maxDataPoints: 1, // dummy
datasourceId: this.id,
@ -207,7 +240,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
? LoadingState.Done
: LoadingState.Loading;
dataQueryResponse.key = message.results[Object.keys(message.results)[0]].refId;
return this.addDataLinksToLogsResponse(dataQueryResponse, options);
return dataQueryResponse;
}),
catchError((err) => {
if (err.data?.error) {
@ -219,21 +252,17 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
);
};
handleLogQueries = (
/**
* Handle query the old way (see handleLiveLogQueries) when websockets are not enabled. As enabling websockets is
* configurable we will have to be able to degrade gracefully for the time being.
* @param logQueries
* @param options
*/
private handleLegacyLogQueries = (
logQueries: CloudWatchLogsQuery[],
options: DataQueryRequest<CloudWatchQuery>
): Observable<DataQueryResponse> => {
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<CloudWatchQuery,
.statsGroups,
}))
)
),
map((response) => this.addDataLinksToLogsResponse(response, options))
)
);
};
@ -393,46 +421,6 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
return withTeardown(queryResponse, () => this.stopQueries());
}
private addDataLinksToLogsResponse(response: DataQueryResponse, options: DataQueryRequest<CloudWatchQuery>) {
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(

@ -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);

@ -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 {

@ -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',
},
],
});
});
});

@ -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<CloudWatchQuery>,
range: TimeRange,
replaceFn: ReplaceFn,
getRegion: (region: string) => string,
tracingDatasourceUid?: string
): Promise<void> {
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,
};
}
Loading…
Cancel
Save