mirror of
https://github.com/grafana/grafana.git
synced 2025-09-22 06:42:52 +08:00
Datasources: Improve error handling for testing data sources (#35120)
* Improve error handling for error messages The error message will be read from error object from the following properties in the following order: - message - data.message - statusText * Convert api/ds/query errors to TestingStatus SQL datasources (mysql, mssql, postgres) and CloudWatch use api/ds/query to test the data source, but previously didn't handle errors returned by this endpoint. If the error cannot be handled it's re-thrown to be handled in public/app/features/datasources/state/actions.ts * Use async/await instead of Promises * Remove incorrect type import TestingStatus is in app/types. Should be pulled down to grafana/data but it depends on HealthCheckResultDetails that is public and lives in grafana/runtime. Ideally TestingStatus should live in grafana/data but I'm not sure if HealthCheckResultDetails can be move there too (?) * Update packages/grafana-data/src/types/datasource.ts Co-authored-by: Erik Sundell <erik.sundell@grafana.com> * Handle errors with no details in toTestingStatus instead of re-throwing * Update packages/grafana-data/src/types/datasource.ts Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com> Co-authored-by: Erik Sundell <erik.sundell@grafana.com> Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
This commit is contained in:
@ -228,7 +228,11 @@ abstract class DataSourceApi<
|
|||||||
abstract query(request: DataQueryRequest<TQuery>): Promise<DataQueryResponse> | Observable<DataQueryResponse>;
|
abstract query(request: DataQueryRequest<TQuery>): Promise<DataQueryResponse> | Observable<DataQueryResponse>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test & verify datasource settings & connection details
|
* Test & verify datasource settings & connection details (returning TestingStatus)
|
||||||
|
*
|
||||||
|
* When verification fails - errors specific to the data source should be handled here and converted to
|
||||||
|
* a TestingStatus object. Unknown errors and HTTP errors can be re-thrown and will be handled here:
|
||||||
|
* public/app/features/datasources/state/actions.ts
|
||||||
*/
|
*/
|
||||||
abstract testDatasource(): Promise<any>;
|
abstract testDatasource(): Promise<any>;
|
||||||
|
|
||||||
@ -472,7 +476,13 @@ export enum DataQueryErrorType {
|
|||||||
|
|
||||||
export interface DataQueryError {
|
export interface DataQueryError {
|
||||||
data?: {
|
data?: {
|
||||||
|
/**
|
||||||
|
* Short information about the error
|
||||||
|
*/
|
||||||
message?: string;
|
message?: string;
|
||||||
|
/**
|
||||||
|
* Detailed information about the error. Only returned when app_mode is development.
|
||||||
|
*/
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { DataQuery, toDataFrameDTO, DataFrame } from '@grafana/data';
|
import { DataQuery, toDataFrameDTO, DataFrame } from '@grafana/data';
|
||||||
import { FetchResponse } from 'src/services';
|
import { FetchError, FetchResponse } from 'src/services';
|
||||||
import { BackendDataSourceResponse, toDataQueryResponse } from './queryResponse';
|
import { BackendDataSourceResponse, toDataQueryResponse, toTestingStatus } from './queryResponse';
|
||||||
|
|
||||||
const resp = ({
|
const resp = ({
|
||||||
data: {
|
data: {
|
||||||
@ -333,4 +333,44 @@ describe('Query Response parser', () => {
|
|||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should convert to TestingStatus', () => {
|
||||||
|
test('from api/ds/query generic errors', () => {
|
||||||
|
const result = toTestingStatus({ status: 500, data: { message: 'message', error: 'error' } } as FetchError);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
message: 'message',
|
||||||
|
details: { message: 'error' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('from api/ds/query result errors', () => {
|
||||||
|
const result = toTestingStatus({
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
results: {
|
||||||
|
A: {
|
||||||
|
error: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as FetchError);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
message: 'error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('unknown errors', () => {
|
||||||
|
expect(() => {
|
||||||
|
toTestingStatus({ status: 503, data: 'Fatal Error' } as FetchError);
|
||||||
|
}).toThrow();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
toTestingStatus({ status: 503, data: {} } as FetchError);
|
||||||
|
}).toThrow();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
toTestingStatus({ status: 503 } as FetchError);
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
DataFrameJSON,
|
DataFrameJSON,
|
||||||
dataFrameFromJSON,
|
dataFrameFromJSON,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { FetchResponse } from '../services';
|
import { FetchError, FetchResponse } from '../services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single response object from a backend data source. Properties are optional but response should contain at least
|
* Single response object from a backend data source. Properties are optional but response should contain at least
|
||||||
@ -127,6 +127,38 @@ export function toDataQueryResponse(
|
|||||||
return rsp;
|
return rsp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data sources using api/ds/query to test data sources can use this function to
|
||||||
|
* handle errors and convert them to TestingStatus object.
|
||||||
|
*
|
||||||
|
* If possible, this should be avoided in favor of implementing /health endpoint
|
||||||
|
* and testing data source with DataSourceWithBackend.testDataSource()
|
||||||
|
*
|
||||||
|
* Re-thrown errors are handled by testDataSource() in public/app/features/datasources/state/actions.ts
|
||||||
|
*
|
||||||
|
* @returns {TestingStatus}
|
||||||
|
*/
|
||||||
|
export function toTestingStatus(err: FetchError): any {
|
||||||
|
const queryResponse = toDataQueryResponse(err);
|
||||||
|
// POST api/ds/query errors returned as { message: string, error: string } objects
|
||||||
|
if (queryResponse.error?.data?.message) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: queryResponse.error.data.message,
|
||||||
|
details: queryResponse.error?.data?.error ? { message: queryResponse.error.data.error } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// POST api/ds/query errors returned in results object
|
||||||
|
else if (queryResponse.error?.refId && queryResponse.error?.message) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: queryResponse.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an object into a DataQueryError -- if this is an HTTP response,
|
* Convert an object into a DataQueryError -- if this is an HTTP response,
|
||||||
* it will put the correct values in the error field
|
* it will put the correct values in the error field
|
||||||
|
@ -299,14 +299,19 @@ export class BackendSrv implements BackendService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
processRequestError(options: BackendSrvRequest, err: FetchError): FetchError {
|
/**
|
||||||
|
* Processes FetchError to ensure "data" property is an object.
|
||||||
|
*
|
||||||
|
* @see DataQueryError.data
|
||||||
|
*/
|
||||||
|
processRequestError(options: BackendSrvRequest, err: FetchError): FetchError<{ message: string; error?: string }> {
|
||||||
err.data = err.data ?? { message: 'Unexpected error' };
|
err.data = err.data ?? { message: 'Unexpected error' };
|
||||||
|
|
||||||
if (typeof err.data === 'string') {
|
if (typeof err.data === 'string') {
|
||||||
err.data = {
|
err.data = {
|
||||||
|
message: err.data,
|
||||||
error: err.statusText,
|
error: err.statusText,
|
||||||
response: err.data,
|
response: err.data,
|
||||||
message: err.data,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,31 @@ const getBackendSrvMock = () =>
|
|||||||
withNoBackendCache: jest.fn().mockImplementationOnce((cb) => cb()),
|
withNoBackendCache: jest.fn().mockImplementationOnce((cb) => cb()),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
const failDataSourceTest = async (error: object) => {
|
||||||
|
const dependencies: TestDataSourceDependencies = {
|
||||||
|
getDatasourceSrv: () =>
|
||||||
|
({
|
||||||
|
get: jest.fn().mockReturnValue({
|
||||||
|
testDatasource: jest.fn().mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any),
|
||||||
|
getBackendSrv: getBackendSrvMock,
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
testingStatus: {
|
||||||
|
message: '',
|
||||||
|
status: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dispatchedActions = await thunkTester(state)
|
||||||
|
.givenThunk(testDataSource)
|
||||||
|
.whenThunkIsDispatched('Azure Monitor', dependencies);
|
||||||
|
|
||||||
|
return dispatchedActions;
|
||||||
|
};
|
||||||
|
|
||||||
describe('Name exists', () => {
|
describe('Name exists', () => {
|
||||||
const plugins = getMockPlugins(5);
|
const plugins = getMockPlugins(5);
|
||||||
|
|
||||||
@ -194,5 +219,36 @@ describe('testDataSource', () => {
|
|||||||
|
|
||||||
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
|
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('then testDataSourceFailed should be dispatched with response error message', async () => {
|
||||||
|
const result = {
|
||||||
|
message: 'Error testing datasource',
|
||||||
|
};
|
||||||
|
const dispatchedActions = await failDataSourceTest({
|
||||||
|
message: 'Error testing datasource',
|
||||||
|
data: { message: 'Response error message' },
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
});
|
||||||
|
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('then testDataSourceFailed should be dispatched with response data message', async () => {
|
||||||
|
const result = {
|
||||||
|
message: 'Response error message',
|
||||||
|
};
|
||||||
|
const dispatchedActions = await failDataSourceTest({
|
||||||
|
data: { message: 'Response error message' },
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
});
|
||||||
|
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('then testDataSourceFailed should be dispatched with response statusText', async () => {
|
||||||
|
const result = {
|
||||||
|
message: 'HTTP error Bad Request',
|
||||||
|
};
|
||||||
|
const dispatchedActions = await failDataSourceTest({ data: {}, statusText: 'Bad Request' });
|
||||||
|
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -99,8 +99,9 @@ export const testDataSource = (
|
|||||||
|
|
||||||
dispatch(testDataSourceSucceeded(result));
|
dispatch(testDataSourceSucceeded(result));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { statusText, message: errMessage, details } = err;
|
const { statusText, message: errMessage, details, data } = err;
|
||||||
const message = statusText ? 'HTTP error ' + statusText : errMessage;
|
|
||||||
|
const message = errMessage || data?.message || 'HTTP error ' + statusText;
|
||||||
|
|
||||||
dispatch(testDataSourceFailed({ message, details }));
|
dispatch(testDataSourceFailed({ message, details }));
|
||||||
}
|
}
|
||||||
|
@ -64,6 +64,7 @@ import { CloudWatchLanguageProvider } from './language_provider';
|
|||||||
import { VariableWithMultiSupport } from 'app/features/variables/types';
|
import { VariableWithMultiSupport } from 'app/features/variables/types';
|
||||||
import { AwsUrl, encodeUrl } from './aws_url';
|
import { AwsUrl, encodeUrl } from './aws_url';
|
||||||
import { increasingInterval } from './utils/rxjs/increasingInterval';
|
import { increasingInterval } from './utils/rxjs/increasingInterval';
|
||||||
|
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
|
|
||||||
const DS_QUERY_ENDPOINT = '/api/ds/query';
|
const DS_QUERY_ENDPOINT = '/api/ds/query';
|
||||||
@ -878,17 +879,22 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testDatasource() {
|
async testDatasource() {
|
||||||
// use billing metrics for test
|
// use billing metrics for test
|
||||||
const region = this.defaultRegion;
|
const region = this.defaultRegion;
|
||||||
const namespace = 'AWS/Billing';
|
const namespace = 'AWS/Billing';
|
||||||
const metricName = 'EstimatedCharges';
|
const metricName = 'EstimatedCharges';
|
||||||
const dimensions = {};
|
const dimensions = {};
|
||||||
|
|
||||||
return this.getDimensionValues(region, namespace, metricName, 'ServiceName', dimensions).then(() => ({
|
try {
|
||||||
|
await this.getDimensionValues(region, namespace, metricName, 'ServiceName', dimensions);
|
||||||
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
message: 'Data source is working',
|
message: 'Data source is working',
|
||||||
}));
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return toTestingStatus(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
awsRequest(url: string, data: MetricRequest): Observable<TSDBResponse> {
|
awsRequest(url: string, data: MetricRequest): Observable<TSDBResponse> {
|
||||||
|
@ -8,6 +8,7 @@ import ResponseParser from './response_parser';
|
|||||||
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
|
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
import { MssqlQueryForInterpolation, MssqlQuery, MssqlOptions } from './types';
|
import { MssqlQueryForInterpolation, MssqlQuery, MssqlOptions } from './types';
|
||||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||||
|
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
|
||||||
|
|
||||||
export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOptions> {
|
export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOptions> {
|
||||||
id: any;
|
id: any;
|
||||||
@ -172,12 +173,7 @@ export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOpti
|
|||||||
.pipe(
|
.pipe(
|
||||||
mapTo({ status: 'success', message: 'Database Connection OK' }),
|
mapTo({ status: 'success', message: 'Database Connection OK' }),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
console.error(err);
|
return of(toTestingStatus(err));
|
||||||
if (err.data && err.data.message) {
|
|
||||||
return of({ status: 'error', message: err.data.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return of({ status: 'error', message: err.status });
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.toPromise();
|
.toPromise();
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { map as _map } from 'lodash';
|
import { map as _map } from 'lodash';
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { catchError, map, mapTo } from 'rxjs/operators';
|
import { catchError, map, mapTo } from 'rxjs/operators';
|
||||||
import { getBackendSrv, DataSourceWithBackend, FetchResponse, BackendDataSourceResponse } from '@grafana/runtime';
|
import { getBackendSrv, DataSourceWithBackend, FetchResponse, BackendDataSourceResponse } from '@grafana/runtime';
|
||||||
import { DataSourceInstanceSettings, ScopedVars, MetricFindValue, AnnotationEvent } from '@grafana/data';
|
import { DataSourceInstanceSettings, ScopedVars, MetricFindValue, AnnotationEvent } from '@grafana/data';
|
||||||
@ -9,6 +8,8 @@ import { MysqlQueryForInterpolation, MySQLOptions, MySQLQuery } from './types';
|
|||||||
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
|
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
import { getSearchFilterScopedVar } from '../../../features/variables/utils';
|
import { getSearchFilterScopedVar } from '../../../features/variables/utils';
|
||||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
|
||||||
|
|
||||||
export class MysqlDatasource extends DataSourceWithBackend<MySQLQuery, MySQLOptions> {
|
export class MysqlDatasource extends DataSourceWithBackend<MySQLQuery, MySQLOptions> {
|
||||||
id: any;
|
id: any;
|
||||||
@ -181,12 +182,7 @@ export class MysqlDatasource extends DataSourceWithBackend<MySQLQuery, MySQLOpti
|
|||||||
.pipe(
|
.pipe(
|
||||||
mapTo({ status: 'success', message: 'Database Connection OK' }),
|
mapTo({ status: 'success', message: 'Database Connection OK' }),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
console.error(err);
|
return of(toTestingStatus(err));
|
||||||
if (err.data && err.data.message) {
|
|
||||||
return of({ status: 'error', message: err.data.message });
|
|
||||||
} else {
|
|
||||||
return of({ status: 'error', message: err.status });
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.toPromise();
|
.toPromise();
|
||||||
|
@ -10,6 +10,7 @@ import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
|||||||
//Types
|
//Types
|
||||||
import { PostgresOptions, PostgresQuery, PostgresQueryForInterpolation } from './types';
|
import { PostgresOptions, PostgresQuery, PostgresQueryForInterpolation } from './types';
|
||||||
import { getSearchFilterScopedVar } from '../../../features/variables/utils';
|
import { getSearchFilterScopedVar } from '../../../features/variables/utils';
|
||||||
|
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
|
||||||
|
|
||||||
export class PostgresDatasource extends DataSourceWithBackend<PostgresQuery, PostgresOptions> {
|
export class PostgresDatasource extends DataSourceWithBackend<PostgresQuery, PostgresOptions> {
|
||||||
id: any;
|
id: any;
|
||||||
@ -174,12 +175,7 @@ export class PostgresDatasource extends DataSourceWithBackend<PostgresQuery, Pos
|
|||||||
return { status: 'success', message: 'Database Connection OK' };
|
return { status: 'success', message: 'Database Connection OK' };
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
console.error(err);
|
return toTestingStatus(err);
|
||||||
if (err.data && err.data.message) {
|
|
||||||
return { status: 'error', message: err.data.message };
|
|
||||||
} else {
|
|
||||||
return { status: 'error', message: err.status };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user