mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
3c433dc36d
commit
fb1c31e1b6
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
88
public/app/plugins/datasource/cloudwatch/utils/datalinks.ts
Normal file
88
public/app/plugins/datasource/cloudwatch/utils/datalinks.ts
Normal file
@ -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…
Reference in New Issue
Block a user