From d6b5edef632a417f646e6e74dad6a6a5681f5ec8 Mon Sep 17 00:00:00 2001 From: Matthew Heidemann Date: Wed, 4 Sep 2024 13:43:54 -0600 Subject: [PATCH] Allow templated legend via attribute keys and values from query results (#57) A few changes were made to the Time Series Value fields: - Labels are appended to the field it self. This will use default formatting and removes the need for our createFieldName function. - Name is changed to use the TIME_SERIES_VALUE_FIELD which has logic to remove the name if there is no displayNameFromDS set and use default label formatting. - Query Name allows for variable names in simple mustache like syntax {{var1}} which is similar to the Prometheus / Loki datasource - If an attribute value doesn't exist in the results set, then the value will be set to --- package-lock.json | 4 +- .../__snapshots__/index.test.js.snap | 149 +++++++++++++++++- src/preprocessors/index.test.js | 143 +++++++++++++---- src/preprocessors/timeseries.test.js | 104 ++---------- src/preprocessors/timeseries.ts | 57 ++++--- src/types.ts | 2 +- 6 files changed, 301 insertions(+), 158 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d6acd9..c27bab6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "servicenow-cloudobservability-datasource", - "version": "3.4.0", + "version": "4.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "servicenow-cloudobservability-datasource", - "version": "3.4.0", + "version": "4.1.1", "license": "Apache-2.0", "dependencies": { "@codemirror/commands": "6.3.2", diff --git a/src/preprocessors/__snapshots__/index.test.js.snap b/src/preprocessors/__snapshots__/index.test.js.snap index e138e7c..61e652a 100644 --- a/src/preprocessors/__snapshots__/index.test.js.snap +++ b/src/preprocessors/__snapshots__/index.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`preprocesses logs successfully 1`] = ` +exports[`logs preprocesses successfully 1`] = ` { "fields": [ { @@ -91,3 +91,150 @@ exports[`preprocesses logs successfully 1`] = ` "refId": "a", } `; + +exports[`preprocesses Timeseries successfully should set displayNameDS with legend formatter 1`] = ` +{ + "fields": [ + { + "config": {}, + "labels": undefined, + "name": "Time", + "type": "time", + "values": [ + 0, + 1, + 2, + ], + }, + { + "config": { + "displayNameFromDS": "/get", + "links": [ + { + "targetBlank": true, + "title": "Create a Notebook in Lightstep", + "url": "https://notebooks", + }, + ], + }, + "labels": { + "operation": "/get", + }, + "name": "Value", + "type": "number", + "values": [ + 1, + 7, + 1, + ], + }, + { + "config": { + "displayNameFromDS": "/load", + "links": [ + { + "targetBlank": true, + "title": "Create a Notebook in Lightstep", + "url": "https://notebooks", + }, + ], + }, + "labels": { + "operation": "/load", + }, + "name": "Value", + "type": "number", + "values": [ + 6, + 5, + 9, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; + +exports[`preprocesses Timeseries successfully should work if there are no labels 1`] = ` +{ + "fields": [ + { + "config": {}, + "labels": undefined, + "name": "Time", + "type": "time", + "values": [ + 0, + 1, + 2, + ], + }, + { + "config": { + "displayNameFromDS": "", + "links": [ + { + "targetBlank": true, + "title": "Create a Notebook in Lightstep", + "url": "https://notebooks", + }, + ], + }, + "labels": {}, + "name": "Value", + "type": "number", + "values": [ + 1, + 7, + 1, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; + +exports[`preprocesses Timeseries successfully should work if there are no labels and no legend 1`] = ` +{ + "fields": [ + { + "config": {}, + "labels": undefined, + "name": "Time", + "type": "time", + "values": [ + 0, + 1, + 2, + ], + }, + { + "config": { + "displayNameFromDS": "", + "links": [ + { + "targetBlank": true, + "title": "Create a Notebook in Lightstep", + "url": "https://notebooks", + }, + ], + }, + "labels": {}, + "name": "Value", + "type": "number", + "values": [ + 1, + 7, + 1, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; diff --git a/src/preprocessors/index.test.js b/src/preprocessors/index.test.js index b0567f2..6c316e4 100644 --- a/src/preprocessors/index.test.js +++ b/src/preprocessors/index.test.js @@ -1,45 +1,118 @@ import { preprocessData } from './index'; -test('preprocesses logs successfully', () => { - const logsDataFrame = preprocessData( - { - data: { - attributes: { - logs: [ - [ - 1691409972788, - { - event: 'one', - severity: 'ErrorSeverity', - tags: { - 'http.status_code': 200, - large_batch: true, - trace_id: 'd29a3fa8fb446ec65eb691a3259a541e', +describe('logs', () => { + it('preprocesses successfully', () => { + const logsDataFrame = preprocessData( + { + data: { + attributes: { + logs: [ + [ + 1691409972788, + { + event: 'one', + severity: 'ErrorSeverity', + tags: { + 'http.status_code': 200, + large_batch: true, + trace_id: 'd29a3fa8fb446ec65eb691a3259a541e', + }, }, - }, - ], - [ - 1691409971908, - { - event: 'two', - severity: 'InfoSeverity', - tags: { - customer: 'hipcore', - large_batch: false, - trace_id: 'd0fa420269652931236c94bc54d2233e', + ], + [ + 1691409971908, + { + event: 'two', + severity: 'InfoSeverity', + tags: { + customer: 'hipcore', + large_batch: false, + trace_id: 'd0fa420269652931236c94bc54d2233e', + }, + k8s_environment: 'production', + k8s_namespace: 'default', + k8s_pod: 'hipcore-pod', }, - k8s_environment: 'production', - k8s_namespace: 'default', - k8s_pod: 'hipcore-pod', - }, + ], ], + }, + }, + }, + { refId: 'a' }, + '' + ); + + expect(logsDataFrame.toJSON()).toMatchSnapshot(); + }); +}); + +describe('preprocesses Timeseries successfully', () => { + it('should set displayNameDS with legend formatter', () => { + const res = { + data: { + attributes: { + series: [ + { + 'group-labels': ['operation=/get'], + points: [ + [0, 1], + [1, 7], + [2, 1], + ], + }, + { + 'group-labels': ['operation=/load'], + points: [ + [0, 6], + [1, 5], + [2, 9], + ], + }, ], }, }, - }, - { refId: 'a' }, - '' - ); + }; + const query = { projectName: 'demo', format: '{{operation}}' }; + expect(preprocessData(res, query, 'https://notebooks')).toMatchSnapshot(); + }); - expect(logsDataFrame.toJSON()).toMatchSnapshot(); + it('should work if there are no labels', () => { + const res = { + data: { + attributes: { + series: [ + { + points: [ + [0, 1], + [1, 7], + [2, 1], + ], + }, + ], + }, + }, + }; + const query = { projectName: 'demo', format: '{{operation}}' }; + expect(preprocessData(res, query, 'https://notebooks')).toMatchSnapshot(); + }); + + it('should work if there are no labels and no legend', () => { + const res = { + data: { + attributes: { + series: [ + { + points: [ + [0, 1], + [1, 7], + [2, 1], + ], + }, + ], + }, + }, + }; + const query = { projectName: 'demo', format: undefined }; + expect(preprocessData(res, query, 'https://notebooks')).toMatchSnapshot(); + }); }); diff --git a/src/preprocessors/timeseries.test.js b/src/preprocessors/timeseries.test.js index 09c08a9..d8fdb29 100644 --- a/src/preprocessors/timeseries.test.js +++ b/src/preprocessors/timeseries.test.js @@ -1,21 +1,4 @@ -import { setTemplateSrv } from '@grafana/runtime'; -import { createSortedTimestamps, createTimestampMap, createFieldName } from './timeseries'; - -beforeAll(() => { - // Create a mock template server - // nb this is used in the createFieldName tests - setTemplateSrv({ - getVariables() { - return []; - }, - replace(target, scopedVars, format) { - if (target.includes('$service')) { - return target.replace('$service', JSON.stringify(['web', 'android', 'ios'])); - } - return target || ''; - }, - }); -}); +import { createSortedTimestamps, createTimestampMap, transformLabels } from './timeseries'; describe('createSortedTimestamps()', () => { test('should assemble a complete set of sorted timestamps for all series', () => { @@ -54,78 +37,21 @@ describe('createTimestampMap()', () => { }); }); -describe('createFieldName', () => { - test.each([ - // NO QUERY NAME DEFINED - { - name: 'returns query text when there are undefined group labels without query name', - format: undefined, - queryText: 'metric requests | delta', - groupLabels: undefined, - options: {}, - expected: 'metric requests | delta', - }, - { - name: 'returns query text when there are empty group labels without query name', - format: undefined, - queryText: 'metric requests | delta', - groupLabels: [], - options: {}, - expected: 'metric requests | delta', - }, - { - name: 'returns formatted single label without query name', - format: undefined, - queryText: 'metric requests | delta', - groupLabels: ['customer=Lightstep'], - options: {}, - expected: '{customer="Lightstep"}', - }, - { - name: 'returns query text when there are empty group labels without query name', - format: undefined, - queryText: 'metric requests | delta', - groupLabels: ['customer=Lightstep', 'service=api', 'method=/pay'], - options: {}, - expected: '{customer="Lightstep", method="/pay", service="api"}', - }, +describe('transformLabels', () => { + it('single label', () => { + expect(transformLabels(['customer=Lightstep'])).toEqual({ customer: 'Lightstep' }); + }); - // CUSTOM QUERY NAME DEFINED - { - name: 'returns query name when defined', - format: 'custom', - queryText: 'metric requests | delta', - groupLabels: undefined, - options: {}, - expected: 'custom', - }, - { - name: 'returns query name when defined with group labels', - format: 'custom', - queryText: 'metric requests | delta', - groupLabels: ['customer=Lightstep', 'service=api', 'method=/pay'], - options: {}, - expected: 'custom {customer="Lightstep", method="/pay", service="api"}', - }, - { - name: 'returns query name when defined with template variables', - format: '$service requests', - queryText: 'metric requests | delta | filter service == $service', - groupLabels: undefined, - options: {}, - expected: '["web","android","ios"] requests', - }, + it('missing label value', () => { + expect(transformLabels(['customer='])).toEqual({ customer: '' }); + }); + + it('multi labels', () => { + expect(transformLabels(['customer=LightStep', 'service=web'])).toEqual({ customer: 'LightStep', service: 'web' }); + }); - // EDGE CASES - { - name: 'handles "=" in group label value', - format: undefined, - queryText: 'metric requests | delta', - groupLabels: ['compare=true==true'], - options: {}, - expected: '{compare="true==true"}', - }, - ])('$name', ({ format, queryText, groupLabels, options, expected }) => { - expect(createFieldName(format, queryText, groupLabels, options)).toBe(expected); + it('no labels', () => { + expect(transformLabels([])).toEqual({}); + expect(transformLabels(undefined)).toEqual({}); }); }); diff --git a/src/preprocessors/timeseries.ts b/src/preprocessors/timeseries.ts index 76cedc9..c685cc5 100644 --- a/src/preprocessors/timeseries.ts +++ b/src/preprocessors/timeseries.ts @@ -1,5 +1,4 @@ -import { Field, FieldType, MutableDataFrame } from '@grafana/data'; -import { getTemplateSrv } from '@grafana/runtime'; +import { Field, FieldType, toDataFrame, Labels, TIME_SERIES_VALUE_FIELD_NAME } from '@grafana/data'; import { LightstepQuery, QueryTimeseriesRes } from '../types'; /** @@ -26,8 +25,8 @@ import { LightstepQuery, QueryTimeseriesRes } from '../types'; * ```js * [ * { name: 'Time', type: FieldType.time, values: [0, 1, 2] }, - * { name: '{operation="/get"}', type: FieldType.number, values: [1, 7, 1] }, - * { name: '{operation="/load"}', type: FieldType.number, values: [6, 5, 9] } + * { name: 'Value', type: FieldType.number, values: [1, 7, 1], labels: {operation:"/get"} }, + * { name: 'Value', type: FieldType.number, values: [6, 5, 9], labels: {operation:"/load"} } * ] * ``` */ @@ -36,7 +35,7 @@ export function preprocessTimeseries(res: QueryTimeseriesRes, query: LightstepQu // If this is an empty query, bail 👋 if (!series) { - return new MutableDataFrame({ + return toDataFrame({ refId: query.refId, fields: [], }); @@ -68,11 +67,16 @@ export function preprocessTimeseries(res: QueryTimeseriesRes, query: LightstepQu }); } + const labels: Labels = transformLabels(s['group-labels']); + dataFrameFields.push({ - name: createFieldName(query.format, query.text, s['group-labels']), + name: TIME_SERIES_VALUE_FIELD_NAME, type: FieldType.number, values, + labels: labels, + config: { + displayNameFromDS: legenedFormatter(query.format, labels), links: [ { url: notebookURL, @@ -84,7 +88,7 @@ export function preprocessTimeseries(res: QueryTimeseriesRes, query: LightstepQu }); }); - return new MutableDataFrame({ + return toDataFrame({ refId: query.refId, fields: dataFrameFields, }); @@ -93,29 +97,15 @@ export function preprocessTimeseries(res: QueryTimeseriesRes, query: LightstepQu // -------------------------------------------------------- // UTILS -/** - * Produces a formatted display name for a series - */ -export function createFieldName(format: string, queryText: string, groupLabels: string[] = []) { - let formattedLabels = ''; - - if (groupLabels.length > 0) { - formattedLabels = `{${groupLabels - .sort((a, b) => a.localeCompare(b)) - // Surround label value in double quotes (e.g. 'key=value' => 'key="value"') - .map((labelKeyAndValue) => labelKeyAndValue.replace('=', '="') + '"') - .join(', ')}}`; - } - - if (format) { - return getTemplateSrv().replace(format) + (formattedLabels.length > 0 ? ' ' + formattedLabels : ''); - } - - if (groupLabels.length > 0) { - return formattedLabels; - } - - return queryText; +export function transformLabels(groupLabels: string[] = []) { + const labels: Labels = {}; + groupLabels.reduce((acc, l) => { + const data = l.split('='); + // if label value is missing grafana goes defaults to something like "Value 5" + acc[data[0]] = data[1] !== '' ? data[1] : ''; + return acc; + }, labels); + return labels; } /** @@ -148,3 +138,10 @@ export function createTimestampMap(timestamps: number[]): Map { return timestampToIndexMap; } + +function legenedFormatter(legend = "", labels: Labels) { + // nb: We're slightly divergent from `renderLegendFormat` available in v10+ + // since they just repeat the key if a label value is undefined + const aliasRegex = /\{\{\s*(.+?)\s*\}\}/g; + return legend.replace(aliasRegex, (_, group) => (labels[group] ? labels[group] : "")); +} diff --git a/src/types.ts b/src/types.ts index 4a58e8f..345d418 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { DataQuery, DataSourceJsonData } from '@grafana/data'; +import { DataQuery, DataSourceJsonData } from '@grafana/schema'; /** * The complete query definition needed to request data from the Lightstep API.