datasource.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import { lastValueFrom, Observable, of } from 'rxjs';
  2. import { createFetchResponse } from 'test/helpers/createFetchResponse';
  3. import {
  4. DataFrame,
  5. dataFrameToJSON,
  6. DataSourceInstanceSettings,
  7. FieldType,
  8. getDefaultTimeRange,
  9. LoadingState,
  10. MutableDataFrame,
  11. PluginType,
  12. } from '@grafana/data';
  13. import { BackendDataSourceResponse, FetchResponse, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
  14. import { DEFAULT_LIMIT, TempoJsonData, TempoDatasource, TempoQuery } from './datasource';
  15. import mockJson from './mockJsonResponse.json';
  16. jest.mock('@grafana/runtime', () => {
  17. return {
  18. ...jest.requireActual('@grafana/runtime'),
  19. reportInteraction: jest.fn(),
  20. };
  21. });
  22. describe('Tempo data source', () => {
  23. // Mock the console error so that running the test suite doesnt throw the error
  24. const origError = console.error;
  25. const consoleErrorMock = jest.fn();
  26. afterEach(() => (console.error = origError));
  27. beforeEach(() => (console.error = consoleErrorMock));
  28. it('returns empty response when traceId is empty', async () => {
  29. const ds = new TempoDatasource(defaultSettings);
  30. const response = await lastValueFrom(
  31. ds.query({ targets: [{ refId: 'refid1', queryType: 'traceId', query: '' } as Partial<TempoQuery>] } as any),
  32. { defaultValue: 'empty' }
  33. );
  34. expect(response).toBe('empty');
  35. });
  36. describe('Variables should be interpolated correctly', () => {
  37. function getQuery(): TempoQuery {
  38. return {
  39. refId: 'x',
  40. queryType: 'traceId',
  41. linkedQuery: {
  42. refId: 'linked',
  43. expr: '{instance="$interpolationVar"}',
  44. },
  45. query: '$interpolationVar',
  46. search: '$interpolationVar',
  47. minDuration: '$interpolationVar',
  48. maxDuration: '$interpolationVar',
  49. };
  50. }
  51. it('when traceId query for dashboard->explore', async () => {
  52. const templateSrv: any = { replace: jest.fn() };
  53. const ds = new TempoDatasource(defaultSettings, templateSrv);
  54. const text = 'interpolationText';
  55. templateSrv.replace.mockReturnValue(text);
  56. const queries = ds.interpolateVariablesInQueries([getQuery()], {
  57. interpolationVar: { text: text, value: text },
  58. });
  59. expect(templateSrv.replace).toBeCalledTimes(7);
  60. expect(queries[0].linkedQuery?.expr).toBe(text);
  61. expect(queries[0].query).toBe(text);
  62. expect(queries[0].serviceName).toBe(text);
  63. expect(queries[0].spanName).toBe(text);
  64. expect(queries[0].search).toBe(text);
  65. expect(queries[0].minDuration).toBe(text);
  66. expect(queries[0].maxDuration).toBe(text);
  67. });
  68. it('when traceId query for template variable', async () => {
  69. const templateSrv: any = { replace: jest.fn() };
  70. const ds = new TempoDatasource(defaultSettings, templateSrv);
  71. const text = 'interpolationText';
  72. templateSrv.replace.mockReturnValue(text);
  73. const resp = ds.applyTemplateVariables(getQuery(), {
  74. interpolationVar: { text: text, value: text },
  75. });
  76. expect(templateSrv.replace).toBeCalledTimes(7);
  77. expect(resp.linkedQuery?.expr).toBe(text);
  78. expect(resp.query).toBe(text);
  79. expect(resp.serviceName).toBe(text);
  80. expect(resp.spanName).toBe(text);
  81. expect(resp.search).toBe(text);
  82. expect(resp.minDuration).toBe(text);
  83. expect(resp.maxDuration).toBe(text);
  84. });
  85. });
  86. it('parses json fields from backend', async () => {
  87. setupBackendSrv(
  88. new MutableDataFrame({
  89. fields: [
  90. { name: 'traceID', values: ['04450900759028499335'] },
  91. { name: 'spanID', values: ['4322526419282105830'] },
  92. { name: 'parentSpanID', values: [''] },
  93. { name: 'operationName', values: ['store.validateQueryTimeRange'] },
  94. { name: 'startTime', values: [1619712655875.4539] },
  95. { name: 'duration', values: [14.984] },
  96. { name: 'serviceTags', values: ['{"key":"servicetag1","value":"service"}'] },
  97. { name: 'logs', values: ['{"timestamp":12345,"fields":[{"key":"count","value":1}]}'] },
  98. { name: 'tags', values: ['{"key":"tag1","value":"val1"}'] },
  99. { name: 'serviceName', values: ['service'] },
  100. ],
  101. })
  102. );
  103. const templateSrv: any = { replace: jest.fn() };
  104. const ds = new TempoDatasource(defaultSettings, templateSrv);
  105. const response = await lastValueFrom(ds.query({ targets: [{ refId: 'refid1', query: '12345' }] } as any));
  106. expect(
  107. (response.data[0] as DataFrame).fields.map((f) => ({
  108. name: f.name,
  109. values: f.values.toArray(),
  110. }))
  111. ).toMatchObject([
  112. { name: 'traceID', values: ['04450900759028499335'] },
  113. { name: 'spanID', values: ['4322526419282105830'] },
  114. { name: 'parentSpanID', values: [''] },
  115. { name: 'operationName', values: ['store.validateQueryTimeRange'] },
  116. { name: 'startTime', values: [1619712655875.4539] },
  117. { name: 'duration', values: [14.984] },
  118. { name: 'serviceTags', values: [{ key: 'servicetag1', value: 'service' }] },
  119. { name: 'logs', values: [{ timestamp: 12345, fields: [{ key: 'count', value: 1 }] }] },
  120. { name: 'tags', values: [{ key: 'tag1', value: 'val1' }] },
  121. { name: 'serviceName', values: ['service'] },
  122. ]);
  123. expect(
  124. (response.data[1] as DataFrame).fields.map((f) => ({
  125. name: f.name,
  126. values: f.values.toArray(),
  127. }))
  128. ).toMatchObject([
  129. { name: 'id', values: ['4322526419282105830'] },
  130. { name: 'title', values: ['service'] },
  131. { name: 'subtitle', values: ['store.validateQueryTimeRange'] },
  132. { name: 'mainstat', values: ['14.98ms (100%)'] },
  133. { name: 'secondarystat', values: ['14.98ms (100%)'] },
  134. { name: 'color', values: [1.000007560204647] },
  135. ]);
  136. expect(
  137. (response.data[2] as DataFrame).fields.map((f) => ({
  138. name: f.name,
  139. values: f.values.toArray(),
  140. }))
  141. ).toMatchObject([
  142. { name: 'id', values: [] },
  143. { name: 'target', values: [] },
  144. { name: 'source', values: [] },
  145. ]);
  146. });
  147. it('runs service graph queries', async () => {
  148. const ds = new TempoDatasource({
  149. ...defaultSettings,
  150. jsonData: {
  151. serviceMap: {
  152. datasourceUid: 'prom',
  153. },
  154. },
  155. });
  156. setDataSourceSrv(backendSrvWithPrometheus as any);
  157. const response = await lastValueFrom(
  158. ds.query({ targets: [{ queryType: 'serviceMap' }], range: getDefaultTimeRange() } as any)
  159. );
  160. expect(response.data).toHaveLength(2);
  161. expect(response.data[0].name).toBe('Nodes');
  162. expect(response.data[0].fields[0].values.length).toBe(3);
  163. // Test Links
  164. expect(response.data[0].fields[0].config.links.length).toBeGreaterThan(0);
  165. expect(response.data[0].fields[0].config.links).toEqual(serviceGraphLinks);
  166. expect(response.data[1].name).toBe('Edges');
  167. expect(response.data[1].fields[0].values.length).toBe(2);
  168. expect(response.state).toBe(LoadingState.Done);
  169. });
  170. it('should handle json file upload', async () => {
  171. const ds = new TempoDatasource(defaultSettings);
  172. ds.uploadedJson = JSON.stringify(mockJson);
  173. const response = await lastValueFrom(
  174. ds.query({
  175. targets: [{ queryType: 'upload', refId: 'A' }],
  176. } as any)
  177. );
  178. const field = response.data[0].fields[0];
  179. expect(field.name).toBe('traceID');
  180. expect(field.type).toBe(FieldType.string);
  181. expect(field.values.get(0)).toBe('60ba2abb44f13eae');
  182. expect(field.values.length).toBe(6);
  183. });
  184. it('should fail on invalid json file upload', async () => {
  185. const ds = new TempoDatasource(defaultSettings);
  186. ds.uploadedJson = JSON.stringify(mockInvalidJson);
  187. const response = await lastValueFrom(
  188. ds.query({
  189. targets: [{ queryType: 'upload', refId: 'A' }],
  190. } as any)
  191. );
  192. expect(response.error?.message).toBeDefined();
  193. expect(response.data.length).toBe(0);
  194. });
  195. it('should build search query correctly', () => {
  196. const templateSrv: any = { replace: jest.fn() };
  197. const ds = new TempoDatasource(defaultSettings, templateSrv);
  198. const duration = '10ms';
  199. templateSrv.replace.mockReturnValue(duration);
  200. const tempoQuery: TempoQuery = {
  201. queryType: 'search',
  202. refId: 'A',
  203. query: '',
  204. serviceName: 'frontend',
  205. spanName: '/config',
  206. search: 'root.http.status_code=500',
  207. minDuration: '$interpolationVar',
  208. maxDuration: '$interpolationVar',
  209. limit: 10,
  210. };
  211. const builtQuery = ds.buildSearchQuery(tempoQuery);
  212. expect(builtQuery).toStrictEqual({
  213. tags: 'root.http.status_code=500 service.name="frontend" name="/config"',
  214. minDuration: duration,
  215. maxDuration: duration,
  216. limit: 10,
  217. });
  218. });
  219. it('should include a default limit', () => {
  220. const ds = new TempoDatasource(defaultSettings);
  221. const tempoQuery: TempoQuery = {
  222. queryType: 'search',
  223. refId: 'A',
  224. query: '',
  225. search: '',
  226. };
  227. const builtQuery = ds.buildSearchQuery(tempoQuery);
  228. expect(builtQuery).toStrictEqual({
  229. tags: '',
  230. limit: DEFAULT_LIMIT,
  231. });
  232. });
  233. it('should include time range if provided', () => {
  234. const ds = new TempoDatasource(defaultSettings);
  235. const tempoQuery: TempoQuery = {
  236. queryType: 'search',
  237. refId: 'A',
  238. query: '',
  239. search: '',
  240. };
  241. const timeRange = { startTime: 0, endTime: 1000 };
  242. const builtQuery = ds.buildSearchQuery(tempoQuery, timeRange);
  243. expect(builtQuery).toStrictEqual({
  244. tags: '',
  245. limit: DEFAULT_LIMIT,
  246. start: timeRange.startTime,
  247. end: timeRange.endTime,
  248. });
  249. });
  250. it('formats native search query history correctly', () => {
  251. const ds = new TempoDatasource(defaultSettings);
  252. const tempoQuery: TempoQuery = {
  253. queryType: 'nativeSearch',
  254. refId: 'A',
  255. query: '',
  256. serviceName: 'frontend',
  257. spanName: '/config',
  258. search: 'root.http.status_code=500',
  259. minDuration: '1ms',
  260. maxDuration: '100s',
  261. limit: 10,
  262. };
  263. const result = ds.getQueryDisplayText(tempoQuery);
  264. expect(result).toBe(
  265. 'Service Name: frontend, Span Name: /config, Search: root.http.status_code=500, Min Duration: 1ms, Max Duration: 100s, Limit: 10'
  266. );
  267. });
  268. it('should get loki search datasource', () => {
  269. // 1. Get lokiSearch.datasource if present
  270. const ds1 = new TempoDatasource({
  271. ...defaultSettings,
  272. jsonData: {
  273. lokiSearch: {
  274. datasourceUid: 'loki-1',
  275. },
  276. },
  277. });
  278. const lokiDS1 = ds1.getLokiSearchDS();
  279. expect(lokiDS1).toBe('loki-1');
  280. // 2. Get traceToLogs.datasource
  281. const ds2 = new TempoDatasource({
  282. ...defaultSettings,
  283. jsonData: {
  284. tracesToLogs: {
  285. lokiSearch: true,
  286. datasourceUid: 'loki-2',
  287. },
  288. },
  289. });
  290. const lokiDS2 = ds2.getLokiSearchDS();
  291. expect(lokiDS2).toBe('loki-2');
  292. // 3. Return undefined if neither is available
  293. const ds3 = new TempoDatasource(defaultSettings);
  294. const lokiDS3 = ds3.getLokiSearchDS();
  295. expect(lokiDS3).toBe(undefined);
  296. // 4. Return undefined if lokiSearch is undefined, even if traceToLogs is present
  297. // since this indicates the user cleared the fallback setting
  298. const ds4 = new TempoDatasource({
  299. ...defaultSettings,
  300. jsonData: {
  301. tracesToLogs: {
  302. lokiSearch: true,
  303. datasourceUid: 'loki-2',
  304. },
  305. lokiSearch: {
  306. datasourceUid: undefined,
  307. },
  308. },
  309. });
  310. const lokiDS4 = ds4.getLokiSearchDS();
  311. expect(lokiDS4).toBe(undefined);
  312. });
  313. });
  314. const backendSrvWithPrometheus = {
  315. async get(uid: string) {
  316. if (uid === 'prom') {
  317. return {
  318. query() {
  319. return of({ data: [totalsPromMetric, secondsPromMetric, failedPromMetric] });
  320. },
  321. };
  322. }
  323. throw new Error('unexpected uid');
  324. },
  325. };
  326. function setupBackendSrv(frame: DataFrame) {
  327. setBackendSrv({
  328. fetch(): Observable<FetchResponse<BackendDataSourceResponse>> {
  329. return of(
  330. createFetchResponse({
  331. results: {
  332. refid1: {
  333. frames: [dataFrameToJSON(frame)],
  334. },
  335. },
  336. })
  337. );
  338. },
  339. } as any);
  340. }
  341. const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = {
  342. id: 0,
  343. uid: '0',
  344. type: 'tracing',
  345. name: 'tempo',
  346. access: 'proxy',
  347. meta: {
  348. id: 'tempo',
  349. name: 'tempo',
  350. type: PluginType.datasource,
  351. info: {} as any,
  352. module: '',
  353. baseUrl: '',
  354. },
  355. jsonData: {
  356. nodeGraph: {
  357. enabled: true,
  358. },
  359. },
  360. };
  361. const totalsPromMetric = new MutableDataFrame({
  362. refId: 'traces_service_graph_request_total',
  363. fields: [
  364. { name: 'Time', values: [1628169788000, 1628169788000] },
  365. { name: 'client', values: ['app', 'lb'] },
  366. { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
  367. { name: 'job', values: ['local_scrape', 'local_scrape'] },
  368. { name: 'server', values: ['db', 'app'] },
  369. { name: 'tempo_config', values: ['default', 'default'] },
  370. { name: 'Value #traces_service_graph_request_total', values: [10, 20] },
  371. ],
  372. });
  373. const secondsPromMetric = new MutableDataFrame({
  374. refId: 'traces_service_graph_request_server_seconds_sum',
  375. fields: [
  376. { name: 'Time', values: [1628169788000, 1628169788000] },
  377. { name: 'client', values: ['app', 'lb'] },
  378. { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
  379. { name: 'job', values: ['local_scrape', 'local_scrape'] },
  380. { name: 'server', values: ['db', 'app'] },
  381. { name: 'tempo_config', values: ['default', 'default'] },
  382. { name: 'Value #traces_service_graph_request_server_seconds_sum', values: [10, 40] },
  383. ],
  384. });
  385. const failedPromMetric = new MutableDataFrame({
  386. refId: 'traces_service_graph_request_failed_total',
  387. fields: [
  388. { name: 'Time', values: [1628169788000, 1628169788000] },
  389. { name: 'client', values: ['app', 'lb'] },
  390. { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
  391. { name: 'job', values: ['local_scrape', 'local_scrape'] },
  392. { name: 'server', values: ['db', 'app'] },
  393. { name: 'tempo_config', values: ['default', 'default'] },
  394. { name: 'Value #traces_service_graph_request_failed_total', values: [2, 15] },
  395. ],
  396. });
  397. const mockInvalidJson = {
  398. batches: [
  399. {
  400. resource: {
  401. attributes: [],
  402. },
  403. instrumentation_library_spans: [
  404. {
  405. instrumentation_library: {},
  406. spans: [
  407. {
  408. trace_id: 'AAAAAAAAAABguiq7RPE+rg==',
  409. span_id: 'cmteMBAvwNA=',
  410. parentSpanId: 'OY8PIaPbma4=',
  411. name: 'HTTP GET - root',
  412. kind: 'SPAN_KIND_SERVER',
  413. startTimeUnixNano: '1627471657255809000',
  414. endTimeUnixNano: '1627471657256268000',
  415. attributes: [
  416. { key: 'http.status_code', value: { intValue: '200' } },
  417. { key: 'http.method', value: { stringValue: 'GET' } },
  418. { key: 'http.url', value: { stringValue: '/' } },
  419. { key: 'component', value: { stringValue: 'net/http' } },
  420. ],
  421. status: {},
  422. },
  423. ],
  424. },
  425. ],
  426. },
  427. ],
  428. };
  429. const serviceGraphLinks = [
  430. {
  431. url: '',
  432. title: 'Request rate',
  433. internal: {
  434. query: {
  435. expr: 'rate(traces_service_graph_request_total{server="${__data.fields.id}"}[$__rate_interval])',
  436. },
  437. datasourceUid: 'prom',
  438. datasourceName: 'Prometheus',
  439. },
  440. },
  441. {
  442. url: '',
  443. title: 'Request histogram',
  444. internal: {
  445. query: {
  446. expr: 'histogram_quantile(0.9, sum(rate(traces_service_graph_request_server_seconds_bucket{server="${__data.fields.id}"}[$__rate_interval])) by (le, client, server))',
  447. },
  448. datasourceUid: 'prom',
  449. datasourceName: 'Prometheus',
  450. },
  451. },
  452. {
  453. url: '',
  454. title: 'Failed request rate',
  455. internal: {
  456. query: {
  457. expr: 'rate(traces_service_graph_request_failed_total{server="${__data.fields.id}"}[$__rate_interval])',
  458. },
  459. datasourceUid: 'prom',
  460. datasourceName: 'Prometheus',
  461. },
  462. },
  463. ];