grafana/public/app/plugins/datasource/loki/datasource.test.ts
2020-11-26 10:32:02 +01:00

597 lines
19 KiB
TypeScript

import { of, throwError } from 'rxjs';
import { take } from 'rxjs/operators';
import { AnnotationQueryRequest, CoreApp, DataFrame, dateTime, FieldCache, TimeRange, TimeSeries } from '@grafana/data';
import { BackendSrvRequest, FetchResponse } from '@grafana/runtime';
import LokiDatasource from './datasource';
import { LokiQuery, LokiResponse, LokiResultType } from './types';
import { getQueryOptions } from 'test/helpers/getQueryOptions';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { backendSrv } from 'app/core/services/backend_srv';
import { CustomVariableModel } from '../../../features/variables/types';
import { initialCustomVariableModelState } from '../../../features/variables/custom/reducer';
import { makeMockLokiDatasource } from './mocks';
import { createFetchResponse } from 'test/helpers/createFetchResponse';
jest.mock('@grafana/runtime', () => ({
// @ts-ignore
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
}));
const timeSrvStub = {
timeRange: () => ({
from: new Date(0),
to: new Date(1),
}),
};
const testLogsResponse: FetchResponse<LokiResponse> = {
data: {
data: {
resultType: LokiResultType.Stream,
result: [
{
stream: {},
values: [['1573646419522934000', 'hello']],
},
],
},
status: 'success',
},
ok: true,
headers: ({} as unknown) as Headers,
redirected: false,
status: 200,
statusText: 'Success',
type: 'default',
url: '',
config: ({} as unknown) as BackendSrvRequest,
};
const testMetricsResponse: FetchResponse<LokiResponse> = {
data: {
data: {
resultType: LokiResultType.Matrix,
result: [
{
metric: {},
values: [[1605715380, '1.1']],
},
],
},
status: 'success',
},
ok: true,
headers: ({} as unknown) as Headers,
redirected: false,
status: 200,
statusText: 'OK',
type: 'basic',
url: '',
config: ({} as unknown) as BackendSrvRequest,
};
describe('LokiDatasource', () => {
const fetchMock = jest.spyOn(backendSrv, 'fetch');
beforeEach(() => {
jest.clearAllMocks();
fetchMock.mockImplementation(() => of(createFetchResponse({})));
});
describe('when creating range query', () => {
let ds: LokiDatasource;
let adjustIntervalSpy: jest.SpyInstance;
beforeEach(() => {
ds = createLokiDSForTests();
adjustIntervalSpy = jest.spyOn(ds, 'adjustInterval');
});
it('should use default intervalMs if one is not provided', () => {
const target = { expr: '{job="grafana"}', refId: 'B' };
const raw = { from: 'now', to: 'now-1h' };
const range = { from: dateTime(), to: dateTime(), raw: raw };
const options = {
range,
};
const req = ds.createRangeQuery(target, options as any, 1000);
expect(req.start).toBeDefined();
expect(req.end).toBeDefined();
expect(adjustIntervalSpy).toHaveBeenCalledWith(1000, expect.anything());
});
it('should use provided intervalMs', () => {
const target = { expr: '{job="grafana"}', refId: 'B' };
const raw = { from: 'now', to: 'now-1h' };
const range = { from: dateTime(), to: dateTime(), raw: raw };
const options = {
range,
intervalMs: 2000,
};
const req = ds.createRangeQuery(target, options as any, 1000);
expect(req.start).toBeDefined();
expect(req.end).toBeDefined();
expect(adjustIntervalSpy).toHaveBeenCalledWith(2000, expect.anything());
});
});
describe('when doing logs queries with limits', () => {
const runLimitTest = async ({
maxDataPoints = 123,
queryMaxLines,
dsMaxLines = 456,
expectedLimit,
expr = '{label="val"}',
}: any) => {
let settings: any = {
url: 'myloggingurl',
jsonData: {
maxLines: dsMaxLines,
},
};
const templateSrvMock = ({
getAdhocFilters: (): any[] => [],
replace: (a: string) => a,
} as unknown) as TemplateSrv;
const ds = new LokiDatasource(settings, templateSrvMock, timeSrvStub as any);
const options = getQueryOptions<LokiQuery>({ targets: [{ expr, refId: 'B', maxLines: queryMaxLines }] });
options.maxDataPoints = maxDataPoints;
fetchMock.mockImplementation(() => of(testLogsResponse));
await expect(ds.query(options).pipe(take(1))).toEmitValuesWith(() => {
expect(fetchMock.mock.calls.length).toBe(1);
expect(fetchMock.mock.calls[0][0].url).toContain(`limit=${expectedLimit}`);
});
};
it('should use datasource max lines when no limit given and it is log query', async () => {
await runLimitTest({ expectedLimit: 456 });
});
it('should use custom max lines from query if set and it is logs query', async () => {
await runLimitTest({ queryMaxLines: 20, expectedLimit: 20 });
});
it('should use custom max lines from query if set and it is logs query even if it is higher than data source limit', async () => {
await runLimitTest({ queryMaxLines: 500, expectedLimit: 500 });
});
it('should use maxDataPoints if it is metrics query', async () => {
await runLimitTest({ expr: 'rate({label="val"}[10m])', expectedLimit: 123 });
});
it('should use maxDataPoints if it is metrics query and using search', async () => {
await runLimitTest({ expr: 'rate({label="val"}[10m])', expectedLimit: 123 });
});
});
describe('when querying', () => {
function setup(expr: string, app: CoreApp, instant?: boolean, range?: boolean) {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr, refId: 'B', instant, range }],
app,
});
ds.runInstantQuery = jest.fn(() => of({ data: [] }));
ds.runRangeQuery = jest.fn(() => of({ data: [] }));
return { ds, options };
}
const metricsQuery = 'rate({job="grafana"}[10m])';
const logsQuery = '{job="grafana"} |= "foo"';
it('should run logs instant if only instant is selected', async () => {
const { ds, options } = setup(logsQuery, CoreApp.Explore, true, false);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).toBeCalled();
expect(ds.runRangeQuery).not.toBeCalled();
});
it('should run metrics instant if only instant is selected', async () => {
const { ds, options } = setup(metricsQuery, CoreApp.Explore, true, false);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).toBeCalled();
expect(ds.runRangeQuery).not.toBeCalled();
});
it('should run only logs range query if only range is selected', async () => {
const { ds, options } = setup(logsQuery, CoreApp.Explore, false, true);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).not.toBeCalled();
expect(ds.runRangeQuery).toBeCalled();
});
it('should run only metrics range query if only range is selected', async () => {
const { ds, options } = setup(metricsQuery, CoreApp.Explore, false, true);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).not.toBeCalled();
expect(ds.runRangeQuery).toBeCalled();
});
it('should run only logs range query if no query type is selected in Explore', async () => {
const { ds, options } = setup(logsQuery, CoreApp.Explore);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).not.toBeCalled();
expect(ds.runRangeQuery).toBeCalled();
});
it('should run only metrics range query if no query type is selected in Explore', async () => {
const { ds, options } = setup(metricsQuery, CoreApp.Explore);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).not.toBeCalled();
expect(ds.runRangeQuery).toBeCalled();
});
it('should run only logs range query in Dashboard', async () => {
const { ds, options } = setup(logsQuery, CoreApp.Dashboard);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).not.toBeCalled();
expect(ds.runRangeQuery).toBeCalled();
});
it('should run only metrics range query in Dashboard', async () => {
const { ds, options } = setup(metricsQuery, CoreApp.Dashboard);
await ds.query(options).toPromise();
expect(ds.runInstantQuery).not.toBeCalled();
expect(ds.runRangeQuery).toBeCalled();
});
it('should return series data for metrics range queries', async () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: metricsQuery, refId: 'B', range: true }],
app: CoreApp.Explore,
});
fetchMock.mockImplementation(() => of(testMetricsResponse));
await expect(ds.query(options)).toEmitValuesWith(received => {
const result = received[0];
const timeSeries = result.data[0] as TimeSeries;
expect(timeSeries.meta?.preferredVisualisationType).toBe('graph');
expect(timeSeries.refId).toBe('B');
expect(timeSeries.datapoints[0]).toEqual([1.1, 1605715380000]);
});
});
it('should return series data for logs range query', async () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: logsQuery, refId: 'B' }],
});
fetchMock.mockImplementation(() => of(testLogsResponse));
await expect(ds.query(options)).toEmitValuesWith(received => {
const result = received[0];
const dataFrame = result.data[0] as DataFrame;
const fieldCache = new FieldCache(dataFrame);
expect(fieldCache.getFieldByName('line')?.values.get(0)).toBe('hello');
expect(dataFrame.meta?.limit).toBe(20);
expect(dataFrame.meta?.searchWords).toEqual(['foo']);
});
});
it('should return custom error message when Loki returns escaping error', async () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: '{job="gra\\fana"}', refId: 'B' }],
});
fetchMock.mockImplementation(() =>
throwError({
data: {
message: 'parse error at line 1, col 6: invalid char escape',
},
status: 400,
statusText: 'Bad Request',
})
);
await expect(ds.query(options)).toEmitValuesWith(received => {
const err: any = received[0];
expect(err.data.message).toBe(
'Error: parse error at line 1, col 6: invalid char escape. Make sure that all special characters are escaped with \\. For more information on escaping of special characters visit LogQL documentation at https://grafana.com/docs/loki/latest/logql/.'
);
});
});
});
describe('when interpolating variables', () => {
let ds: LokiDatasource;
let variable: CustomVariableModel;
beforeEach(() => {
ds = createLokiDSForTests();
variable = { ...initialCustomVariableModelState };
});
it('should only escape single quotes', () => {
expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", variable)).toEqual("abc\\\\'$^*{}[]+?.()|");
});
it('should return a number', () => {
expect(ds.interpolateQueryExpr(1000, variable)).toEqual(1000);
});
describe('and variable allows multi-value', () => {
beforeEach(() => {
variable.multi = true;
});
it('should regex escape values if the value is a string', () => {
expect(ds.interpolateQueryExpr('looking*glass', variable)).toEqual('looking\\\\*glass');
});
it('should return pipe separated values if the value is an array of strings', () => {
expect(ds.interpolateQueryExpr(['a|bc', 'de|f'], variable)).toEqual('a\\\\|bc|de\\\\|f');
});
});
describe('and variable allows all', () => {
beforeEach(() => {
variable.includeAll = true;
});
it('should regex escape values if the array is a string', () => {
expect(ds.interpolateQueryExpr('looking*glass', variable)).toEqual('looking\\\\*glass');
});
it('should return pipe separated values if the value is an array of strings', () => {
expect(ds.interpolateQueryExpr(['a|bc', 'de|f'], variable)).toEqual('a\\\\|bc|de\\\\|f');
});
});
});
describe('when performing testDataSource', () => {
describe('and call succeeds', () => {
it('should return successfully', async () => {
fetchMock.mockImplementation(() => of(createFetchResponse({ values: ['avalue'] })));
const ds = createLokiDSForTests({} as TemplateSrv);
const result = await ds.testDatasource();
expect(result.status).toBe('success');
});
});
describe('and call fails with 401 error', () => {
it('should return error status and a detailed error message', async () => {
fetchMock.mockImplementation(() =>
throwError({
statusText: 'Unauthorized',
status: 401,
data: {
message: 'Unauthorized',
},
})
);
const ds = createLokiDSForTests({} as TemplateSrv);
const result = await ds.testDatasource();
expect(result.status).toEqual('error');
expect(result.message).toBe('Loki: Unauthorized. 401. Unauthorized');
});
});
describe('and call fails with 404 error', () => {
it('should return error status and a detailed error message', async () => {
fetchMock.mockImplementation(() =>
throwError({
statusText: 'Not found',
status: 404,
data: {
message: '404 page not found',
},
})
);
const ds = createLokiDSForTests({} as TemplateSrv);
const result = await ds.testDatasource();
expect(result.status).toEqual('error');
expect(result.message).toBe('Loki: Not found. 404. 404 page not found');
});
});
describe('and call fails with 502 error', () => {
it('should return error status and a detailed error message', async () => {
fetchMock.mockImplementation(() =>
throwError({
statusText: 'Bad Gateway',
status: 502,
data: '',
})
);
const ds = createLokiDSForTests({} as TemplateSrv);
const result = await ds.testDatasource();
expect(result.status).toEqual('error');
expect(result.message).toBe('Loki: Bad Gateway. 502');
});
});
});
describe('when creating a range query', () => {
// Loki v1 API has an issue with float step parameters, can be removed when API is fixed
it('should produce an integer step parameter', () => {
const ds = createLokiDSForTests();
const query: LokiQuery = { expr: 'foo', refId: 'bar' };
const range: TimeRange = {
from: dateTime(0),
to: dateTime(1e9 + 1),
raw: { from: '0', to: '1000000001' },
};
// Odd timerange/interval combination that would lead to a float step
const options = { range, intervalMs: 2000 };
expect(Number.isInteger(ds.createRangeQuery(query, options as any, 1000).step!)).toBeTruthy();
});
});
describe('when calling annotationQuery', () => {
const getTestContext = (response: any) => {
const query = makeAnnotationQueryRequest();
fetchMock.mockImplementation(() => of(response));
const ds = createLokiDSForTests();
const promise = ds.annotationQuery(query);
return { promise };
};
it('should transform the loki data to annotation response', async () => {
const response: FetchResponse = ({
data: {
data: {
resultType: LokiResultType.Stream,
result: [
{
stream: {
label: 'value',
label2: 'value ',
},
values: [['1549016857498000000', 'hello']],
},
{
stream: {
label2: 'value2',
},
values: [['1549024057498000000', 'hello 2']],
},
],
},
status: 'success',
},
} as unknown) as FetchResponse;
const { promise } = getTestContext(response);
const res = await promise;
expect(res.length).toBe(2);
expect(res[0].text).toBe('hello');
expect(res[0].tags).toEqual(['value']);
expect(res[1].text).toBe('hello 2');
expect(res[1].tags).toEqual(['value2']);
});
});
describe('metricFindQuery', () => {
const getTestContext = (mock: LokiDatasource) => {
const ds = createLokiDSForTests();
ds.getVersion = mock.getVersion;
ds.metadataRequest = mock.metadataRequest;
return { ds };
};
const mocks = makeMetadataAndVersionsMocks();
mocks.forEach((mock, index) => {
it(`should return label names for Loki v${index}`, async () => {
const { ds } = getTestContext(mock);
const res = await ds.metricFindQuery('label_names()');
expect(res).toEqual([{ text: 'label1' }, { text: 'label2' }]);
});
});
mocks.forEach((mock, index) => {
it(`should return label values for Loki v${index}`, async () => {
const { ds } = getTestContext(mock);
const res = await ds.metricFindQuery('label_values(label1)');
expect(res).toEqual([{ text: 'value1' }, { text: 'value2' }]);
});
});
mocks.forEach((mock, index) => {
it(`should return empty array when incorrect query for Loki v${index}`, async () => {
const { ds } = getTestContext(mock);
const res = await ds.metricFindQuery('incorrect_query');
expect(res).toEqual([]);
});
});
mocks.forEach((mock, index) => {
it(`should return label names according to provided rangefor Loki v${index}`, async () => {
const { ds } = getTestContext(mock);
const res = await ds.metricFindQuery('label_names()', { range: { from: new Date(2), to: new Date(3) } });
expect(res).toEqual([{ text: 'label1' }]);
});
});
});
});
function createLokiDSForTests(
templateSrvMock = ({
getAdhocFilters: (): any[] => [],
replace: (a: string) => a,
} as unknown) as TemplateSrv
): LokiDatasource {
const instanceSettings: any = {
url: 'myloggingurl',
};
const customData = { ...(instanceSettings.jsonData || {}), maxLines: 20 };
const customSettings = { ...instanceSettings, jsonData: customData };
return new LokiDatasource(customSettings, templateSrvMock, timeSrvStub as any);
}
function makeAnnotationQueryRequest(): AnnotationQueryRequest<LokiQuery> {
const timeRange = {
from: dateTime(),
to: dateTime(),
};
return {
annotation: {
expr: '{test=test}',
refId: '',
datasource: 'loki',
enable: true,
name: 'test-annotation',
},
dashboard: {
id: 1,
} as any,
range: {
...timeRange,
raw: timeRange,
},
rangeRaw: timeRange,
};
}
function makeMetadataAndVersionsMocks() {
const mocks = [];
for (let i = 0; i <= 1; i++) {
const mock: LokiDatasource = makeMockLokiDatasource({ label1: ['value1', 'value2'], label2: ['value3', 'value4'] });
mock.getVersion = jest.fn().mockReturnValue(`v${i}`);
mocks.push(mock);
}
return mocks;
}