From 50381376622e09cd896a3796639cc9ce1e6d97ba Mon Sep 17 00:00:00 2001
From: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com>
Date: Wed, 30 Aug 2023 13:45:39 +0200
Subject: [PATCH] Dashboard: Add support for Tempo query variables (#72745)
---
.betterer.results | 20 ++--
.../tempo/VariableQueryEditor.test.tsx | 68 +++++++++++
.../datasource/tempo/VariableQueryEditor.tsx | 106 ++++++++++++++++++
.../datasource/tempo/datasource.test.ts | 101 +++++++++++++++++
.../plugins/datasource/tempo/datasource.ts | 62 ++++++++++
.../datasource/tempo/language_provider.ts | 2 +-
public/app/plugins/datasource/tempo/mocks.ts | 68 +++++++++++
.../datasource/tempo/variables.test.ts | 51 +++++++++
.../app/plugins/datasource/tempo/variables.ts | 25 +++++
9 files changed, 493 insertions(+), 10 deletions(-)
create mode 100644 public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx
create mode 100644 public/app/plugins/datasource/tempo/VariableQueryEditor.tsx
create mode 100644 public/app/plugins/datasource/tempo/mocks.ts
create mode 100644 public/app/plugins/datasource/tempo/variables.test.ts
create mode 100644 public/app/plugins/datasource/tempo/variables.ts
diff --git a/.betterer.results b/.betterer.results
index a666a654948..fe00c7886fd 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -4298,22 +4298,24 @@ exports[`better eslint`] = {
],
"public/app/plugins/datasource/tempo/datasource.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "1"],
+ [0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
- [0, 0, 0, "Do not use any type assertions.", "3"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "4"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "5"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "3"],
+ [0, 0, 0, "Do not use any type assertions.", "4"],
+ [0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
- [0, 0, 0, "Do not use any type assertions.", "8"],
- [0, 0, 0, "Do not use any type assertions.", "9"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "8"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Do not use any type assertions.", "10"],
[0, 0, 0, "Do not use any type assertions.", "11"],
[0, 0, 0, "Do not use any type assertions.", "12"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "13"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "14"],
+ [0, 0, 0, "Do not use any type assertions.", "13"],
+ [0, 0, 0, "Do not use any type assertions.", "14"],
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "16"]
+ [0, 0, 0, "Unexpected any. Specify a different type.", "16"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "17"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "18"]
],
"public/app/plugins/datasource/tempo/language_provider.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
diff --git a/public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx
new file mode 100644
index 00000000000..57bf7f1d1ac
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
+
+import { TemplateSrv } from '@grafana/runtime';
+
+import {
+ TempoVariableQuery,
+ TempoVariableQueryEditor,
+ TempoVariableQueryEditorProps,
+ TempoVariableQueryType,
+} from './VariableQueryEditor';
+import { createTempoDatasource } from './mocks';
+
+const refId = 'TempoDatasourceVariableQueryEditor-VariableQuery';
+
+describe('TempoVariableQueryEditor', () => {
+ let props: TempoVariableQueryEditorProps;
+ let onChange: (value: TempoVariableQuery) => void;
+
+ beforeEach(() => {
+ props = {
+ datasource: createTempoDatasource({} as unknown as TemplateSrv),
+ query: { type: 0, refId: 'test' },
+ onChange: (_: TempoVariableQuery) => {},
+ };
+
+ onChange = jest.fn();
+ });
+
+ test('Allows to create a Label names variable', async () => {
+ expect(onChange).not.toHaveBeenCalled();
+ render();
+
+ await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names');
+ await userEvent.click(document.body);
+
+ expect(onChange).toHaveBeenCalledWith({
+ type: TempoVariableQueryType.LabelNames,
+ label: '',
+ refId,
+ });
+ });
+
+ test('Allows to create a Label values variable', async () => {
+ jest.spyOn(props.datasource, 'labelNamesQuery').mockResolvedValue([
+ {
+ text: 'moon',
+ },
+ {
+ text: 'luna',
+ },
+ ]);
+ expect(onChange).not.toHaveBeenCalled();
+ render();
+
+ await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
+ await selectOptionInTest(screen.getByLabelText('Label'), 'luna');
+ await userEvent.click(document.body);
+
+ expect(onChange).toHaveBeenCalledWith({
+ type: TempoVariableQueryType.LabelValues,
+ label: 'luna',
+ refId,
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/tempo/VariableQueryEditor.tsx b/public/app/plugins/datasource/tempo/VariableQueryEditor.tsx
new file mode 100644
index 00000000000..b150c7e3381
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/VariableQueryEditor.tsx
@@ -0,0 +1,106 @@
+import React, { useEffect, useState } from 'react';
+
+import { DataQuery, SelectableValue } from '@grafana/data';
+import { InlineField, InlineFieldRow, Select } from '@grafana/ui';
+
+import { TempoDatasource } from './datasource';
+
+export enum TempoVariableQueryType {
+ LabelNames,
+ LabelValues,
+}
+
+export interface TempoVariableQuery extends DataQuery {
+ type: TempoVariableQueryType;
+ label?: string;
+ stream?: string;
+}
+
+const variableOptions = [
+ { label: 'Label names', value: TempoVariableQueryType.LabelNames },
+ { label: 'Label values', value: TempoVariableQueryType.LabelValues },
+];
+
+const refId = 'TempoDatasourceVariableQueryEditor-VariableQuery';
+
+export type TempoVariableQueryEditorProps = {
+ onChange: (value: TempoVariableQuery) => void;
+ query: TempoVariableQuery;
+ datasource: TempoDatasource;
+};
+
+export const TempoVariableQueryEditor = ({ onChange, query, datasource }: TempoVariableQueryEditorProps) => {
+ const [label, setLabel] = useState(query.label || '');
+ const [type, setType] = useState(query.type);
+ const [labelOptions, setLabelOptions] = useState>>([]);
+
+ useEffect(() => {
+ if (type === TempoVariableQueryType.LabelValues) {
+ datasource.labelNamesQuery().then((labelNames: Array<{ text: string }>) => {
+ setLabelOptions(labelNames.map(({ text }) => ({ label: text, value: text })));
+ });
+ }
+ }, [datasource, query, type]);
+
+ const onQueryTypeChange = (newType: SelectableValue) => {
+ setType(newType.value);
+ if (newType.value !== undefined) {
+ onChange({
+ type: newType.value,
+ label,
+ refId,
+ });
+ }
+ };
+
+ const onLabelChange = (newLabel: SelectableValue) => {
+ const newLabelValue = newLabel.value || '';
+ setLabel(newLabelValue);
+ if (type !== undefined) {
+ onChange({
+ type,
+ label: newLabelValue,
+ refId,
+ });
+ }
+ };
+
+ const handleBlur = () => {
+ if (type !== undefined) {
+ onChange({ type, label, refId });
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {type === TempoVariableQueryType.LabelValues && (
+
+
+
+
+
+ )}
+ >
+ );
+};
diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts
index 59b434cf951..f494401a1c8 100644
--- a/public/app/plugins/datasource/tempo/datasource.test.ts
+++ b/public/app/plugins/datasource/tempo/datasource.test.ts
@@ -18,6 +18,7 @@ import { BackendDataSourceResponse, FetchResponse, setBackendSrv, setDataSourceS
import { BarGaugeDisplayMode, TableCellDisplayMode } from '@grafana/schema';
import { TemplateSrv } from 'app/features/templating/template_srv';
+import { TempoVariableQueryType } from './VariableQueryEditor';
import { TraceqlSearchScope } from './dataquery.gen';
import {
DEFAULT_LIMIT,
@@ -32,6 +33,7 @@ import {
} from './datasource';
import mockJson from './mockJsonResponse.json';
import mockServiceGraph from './mockServiceGraph.json';
+import { createMetadataRequest, createTempoDatasource } from './mocks';
import { TempoJsonData, TempoQuery } from './types';
let mockObservable: () => Observable;
@@ -769,6 +771,105 @@ describe('Tempo service graph view', () => {
});
});
+describe('label names - v2 tags', () => {
+ let datasource: TempoDatasource;
+
+ beforeEach(() => {
+ datasource = createTempoDatasource();
+ jest.spyOn(datasource, 'metadataRequest').mockImplementation(
+ createMetadataRequest({
+ data: {
+ scopes: [{ name: 'span', tags: ['label1', 'label2'] }],
+ },
+ })
+ );
+ });
+
+ it('get label names', async () => {
+ // label_names()
+ const response = await datasource.executeVariableQuery({ refId: 'test', type: TempoVariableQueryType.LabelNames });
+
+ expect(response).toEqual([{ text: 'label1' }, { text: 'label2' }]);
+ });
+});
+
+describe('label names - v1 tags', () => {
+ let datasource: TempoDatasource;
+
+ beforeEach(() => {
+ datasource = createTempoDatasource();
+ jest
+ .spyOn(datasource, 'metadataRequest')
+ .mockImplementationOnce(() => {
+ throw Error;
+ })
+ .mockImplementation(
+ createMetadataRequest({
+ data: {
+ tagNames: ['label1', 'label2'],
+ },
+ })
+ );
+ });
+
+ it('get label names', async () => {
+ // label_names()
+ const response = await datasource.executeVariableQuery({ refId: 'test', type: TempoVariableQueryType.LabelNames });
+ expect(response).toEqual([{ text: 'label1' }, { text: 'label2' }, { text: 'status.code' }]);
+ });
+});
+
+describe('label values', () => {
+ let datasource: TempoDatasource;
+
+ beforeEach(() => {
+ datasource = createTempoDatasource();
+ jest.spyOn(datasource, 'metadataRequest').mockImplementation(
+ createMetadataRequest({
+ data: {
+ tagValues: [
+ {
+ type: 'value1',
+ value: 'value1',
+ label: 'value1',
+ },
+ {
+ type: 'value2',
+ value: 'value2',
+ label: 'value2',
+ },
+ ],
+ },
+ })
+ );
+ });
+
+ it('get label values for given label', async () => {
+ // label_values("label")
+ const response = await datasource.executeVariableQuery({
+ refId: 'test',
+ type: TempoVariableQueryType.LabelValues,
+ label: 'label',
+ });
+
+ expect(response).toEqual([
+ { text: { type: 'value1', value: 'value1', label: 'value1' } },
+ { text: { type: 'value2', value: 'value2', label: 'value2' } },
+ ]);
+ });
+
+ it('do not raise error when label is not set', async () => {
+ // label_values()
+ const response = await datasource.executeVariableQuery({
+ refId: 'test',
+ type: TempoVariableQueryType.LabelValues,
+ label: undefined,
+ });
+
+ expect(response).toEqual([]);
+ });
+});
+
const backendSrvWithPrometheus = {
async get(uid: string) {
if (uid === 'prom') {
diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts
index 9eb12e4332b..1b110474aa0 100644
--- a/public/app/plugins/datasource/tempo/datasource.ts
+++ b/public/app/plugins/datasource/tempo/datasource.ts
@@ -39,6 +39,7 @@ import { PrometheusDatasource } from '../prometheus/datasource';
import { PromQuery } from '../prometheus/types';
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
+import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
import {
failedMetric,
@@ -63,6 +64,7 @@ import {
import { doTempoChannelStream } from './streaming';
import { SearchQueryParams, TempoQuery, TempoJsonData } from './types';
import { getErrorMessage } from './utils';
+import { TempoVariableSupport } from './variables';
export const DEFAULT_LIMIT = 20;
@@ -115,6 +117,66 @@ export class TempoDatasource extends DataSourceWithBackend>(() => []);
+ }
+
+ switch (query.type) {
+ case TempoVariableQueryType.LabelNames: {
+ return await this.labelNamesQuery();
+ }
+ case TempoVariableQueryType.LabelValues: {
+ return this.labelValuesQuery(query.label);
+ }
+ default: {
+ throw Error('Invalid query type', query.type);
+ }
+ }
+ }
+
+ async labelNamesQuery(): Promise> {
+ await this.languageProvider.fetchTags();
+ const tags = this.languageProvider.getAutocompleteTags();
+ return tags.filter((tag) => tag !== undefined).map((tag) => ({ text: tag })) as Array<{ text: string }>;
+ }
+
+ async labelValuesQuery(labelName?: string): Promise> {
+ if (!labelName) {
+ return [];
+ }
+
+ let options;
+ try {
+ // Retrieve the scope of the tag
+ // Example: given `http.status_code`, we want scope `span`
+ // Note that we ignore possible name clashes, e.g., `http.status_code` in both `span` and `resource`
+ const scope: string | undefined = (this.languageProvider.tagsV2 || [])
+ // flatten the Scope objects
+ .flatMap((tagV2) => tagV2.tags.map((tag) => ({ scope: tagV2.name, name: tag })))
+ // find associated scope
+ .find((tag) => tag.name === labelName)?.scope;
+ if (!scope) {
+ throw Error(`Scope for tag ${labelName} not found`);
+ }
+
+ // For V2, we need to send scope and tag name, e.g. `span.http.status_code`,
+ // unless the tag has intrinsic scope
+ const scopeAndTag = scope === 'intrinsic' ? labelName : `${scope}.${labelName}`;
+ options = await this.languageProvider.getOptionsV2(scopeAndTag);
+ } catch {
+ // For V1, the tag name (e.g. `http.status_code`) is enough
+ options = await this.languageProvider.getOptionsV1(labelName);
+ }
+
+ return options.filter((option) => option.value !== undefined).map((option) => ({ text: option.value })) as Array<{
+ text: string;
+ }>;
}
query(options: DataQueryRequest): Observable {
diff --git a/public/app/plugins/datasource/tempo/language_provider.ts b/public/app/plugins/datasource/tempo/language_provider.ts
index 23e53fd6b21..f17074f4053 100644
--- a/public/app/plugins/datasource/tempo/language_provider.ts
+++ b/public/app/plugins/datasource/tempo/language_provider.ts
@@ -129,7 +129,7 @@ export default class TempoLanguageProvider extends LanguageProvider {
return options;
}
- async getOptionsV2(tag: string, query: string): Promise>> {
+ async getOptionsV2(tag: string, query?: string): Promise>> {
const response = await this.request(`/api/v2/search/tag/${tag}/values`, query ? { q: query } : {});
let options: Array> = [];
if (response && response.tagValues) {
diff --git a/public/app/plugins/datasource/tempo/mocks.ts b/public/app/plugins/datasource/tempo/mocks.ts
new file mode 100644
index 00000000000..8a9c568ac81
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/mocks.ts
@@ -0,0 +1,68 @@
+import { DataSourceInstanceSettings, PluginType, toUtc } from '@grafana/data';
+import { TemplateSrv } from '@grafana/runtime';
+
+import { TempoDatasource } from './datasource';
+import { TempoJsonData } from './types';
+
+const rawRange = {
+ from: toUtc('2018-04-25 10:00'),
+ to: toUtc('2018-04-25 11:00'),
+};
+
+const defaultTimeSrvMock = {
+ timeRange: jest.fn().mockReturnValue({
+ from: rawRange.from,
+ to: rawRange.to,
+ raw: rawRange,
+ }),
+};
+
+const defaultTemplateSrvMock = {
+ replace: (input: string) => input,
+};
+
+export function createTempoDatasource(
+ templateSrvMock: Partial = defaultTemplateSrvMock,
+ settings: Partial> = {},
+ timeSrvStub = defaultTimeSrvMock
+): TempoDatasource {
+ const customSettings: DataSourceInstanceSettings = {
+ url: 'myloggingurl',
+ id: 0,
+ uid: '',
+ type: '',
+ name: '',
+ meta: {
+ id: 'id',
+ name: 'name',
+ type: PluginType.datasource,
+ module: '',
+ baseUrl: '',
+ info: {
+ author: {
+ name: 'Test',
+ },
+ description: '',
+ links: [],
+ logos: {
+ large: '',
+ small: '',
+ },
+ screenshots: [],
+ updated: '',
+ version: '',
+ },
+ },
+ readOnly: false,
+ jsonData: {},
+ access: 'direct',
+ ...settings,
+ };
+
+ // @ts-expect-error
+ return new TempoDatasource(customSettings, templateSrvMock, timeSrvStub);
+}
+
+export function createMetadataRequest(labelsAndValues: Record>) {
+ return async () => labelsAndValues;
+}
diff --git a/public/app/plugins/datasource/tempo/variables.test.ts b/public/app/plugins/datasource/tempo/variables.test.ts
new file mode 100644
index 00000000000..1b5f9fce94c
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/variables.test.ts
@@ -0,0 +1,51 @@
+import { lastValueFrom } from 'rxjs';
+
+import { DataQueryRequest, TimeRange } from '@grafana/data';
+
+import { TempoVariableQuery } from './VariableQueryEditor';
+import { createMetadataRequest, createTempoDatasource } from './mocks';
+import { TempoVariableSupport } from './variables';
+
+describe('TempoVariableSupport', () => {
+ let TempoVariableSupportMock: TempoVariableSupport;
+
+ beforeEach(() => {
+ const datasource = createTempoDatasource();
+ jest.spyOn(datasource, 'metadataRequest').mockImplementation(
+ createMetadataRequest({
+ data: {
+ tagNames: ['label1', 'label2'],
+ scopes: [{ name: 'span', tags: ['label1', 'label2'] }],
+ },
+ })
+ );
+ TempoVariableSupportMock = new TempoVariableSupport(datasource);
+ });
+
+ it('should return label names for Tempo', async () => {
+ const response = TempoVariableSupportMock.query({
+ app: 'undefined',
+ startTime: 0,
+ requestId: '1',
+ interval: 'undefined',
+ scopedVars: {},
+ timezone: 'undefined',
+ type: 0,
+ maxDataPoints: 10,
+ intervalMs: 5000,
+ targets: [
+ {
+ refId: 'A',
+ datasource: { uid: 'GRAFANA_DATASOURCE_NAME', type: 'sample' },
+ type: 0,
+ },
+ ],
+ panelId: 1,
+ publicDashboardAccessToken: '',
+ range: { from: new Date().toLocaleString(), to: new Date().toLocaleString() } as unknown as TimeRange,
+ } as DataQueryRequest);
+
+ const data = (await lastValueFrom(response)).data;
+ expect(data).toEqual([{ text: 'label1' }, { text: 'label2' }]);
+ });
+});
diff --git a/public/app/plugins/datasource/tempo/variables.ts b/public/app/plugins/datasource/tempo/variables.ts
new file mode 100644
index 00000000000..ce13d60056a
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/variables.ts
@@ -0,0 +1,25 @@
+import { from, Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { DataQueryRequest, DataQueryResponse, CustomVariableSupport } from '@grafana/data';
+
+import { TempoVariableQuery, TempoVariableQueryEditor } from './VariableQueryEditor';
+import { TempoDatasource } from './datasource';
+
+export class TempoVariableSupport extends CustomVariableSupport {
+ editor = TempoVariableQueryEditor;
+
+ constructor(private datasource: TempoDatasource) {
+ super();
+ this.query = this.query.bind(this);
+ }
+
+ query(request: DataQueryRequest): Observable {
+ if (!this.datasource) {
+ throw new Error('Datasource not initialized');
+ }
+
+ const result = this.datasource.executeVariableQuery(request.targets[0]);
+ return from(result).pipe(map((data) => ({ data })));
+ }
+}